1. 从一次线上告警说起:几万条已读 ID 是怎么把查询拖垮的
事情的起点很朴素。我们做的是一个内容推荐流的场景,用户刷过的内容要标记成"已读",下次请求时把已读的 ID 从候选池里剔除掉,避免重复推送。早期用户量小,已读列表撑死几百条,not in写起来顺手,查询也是毫秒级返回,谁也没多想。
后来日活涨上来,重度用户一天能刷几千条内容,累积几周之后,单个用户的已读 ID 列表轻松破万,运营侧甚至有几万条的极端 case。然后监控开始报警:推荐接口的 P99 从 30ms 一路爬到 800ms,个别请求直接超时。排查下来,慢查询全部集中在同一个模式上——带not in大列表的向量检索。
这篇文章就把这次排查和优化的完整链路摊开讲。核心关键词是Milvus、not in、filter、查询慢、ID。如果你也在用 Milvus 做向量检索,并且习惯用not in或者类似的否定式过滤来排除一批 ID,那这篇基本就是写给你的。不管你是刚接触 Milvus 的新手,还是已经跑了一段时间 standalone 模式的老手,下面这些坑和结论都能直接拿去用。
先说结论,免得你看到一半才发现方向不对:在 Milvus 里用超大not in列表做过滤,性能会随着列表长度和集合规模双重恶化,这不是配置问题,是过滤执行机制决定的。正确的做法是把"排除"逻辑从查询期挪到写入期或者用位图/标记位的方式重构。
下面我按"现象—原理—复现—方案—验证"的顺序,把每一步都讲透。
2. not in 在 Milvus 里到底发生了什么:过滤执行机制拆解
2.1 标量过滤和向量检索是两套东西在配合
很多人对 Milvus 的过滤有个误解,以为not in就是简单地"把结果里符合条件的删掉"。实际上 Milvus 的查询流程大致是这样的:先根据标量条件做一轮过滤,得到一个满足条件的候选 ID 集合(或者叫位图),然后在这个候选集合上做向量相似度计算,最后返回 TopK。
关键在于,标量过滤这一轮的成本,和过滤条件的形态强相关。等值过滤(id == 123)可以走索引,直接定位;范围过滤(age > 18)也能借助索引做区间扫描;但not in这种否定式、集合式的条件,处理起来完全是另一回事。
2.2 否定式过滤为什么天然吃亏
用一个生活化的类比:你有一本一万页的电话簿,要找"除了张三、李四、王五……这几万个人之外的所有人"。正向查找是"翻到张三是第几页",否定式查找是"把这几万个名字逐个在电话簿里划掉,剩下的才算数"。
Milvus 处理not in时,本质上要做的是:对候选集合里的每一个元素,判断它是否落在你给的那几万个 ID 里。如果这个判断是逐个比对,那复杂度就是候选数量 × 排除列表长度。几万乘几万,量级直接爆炸。
当然 Milvus 不会傻到纯线性比对,它内部会用哈希集合来做成员判断,单次判断是 O(1)。但问题在于,这个判断要对候选集合里的每个元素都执行一遍,而且候选集合本身可能非常大。集合里有一千万条向量,你就要做一千万次哈希查找,再加上构建那个几万元素的哈希集合本身的开销,累积起来就很可观了。
2.3 过滤和索引的交互:为什么加了索引还是慢
有人会说,我给标量字段建了索引啊,怎么还慢?这里有个认知差:标量索引加速的是"定位",不是"排除"。
对于in这种正向条件,索引能帮你快速圈定一个小的候选集,后续向量计算量就小。但对于not in,索引帮不上太多忙——因为你要的是"补集",补集的范围几乎等于全集,索引没法把全集变小。结果就是过滤阶段筛出来的候选集依然巨大,后面的向量计算照样要跑满。
更麻烦的是,如果过滤条件写在向量检索的expr里,Milvus 需要先算出满足条件的 ID 位图,再拿这个位图去和向量索引做交集。位图本身如果很大,构建和传递都是开销。这就是为什么你会观察到:排除列表越长,查询越慢,而且不是线性变慢,是带着常数项和内存压力一起恶化。
2.4 一个容易被忽略的点:表达式解析和序列化成本
还有个隐蔽的成本:那几万个 ID 是要通过网络传到 Milvus 的。表达式字符串动辄几百 KB 甚至上 MB,解析这个表达式、构建内部数据结构,本身就要花时间。如果你的客户端每次都重新拼这个超长表达式,光是字符串拼接和网络传输就能吃掉几十毫秒。这部分成本在列表短的时候看不出来,列表一长就原形毕露。
3. 复现问题:我是怎么把"越来越慢"量化出来的
3.1 测试环境与数据构造
光说慢没有说服力,我搭了个最小复现环境。Milvus 用的是 standalone 模式(单机部署,适合验证和中小规模场景),集合规模 100 万条向量,维度 768,索引用 HNSW,标量字段doc_id建了倒排索引。
排除列表从 100 条开始,逐步加到 1000、10000、50000,每次测 100 次查询取 P50 和 P99。
3.2 实测数据:慢得很有规律
| 排除列表长度 | P50 延迟 | P99 延迟 | 备注 |
|---|---|---|---|
| 100 | 12ms | 28ms | 基本无感 |
| 1000 | 35ms | 90ms | 开始有感知 |
| 10000 | 180ms | 520ms | 明显变慢 |
| 50000 | 620ms | 1800ms | 接近不可用 |
这张表基本印证了前面的判断:延迟随排除列表长度增长,而且 P99 的恶化比 P50 更严重,说明存在长尾——某些查询恰好命中了最坏情况,比如候选集特别大、位图构建特别慢。
3.3 对比实验:换成 in 会怎样
为了确认问题确实出在"否定式"上,我做了个对照:把not in换成in,排除列表长度同样是 50000,但这次是"只查这 50000 个 ID 里的内容"。
结果in的 P99 只有 60ms 左右。差距接近 30 倍。
这个对比很关键,它说明问题不在"列表长",而在"否定式过滤"这个语义本身。正向条件能把候选集缩小,否定式条件几乎不缩小候选集,反而额外增加了一轮全量判断。
3.4 慢查询日志里能看到什么
Milvus 的慢查询日志(如果你开了的话)会记录查询耗时、表达式、返回条数。我翻日志发现,慢查询的表达式字段长度普遍在 300KB 以上,而且search阶段的耗时占比超过 80%。这进一步确认:瓶颈在过滤和候选集处理,不在网络返回。
提示:如果你还没开慢查询日志,建议在配置里把慢查询阈值调低一点(比如 100ms),先看清楚慢在哪,再动手优化。盲目调参是最容易走弯路的。
4. 为什么"把已读 ID 塞进 not in"是个结构性错误
4.1 已读状态是高频变更的数据,不该塞进查询条件
已读 ID 的本质是什么?是用户维度的状态数据,而且变更极其频繁——用户每刷一条内容,这个列表就长一条。把它塞进查询表达式,等于每次查询都要重新传输、重新解析、重新构建一遍这个巨大的状态。
这就像你每次去超市买东西,都要把家里冰箱里所有东西的清单背一遍给收银员听,告诉他"除了这些我都要"。荒谬且低效。
正确的思路是:状态数据应该有状态数据的存法,查询条件应该只承载"筛选意图",而不是"全量状态"。
4.2 否定式过滤的候选集几乎不缩小
前面已经讲过,not in的补集范围约等于全集。这意味着无论你怎么优化,过滤后的候选集依然很大,向量计算量下不去。这是语义层面的天花板,不是工程优化能突破的。
4.3 列表长度和集合规模是乘法关系
最要命的是,这个成本是排除列表长度 × 候选集规模的乘法关系。用户越活跃,已读越多;内容库越大,候选越多。两个方向同时增长,延迟就是平方级恶化。这就是为什么"越来越慢"——它不是某个阈值突然触发,而是随着业务增长持续劣化。
5. 重构方案:把排除逻辑从查询期挪走
5.1 方案一:写入期打标记,查询期用等值过滤
最直接的思路:给每条向量加一个"是否已读"的标量字段,但这个字段是用户维度的,不能直接存在向量上(因为同一条内容对不同用户已读状态不同)。
所以更实际的做法是:为每个用户维护一个独立的"已读集合",查询时先用向量检索召回一个较大的候选集(比如 TopK 的 3-5 倍),然后在应用层用已读集合做一次过滤。
这个方案的好处是:Milvus 只负责向量召回,不背过滤的锅;已读集合放在 Redis 或者内存里,用哈希结构做 O(1) 判断,几万条也就几 MB 内存,判断几万次是微秒级的事。
代价是召回数量要放大,因为过滤会砍掉一部分。但实测下来,召回放大 3 倍带来的向量计算增量,远小于not in带来的过滤开销。
5.2 方案二:用分区或者多集合隔离
如果已读状态可以接受一定的延迟同步,可以考虑按"已读/未读"做分区。但用户维度太细,分区数量会爆炸,这个方案只适合粗粒度场景,比如"全局黑名单"这种。
对于"全局排除一批 ID"的场景(比如下架内容、违规内容),分区或者单独的排除集合是可行的。查询时先查主集合,再在应用层剔除排除集合里的结果。
5.3 方案三:位图标记 + 定期重建
如果一定要在 Milvus 侧做过滤,可以用位图字段。Milvus 支持BINARY类型的向量或者用标量数组模拟位图,但用户维度的位图没法直接存。
折中方案是:把"已读"这个状态做成一个独立的标量字段,但用分片的方式——比如按用户 ID 哈希到不同的集合,每个集合里存该分片用户的已读标记。这样单次查询的过滤范围就小了。不过这个方案工程复杂度高,除非规模真的很大,否则不推荐。
5.4 方案选型对比
| 方案 | 改动成本 | 性能收益 | 适用场景 |
|---|---|---|---|
| 应用层过滤 | 低 | 高 | 已读、个性化排除 |
| 分区/多集合 | 中 | 中 | 全局黑名单 |
| 位图标记 | 高 | 中高 | 大规模、状态稳定 |
| 维持 not in | 无 | 无 | 列表很短(<1000) |
我的选择是方案一,因为它改动最小、收益最直接,而且符合"状态归状态、查询归查询"的架构原则。
6. 落地细节:应用层过滤怎么写才不踩坑
6.1 召回放大倍数的确定
召回放大不是拍脑袋定的。我的做法是:统计已读列表长度和候选集规模的比例,取一个覆盖 P95 的倍数。比如已读 1 万条、候选池 100 万条,已读占比 1%,那召回放大 1.5 倍基本够用。但如果已读占比到 10%,就得放大到 3 倍以上。
公式大致是:召回数 = TopK / (1 - 已读占比) × 安全系数。安全系数取 1.2-1.5。
6.2 已读集合的存储选型
Redis 的 Set 结构最顺手,SISMEMBER是 O(1)。但几万条的 Set 在高并发下也有网络往返成本。如果 QPS 很高,可以考虑把已读集合缓存在应用进程内存里,用本地哈希表,定期从 Redis 同步。
我用的是本地缓存 + Redis 兜底:热用户的已读集合常驻内存,冷用户走 Redis。内存占用控制在几百 MB 以内。
6.3 过滤后的补召回逻辑
应用层过滤有个副作用:如果过滤太狠,返回条数可能不够。这时候需要补召回——再查一次 Milvus,把上次的 offset 往后挪,继续过滤,直到凑够条数或者达到最大重试次数。
这里要注意设置最大重试次数,否则极端情况下(比如用户已读了 99% 的内容)会陷入无限补召回。我的做法是重试 3 次,还不够就返回实际条数,前端做兜底展示。
6.4 一个容易忽略的坑:过滤顺序
应用层过滤时,先过滤再排序还是先排序再过滤,结果完全不同。正确做法是:Milvus 返回的是按相似度排序的结果,你在应用层过滤时,要保持这个顺序,过滤掉已读的,剩下的自然还是有序的。千万别过滤完又重新排序,那会打乱相似度顺序。
7. 优化后的实测对比与长期观察
7.1 延迟对比
| 方案 | P50 | P99 | 备注 |
|---|---|---|---|
| not in 50000 | 620ms | 1800ms | 优化前 |
| 应用层过滤 | 18ms | 45ms | 优化后 |
| 应用层过滤 + 补召回 | 22ms | 70ms | 含补召回 |
P99 从 1800ms 降到 70ms,提升超过 25 倍。而且这个数字不随已读列表增长而恶化,因为已读集合的判断是 O(1),列表长度只影响内存占用,不影响单次判断耗时。
7.2 长期观察:稳定性才是关键
优化上线后我盯了两周。最明显的感受是:延迟曲线变平了。以前随着用户活跃度上升,P99 会缓慢爬升,现在基本是一条水平线。这说明我们消除了那个"随业务增长而劣化"的结构性隐患。
另一个收益是 Milvus 的负载下降了。以前大量 CPU 花在过滤和位图构建上,现在这些算力都省下来了,同样的硬件能扛更高的 QPS。
7.3 什么情况下 not in 还是可以用的
不是说not in一无是处。如果排除列表很短(比如几百条以内),或者查询频率很低(离线任务),用not in完全没问题,代码还简单。判断标准是:排除列表长度 × 查询频率 × 候选集规模,这三个乘起来如果不大,就别过度设计。
我给自己定的红线是:排除列表超过 1000 条,或者查询 QPS 超过 100,就考虑重构。
8. 几个相关的坑和延伸思考
8.1 别用 not in 做"软删除"
有人用not in来排除已删除的内容 ID。这个场景其实更适合用标量字段is_deleted做等值过滤,因为删除状态是内容维度的,可以存在向量上。用not in排除删除 ID,等于把删除列表也塞进查询条件,同样会踩坑。
8.2 表达式长度是有上限的
Milvus 对表达式长度有隐式限制,太长的表达式可能直接被拒绝或者解析超时。我遇到过表达式超过 1MB 时查询直接失败的情况。所以就算你要用not in,也得控制列表长度,别指望它能扛几万条。
8.3 索引不是万能的
这次排查最大的收获是:索引解决的是"查找"问题,解决不了"排除"问题。否定式语义天然和索引不友好,这是信息检索领域的老问题,不是 Milvus 的锅。理解这一点,很多性能问题就能提前避开。
8.4 监控要盯"表达式长度"这个指标
常规监控盯 QPS、延迟、错误率,但这次问题暴露后,我加了一个自定义指标:查询表达式的平均长度。这个指标一旦上涨,就说明有人在往查询里塞大列表,能提前预警。这个经验分享给做类似系统的朋友,挺有用的。
最后说个个人体会:性能问题往往不是某个参数没调好,而是数据模型和查询语义不匹配。已读状态是状态,不是查询条件,把它放对位置,问题自然就消失了。与其在 Milvus 的参数里反复折腾,不如先想清楚"这个数据到底该存在哪、该在哪个阶段参与计算"。这个思路,比任何调参技巧都值钱。