数据量涨到 2300 万,向量检索 P99 从 60ms 崩到 1.8 秒
我们的商品语义搜索,7 月份上线时是 420 万条向量,P99 稳定在 60 毫秒。到 10 月底商品库扩到 2300 万条,接口 P99 涨到 1820 毫秒,同时 Milvus 集群的内存水位长期在 91%,隔三差五触发一次 OOMKill。
# Prometheus 里拉出来的趋势
milvus_search_latency_p99{collection="sku"} 2024-07-15 0.061
milvus_search_latency_p99{collection="sku"} 2024-09-01 0.284
milvus_search_latency_p99{collection="sku"} 2024-10-28 1.821
container_memory_usage_bytes{pod="querynode-2"} 2024-10-28 0.91 * limit
这篇记录从 10 月 28 号到 11 月 10 号,我们怎么把它压回到 95 毫秒的。
先搞清楚慢在哪:三个阶段分别量
Milvus 的一次查询可以拆成三段,我在客户端打了分段埋点(用的是 Milvus 的 Java SDK 2.4.x):
SearchResp resp = client.search(SearchReq.builder()
.collectionName("sku")
.data(Collections.singletonList(new FloatVec(queryVec)))
.topK(50)
.filter("category_id in [101, 205, 308] and price <= 500")
.searchParams(params)
.build());
配合服务端 metrics 拆出来:
| 阶段 | 7 月(420 万) | 10 月(2300 万) |
|---|---|---|
| 队列等待 | 3 ms | 410 ms |
| 向量检索(粗排) | 38 ms | 680 ms |
| 标量过滤 + 回表取字段 | 16 ms | 730 ms |
三个环节全慢了,但原因不同。
队列等待:QueryNode 不够,且没开副本
$ kubectl get pod -n milvus | grep querynode
querynode-0 1/1 Running 3 (2d ago) # 重启过 3 次
querynode-1 1/1 Running 1 (5d ago)
querynode-2 0/1 OOMKilled 4
三个 QueryNode 里挂了一个,剩下的扛全部流量,队列就堆起来了。内存爆是因为我们把 2300 万条原始向量全量加载在内存里,用的是 IVF_FLAT 索引,没有任何压缩。
标量过滤慢:表达式走了暴力扫描
这段最意外。price <= 500 这种范围过滤,在没有标量索引的情况下是逐条比对的。2300 万条里符合 category_id in [...] 的只有 41 万条,但过滤是在向量检索之后做的后过滤——先召回 topK×N 个候选,再逐条判断,判断不通过就丢弃。
当时的 nprobe 是默认的 10,召回 1000 个候选里可能只有 200 个满足过滤条件,剩下的全白干。
索引选型与参数调优
我们拿 2300 万全量数据做了四组对比测试,机器是 32C128G 单 QueryNode,topK=50,并发 50:
| 索引 | 参数 | 内存占用 | P99 延迟 | 召回率@50 | 构建耗时 |
|---|---|---|---|---|---|
| IVF_FLAT | nlist=4096, nprobe=10 | 88 GB | 1240 ms | 0.912 | 42 min |
| IVF_FLAT | nlist=4096, nprobe=64 | 88 GB | 1890 ms | 0.981 | 42 min |
| IVF_SQ8 | nlist=8192, nprobe=32 | 23 GB | 310 ms | 0.968 | 55 min |
| HNSW | M=32, efConstruction=200, ef=128 | 121 GB | 78 ms | 0.993 | 3 h 20 min |
| DISKANN | 默认 | 9 GB | 420 ms | 0.941 | 1 h 50 min |
最终选了 IVF_SQ8,理由如下:
- HNSW 速度最快、召回最高,但 121 GB 内存我们给不起(单节点 128G,还要留余量给系统和其他组件)。
- DISKANN 内存只要 9 GB,但延迟 420 毫秒且依赖 NVMe,我们的云盘是普通 SSD,实测会更差。
- IVF_SQ8 用标量量化把 768 维 float32 压成 int8,内存降到四分之一,召回只掉 1.3 个点,是我们能接受的最优点。
nlist 和 nprobe 的经验取值
这两个参数决定了 IVF 系列的精度和速度,调起来有章可循:
- nlist = 4 × sqrt(N)。2300 万开方约 4796,我们取 8192(Milvus 建议往上取到 2 的幂)。
- 每个簇要有 500 到 2000 条。2300 万 / 8192 ≈ 2800 条每簇,稍微偏大,但可接受。
- nprobe 从 N/nlist 的 1% 往上试。我们最后定在 32,此时扫描 32 个簇约 9 万条,占全量 0.39%。
Map<String, Object> params = new HashMap<>();
params.put("nprobe", 32);
params.put("metric_type", "COSINE");
IndexParam index = IndexParam.builder()
.fieldName("embedding")
.indexType(IndexParam.IndexType.IVF_SQ8)
.metricType(IndexParam.MetricType.COSINE)
.extraParams(Map.of("nlist", 8192))
.build();
nprobe 增大召回率会上升,但不是线性的。我们从 16 调到 32,召回从 0.941 涨到 0.968,翻到 64 只涨到 0.972,延迟却翻倍。把召回率推到 1.0 是极其不划算的,找到拐点就停手。
混合过滤:三种做法的取舍
做法一:Partition(我们最终选择的)
商品有明确的一级类目(约 60 个),按类目建分区是最直接的剪枝:
client.createPartition(CreatePartitionReq.builder()
.collectionName("sku").partitionName("cat_101").build());
// 查询时只搜指定分区
client.search(SearchReq.builder()
.collectionName("sku")
.partitionNames(List.of("cat_101", "cat_205", "cat_308"))
...);
效果显著:搜 3 个类目的数据量从 2300 万降到 140 万,延迟从 310 毫秒降到 42 毫秒。代价是分区数不能太多(Milvus 官方建议单集合分区不超过 4096),而且一次查询跨太多分区就没意义了。
做法二:标量索引 + 迭代过滤
对于 price、brand_id 这类没法做分区的过滤条件,Milvus 2.4 支持开启迭代过滤(iterative filtering):它会在向量检索的过程中逐批检查过滤条件,直到凑够 topK,而不是一次性召回一大堆再筛。
params.put("iterator_extension_ratio", 2.0); // 每轮多召回的比例
client.search(SearchReq.builder()
.filter("brand_id == 8821 and price <= 500 and status == 1")
.searchParams(params)
.build());
开启前后对比(过滤命中率 8% 的场景):
| 过滤方式 | 候选扫描量 | P99 延迟 | topK 是否凑满 |
|---|---|---|---|
| 后过滤(nprobe=32) | 9 万 | 310 ms | 是 |
| 后过滤 + 强过滤(命中率 0.3%) | 9 万 | 295 ms | 否,只返回 11 条 |
| 迭代过滤(ratio=2.0) | 约 26 万 | 640 ms | 是 |
迭代过滤解决了"凑不满 topK"的问题,但延迟翻倍。我们的选择是:过滤命中率低于 1% 的条件才开迭代过滤,其余用后过滤。判断命中率靠离线统计,每天跑一次采样。
做法三:把过滤条件编码进向量
这个思路有点野,但对"价格区间"这种有序标量意外地好使。做法是把归一化后的价格作为一个额外维度拼进向量,检索时把目标价格也拼进查询向量。
// 768 维语义向量 + 1 维价格权重
float[] hybrid = new float[769];
System.arraycopy(semanticVec, 0, hybrid, 0, 768);
hybrid[768] = normalizePrice(price) * 0.15f; // 权重需要调优
我们测试下来,价格相关性从 0.62 提到 0.79,但语义相关性从 0.968 掉到 0.912。最后只在一个"找同价位替代品"的场景用了它,主搜索没用。
数据同步:全量重建不能停服
商品数据每天变动约 40 万条,还有每周一次的全量重算(模型换了要重新 embedding)。早期我们是直接往同一个集合写,重建期间查询抖动严重。
现在的架构:
MySQL binlog → Canal → Kafka → 增量同步服务 → Milvus(实时写入)
↓
每周全量:新建 sku_v20241111 集合 → 灌数据
↓
建索引 → 灰度验证 → alias 原子切换
核心是 alias 切换:
// 1. 建新集合并灌数据
client.createCollection(buildSchema("sku_v20241111"));
bulkLoad("sku_v20241111", allRows);
client.createIndex(...);
// 2. 灰度:5% 流量打过去,对比召回结果
verify("sku_v20241111", sampleQueries(500));
// 3. 原子切换,业务代码永远用 alias "sku_online"
client.alterAlias(AlterAliasReq.builder()
.collectionName("sku_v20241111")
.alias("sku_online")
.build());
// 4. 观察 24 小时后删老集合
client.dropCollection(DropCollectionReq.builder()
.collectionName("sku_v20241106").build());
增量同步这边有三个必须处理的点:
- Upsert 的幂等。Kafka 重投会导致重复写,Milvus 的主键 upsert 是幂等的,但要注意主键必须是业务 ID(我们的 sku_id),不能用自增。
- 删除要单独处理。Milvus 的
delete是软删除,会留下 tombstone,删多了要compact。我们每天凌晨跑一次 compaction。 - Embedding 失败要有死信队列。商品描述为空、超长(超过模型 8192 token 限制)的记录会 embedding 失败,进 DLQ 人工处理,不能阻塞主流程。
// 消费端的骨架
@KafkaListener(topics = "sku_cdc", concurrency = "8")
public void onMessage(List<SkuEvent> events) {
List<FloatVec> vecs = new ArrayList<>();
for (SkuEvent e : events) {
try {
vecs.add(embed(e));
} catch (Exception ex) {
dlq.send(e, ex); // 不阻塞整批
Metrics.counter("embed.fail").increment();
}
}
client.upsert(UpsertReq.builder().collectionName("sku_online").data(rows).build());
}
一致性级别:我最后选了 Bounded
Milvus 提供四种一致性级别,选错了要么读到脏数据,要么延迟暴涨。这是我在调优中期才发现的一个隐藏开关。
| 级别 | 语义 | P99 延迟 | 我们的场景 |
|---|---|---|---|
| Strong | 读到的永远是最新的 | 340 ms | 太慢,且商品数据容忍秒级延迟 |
| Bounded | 容忍固定时间窗口的陈旧(默认 3 秒) | 96 ms | 采用 |
| Session | 自己写的最新的能读到 | 102 ms | 我们没有"写完立刻读"的场景 |
| Eventually | 不保证 | 88 ms | 省 8 毫秒,不值得冒数据不一致的风险 |
client.search(SearchReq.builder()
.consistencyLevel(ConsistencyLevel.BOUNDED)
.guaranteeTimestamp(...)
.build());
Bounded 相比 Strong 省了 244 毫秒,代价是刚写入的数据最多 3 秒后才可见。商品上下架延迟 3 秒被检索到,业务上完全接受。
我们挂的告警
改造完之后补了一套监控,这几个指标是我觉得最有用的:
# 1. 查询延迟分位
histogram_quantile(0.99, rate(milvus_proxy_search_latency_bucket[5m])) > 0.15
# 2. 队列积压(这个最灵敏,比延迟更早发现问题)
milvus_proxy_search_queue_length > 20
# 3. 内存水位,超过 80% 就要考虑扩容或者换索引
container_memory_usage_bytes{pod=~"querynode.*"}
/ container_spec_memory_limit_bytes > 0.8
# 4. 增量同步延迟,超过 5 分钟说明 Canal 或 Kafka 有问题
kafka_consumergroup_lag{consumergroup="sku-milvus-sync"} > 10000
第二个指标(队列积压)是我在事故复盘时加的。它比延迟更早暴露问题——延迟是结果,队列长度是原因。后来有两次 QueryNode 内存缓慢上涨,就是队列长度先抖动的,比延迟告警早了 8 分钟。
成本
改造前后的集群规格和费用(月度,云上按量):
| 项目 | 改造前 | 改造后 |
|---|---|---|
| QueryNode 规格 | 3 × 32C128G | 4 × 16C64G |
| 索引内存总量 | 264 GB | 92 GB |
| OOMKill 次数/月 | 11 | 0 |
| 月度成本 | 2.86 万元 | 1.94 万元 |
| P99 延迟 | 1820 ms | 95 ms |
| 召回率@50 | 0.912 | 0.968 |
机器变多了但单机规格降了,总成本反而低。这里的关键是SQ8 量化把内存从 88 GB 降到 23 GB,让我们能用便宜的小规格机器横向扩展,而不是堆大内存机器。
下篇预告
这篇先把《向量数据库生产实践:从选型到性能调优》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。