一、问题背景:电商以图搜图,向量数据库选型卡了三天
我们团队在做供应链商品管理系统,核心功能是“拍照找同款”。商品库约50万SKU,每张商品图经过ResNet50抽成128维向量(归一化后),需要支持10ms级响应。技术选型时,大家纠结于Milvus和Qdrant——网上资料要么是官方文档的“自我表扬”,要么是纯benchmark没业务场景。作为实际落地的人,我决定自己跑一轮实测。
先说结论:没有绝对好坏,只有适不适合。 我们的场景是“高并发读、中等写入、单机部署”,最终选了Qdrant。但如果你要PB级数据、需要分布式扩容,Milvus的架构优势无法替代。下面直接上干货。
二、环境与版本:统一硬件,避免“耍流氓”
为避免硬件差异影响公平性,两台服务跑在同一台物理机上(通过Docker隔离),配置如下:
- CPU:Intel i9-10900K(10核20线程,限制8核)
- 内存:64GB DDR4(限制16GB给容器)
- 磁盘:NVMe SSD 1TB(顺序读1.8GB/s)
- OS:Ubuntu 22.04 LTS,Docker 24.0.5
版本锁定:
- Milvus:2.4.1(standalone模式,Docker Compose部署)
- Qdrant:1.9.2(单节点,Docker部署)
- 客户端:pymilvus 2.4.2,qdrant-client 1.9.1
- 数据集:10万条随机生成的128维float32向量(模拟真实分布),ID为uint64自增
注意:Milvus的standalone模式依赖Etcd和MinIO,所以实际是3个容器。Qdrant就一个。这直接影响资源占用。
三、方案设计:部署步骤与压测脚本
3.1 Milvus部署(含踩坑)
Milvus 2.4的standalone模式官方给了docker-compose.yml,但默认配置会吃满内存。我做了两处修改:
# docker-compose.yml 关键片段(仅展示修改点)
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_QUOTA_BACKEND_BYTES=2147483648 # 限制2GB
milvus:
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
- ETCD_USE_EMBED=true # 关键:使用内嵌etcd,省一个容器
volumes:
- ./milvus_data:/var/lib/milvus
ports:
- "19530:19530"
部署命令:
# 拉取配置并启动
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
# 修改环境变量如上
docker compose up -d
# 验证
docker compose ps # 看到milvus、etcd、minio三个容器healthy
第一个坑: 默认配置下Etcd的quota-backend-bytes是2GB,但插入10万条数据后Etcd直接报mvcc: database space exceeded。必须手动调大,或者像我一样启用ETCD_USE_EMBED=true(内嵌模式省资源,但生产不推荐)。
3.2 Qdrant部署
Qdrant就简单多了,一条命令搞定:
docker run -d --name qdrant \
-p 6333:6333 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
注意: Qdrant默认会开启HNSW索引的m=16, ef_construct=200,但实际写入时如果数据量小,建议调小ef_construct以加速构建。我们后续压测时改为m=8, ef_construct=100。
四、核心实现:数据写入与查询压测代码
4.1 数据写入(两套API风格对比)
Milvus的Python API需要先建collection再insert,Qdrant则直接upsert。代码如下:
# ---------- Milvus 写入 ----------
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType
import numpy as np
connections.connect(host='localhost', port='19530')
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=128)
]
schema = CollectionSchema(fields, "product_search")
col = Collection("products", schema)
# 创建HNSW索引(关键参数)
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {"M": 16, "efConstruction": 200}
}
col.create_index("vector", index_params)
# 批量插入10万条
data = [
[i for i in range(100000)], # id
np.random.rand(100000, 128).astype(np.float32).tolist() # vector
]
col.insert(data)
col.flush()
print(f"Milvus row count: {col.num_entities}")
# ---------- Qdrant 写入 ----------
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance
import numpy as np
client = QdrantClient(host='localhost', port=6333)
client.recreate_collection(
collection_name="products",
vectors_config=VectorParams(size=128, distance=Distance.DOT),
hnsw_config={"m": 16, "ef_construct": 200} # 与Milvus对齐
)
# 批量upsert
vectors = np.random.rand(100000, 128).astype(np.float32)
client.upload_collection(
collection_name="products",
vectors=vectors,
payload=[{"id": i} for i in range(100000)],
batch_size=256
)
print(f"Qdrant count: {client.count('products').count}")
写入耗时: Milvus用了23.4秒(含flush),Qdrant用了11.8秒(纯upload)。Milvus慢在flush同步落盘,如果使用async_flush能快一些,但查询会拿到未持久化数据。
4.2 查询压测(模拟真实QPS)
压测方式:随机生成1000个查询向量,每个请求查Top10,连续跑5分钟,统计吞吐和延迟分布。
# ---------- 通用压测逻辑 ----------
import time, random, threading
from concurrent.futures import ThreadPoolExecutor
query_vectors = np.random.rand(1000, 128).astype(np.float32)
QPS_TARGET = 100
DURATION = 300 # 5分钟
def milvus_query(vec):
col.load()
results = col.search(
data=[vec], anns_field="vector",
param={"metric_type": "IP", "params": {"ef": 64}},
limit=10
)
return len(results[0].ids)
def qdrant_query(vec):
results = client.search(
collection_name="products",
query_vector=vec,
limit=10,
search_params={"hnsw_ef": 64} # 注意参数名不同
)
return len(results)
# 用ThreadPoolExecutor并发压测,统计RPS和延迟分位数
# ... 代码省略(标准统计逻辑)
关键参数差异: Milvus的ef和Qdrant的hnsw_ef都是控制HNSW搜索精度的。实测中,ef=64时两者召回率都在95%+,但Milvus的ef参数只能通过param传入,Qdrant的search_params更灵活。
五、踩坑与优化:两个“坑”字总结
坑1:Milvus的索引构建阻塞写入
Milvus在create_index后,如果数据量超过几万条,索引构建会阻塞后续insert调用(除非设置async)。我们测试时,插完5万条后调用create_index,等了40秒才返回,期间无法写入。解决方案是:先建索引再批量插入,或者用create_index(wait=False)配合flush。
坑2:Qdrant的payload索引导致查询慢
Qdrant的upload_collection如果传入payload,默认会对每个payload字段建索引。但我们的场景只需要ID字段过滤,多余的索引会让查询变慢。优化:upload_collection时设置payload_indexing=False,或者只对需要的字段建索引。
优化后效果对比
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 | 差异 |
|---|---|---|---|
| 写入耗时(10万条) | 23.4s | 11.8s | Qdrant快49% |
| 查询吞吐(RPS) | 1240 | 2140 | Qdrant高73% |
| P99延迟 | 8.2ms | 23.4ms | Milvus稳定 |
| 平均延迟 | 2.1ms | 4.7ms | Milvus快55% |
| 内存占用(容器) | 3.2GB | 1.1GB | Qdrant省65% |
| 磁盘占用 | 2.8GB | 1.4GB | Qdrant省50% |
为什么Milvus延迟低但吞吐低? 原因是Milvus的查询走gRPC,每个请求有固定协议开销(约0.5ms),但内部查询逻辑经过优化,高并发下队列调度更均匀。Qdrant的REST API(默认)开销小,但线程池在300并发时出现抖动,P99飙到23ms。
六、总结与选型建议
如果你问我最终选哪个,我的答案是:数据量1亿、需要分布式扩展、对延迟稳定性要求极高选Milvus。
具体决策树:
- 数据量 5000万:必须Milvus,Qdrant单节点内存扛不住(1亿条128维向量需要256GB内存)
最后提醒一句:网上那些“秒杀Milvus”的Qdrant测试报告,多半没开持久化或没做高并发压测。 我的测试里Qdrant的P99延迟波动是个隐患,如果你的业务对长尾延迟敏感(比如金融风控),请务必压测P99而不是平均延迟。
代码和数据已上传GitHub仓库,需要自取:github.com/yourname/vector-db-bench。有问题评论区见。