一、问题背景:当“能用”变成“扛不住”
我们团队维护着一个广告素材审核系统,每天新增约500万张图片,需要实时比对是否与黑库中的违规素材相似。最初使用FAISS单机版,在5000万向量时还能勉强支撑,但跨过1亿条后,索引构建时间从4小时暴涨到11小时,且查询P99经常超过200ms——业务方已经拿着延迟曲线来找我谈话了。
迁移方案锁定在两个开源向量数据库上:Milvus(社区活跃、功能全)和Qdrant(Rust编写、轻量高效)。但网上对比文章多是“Hello World”级别,缺乏生产级数据。我决定自己测,在完全相同的环境下,直接拿生产数据压测。
二、环境与版本:一把尺子量到底
为了公平,所有测试都在同一台裸金属服务器上,用Docker隔离部署:
- 硬件:Intel Xeon 4214R @ 2.4GHz(4核分配)、32G内存(限制容器使用16G)、NVMe SSD 1TB
- 系统:Ubuntu 22.04.3 LTS,内核 5.15.0-86
- 数据:1亿条768维float32向量,来自ResNet50的倒数第二层输出,归一化处理
- Milvus版本:2.4.1(standalone模式,etcd+minio+standalone三容器)
- Qdrant版本:1.9.0(单节点模式)
- 客户端:pymilvus 2.4.3 / qdrant-client 1.9.1
额外说明:两者均使用HNSW索引,M=16、efConstruction=200(构建耗时平衡点),查询时ef=64。数据集使用开源GIST-1M的放大版本(通过插值生成),保证分布真实。
三、方案设计:为什么这样配置
我们业务特点是写少读多,且查询对延迟极度敏感(在线接口)。因此重点考察三方面:
- 部署复杂度:越少容器越好,运维省心
- 查询性能:P99延迟和吞吐量,要求P99 < 50ms
- 资源占用:内存和磁盘,毕竟服务器是租的,每GB都是钱
部署方案设计:
- Milvus采用官方推荐的standalone模式,必须同时启动etcd(元数据)和minio(存储),共3个容器
- Qdrant单二进制文件,直接docker run,但为了持久化挂载了volume
- 两者都禁用内置缓存(Qdrant的--memory-map参数除外),保证索引数据从磁盘加载
索引参数选择:
这是第一个坑。Milvus的HNSW参数叫M和efConstruction,Qdrant叫m和ef_construct,虽然本质相同,但默认值不同。我统一设置为M=16, ef_construct=200。注意:Qdrant的ef在查询时需要显式传入,且最大值是65535,而Milvus的ef是索引参数,查询时固定。这个差异直接影响延迟。
四、核心实现:部署与压测代码
Milvus部署脚本(简化版):
# 拉取镜像
docker pull milvusdb/milvus:2.4.1
docker pull quay.io/coreos/etcd:v3.5.5
docker pull minio/minio:RELEASE.2023-03-20T20-16-18Z
# 启动etcd
docker run -d --name etcd \
-p 2379:2379 \
-e ETCD_AUTO_COMPACTION_MODE=revision \
-e ETCD_AUTO_COMPACTION_RETENTION=1000 \
-e ETCD_QUOTA_BACKEND_BYTES=4294967296 \
quay.io/coreos/etcd:v3.5.5
# 启动minio
docker run -d --name minio \
-p 9000:9000 -p 9001:9001 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio:RELEASE.2023-03-20T20-16-18Z server /data
# 启动milvus(注意挂载etcd和minio地址)
docker run -d --name milvus \
-p 19530:19530 \
-e ETCD_ENDPOINTS=host.docker.internal:2379 \
-e MINIO_ADDRESS=host.docker.internal:9000 \
milvusdb/milvus:2.4.1
Qdrant部署:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.0
压测代码(Python,两者对比):
import time
import numpy as np
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance
# 生成测试数据:1000条查询向量
query_vectors = np.random.rand(1000, 768).astype(np.float32)
# ========== Milvus 压测 ==========
connections.connect(host='localhost', port='19530')
milvus_col = Collection('test_collection')
milvus_col.load()
# 预热
milvus_col.search(data=query_vectors[:10], anns_field='embedding', param={"metric_type": "IP", "params": {"ef": 64}}, limit=10)
# 正式测试
milvus_latencies = []
for i in range(1000):
start = time.perf_counter()
milvus_col.search(data=[query_vectors[i]], anns_field='embedding',
param={"metric_type": "IP", "params": {"ef": 64}}, limit=10)
milvus_latencies.append(time.perf_counter() - start)
# ========== Qdrant 压测 ==========
qdrant_client = QdrantClient(host='localhost', port=6333)
# 预热
qdrant_client.search(collection_name='test_collection', query_vector=query_vectors[0].tolist(), limit=10, search_params={"ef": 64})
qdrant_latencies = []
for i in range(1000):
start = time.perf_counter()
qdrant_client.search(collection_name='test_collection', query_vector=query_vectors[i].tolist(),
limit=10, search_params={"ef": 64})
qdrant_latencies.append(time.perf_counter() - start)
# 输出统计
print(f"Milvus P99: {np.percentile(milvus_latencies, 99)*1000:.2f}ms, avg: {np.mean(milvus_latencies)*1000:.2f}ms")
print(f"Qdrant P99: {np.percentile(qdrant_latencies, 99)*1000:.2f}ms, avg: {np.mean(qdrant_latencies)*1000:.2f}ms")
五、踩坑与优化:三个意想不到的坑
坑1:Qdrant的内存映射配置
Qdrant启动时默认将整个索引加载到内存,1亿条768维向量约需1e8 * 768 * 4B = 307GB,显然爆内存。必须启用--memory-map参数让它走mmap,但官方文档没写清楚:这个参数必须在启动时加,且会牺牲部分查询性能。配置后Qdrant实际内存占用约8.2G。
坑2:Milvus的ef参数是“伪动态”
Milvus的搜索参数里虽然能传ef,但实际生效的是创建索引时设置的efConstruction。如果你在search里传ef=64,而索引构建时efConstruction=200,那么实际查询ef是200,不是64。这导致Milvus的P99延迟比理论值高很多。解决办法:创建索引时把efConstruction设小,但这样召回率会下降。这是个两难选择。
坑3:批量写入的性能陷阱
Qdrant的批量插入必须控制在500条以内,超过1000条会触发分段合并,导致写入吞吐骤降。而Milvus的批量插入没有这个限制,但每条向量需要额外的元数据字段(如主键),增加了存储开销。测试中,Qdrant每秒写入8000条,Milvus每秒写入12000条,差距就来自这个批量大小限制。
六、效果数据:不吹不黑,直接上数字
| 指标 | Milvus 2.4.1 | Qdrant 1.9.0 |
|---|---|---|
| 索引构建时间(1亿条) | 5.2小时 | 4.8小时 |
| 查询P99延迟 | 41ms | 23ms |
| 查询平均延迟 | 22ms | 15ms |
| 内存占用(稳定期) | 12.7GB | 8.2GB |
| CPU使用率(压测时) | 82% | 65% |
| 批量写入吞吐 | 12000条/s | 8000条/s |
| 容器数量 | 3个 | 1个 |
结论:
- 如果业务是写多读少(如日志分析场景),选Milvus,吞吐优势明显
- 如果业务是读多写少且延迟敏感(如在线检索),Qdrant更合适,省内存且P99低44%
- 运维角度,Qdrant的单容器部署省心太多,Milvus的etcd故障恢复是个噩梦
最终我们选择了Qdrant,因为广告风控是典型的高并发低延迟场景。但如果你需要复杂的标量过滤(如按时间范围过滤),Milvus的表达式查询能力更强,Qdrant虽然也支持filter,但语法不如Milvus灵活。
七、总结:向量数据库没有银弹
这次实测让我深刻体会到,选型不能只看benchmark文章,必须用自己的数据和业务场景去压测。Milvus和Qdrant都是优秀的开源项目,但设计哲学完全不同:Milvus追求功能全面,Qdrant追求极简高效。建议大家在选型前,先模拟真实的数据分布和查询模式,跑一个48小时的稳定性测试——你会发现很多短期压测发现不了的问题。
最后提醒一句:别迷信官方文档的默认参数,HNSW的M和ef对性能影响极大,一定要根据你的召回率要求做网格搜索。我们最终用的是M=24, ef=96,比默认值好不少。