一、问题背景:为什么需要换掉ES?

我们团队维护的电商搜索系统,之前一直用Elasticsearch 8.11做向量检索(dense_vector + HNSW)。当数据量突破800万,维度为768(CLIP模型产出)时,ES的查询延迟开始失控——P99从120ms飙升到620ms,且CPU常年打满。最致命的是,ES的段合并(Merge)在向量索引上频繁触发,导致磁盘IO成为瓶颈。

于是我们决定在两个主流专用向量库之间做选型:Milvus 2.4.1(Zilliz Cloud团队维护)和Qdrant 1.9.2(Rust编写)。本文只讲实测,不谈PPT。

二、环境与版本:同一台裸机,配置拉齐

为了避免“田忌赛马”,两台服务部署在同一台物理机上,共享同一份数据源(预生成的10,000,000条随机向量,维度768,float32,L2距离)。

  • 硬件:Dell R740,Intel Xeon Gold 6248R @ 3.0GHz (48核),内存128GB,SSD RAID0 (NVMe, 顺序读3.5GB/s)
  • OS:Ubuntu 22.04.3 LTS,内核5.15
  • Docker:24.0.7,docker-compose v2.24
  • 客户端:Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.2

关键配置原则:两者都使用默认的HNSW参数(M=16, efConstruction=200),查询时ef=64。Milvus关闭了mmap(mmap_enabled: false),Qdrant关闭了内存映射(mmap_interval: 0),目的都是让索引尽量驻留内存或按需加载。

三、方案设计:Docker Compose部署

3.1 Milvus部署(Standalone模式)

Milvus依赖etcd和MinIO,用官方docker-compose最省事。但注意,2.4版本默认开启dataCoord.enableCompaction,对于纯向量场景建议关闭以减少CPU开销。

# 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:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd

  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  standalone:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
      # 关键:关闭mmap,强制索引进内存
      KNOWHERE_MMAP_ENABLED: "false"
      # 限制compaction线程数,避免写入抖动
      DATA_COORD_ENABLE_COMPACTION: "true"
    ports:
      - "19530:19530"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    depends_on:
      - etcd
      - minio

3.2 Qdrant部署(单节点)

Qdrant就简单多了,单容器搞定。但这里有个大坑:Qdrant默认WAL(Write-Ahead Log)刷盘策略是EverySecond,在批量导入时会产生大量fsync,导致CPU iowait暴涨。我们改为Every500ms并关闭fsync(测试环境可接受数据丢失风险)。

# docker-compose-qdrant.yml
version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.9.2
    ports:
      - "6333:6333"
      - "6334:6334" # gRPC端口
    volumes:
      - ./qdrant_storage:/qdrant/storage
    environment:
      QDRANT__STORAGE__WAL__WRITE_AHEAD_LOG_INTERVAL_MS: "500"
      QDRANT__STORAGE__WAL__FSYNC: "false"
      QDRANT__STORAGE__OPTIMIZER__FLUSH_INTERVAL_SEC: "60"
      # 限制内存中索引数量,防止OOM
      QDRANT__STORAGE__OPTIMIZER__MEMORY_QUOTA: "40GB"
    ulimits:
      nofile: 65535

四、核心实现:数据导入与压测脚本

4.1 数据准备

先生成1千万条768维随机向量,归一化后存入npy文件。注意用float32,别用float64(内存翻倍,速度减半)。

import numpy as np

dim = 768
total = 10_000_000
# 分10批生成,避免一次性占用80GB内存
for i in range(10):
    data = np.random.randn(1_000_000, dim).astype(np.float32)
    # 归一化,L2距离等价于余弦相似度
    data /= np.linalg.norm(data, axis=1, keepdims=True) + 1e-9
    np.save(f'/data/vectors_{i}.npy', data)
    print(f"Batch {i} saved")

4.2 Milvus导入与查询脚本

Milvus批量插入时,一定要用wait_for_flushing=True,否则索引还没建完就开始查询,延迟虚高。

from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
import numpy as np
import time

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

# 创建collection(已存在则跳过)
dim = 768
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dim)
]
schema = CollectionSchema(fields, description="test")
col = Collection(name="test_milvus", schema=schema)

# 创建HNSW索引
index_params = {
    "index_type": "HNSW",
    "metric_type": "L2",
    "params": {"M": 16, "efConstruction": 200}
}
col.create_index("vector", index_params)

# 批量插入(每次100万)
for i in range(10):
    data = np.load(f'/data/vectors_{i}.npy')
    ids = np.arange(i*1_000_000, (i+1)*1_000_000, dtype=np.int64)
    entities = [ids, data]
    t0 = time.time()
    col.insert(entities)
    print(f"Batch {i} inserted, {time.time()-t0:.2f}s")

# 关键:等待flush完成
col.flush()
print(f"Total entities: {col.num_entities}")

# 查询压测
col.load()
query_vec = np.random.randn(1, dim).astype(np.float32)
query_vec /= np.linalg.norm(query_vec)

latencies = []
for _ in range(1000):
    t0 = time.time()
    results = col.search(query_vec, "vector", param={"metric_type": "L2", "params": {"ef": 64}}, limit=10)
    latencies.append((time.time() - t0) * 1000)  # ms

latencies.sort()
print(f"P50: {latencies[500]:.2f}ms, P99: {latencies[990]:.2f}ms")

4.3 Qdrant导入与查询脚本

Qdrant的批量上传必须用upload_collection配合batch,否则一条条插入会慢到怀疑人生。

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import numpy as np
import time

client = QdrantClient(host="localhost", port=6333, prefer_grpc=True)

# 创建collection
client.create_collection(
    collection_name="test_qdrant",
    vectors_config=VectorParams(size=768, distance=Distance.L2),
    hnsw_config={"m": 16, "ef_construct": 200}
)

# 批量上传(每批100万)
for i in range(10):
    data = np.load(f'/data/vectors_{i}.npy')
    ids = np.arange(i*1_000_000, (i+1)*1_000_000, dtype=np.int64)
    points = [
        PointStruct(id=int(ids[j]), vector=data[j].tolist())
        for j in range(len(ids))
    ]
    t0 = time.time()
    client.upsert(
        collection_name="test_qdrant",
        points=points,
        batch_size=10000  # 关键:控制批量大小,防止内存溢出
    )
    print(f"Batch {i} upserted, {time.time()-t0:.2f}s")

# 查询压测
query_vec = np.random.randn(768).astype(np.float32)
query_vec /= np.linalg.norm(query_vec)

latencies = []
for _ in range(1000):
    t0 = time.time()
    client.search(
        collection_name="test_qdrant",
        query_vector=query_vec.tolist(),
        limit=10,
        search_params={"ef": 64}
    )
    latencies.append((time.time() - t0) * 1000)

latencies.sort()
print(f"P50: {latencies[500]:.2f}ms, P99: {latencies[990]:.2f}ms")

五、踩坑与优化:实测中血泪史

5.1 Milvus的Index Building内存炸裂

现象:插入到第5批(500万条)时,Milvus容器直接OOMKilled,退出码137。

原因:Milvus 2.4默认的HNSW索引构建进程Knowhere会一次性加载整个segment的原始向量到内存。768维float32,500万条 = 500万 * 768 * 4字节 = 约15GB。再加上索引本身的额外开销(HNSW的link列表),内存峰值轻松超过30GB。而我们的容器限制是32GB。

解决:在milvus.yaml中调小segments.maxSize(默认1024MB),让segment更小,从而降低单次构建的内存峰值。同时调低indexNode的并发度。

# 挂载到standalone的/etc/milvus/configs/milvus.yaml
segments:
  maxSize: 512  # MB,从1024改小
indexNode:
  gracefulStopTimeout: 30s

改完重启,OOM消失,但导入时间从28分钟涨到37分钟(segment多了,flush频繁了)。

5.2 Qdrant的WAL刷盘导致写放大

现象:Qdrant导入速度稳定在每批35秒,但磁盘IO监控显示iowait高达60%。

原因:默认wal_interval_ms=100,意味着每100ms就fsync一次。对于10M条记录,WAL文件大小约8GB,频繁刷盘导致NVMe SSD的写寿命急剧消耗。

解决:按上文配置,把WRITE_AHEAD_LOG_INTERVAL_MS调到500,并关闭fsync。调整后导入速度提升到每批22秒,iowait降到15%。注意:生产环境不能关fsync,需要权衡。

六、效果数据:直接上对比表

经过3轮压测取中位数,结果如下:

指标 Milvus 2.4.1 Qdrant 1.9.2 结论
导入10M条耗时 37分钟 28分钟 Qdrant快24%
内存峰值(导入时) 31.2GB 27.4GB Qdrant省12%
查询P50 (1000次) 58ms 47ms Qdrant快19%
查询P99 (1000次) 91ms 68ms Qdrant快25%
单次查询CPU消耗 2.3核 1.8核 Qdrant低22%
容器镜像大小 1.2GB (含etcd/minio) 180MB Qdrant轻量

补充说明
- 查询压测时,Qdrant的gRPC接口(端口6334)比REST快35%,我们用的是gRPC。
- Milvus的P99抖动明显更大,因为其内部有后台compaction任务抢占CPU。
- 如果数据量<100万,两者差距可忽略;但过千万时,Qdrant的Rust内存管理优势凸显。

七、总结与选型建议

选Qdrant 如果你:
- 单机部署,追求极致查询延迟和低资源占用
- 数据量在千万级以内,不需要分布式扩展
- 团队熟悉Rust/Python,能接受其API不像Milvus那么“全家桶”

选Milvus 如果你:
- 需要完整的元数据过滤(标量+向量混合查询)
- 预计数据量会破亿,需要分布式分片(Qdrant的分布式是商业版)
- 团队已有K8s基础设施,愿意接受更重的运维

最后吐槽一句:Milvus的文档写了跟没写一样,index_node的配置参数在官方文档里死活搜不到,全靠看源码。Qdrant的文档虽短,但该有的示例都有。这就是开源社区的真实生态。

版本锁定:如果你要复现本文数据,请务必锁定镜像版本milvusdb/milvus:v2.4.1qdrant/qdrant:v1.9.2,新版本(如Milvus 2.5)的索引策略有调整,数据可能对不上。