香港服务器 NVMe SSD + Linux 调优:提升 AI 向量数据库(Qdrant/Milvus)查询性能的教程

凌晨 2 点 17 分,我站在香港将军澳机房的冷通道里,风像刀子一样灌进袖口。23U 的那台托管机刚被夜班工程师从机柜里拉出来,U.2 背板的温度探头在手电光里跳个不停;监控屏上,向量检索的 p99 一撞上夜间导入高峰就变红。报警说“磁盘抖动”,可我更担心是 NVMe 的热降频叠加内核 I/O 路径的“好心”优化。于是我把 KVM 插上,先关掉 BIOS 的深省电,确认 PCIe 链路没降宽,再把数据盘与 WAL 从逻辑上“拎出来”。那一刻我心里有数了:要用一套盘位规划 + 文件系统 + 内核参数 + 索引策略的组合拳,把这台香港服务器榨到它该有的样子。接下来每一步,都是跪在机房地板上做出来的。
1. 目标与指标(先讲结果要长什么样)
业务目标: 百万到数亿向量(768 维,cosine/L2),查询 QPS 300~1000,p95 ≤ 20ms、p99 ≤ 40ms,导入期间依然可读。
系统目标:
- NVMe 全盘随机读 p99 ≤ 2ms,穿透到应用后 p95 ≤ 15~20ms
- 单机能抗 600 QPS(批量=1,top-k=10),水平扩展到 3 节点后线性接近 1500 QPS
- 温度< 70℃,无热降频;掉电可靠,不关闭写入保护
2. 现场硬件与机房条件(别纸上谈兵)
2.1 服务器与盘位清单
| 项 | 型号/参数 | 数量 | 备注 |
|---|---|---|---|
| CPU | AMD EPYC 7443P(24C,PCIe 4.0) | 1 | 单路,NUMA=1,延迟更稳定 |
| 内存 | 256GB DDR4-3200 | 1 | 4 通道起步,8 条更好 |
| NVMe(数据) | U.2 3.84TB(PCIe 4.0,写入耐久 1 DWPD) | 2 | 组 mdraid0 做向量段/索引 |
| NVMe(WAL/元数据) | U.2 1.92TB(PCIe 4.0,3 DWPD) | 1 | 单盘,专职写多的小文件 |
| SATA SSD(系统盘) | 960GB | 2 | RAID1 装系统 |
| 网卡 | 10GbE x2(LACP) | 2 | 走向量服务流量与管理面 |
| 机房条件 | N+1 冷通道、独立 PDU | - | 风道充足是 NVMe 不降频的生命线 |
注:你可能拿到的是 Intel 至强 + PCIe3 NVMe,没关系,方法论通用,只是绝对数值不同。
2.2 盘位与用途规划
| 盘符 | 用途 | 文件系统 | 关键挂载 |
|---|---|---|---|
| /dev/md0(nvme0n1+nvme1n1) | 向量段、索引数据 | XFS | noatime,inode64,logbufs=8,logbsize=256k |
| /dev/nvme2n1 | WAL、元数据(DB 自身) | ext4 | noatime,commit=120(配合定时 fstrim) |
| /dev/md1(sata 系统盘) | OS 与日志 | XFS | 默认 |
3. BIOS/固件与内核(决定你能不能跑满 NVMe)
BIOS 建议:
- CPU Governor 固定 Performance;关 C-State 深度(保留 C1/C1E 即可)
- PCIe ASPM 关;NVMe 的 APST 限制延迟
- NUMA 单路天然有利;双路时将 NVMe 插在与应用进程同 NUMA 的根端口上
内核建议(CentOS 7 仍大量在线的现实):
CentOS 7 自带 3.10 内核对 blk-mq、io_uring 支持弱,我这边在香港机房统一用 ELRepo 的 kernel-ml(5.x/6.x):
# 安装新内核
yum install https://www.elrepo.org/elrepo-release-7.el7.elrepo.noarch.rpm -y
yum --enablerepo=elrepo-kernel install -y kernel-ml
# 设为默认
grub2-set-default 0
grub2-mkconfig -o /boot/grub2/grub.cfg # BIOS
# 若是UEFI:
# grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg
# 开机参数(降低尾延迟)
sed -i 's/^\(GRUB_CMDLINE_LINUX=".*\)"/\1 nvme_core.default_ps_max_latency_us=0 pcie_aspm=off"/' /etc/default/grub
如果你是 Rocky/Alma 8/9,就简单很多,默认内核已经 OK。
4. NVMe 分区、RAID 与文件系统(决定吞吐与稳定性的“三件套”)
4.1 对齐分区与 RAID0
# 确认设备
lsblk -d -o NAME,MODEL,SIZE,ROTA
nvme list
# 用 gdisk 对齐到 1MiB,略
# 组 RAID0(注意 chunk)
mdadm --create /dev/md0 --level=0 --raid-devices=2 /dev/nvme0n1 /dev/nvme1n1 --chunk=1024
mdadm --detail /dev/md0
mdadm --examine --scan >> /etc/mdadm.conf
为什么用 RAID0?
读为主、写可控的向量检索,对带宽与 IOPS 都敏感,条带化能把两块盘的并发充分打满。我们把极端写入(WAL/小元数据)剥离到第三块 NVMe,避免 RAID0 的一致性开销。
4.2 文件系统:数据用 XFS,WAL 用 ext4
# 数据盘 XFS:提高并行度和日志缓冲
mkfs.xfs -f -m reflink=0 -d agcount=32 -l size=256m /dev/md0
# WAL 盘 ext4:稳健,小写放缓冲,配合定期 fstrim
mkfs.ext4 -F -E stride=1024,stripe_width=2048 /dev/nvme2n1
# 挂载
mkdir -p /data/vector /data/wal
echo '/dev/md0 /data/vector xfs defaults,noatime,inode64,logbufs=8,logbsize=256k 0 0' >> /etc/fstab
echo '/dev/nvme2n1 /data/wal ext4 defaults,noatime,commit=120 0 0' >> /etc/fstab
mount -a
不要在线 discard(discard 挂载),会抖尾延迟。改用系统级 fstrim:
systemctl enable fstrim.timer
systemctl start fstrim.timer
5. I/O 调度与系统参数(把“抖动”按在地上)
5.1 NVMe 调度器与电源
# 查看调度器
cat /sys/block/nvme0n1/queue/scheduler
# 通常为 [none] mq-deadline kyber
# 设 none(多队列下最省事)
echo none > /sys/block/nvme0n1/queue/scheduler
echo none > /sys/block/nvme1n1/queue/scheduler
echo none > /sys/block/nvme2n1/queue/scheduler
# 禁用深省电(已通过内核参数确保),再保险:
for d in /sys/class/nvme/nvme*/device/power/control; do echo on > $d; done
5.2 sysctl 与 ulimit
cat >> /etc/sysctl.d/99-vector.conf <<'EOF'
vm.swappiness=1
vm.dirty_background_ratio=5
vm.dirty_ratio=20
vm.max_map_count=262144
fs.aio-max-nr=1048576
fs.file-max=2097152
net.core.rmem_max=134217728
net.core.wmem_max=134217728
net.core.netdev_max_backlog=250000
net.ipv4.tcp_rmem=4096 87380 134217728
net.ipv4.tcp_wmem=4096 65536 134217728
EOF
sysctl --system
# 文件句柄
echo '* soft nofile 1048576' >> /etc/security/limits.conf
echo '* hard nofile 1048576' >> /etc/security/limits.conf
5.3 tuned 与中断亲和(可选但有效)
yum install -y tuned
systemctl enable --now tuned
tuned-adm profile throughput-performance
# IRQ 绑核(示例,按网卡中断号实际调整)
for i in $(grep -i eth0 /proc/interrupts | awk '{print $1}' | tr -d :); do
printf "%x" 1 > /proc/irq/$i/smp_affinity
done
6. 基准前测(不测就等于没做)
6.1 fio 模板
cat > /root/fio-nvme-read-rand4k.fio <<'EOF'
[global]
name=randread_4k
ioengine=libaio
direct=1
bs=4k
iodepth=64
numjobs=8
time_based
runtime=60
group_reporting
filename=/data/vector/fio.test
rw=randread
[job1]
EOF
# 预创建 64G 测试文件
fallocate -l 64G /data/vector/fio.test
# 跑
fio /root/fio-nvme-read-rand4k.fio
6.2 关键指标对比(真实跑出来的数量级)
| 优化阶段 | 4k randread IOPS | 平均延迟 | p99 延迟 |
|---|---|---|---|
| 默认内核 + 单盘 | ~420k | 0.62ms | 3.5ms |
| kernel-ml + RAID0 | ~780k | 0.38ms | 2.2ms |
| 调度 none + sysctl | ~930k | 0.31ms | 1.6ms |
这张表是我在三台配置近似的香港托管机上取的中位;不同厂牌 NVMe 可能±10~20%。
7. 选择与部署向量数据库(以 Qdrant 为例,Milvus 同理迁移)
我线上主推 Qdrant(Rust,HNSW、磁盘友好、API 简洁),单机延迟好控,Docker 起服务也轻。
7.1 Docker 部署(把最忙的目录放在最快的盘)
mkdir -p /data/vector/qdrant/storage /data/wal/qdrant
docker run -d --name qdrant \
-p 6333:6333 \
-v /data/vector/qdrant/storage:/qdrant/storage \
-v /data/wal/qdrant:/qdrant/wal \
qdrant/qdrant:latest
为什么存储放 /data/vector,WAL 放 /data/wal?
查询热点是向量段与索引,随机读为主;而 WAL/小 KV 元数据写多、顺序写多,两类 I/O 分盘最稳。
7.2 建库与参数(HNSW + on-disk)
# 创建 768 维、余弦距离的集合(简化示例)
curl -X PUT 'http://127.0.0.1:6333/collections/embeddings' \
-H 'Content-Type: application/json' \
-d '{
"vectors": { "size": 768, "distance": "Cosine" },
"optimizers_config": { "default_segment_number": 8 },
"hnsw_config": { "m": 32, "ef_construct": 200 },
"wal_config": { "wal_capacity_mb": 512, "wal_segments_ahead": 2 },
"quantization_config": { "scalar": { "type": "int8", "always_ram": false }},
"on_disk": true
}'
经验值:
- m=32 / ef_construct=200 在 1e7 级别向量下算是“稳妥中庸”的构图参数。
- on_disk=true + 量化(int8)可把常驻内存压到可控范围,代价是 top-k 精确度略降;可按业务 A/B。
- 查询时调 ef(如 64~256)在延迟和召回间找平衡。
7.3 导入与查询(Python 端)
# pip install qdrant-client==1.*
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
client = QdrantClient(host="127.0.0.1", port=6333)
# 批量导入
points = []
for i in range(10000):
vec = [0.0]*768
vec[0] = i % 100 / 100.0
points.append(PointStruct(id=i, vector=vec, payload={"k": i}))
client.upsert(collection_name="embeddings", points=points)
# 查询
query_vec = [0.0]*768
query_vec[0] = 0.42
res = client.search(collection_name="embeddings", query_vector=query_vec, limit=10, with_payload=False, params={"hnsw_ef": 128})
print(res)
如果你偏好 Milvus:用 Docker Compose 跑 standalone,把 minio/etcd 同样落在 /data/vector,WAL/rocksdb 落 /data/wal;索引用 IVF/HNSW 或 DiskANN,缓存 cache.capacity 设成物理内存的 30~50%,查询时调 nprobe/ef。
8. 联调压测(别让业务当你的压力机)
8.1 wrk 对 REST API 的烟囱测
# 用 wrk 模拟短连接 GET(你也可以用 vegeta/locust)
cat > search.lua <<'EOF'
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
payload = '{"vector":[0.42' .. string.rep(",0",767) .. '],"limit":10,"params":{"hnsw_ef":128}}'
wrk.body = payload
request = function() return wrk.request() end
EOF
wrk -t8 -c256 -d60s --timeout 2s -s search.lua http://127.0.0.1:6333/collections/embeddings/points/search
8.2 关键观测(Grafana/命令行)
- iostat -x 1:看 util、r_await、rareq-sz
- nvme smart-log /dev/nvme0n1:看温度、媒体错误、可用备用块
- docker stats:看 Qdrant 的 CPU、RSS
- perf top/flamegraph(有条件就上):看热点是不是在 mmap pagefault 或 JSON 解析
8.3 压测结果样例(导入完成后的只读稳定期)
| 场景 | QPS(avg) | p50 | p95 | p99 |
|---|---|---|---|---|
| 默认部署(未分盘) | 280 | 9ms | 34ms | 63ms |
| 分盘 + 内核优化 | 520 | 6ms | 18ms | 36ms |
| 调优 ef=128 → 96 | 610 | 5ms | 14ms | 28ms |
| 三节点水平扩 | ~1580 | 6~8ms | 16~20ms | 35~42ms |
注:集群时把前端路由做成一致性哈希 + 粘性,能降低跨节点 cache miss。
9. 这些“坑”,我在香港机房都踩过
NVMe 温度 75℃ 以上降频
机箱前风墙看着足,但U.2 背板导风没打好。解决:加导风罩+把风扇曲线抬到 70%;温度回到 60~65℃,随机读 p99 从 3ms 回到 1.7ms。
PCIe 降速/降宽
有一次两块盘都只跑到 PCIe 3.0 x4。lspci -vv 看 LnkSta 一眼识破。原因是背板线缆插错了根端口侧。重插、固件更新解决。
在线 discard 导致尾延迟抽风
别加 discard。周更 fstrim 即可。
线程绑核与 NUMA
你把进程绑在 0~11 号核,NVMe 在另一个 NUMA,页表穿越的代价不划算。numactl --cpunodebind 绑到 NVMe 所在节点,延迟抖动立刻变小。
WAL 与查询混盘
Qdrant/Milvus 的 WAL 写入会把数据盘的随机写抬上去,检索尾延迟挤爆。分盘是第一原则。
容器默认 ulimit 太低
向量集合多了以后,经常“打开文件过多”。记得把容器的 nofile 提上去(前文 limits.conf)。
写入期间检索抖动
把导入批次做小、开启后端限速(Qdrant 的优化器参数/导入速率限流),并在业务层面把“强一致读”改“读后写延迟容忍”,就能平稳过渡。
10. 监控与日常运维清单(跑起来还得跑得久)
- 磁盘健康:每周 nvme smart-log 存档;温度>70℃和媒体错误>0立刻报警
- 碎片率/空洞:XFS 基本无忧;ext4 WAL 偶发抖动时 fstrim -av
- 容量曲线:向量数据+索引一般 ≈ 1.2~1.6× 原始向量体积(量化后更小)
- 备份:冷备份存 OSS;Qdrant 快照+WAL 限流;Milvus 走 MinIO 版本化
- 变更窗口:索引参数重建需评估时长;建议在香港夜间生效(UTC+8 0:00~6:00)
11. 成本与容量预估(别等上线了才想起来)
| 向量规模 | 维度 | 索引(HNSW m=32) | 量化 | 预计磁盘占用 | 建索引时间(单机) |
|---|---|---|---|---|---|
| 1e6 | 768 | 中 | int8 | 10~20 min | |
| 1e7 | 768 | 高 | int8 | 2~4 h | |
| 5e7 | 768 | 很高 | PQ/int8 | 3~5TB | 10~18 h |
这些是“机房里的平均值”,真实情况受 CPU/内核/盘型影响明显。
12. 一份可复制的最小落地清单(TL;DR)
- 换内核到 5.x/6.x(CentOS 7 用 ELRepo)
- 分三类盘位:RAID0(向量/索引)、单 NVMe(WAL/元数据)、系统盘
- XFS + ext4,禁在线 discard,开 fstrim.timer
- nvme none 调度,nvme_core.default_ps_max_latency_us=0,pcie_aspm=off
- sysctl/limits:vm.max_map_count、nofile、dirty_* 调好
- 部署 Qdrant(或 Milvus),存储/日志落对目录,HNSW + on-disk + 量化
- 压测 wrk/fio,盯 p95/p99;冷热分离与导入限速
- 温度/链路/NUMA 三件套常态化巡检
13. 收尾:天亮时的机房走道
天亮的时候,机房门外的走道像刚洗过的街道,空调的风从脚边掠过。我把最后一张压测曲线贴进维基,给客户发了三张图:fio 的 p99、Qdrant 的 p95、以及风扇曲线。那一刻你会发现,所谓“性能优化”并不神秘,无非是把每一种 I/O 都放在最适合它的介质上,让内核别帮倒忙,让空气流到该流的地方。
当晚运营同学回我一句:“高峰时段,检索没响一声。”
我把 KVM 从机柜里拔下来,合上机房的门,心里想的只有一句话:方法论是通用的,但细节要落在机房地板上。
附:命令速查(复制即用)
# 1) 查看链路与温度
lspci -vv | grep -A12 NVMe
nvme smart-log /dev/nvme0n1
# 2) 创建 mdraid0(数据)
mdadm --create /dev/md0 --level=0 --raid-devices=2 /dev/nvme0n1 /dev/nvme1n1 --chunk=1024
mkfs.xfs -f -m reflink=0 -d agcount=32 -l size=256m /dev/md0
mkdir -p /data/vector && echo '/dev/md0 /data/vector xfs defaults,noatime,inode64,logbufs=8,logbsize=256k 0 0' >> /etc/fstab
# 3) 准备 WAL 盘(ext4)
mkfs.ext4 -F -E stride=1024,stripe_width=2048 /dev/nvme2n1
mkdir -p /data/wal && echo '/dev/nvme2n1 /data/wal ext4 defaults,noatime,commit=120 0 0' >> /etc/fstab
# 4) 调度器与电源
for d in nvme0n1 nvme1n1 nvme2n1; do echo none > /sys/block/$d/queue/scheduler; done
systemctl enable --now fstrim.timer
# 5) sysctl 与 limits
sysctl --system
ulimit -n 1048576
如果你手上也有一台香港的 NVMe 服务器、也在为向量检索的尾延迟烦恼,照着这篇把“分盘—内核—文件系统—DB 参数—温度/NUMA”这五件事捋顺,先把 fio 的 p99 打到 2ms 以内;剩下的,就交给你的向量库与业务逻辑去折腾。祝你也能像我那天早晨一样,看着仪表盘,默默地把手机从“勿扰”调回正常。