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

凌晨 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 和电源管理这些底座打牢。工具是一样的,胜负常在细节里。祝你一把打穿尾延迟,回到酒店能睡个好觉。
附:完整变更检查清单(给实操同学)
- BIOS:VT-d/AMD-Vi、Above 4G Decoding 打开
- 宿主机:内核 ≥ 5.4(elrepo kernel-ml)
- IOMMU:intel_iommu=on iommu=pt 已生效
- HugePages:default_hugepagesz=1G hugepagesz=1G hugepages=8(按需)
- IOMMU Group:NVMe 所在组干净或可接受
- VFIO 绑定:vfio-pci ids=vendor:device 生效(lspci -nnk 确认)
- libvirt XML:<hostdev managed='yes'>、NUMA/memoryBacking/cputune 正确
- Guest:nvme_core.default_ps_max_latency_us=0(可选)
- FS:ext4/XFS,noatime,discard(按 SSD/NVMe 习惯)
- DB:shared_buffers / effective_io_concurrency / checkpoint 等已调
- 压测:fio + pgbench 数据有对比基线
- 回滚与备份:方案可随时落地,演练过
有问题,直接把你的 lspci -nnk、IOMMU group 输出、libvirt XML 发我一眼看,我帮你迅速对位问题点。