1. 问题背景

最近在负责一个电商平台的以图搜图功能升级,需要将商品主图用ResNet50提取为256维向量,存储到向量数据库并支持TOP10相似检索。原先基于Faiss的方案在增量更新和持久化上越来越吃力,团队决定上向量数据库。

选型时主要考虑了三点:部署复杂度、查询性能、资源开销。Milvus是明星项目,生态成熟;Qdrant走轻量路线,Rust实现。我们拿真实业务数据做了对比测试,结果让人意外——Qdrant在单机上跑出了Milvus两倍的QPS。

2. 环境与版本

硬件信息:
- CPU:Intel Xeon Gold 6248R (8核)
- 内存:32GB DDR4
- 磁盘:NVMe SSD 500GB
- 网络:千兆内网

软件版本:
- Milvus:2.3.2(standalone模式,单机部署)
- Qdrant:1.7.2(单节点模式)
- 客户端:Python 3.10 + pymilvus 2.3.2 + qdrant-client 1.9.0
- 数据集:10万条商品图片向量,256维,float32类型,来自真实业务

测试工具:使用asyncio + aiohttp模拟10并发请求,每个请求随机选一个向量做搜索,记录P99延迟和QPS。

3. 方案设计

业务需求:支持实时写入(每秒约50条)、秒级响应(P99 < 200ms)、磁盘持久化。运维团队只有1人,要求部署越简单越好。

两个方案:
- 方案A(Milvus):Docker Compose启动,依赖etcd和minio,配置相对复杂。
- 方案B(Qdrant):单容器启动,无外部依赖,配置简单。

考虑到团队运维能力,Qdrant的零依赖优势很明显。但Milvus的社区活跃度更高,支持GPU加速,未来可能有扩展需求。所以决定两个都部署验证。

4. 核心实现

4.1 Milvus部署与数据写入

Milvus 2.3.2 standalone模式需要三个容器:milvus、etcd、minio。这里用docker-compose.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/etcd:/etcd
  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    volumes:
      - /data/minio:/data
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    command: server /data
  milvus:
    image: milvusdb/milvus:v2.3.2
    depends_on:
      - etcd
      - minio
    ports:
      - "19530:19530"
    volumes:
      - /data/milvus:/var/lib/milvus
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000

启动后,Python写入代码:

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility

connections.connect(host='10.0.0.1', port='19530')

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=256),
    FieldSchema(name="product_id", dtype=DataType.INT64)
]
schema = CollectionSchema(fields, "product image search")
collection = Collection("product_vectors", schema)

# 创建IVF_FLAT索引,nlist=128
index_params = {
    "metric_type": "L2",
    "index_type": "IVF_FLAT",
    "params": {"nlist": 128}
}
collection.create_index("embedding", index_params)

# 批量写入10万条
import numpy as np
vectors = np.random.rand(100000, 256).astype(np.float32)
product_ids = np.arange(100000)
entities = [vectors.tolist(), product_ids.tolist()]
collection.insert(entities)
print(f"Milvus写入完成,向量数:{collection.num_entities}")

4.2 Qdrant部署与数据写入

Qdrant部署简单到难以置信:

docker run -d --name qdrant \
  -p 6333:6333 \
  -p 6334:6334 \
  -v /data/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.7.2

Python写入代码:

from qdrant_client import QdrantClient
from qdrant_client.http import models
import numpy as np

client = QdrantClient(host='10.0.0.1', port=6333)

# 创建集合,配置HNSW索引
client.recreate_collection(
    collection_name="product_vectors",
    vectors_config=models.VectorParams(
        size=256,
        distance=models.Distance.COSINE,
        on_disk=True  # 启用磁盘存储,节省内存
    ),
    optimizers_config=models.OptimizersConfigDiff(
        default_segment_number=2,
        memmap_threshold_kb=20000
    )
)

# 批量写入
vectors = np.random.rand(100000, 256).astype(np.float32)
points = [
    models.PointStruct(
        id=i,
        vector=vectors[i].tolist(),
        payload={"product_id": i}
    )
    for i in range(100000)
]
client.upsert(
    collection_name="product_vectors",
    points=points,
    wait=True
)
print(f"Qdrant写入完成,向量数:{client.count('product_vectors').count}")

5. 踩坑与优化

Milvus踩坑记录

  1. 内存爆满:首次写入10万条时,Milvus默认索引构建占用大量内存,导致OOM。解决方案:设置system_config.pulsar.maxMessageSize=10485760,并限制索引构建线程数indexNode.gpu.mem=4(虽然我们用CPU)。
  2. 查询超时:默认nprobe=8,召回率只有85%。将nprobe提升到32后,P99延迟从120ms飙升到350ms。最终平衡为nprobe=16,召回率93%,延迟180ms。
  3. etcd日志爆炸:etcd默认日志级别是info,一天产生2GB日志。改为warn级别解决。

Qdrant踩坑记录

  1. on_disk参数:第一次没加on_disk=True,10万条向量直接吃掉8GB内存。开启后内存降到2GB,查询延迟只增加15%。
  2. HNSW参数调优:默认ef_construct=100,ef=50。我们业务需要高召回率,调整为ef_construct=200,ef=100,P99延迟从80ms升到110ms,但召回率从92%提升到98%。
  3. gRPC vs HTTP:Qdrant默认使用HTTP 6333端口,并发测试时出现大量超时。切换到gRPC 6334端口后,QPS提升2倍。代码中加一行:client = QdrantClient(host='10.0.0.1', port=6334, prefer_grpc=True)

6. 效果数据

在同一台机器上,10个并发客户端,随机查询1000次,取P99延迟和平均QPS:

指标 Milvus 2.3.2 (nprobe=16) Qdrant 1.7.2 (ef=100)
P99延迟 185ms 112ms
平均QPS 420 890
内存占用 6.2GB 3.8GB
磁盘占用 8.5GB (含etcd+minio) 4.1GB
召回率@10 93% 96%
部署容器数 3 1
首次启动时间 45秒 3秒

Qdrant在延迟和吞吐上全面领先,内存占用比Milvus低40%。Milvus的优势在于生态完善,支持GPU和分布式,但单机场景下Qdrant性能更优。

7. 总结

最终我们选择了Qdrant。原因很简单:部署简单、性能好、资源省。Milvus是优秀的项目,但它的设计假设是分布式集群,单机场景下太重了。Qdrant用Rust实现,内存管理更高效,API设计也更现代(支持gRPC和REST)。

不过如果你有以下需求,建议选Milvus:
- 需要GPU加速索引构建
- 数据量超过千万级,需要分布式扩展
- 团队有K8s运维经验,能处理etcd/minio的维护

选型没有银弹,关键是匹配你的业务场景和团队能力。我们做了7天线上压测,Qdrant稳定运行,P99延迟始终低于150ms,CPU使用率不到60%。后续会考虑升级到Qdrant集群模式,用Raft协议保证高可用。

最后说一句:永远不要只看官方benchmark,拿自己数据跑一遍才是最靠谱的。