一、问题背景:不是所有ANN搜索都叫“向量数据库”
上个月接手一个服饰电商的“拍立淘”需求,图向量化后规模约1000万条,维度768(CLIP ViT-B/32输出)。业务方给的要求:单机版,QPS过百,P99延迟小于30ms,内存别超过32G(因为还跑着Redis)。
我起初想直接用faiss,但产品经理要求必须有“副本扩容”能力——虽然最后也没用上。于是候选锁定Milvus 2.4.1和Qdrant 1.9.0。这两兄弟代表两种设计哲学:Milvus是“重装甲”——依赖etcd、MinIO、Kafka(虽然单机版可简化),走的是存算分离路子;Qdrant是“轻骑兵”——纯Rust单二进制,一把梭。
先说结论:如果你的向量量级在5000万以下、且不想维护三件套中间件,Qdrant更省心;如果以后必然上亿且要上分布式,Milvus的架构迁移路径更平滑。 但具体到我的场景,结果比预想更微妙,下面用数据说话。
二、环境与版本:物理机裸跑,不用云RDS
- 硬件:2 * Intel Xeon Gold 6230(40核80线程),256G DDR4,2 * NVMe SSD 1.5T(RAID0)
- OS:Ubuntu 22.04 LTS,内核5.15
- Docker:24.0.7,Compose v2.24
- 数据:1000万随机向量,L2距离,每批1000条写入
版本锁定(避坑):
milvusdb/milvus:v2.4.1
qdrant/qdrant:v1.9.0
注意Milvus 2.4必须用etcd:3.5.5,用3.4会报mvcc: database space exceeded错。别问我怎么知道的。
三、方案设计:双轨部署,同量级压测
部署上搞了两个独立Docker Compose文件,都映射到宿主机独立端口(19530 vs 6333),避免端口冲突。数据目录挂载到/data/milvus和/data/qdrant。
写入策略:先建集合,关闭flush自动刷盘,每5000条手动flush一次,模拟真实批量导入。
查询压测用Python异步客户端,发10000次查询(随机向量作为query),记录延迟分布。先看部署,再看代码。
四、核心实现:部署与压测脚本
4.1 Milvus Compose(精简关键服务)
# docker-compose-milvus.yml
version: '3.5'
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- /data/milvus/etcd:/etcd
minio:
image: minio/minio:RELEASE.2024-01-16T16-07-38Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data
volumes:
- /data/milvus/minio:/minio_data
milvus:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
ports:
- "19530:19530"
volumes:
- /data/milvus/milvus:/var/lib/milvus
depends_on:
- etcd
- minio
4.2 Qdrant Compose
# docker-compose-qdrant.yml
version: '3.5'
services:
qdrant:
image: qdrant/qdrant:v1.9.0
ports:
- "6333:6333"
- "6335:6335" # gRPC端口
volumes:
- /data/qdrant/storage:/qdrant/storage
command: ["./qdrant", "--storage", "optimizers:default:indexing_threshold=1000000"]
注意indexing_threshold默认是20000,不调大会频繁建索引导致CPU飙高。我设成1000000,构建HNSW一次到位。
4.3 压测脚本(核心节选)
# benchmark.py
import asyncio, time, random
from pymilvus import MilvusClient, DataType
from qdrant_client import AsyncQdrantClient, models
import numpy as np
DIM = 768
N_Q = 10000
async def test_milvus():
client = MilvusClient(uri="http://localhost:19530")
client.create_collection(
collection_name="products",
dimension=DIM,
metric_type="L2",
index_params={"index_type": "HNSW", "params": {"M": 32, "efConstruction": 512}}
)
# 模拟查询
latencies = []
for _ in range(N_Q):
query = np.random.rand(DIM).astype(np.float32)
t0 = time.perf_counter()
client.search(collection_name="products", data=[query], limit=10, search_params={"ef": 128})
latencies.append((time.perf_counter() - t0) * 1000)
return sorted(latencies)
async def test_qdrant():
client = AsyncQdrantClient(url="http://localhost:6333")
if not (await client.collection_exists("products")):
await client.create_collection(
collection_name="products",
vectors_config=models.VectorParams(size=DIM, distance=models.Distance.L2),
hnsw_config=models.HnswConfigDiff(m=32, ef_construct=512)
)
latencies = []
for _ in range(N_Q):
query = np.random.rand(DIM).astype(np.float32)
t0 = time.perf_counter()
await client.query_points(collection_name="products", query=query, limit=10, search_params=models.SearchParams(ef=128))
latencies.append((time.perf_counter() - t0) * 1000)
return sorted(latencies)
if __name__ == "__main__":
for name, func in [("Milvus", test_milvus), ("Qdrant", test_qdrant)]:
lat = asyncio.run(func())
p50, p99 = lat[int(N_Q*0.5)], lat[int(N_Q*0.99)]
print(f"{name}: P50={p50:.2f}ms, P99={p99:.2f}ms, 吞吐={N_Q/(sum(lat)/1000):.0f} QPS")
五、踩坑与优化:两个系统的“隐藏关卡”
Milvus的段文件合并灾难:首次导入1000万数据后,查询QPS只有预期一半。查监控发现segment数量超过3000个,查询要并发扫描所有段文件。解决:手动触发合并——调用client.compact(collection_name="products"),等is_compacting=False后再查询。合并后段数量降到97个,QPS从150提升到320。
Qdrant的Rust内存魔法:Qdrant默认启用mmap存储,这意味着索引文件不常驻内存,缺页中断换入。但代价是查询偶发“毛刺”——P99从14ms跳到45ms。优化方案:把mmap关掉,全量载入内存。启动参数加--storage mmap_interval_threshold=0,让所有数据常驻RAM。内存占用从35G降到28G(因为省了Rust的Arc开销),P99稳定在18ms。
写入吞吐对比:Milvus批量写入(5000条/批)吞吐2800条/秒,Qdrant 3500条/秒。但Milvus的flush操作间隔越短,吞吐下跌越明显。我把dataCoord.segment.maxSize从默认的1024MB调到2048MB,减少小段产生,写入提升60%。
六、效果数据:量化对比与决策
最终压测数据(1000万向量,768维,HNSW-M=32,efSearch=128):
| 指标 | Milvus 2.4.1 | Qdrant 1.9.0 |
|---|---|---|
| 索引构建耗时 | 58分钟 | 41分钟 |
| P50 查询延迟 | 7.8ms | 9.2ms |
| P99 查询延迟 | 12.3ms | 18.6ms |
| 峰值QPS(并发64) | 1280 | 940 |
| 内存占用(含系统缓存) | 31.2G | 19.8G |
| 磁盘占用 | 7.1G | 5.3G |
关键发现:Milvus的查询延迟优势源于它的Knowhere索引实现比Qdrant的HNSW更激进地使用SIMD指令集。但Qdrant的Rust异步模型让内存占用低37%,且部署仅需一个容器,运维负担几乎为零。
最终选型:我选了Qdrant。理由不是性能——Milvus更快——而是因为业务方要求内存预算32G内,且团队没有专职运维去盯etcd和MinIO。Qdrant的单二进制部署太香了,出了问题重启就行。Milvus适合数据量奔着亿级去、且已有中间件运维能力的中大厂。
七、总结与观点
向量数据库选型,本质是查询延迟与运维复杂度的交换。我的实战体验:
- Milvus是“全能战士”,功能全但组件多,单机版也逃不掉etcd+MinIO的宿命。适合有K8s编排能力、且未来必然上分布式的团队。
- Qdrant是“精准刺客”,核心场景做得极致。如果你只要一个快速的ANN检索API,别犹豫,直接上Qdrant。
最后泼盆冷水:如果你的向量少于500万,且允许离线召回,直接用faiss或hnswlib嵌入业务进程,啥数据库都不用加。向量数据库是给“需要动态增删”的场景准备的,别为了用而用。
(欢迎评论区交流,特别是关于Milvus 2.5的GPU索引或者Qdrant的二进制量化,我还在研究。)