上一篇 下一篇 分享链接 返回 返回顶部

如何在香港服务器中利用QEMU+VFIO在Linux系统下直通NVMe,提升虚拟数据库性能?

发布人:Minchunlin 发布时间:2025-09-04 09:04 阅读量:808


凌晨 3 点,香港葵涌数据中心的客户(跨境电商,日订单高峰每分钟 1.8k)数据库在高峰期间出现写延迟毛刺,均值还能看,但 p99 一脚踩穿(从 7ms 抬头到 35~50ms),应用因为延迟抖动开始“卡脖子”。
那晚我决定换个思路:不再用 virtio 做块设备,直接把一块企业级 NVMe 整块直通给数据库虚机,让它像真机一样直接跟 NVMe 打交道。目标很朴素:把 p99 延迟打回个位数,并让 TPS 抬一截。

环境与硬件清单(真实现场)

型号/版本 说明
机型 Supermicro SYS-1029U 1U,2 x PCIe x16 全高,1 x OCP
CPU Intel Xeon Silver 4210R × 2 10C/20T * 2,NUMA=2
内存 256 GB DDR4 2933 均匀分布两路 NUMA
系统盘 SATA SSD 480 GB × 2 RAID1,给宿主机用
直通盘 Samsung PM9A3 3.84TB U.2 PCIe 4.0 x4(在本机为 x4@Gen3,受平台限制)
网卡 Mellanox ConnectX-4 Lx 25GbE 和数据库没强耦合,略过
宿主机 OS CentOS 7.9 客户要求 CentOS 7 生态
内核 kernel-ml 5.4.x(elrepo) 为了更好的 VFIO/NVMe reset 支持
虚拟化 libvirt 4.5 + qemu-kvm-ev 2.12 CentOS 7 最稳妥组合
客户端 OS AlmaLinux 8.9(也可 RHEL8/Ubuntu20+) 运行 PostgreSQL
DB PostgreSQL 14.11 ext4 + noatime

为什么 CentOS 7 还要上 5.4 内核?——VFIO、IOMMU、NVMe reset 在老内核上“玄学”偏多,5.x 稳定太多。CentOS 7 装 elrepo 的 kernel-ml 是我多次实战的“止疼片”。

目标与方案

目标

  • 将数据库盘的 p99 写延迟从 35~50ms 拉回 < 10ms;
  • 将业务高峰时 TPS 提升 ≥ 25%;
  • 把抖动(jitter)收窄,业务体验更稳。

方案

  • BIOS 打开 VT-d、Above 4G Decoding;
  • 宿主机开启 IOMMU,确认 NVMe 的 IOMMU group 独立;
  • 用 VFIO 把 NVMe 从宿主机“解绑”,直通给虚机(libvirt hostdev);
  • 做 NUMA/CPU pinning、HugePages、磁盘挂载参数优化;
  • 在 Guest 中以 NVMe 原生块设备建文件系统,数据库直接用。

步骤 1:BIOS 设置(两件事最关键)

Intel VT-d(或 AMD-Vi):Enabled

Above 4G Decoding:Enabled(没有它,大容量 BAR 的设备容易资源分配失败)

如果机框有 PCIe ACS 选项,开了更好(更容易得到干净的 IOMMU 组)。没有也不强求,后面再看分组情况。

步骤 2:升级内核 & 安装虚拟化栈(CentOS 7)

# 1) 安装 elrepo,升级内核到 5.4 LTS
yum install -y https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm
yum --enablerepo=elrepo-kernel install -y kernel-ml
grub2-set-default 0
reboot
uname -r   # 确认是 5.x

# 2) 安装 KVM/Libvirt/QEMU
yum install -y epel-release
yum install -y centos-release-qemu-ev
yum install -y qemu-kvm-ev qemu-kvm-ev-tools qemu-img libvirt \
               libvirt-daemon-kvm virt-install numactl tuned
systemctl enable --now libvirtd

步骤 3:开启 IOMMU、HugePages

编辑 /etc/default/grub 里的 GRUB_CMDLINE_LINUX,在末尾加上:

intel_iommu=on iommu=pt default_hugepagesz=1G hugepagesz=1G hugepages=8

AMD 平台改为 amd_iommu=on iommu=pt。

然后重建 grub:

# Legacy BIOS:
grub2-mkconfig -o /boot/grub2/grub.cfg
# UEFI:
grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg
reboot

确认 IOMMU 生效:

dmesg | grep -i iommu

步骤 4:确认 IOMMU 组 & 找到要直通的 NVMe

lspci -nn | egrep -i 'non|nvm|storage'
# 示例输出(留意方括号里的 vendor:device):
# 5e:00.0 Non-Volatile memory controller [0108]: Samsung Electronics Co Ltd Device [144d:a808]

查看 IOMMU 分组是否“干净”(理想是一个组里只有这块 NVMe 或同一个设备的功能):

# 展示所有 IOMMU 组
for g in /sys/kernel/iommu_groups/*; do
  echo "IOMMU Group ${g##*/}:"
  ls -l $g/devices
  echo
done

检查这块 NVMe 的 NUMA 归属,用来后面 pinning:

cat /sys/bus/pci/devices/0000:5e:00.0/numa_node
# 输出示例:0  -> 表示在 NUMA 节点 0

坑 1(IOMMU group 太“脏”):

某些主板上 NVMe 和同一个 Root Port 下的其他设备会落在同一组。最干净的做法是换槽位/走独立 root port。

实在不行可尝试 pcie_acs_override=downstream,multifunction(风险自负,生产不建议)。

步骤 5:把 NVMe 交给 VFIO(避免宿主机 nvme 驱动占用)

两种方式,二选一:

方式 A(安全、可控,适合系统盘不是 NVMe 的情况)

写 modprobe 参数并提前注入 initramfs:

echo "options vfio-pci ids=144d:a808" > /etc/modprobe.d/vfio.conf
echo 'add_drivers+=" vfio vfio-pci vfio_iommu_type1 "' > /etc/dracut.conf.d/vfio.conf
dracut -f
reboot

# 确认绑定:
lspci -nnk -s 5e:00.0
# Kernel driver in use: vfio-pci

方式 B(不重启临时切换,适合验证)

modprobe vfio-pci
echo 144d a808 > /sys/bus/pci/drivers/vfio-pci/new_id
echo 0000:5e:00.0 > /sys/bus/pci/drivers/nvme/unbind
echo 0000:5e:00.0 > /sys/bus/pci/drivers/vfio-pci/bind

警告:如果宿主机系统盘也是 NVMe,千万不要全局 blacklist nvme,否则宿主机起不来。务必用 按设备 ID 的方式绑定。

步骤 6:创建/编辑虚机(libvirt XML)

核心是 <hostdev> 直通 + NUMA/CPU pinning + HugePages。

<domain type='kvm'>
  <name>pg-nvme-prod</name>
  <memory unit='GiB'>64</memory>
  <currentMemory unit='GiB'>64</currentMemory>
  <vcpu placement='static'>16</vcpu>

  <memoryBacking>
    <hugepages>
      <page size='1' unit='GiB'/>
    </hugepages>
  </memoryBacking>

  <numatune>
    <memory mode='strict' nodeset='0'/>
  </numatune>

  <cputune>
    <!-- 把 vCPU 固定在 NUMA0 的核心上(示例:0-15) -->
    <vcpupin vcpu='0' cpuset='0'/>
    <vcpupin vcpu='1' cpuset='1'/>
    <vcpupin vcpu='2' cpuset='2'/>
    <vcpupin vcpu='3' cpuset='3'/>
    <vcpupin vcpu='4' cpuset='4'/>
    <vcpupin vcpu='5' cpuset='5'/>
    <vcpupin vcpu='6' cpuset='6'/>
    <vcpupin vcpu='7' cpuset='7'/>
    <vcpupin vcpu='8' cpuset='8'/>
    <vcpupin vcpu='9' cpuset='9'/>
    <vcpupin vcpu='10' cpuset='10'/>
    <vcpupin vcpu='11' cpuset='11'/>
    <vcpupin vcpu='12' cpuset='12'/>
    <vcpupin vcpu='13' cpuset='13'/>
    <vcpupin vcpu='14' cpuset='14'/>
    <vcpupin vcpu='15' cpuset='15'/>
    <emulatorpin cpuset='0-3'/>
  </cputune>

  <!-- 这里是关键:把 0000:5e:00.0 这块 NVMe 直通 -->
  <devices>
    <hostdev mode='subsystem' type='pci' managed='yes'>
      <source>
        <address domain='0x0000' bus='0x5e' slot='0x00' function='0x0'/>
      </source>
    </hostdev>

    <!-- 其他设备略(网卡、串口等) -->
  </devices>
</domain>

应用后启动虚机:

virsh define /etc/libvirt/qemu/pg-nvme-prod.xml
virsh start pg-nvme-prod

步骤 7:Guest 里准备文件系统

进到虚机后,能看到 nvme0n1:

lsblk
# nvme0n1 3.5T ...
nvme list    # 需要 nvme-cli

分区并格式化(我用 ext4,XFS 也可):

parted /dev/nvme0n1 --script "mklabel gpt mkpart primary 1MiB 100%"
mkfs.ext4 -E stride=128,stripe-width=128 -O ^has_journal /dev/nvme0n1p1
# PostgreSQL 自己有 WAL,一般数据盘可去 journal,按需选择

mkdir -p /pgdata
echo '/dev/nvme0n1p1 /pgdata ext4 defaults,noatime,discard 0 1' >> /etc/fstab
mount -a

步骤 8:PostgreSQL 调优(与 NVMe 直通配合)

postgresql.conf 关键项(示例,按你的核心/内存调整):

shared_buffers = 16GB
wal_level = replica
max_wal_size = 8GB
checkpoint_timeout = 15min
effective_cache_size = 48GB
random_page_cost = 1.1
effective_io_concurrency = 256
maintenance_work_mem = 2GB
synchronous_commit = on   # 生产根据一致性需求决定

Guest 内核参数(减少深睡影响 NVMe 延迟抖动):

# /etc/default/grub 里追加(Guest VM 内)
nvme_core.default_ps_max_latency_us=0

步骤 9:压测与对比

我做了三组对比(同一台宿主机、同一虚机 vCPU/内存/NUMA):

  • virtio-blk(qcow2,缓存 none,io=native)
  • virtio-scsi(raw LVM,io=native)
  • NVMe 直通(VFIO)(本文方案)

fio(4 并发,64 深度,随机写 4k,5 分钟)

fio --name=randwrite --filename=/pgdata/fio.test --size=20G \
    --rw=randwrite --bs=4k --ioengine=libaio --iodepth=64 --numjobs=4 \
    --direct=1 --time_based=1 --runtime=300 --group_reporting
方案 IOPS(写) 平均延迟 p99 延迟
virtio-blk (qcow2) 95,000 0.67 ms 11.8 ms
virtio-scsi (raw) 141,000 0.45 ms 8.6 ms
NVMe 直通 228,000 0.28 ms 4.1 ms

pgbench(TPC-B-like,小而高并发,60 秒)

# 预热
pgbench -i -s 100
# 压测(示例 64 并发,8 客户端 * 8 线程)
pgbench -c 64 -j 8 -T 60 --progress=10
方案 TPS(包括提交) 平均延迟 p95 延迟
virtio-blk (qcow2) 41,200 1.51 ms 6.9 ms
virtio-scsi (raw) 49,700 1.24 ms 5.2 ms
NVMe 直通 63,900 0.97 ms 3.6 ms

结论:NVMe 直通把 fio 写 IOPS 提升 62%~140%,把 p99 延迟从 ~12ms 打到 ~4ms;pgbench TPS 提升 约 29%,尾延迟明显收窄,高峰“抖动”肉眼可见地变少。

线上坑点与快速处置

坑 2:VM 重启后 NVMe 不认 / reset 失败

现象:dmesg 里看到 NVMe reset/timeout。

处理:

宿主机保持 5.x 内核(我用 5.4 LTS),这个问题显著减少;

VM 内加入 nvme_core.default_ps_max_latency_us=0 禁深睡;

VM 关机再启动优于热重启;必要时在宿主机对 VFIO 设备先 virsh nodedev-detach/attach。

坑 3:IOMMU 分组里不只有 NVMe

优先换槽位到独立 Root Port。

不行再考虑 pcie_acs_override(生产不建议长期使用)。

还有一种“外科手术”是把跟 NVMe 同组的设备也一并直通,但通常不值得冒险。

坑 4:直通设备导致迁移受限

VFIO 直通的 VM 不能做 live migration。我给生产做的方案是:

主从库架构 + 备用节点(另一台机器活备),在维护窗口做主从切换。

备机也配一块同型号 NVMe,随时可“提权”。

坑 5:宿主机更新后绑定失效

更新内核后,vfio-pci 的绑定可能失效。

记得用 方式 A(vfio-pci ids=... + dracut 注入),并在 Ansible 里把 vendor:device 做成变量固定下来。

坑 6:温度与节能策略

香港机房夏天外面湿热,机房里风大但进风温度常年偏高。

PM9A3 在 70℃ 往上会降速。我给底托加了导风罩,风道对准 U.2 硬盘位,温度从 61℃ 降到 49℃,压测更加稳定。

监控与日常维护(我在线上这么做)

宿主机

smartctl -a -d nvme /dev/nvme0 和 nvme smart-log /dev/nvme0 做硬盘健康巡检;

turbostat/powertop 查看 C-State,避免过深省电;

numactl --hardware 定期确认拓扑没变(换槽后及时更新 pinning)。

Guest

iostat -x 1/pidstat 观察 DB 进程与 IO;

Prometheus + node_exporter + postgres_exporter,重点盯:pg_stat_bgwriter、checkpoint、WAL、IO wait;

周期 VACUUM/REINDEX 计划配合业务低谷。

QEMU 命令行(参考)

如果你习惯裸 qemu-system-x86_64(我线上用 libvirt,这里只给参考):

qemu-system-x86_64 \
  -enable-kvm -cpu host -smp 16,sockets=1,cores=16,threads=1 \
  -m 64G -object memory-backend-file,id=mem,size=64G,mem-path=/dev/hugepages,share=on,prealloc=yes \
  -numa node,memdev=mem,nodeid=0 \
  -device vfio-pci,host=0000:5e:00.0,id=nvme0 \
  -netdev tap,id=net0,script=/etc/qemu-ifup -device virtio-net-pci,netdev=net0 \
  -nographic

变更回滚与应急预案(别等出事才想)

回滚:保留一套 virtio-scsi(raw LVM)的旧盘路径,VM 关机后把 XML 切回,重启即回滚。

备份:Guest 内用 pg_basebackup + WAL 归档(S3 兼容对象存储),周更物理备份,日更逻辑备份。

容量预警:NVMe SMART 的可用寿命、写入总量触发告警。

演练:每季度演练一次“宿主机故障 -> 备机接管”。

FAQ:几个常被问到的问题

直通 vs virtio-blk/raw?
virtio-blk/raw + io=native 已经不错,但 NVMe 直通把虚拟化抽象和宿主层缓存完全绕过,减少上下文和 emulation,长尾延迟改善最明显。

文件系统选 ext4 还是 XFS?
两个都行。高并发小 IO,我更偏 ext4 + noatime + 合理 stride。大文件/并发场景 XFS 也很好。

HugePages 必须 1G 吗?
不是“必须”,但 1G 页大幅降低 TLB miss,对长时间压测更稳。内存充足就上。

能不能直通分区而不是整块 NVMe?
VFIO 直通的是PCIe 设备,所以是整块。如果你只想给一部分,用 LVM/分区再 virtio 提供,而不是 VFIO。

结尾:回到机房外的清晨

那次变更做完已是清晨,电梯里镜子映着通宵后的熊猫眼。Grafana 上 p99 曲线从“锯齿”变成了“细线”,业务侧监控的“红点”一颗颗灭掉。
两周后的复盘上,DB 团队一句话很打动我:“不是 TPS 多了多少,而是高峰没再抖了。”这就是我喜欢 QEMU+VFIO 直通 NVMe 的原因——不是炫技,而是把复杂性减到最小,让数据库像在“真机”上一样不受惊扰地跑。

如果你也在香港或者别的机房跟延迟搏斗,照着这份实操走一遍,先把 IOMMU 分组、NUMA 对齐、HugePages 和电源管理这些底座打牢。工具是一样的,胜负常在细节里。祝你一把打穿尾延迟,回到酒店能睡个好觉。

附:完整变更检查清单(给实操同学)

  1.  BIOS:VT-d/AMD-Vi、Above 4G Decoding 打开
  2.  宿主机:内核 ≥ 5.4(elrepo kernel-ml)
  3.  IOMMU:intel_iommu=on iommu=pt 已生效
  4.  HugePages:default_hugepagesz=1G hugepagesz=1G hugepages=8(按需)
  5.  IOMMU Group:NVMe 所在组干净或可接受
  6.  VFIO 绑定:vfio-pci ids=vendor:device 生效(lspci -nnk 确认)
  7.  libvirt XML:<hostdev managed='yes'>、NUMA/memoryBacking/cputune 正确
  8.  Guest:nvme_core.default_ps_max_latency_us=0(可选)
  9.  FS:ext4/XFS,noatime,discard(按 SSD/NVMe 习惯)
  10.  DB:shared_buffers / effective_io_concurrency / checkpoint 等已调
  11.  压测:fio + pgbench 数据有对比基线
  12.  回滚与备份:方案可随时落地,演练过

有问题,直接把你的 lspci -nnk、IOMMU group 输出、libvirt XML 发我一眼看,我帮你迅速对位问题点。

目录结构
全文