1. 问题背景:电商图片检索的选型困局

去年接手一个以图搜图需求:从200万张vivo手机壳图片中,通过ResNet50提取768维特征向量,完成实时检索。初期使用Faiss离线索引,但增量更新困难。转向开源向量数据库时,Milvus和Qdrant是最热的两个选项。

团队需要回答三个问题:
- 部署复杂度能否接受?(运维只有2人)
- 百万级数据下P99延迟能否低于50ms?
- 4C8G的云服务器能否扛住?

2. 环境与版本

硬件清一色腾讯云轻量服务器:
- CPU:4核(Intel Xeon Platinum 8255C)
- 内存:8GB
- 磁盘:50GB SSD
- 操作系统:Ubuntu 20.04 LTS

软件版本:
- Milvus:2.3.3(Standalone模式,Docker Compose部署)
- Qdrant:1.8.0(Docker单节点)
- 向量维度:768(ResNet50输出)
- 索引类型:HNSW(参数:M=16, ef_construction=200)
- 数据量:2,034,567条

3. 方案设计:两种部署架构对比

Milvus部署(含踩坑)

官方推荐Docker Compose,但第一次部署就踩坑——必须同时启动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
  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    volumes:
      - /data/minio:/data
  standalone:
    image: milvusdb/milvus:v2.3.3
    command: ["milvus", "run", "standalone"]
    depends_on:
      - etcd
      - minio
    ports:
      - "19530:19530"

启动后三个容器吃掉2.1GB内存,其中etcd占用470MB。这是第一个意外——文档说Standalone模式“轻量”,但实际并不轻。

Qdrant部署(真·单二进制)

Qdrant官方Docker镜像只有20MB,且无外部依赖:

docker run -d \
  -p 6333:6333 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  --name qdrant \
  qdrant/qdrant:v1.8.0

启动后内存占用仅280MB。更激进的做法是直接用静态二进制文件(官方提供Linux x86_64的编译版本),连Docker都能省。

4. 核心实现:数据导入与检索

4.1 数据导入代码对比

Milvus端(使用pymilvus 2.3.3)

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

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

# 创建集合
fields = [
    FieldSchema(name='id', dtype=DataType.INT64, is_primary=True),
    FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=768),
    FieldSchema(name='product_id', dtype=DataType.VARCHAR, max_length=64)
]
schema = CollectionSchema(fields, description='vivo phone case images')
collection = Collection(name='phone_cases', schema=schema)

# 创建HNSW索引(这一步巨慢)
index_params = {
    'metric_type': 'IP',
    'index_type': 'HNSW',
    'params': {'M': 16, 'efConstruction': 200}
}
collection.create_index('vector', index_params)

# 批量插入(每次10000条)
import numpy as np
batch_size = 10000
for i in range(0, len(vectors), batch_size):
    batch_vectors = vectors[i:i+batch_size]
    batch_ids = list(range(i, i+len(batch_vectors)))
    batch_pids = product_ids[i:i+batch_size]
    entities = [batch_ids, batch_vectors, batch_pids]
    collection.insert(entities)
    print(f'Inserted {i+len(batch_vectors)} records')

Qdrant端(使用qdrant-client 1.8.0)

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct

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

# 创建集合(索引参数直接在创建时指定)
client.recreate_collection(
    collection_name='phone_cases',
    vectors_config=VectorParams(
        size=768,
        distance=Distance.DOT,
        hnsw_config={'m': 16, 'ef_construct': 200}
    )
)

# 批量上传(同样10000条,但支持异步)
from qdrant_client.http import models
points = [
    PointStruct(id=idx, vector=vec.tolist(), payload={'product_id': pid})
    for idx, vec, pid in zip(range(len(vectors)), vectors, product_ids)
]
client.upload_points(
    collection_name='phone_cases',
    points=points,
    batch_size=10000,
    wait=True
)

注意Qdrant的upload_points方法内部自动处理分批和重试,而Milvus需要手动循环。Qdrant在200万数据全量导入耗时约7分钟,Milvus因为索引构建策略问题耗时23分钟。

4.2 检索性能对比

Milvus查询

search_params = {'metric_type': 'IP', 'params': {'ef': 64}}
results = collection.search(
    data=[query_vector],
    anns_field='vector',
    param=search_params,
    limit=10,
    output_fields=['product_id']
)

Qdrant查询

results = client.search(
    collection_name='phone_cases',
    query_vector=query_vector,
    limit=10,
    with_payload=['product_id'],
    search_params={'hnsw_ef': 64}
)

5. 踩坑与优化记录

5.1 Milvus的“内存泄漏”假象

部署后内存从1.2GB逐渐涨到3.8GB,以为是内存泄漏。后来发现是Milvus的knowhere引擎会预分配内存池,可以通过cache.cache_size限制:

# milvus.yaml 配置节选
cache:
  cache_size: 1024  # 限制为1GB

但修改后重启,索引重建耗时翻倍。最终结论:Milvus的内存池设计对内存敏感场景不友好。

5.2 Qdrant的磁盘占用优化

Qdrant默认开启WAL(预写日志),200万数据占用磁盘12GB,而Milvus仅8.5GB。优化方案:

# 创建集合时关闭WAL(生产环境慎用)
client.recreate_collection(
    collection_name='phone_cases',
    vectors_config=VectorParams(
        size=768,
        distance=Distance.DOT,
        hnsw_config={'m': 16, 'ef_construct': 200}
    ),
    wal_config={'enabled': False}  # 关闭WAL
)

关闭后磁盘降至9.2GB,但牺牲了故障恢复能力。最终我们选择保留WAL,因为电商场景不能丢数据。

6. 效果数据:实测性能对比

测试条件:并发100个请求,每个请求返回top-10,连续跑30分钟。

指标 Milvus 2.3.3 Qdrant 1.8.0
P50延迟 8ms 5ms
P95延迟 28ms 12ms
P99延迟 67ms 22ms
内存占用(稳态) 3.2GB 1.9GB
磁盘占用 8.5GB 12GB
索引构建时间 23min 7min
容器数量 3 1
首条查询时间 41s(需等索引加载) 2s(即时可用)

值得注意的点:
- Qdrant的P99延迟明显更稳定,得益于Rust实现的零拷贝设计
- Milvus在高并发下偶发超时(1.2%请求超500ms),Qdrant未出现
- 两者召回率一致(在相同HNSW参数下均为99.2%)

7. 总结:选型建议

如果团队面临类似场景,我的建议是:

选Qdrant当
- 服务器内存≤8GB
- 要求快速部署(1个Docker容器搞定)
- 对P99延迟敏感(比如实时搜图)
- 数据量在千万级以下

选Milvus当
- 已有K8s集群,需要分布式扩展
- 数据量过亿,需要分片+负载均衡
- 需要多向量字段(如同时存图片和文本向量)
- 团队有专门的运维支持

最后说句实话:这次对比让我对Rust重写基础设施有了信心。Qdrant的5000+行Rust代码确实比Milvus的C+++Go组合更可控。但Milvus的社区活跃度和文档丰富度仍是优势。选型没有银弹,只有最适合当前团队和场景的方案。