先说你最可能在哪儿遇到这个问题:电商搜索、信息流推荐、垂类内容筛选,凡是后端存在"先用 Redis 出候选、再交给 ES 做精细化过滤"这种两段式检索的系统,都躲不开一个诡异的线上现象——用户明明没做错任何操作,前端却返回了"没有找到相关商品"或者"暂无数据"。
我排查过不止一次这类事故,链路看多了之后发现,问题的根源非常统一:Redis 先召回 → ES 再过滤,如果 ES 的过滤逻辑把 Redis 召回的候选全灭了,下游又没有任何兜底,用户看到的就是一张空页面。行业里管这种情况叫"后过滤召回塌陷"。本文就围绕这个场景,把塌陷的原因、防线、兜底方案和数据一致性治理一次讲透,重点是回答标题那个问题:全部被过滤掉之后,系统到底该怎么办。
1. "后过滤"塌陷长什么样:一次线上空结果事故复盘
1.1 用户搜索"iPhone 15 蓝色 256G",结果一个都没剩
先还原一个我实际处理过的案例。某电商 App 的商品搜索,当时架构是:
- 用户输入 query → 先到 Redis 里取一批商品 ID;
- Redis 里存的是运营配置的热门商品池、类目热销榜、用户最近浏览过的商品等轻量级列表;
- 拿到这批 ID 后,再去 ES 里根据完整的商品文档做过滤,比如品牌、颜色、存储容量、库存、配送区域、优惠券可用性等;
- ES 返回过滤后的结果,直接展示给用户。
某天线上监控发现,"iPhone 15 蓝色 256G"这个搜索词的空结果率突然飙到 30% 以上。用户搜索后看到的不是商品,而是"没有找到相关商品,换个关键词试试吧"。
跟进链路数据后真相很简单:Redis 里热销榜的 iPhone 15 有 200 个商品 ID,但 ES 过滤时叠加了五个条件——品牌包含 Apple、颜色为蓝色、存储容量 256G、当前库存大于 0、配送区域覆盖华东。五个条件用 And 串起来之后,200 个 ID 最终只剩 0 个。
问题不在 ES,也不在 Redis,而在于:后过滤本身就是一个天然存在"塌陷概率"的环节。只要过滤条件不是恒真,那么"候选集被全部过滤掉"就是一个概率事件,条件越多、候选集越小,概率就越高。
1.2 Redis先召回、ES再过滤,问题恰恰出在"再"
"后过滤"这个词,对应的是"前过滤"。前过滤是在数据写入时就按规则分桶,查询时直接命中某个桶,比如"华东区商品桶""高转化商品桶",里面放的都是已经符合过滤条件的数据。这种模式的好处是查询快,坏处是规则一变,桶就要重建,灵活性差。
后过滤则是先粗召回、再精过滤,Redis 负责粗召回,ES 负责精过滤。这种两段式架构很常见,但很少有人认真想过:Redis 召回与 ES 过滤之间,存在一条信任边界。
Redis 说"这些是候选",ES 说"这些才符合条件"。两者一旦出现数据不同步、规则口径不一致、召回量过小,信任就崩塌了,表现就是过滤后结果为 0。
从系统架构看,Redis 召回和 ES 过滤其实是两个团队、两套数据、两套更新链路,中间通常只靠消息队列异步同步。只要一次同步延迟,或者一次数据写失败,Redis 里的 ID 在 ES 里就可能是"不存在""已下架""库存为 0"的状态。这种脏数据一旦被过滤逻辑扫过,自然就没了。
1.3 塌陷是概率事件,不是bug
很多人第一次遇到"全部被过滤掉"时,第一反应是查 bug,但查到最后发现每个环节都"没毛病":Redis 有数据,ES 过滤逻辑正确,数据同步也正常。那为什么结果是空?
因为塌陷是概率性故障,而不是确定性 bug。候选集大小、过滤条件数量、用户画像匹配度、并发下缓存状态,这些因素叠加后,某些 query 在某个时间片内就是会被过滤空。
我做过一个统计:当 Redis 召回的候选数量小于 20 个时,过滤后为空的比例超过 15%;候选数量在 100 个以上时,这个比例降到 1% 以下。这不是某段代码写错了,而是数学上必然存在的长尾。如果你的系统没有针对这个概率做防线,那么每天线上都会有小流量用户撞上空结果,区别只是你有没有监控到。
2. 为什么Redis召回的数据,会被ES一把梭清空
2.1 Redis擅长的是"快",不是"精细化过滤"
很多人对 Redis 有误解,觉得 Redis 又 has his own kind of "memory" nuance。实际上 Redis 的核心价值是 O(1) 或 O(logN) 的读写性能和丰富的数据结构,适合存 ID 列表、排行榜、用户最近访问、去重集合这类轻量数据。
但 Redis 不适合做复杂条件过滤。你可以用 SINTER、SUNION 做集合运算,也可以用 Lua 脚本做简单逻辑,可一旦涉及"库存大于 0 且品牌为 Apple 且配送区域覆盖华东"这种多字段联合过滤,Redis 就力不从心了。强行把所有过滤维度都塞进 Redis,要么数据冗余爆炸,要么维护成本高到没法看。
所以在两段式架构里,Redis 的定位是快速缩小范围——从千万级商品里先圈出几百个"可能相关"的候选,真正的精细化过滤交给 ES。这个定位本身没问题,问题在于 Redis 圈出的候选往往只基于一个维度,比如"热销""最近浏览""运营推荐",而 ES 过滤是基于全维度。当用户画像、筛选条件、库存状态这些维度一起压上来时,单维度的候选根本扛不住。
2.2 ES过滤条件一多,And逻辑会"吞噬"整个候选集
ES 里的过滤,本质上是布尔 And 逻辑。你写的每个 filter 子句,都是在做一次"淘汰赛"。候选集在每一轮都会被砍掉一部分,条件越多,最后剩下的就越少。
举一个我常用的数据来说明:
| Redis召回候选数 | 过滤条件数 | 单条件平均通过率 | 最终结果数 |
|---|---|---|---|
| 200 | 1 | 80% | 160 |
| 200 | 2 | 80% | 128 |
| 200 | 3 | 80% | 102 |
| 200 | 5 | 70% | 33 |
| 200 | 4 | 50% | 12 |
| 20 | 5 | 70% | 0 |
注意最后一行:候选集小的时候,即使每个条件通过率都不低,连乘之后也会归零。这不是 ES 的问题,是概率问题。如果你的过滤条件里有"库存 > 0"这种实时性很强的条件,而 Redis 里的候选本身是从缓存里来的,那么塌陷概率会更大。
2.3 数据不一致:Redis列表里躺着ES早就删掉的ID
除了概率因素,数据不一致是"全部被过滤掉"最常见的确定性原因。
我见过几种典型的脏数据来源:
- 商品在运营后台下架了,MySQL 状态改了、ES 也更新了,但 Redis 里的热销榜还是旧数据,因为榜单缓存过期时间是 30 分钟;
- 运营把某个商品移出活动页,活动页缓存由 Redis 管理,但商品的库存、配送区域等字段由 ES 管理,两边更新不是原子的;
- 数据同步链路用了消息队列,但某次消费者报错后没有重试,消息丢了,Redis 和 ES 从此分叉;
- 多机房部署时,Redis 主从切换导致部分数据未同步,ES 那边却是完整的。
ES 过滤时遇到这些"幽灵 ID",要么 doc 不存在被过滤掉,要么 doc 存在但状态字段不满足条件被过滤掉。这也就是为什么,你会看到 Redis 里明明有几十个候选,ES 过滤后一个都不剩。
3. 第一道防线:过滤前先给召回量做体检
既然塌陷是概率事件,而且数据不一致很难完全消灭,那么第一步不是设计兜底,而是在过滤逻辑执行之前加一道体检,尽量别让 ES 有机会把候选集清空。
3.1 召回量小于阈值时,跳过精细化过滤
我建议在 Redis 召回之后、ES 过滤之前,先判断候选集数量。如果候选数量低于某个阈值,就不要走完整过滤链路,而是走"安全过滤"模式。
这里说的"安全过滤",是指只剔除那些绝对不能展示的数据,比如已删除、已违规、未上架。至于库存、配送区域、价格区间这类"软条件",直接忽略。
关键问题是阈值怎么定。我自己的经验是:先看历史日志,统计"过滤后为空"时对应的平均召回量,取一个分位点。比如线上数据显示 95% 的空结果事故中,召回量都小于 30,那阈值就定在 30。另外还要估算一下当前业务里"冷门 query 的召回量分布",不要让阈值误伤正常场景。
代码逻辑上大体是这样:
public SearchResult search(SearchRequest request) { // 1. Redis 召回 List<String> candidateIds = redisRecaller.recall(request); // 2. 召回量体检 if (candidateIds.size() < RECALL_MIN_THRESHOLD) { // 候选太少,不做过细过滤,只做安全过滤 List<Item> safeItems = esFilter.safeFilter(candidateIds); if (!safeItems.isEmpty()) { return buildResult(safeItems, true); } // 安全过滤也空了,才走完整兜底链路 return fallbackChain.fallback(request, candidateIds); } // 3. 正常后过滤 List<Item> filteredItems = esFilter.strictFilter(candidateIds, request); if (filteredItems.isEmpty()) { return fallbackChain.fallback(request, candidateIds); } return buildResult(filteredItems, false); }这个逻辑看起来简单,但很多人不敢做,怕"跳过过滤"会展示不符合条件的数据。我的观点是:在召回量极小的情况下,展示几条"不完全满足软条件"的数据,远比给用户一个空页面要好。你可以在前端标注"已为您放宽筛选条件",用户是能理解的。
3.2 分级过滤:安全条件、硬条件、软条件各管各的
更进一步的做法,是不要把过滤条件一锅端扔给 ES。把条件分成三档:
| 条件类型 | 举例 | 过滤失败时怎么办 |
|---|---|---|
| 安全条件 | 商品未删除、未违规、已上架 | 不能放宽,放宽了会出合规风险 |
| 硬条件 | 用户主动筛选的品牌、类目、是否有货 | 尽量保留,若为空可提示用户调整 |
| 软条件 | 价格区间、配送时效、优惠券可用 | 可以逐级放宽,不影响核心意图 |
在实现时,ES 的 filter 子句不要一次性全部拼接。先拼安全条件,过滤一次;如果结果为空,说明候选集本身就全是脏数据,直接走兜底。如果安全条件过滤后有结果但不满足硬条件,就先去掉硬条件和软条件,只保留安全条件再查一次。
这里有一个细节:硬条件里的"用户主动筛选"和"系统默认条件"要区分开。用户主动选择了"只看有货",那你要谨慎放宽;如果只是系统推荐流里的默认过滤,放宽后用户感知不强。最简单的做法是,把用户显式选择的筛选条件传到前端,由前端在空结果页给出"清除筛选条件"的按钮,后端同时悄悄返回放宽后的结果。
4. 真正要回答的问题:全部被过滤掉之后怎么办
防线只能降低塌陷概率,不能消除它。所以这一节才是核心:当 ES 过滤后结果为空,下一步怎么走。我会按优先级给出四级兜底链路,每一级都要承担一部分流量。
4.1 一级兜底:透传Redis原始候选,但先剔除安全硬伤
ES 过滤为空时,最直接、成本最低的兜底就是"信任 Redis 一次",把 Redis 召回的原始候选直接返回。
注意,这里不是原封不动全量返回。你需要做一层极轻量的过滤:查一下这些 ID 在 ES 里是否存在、是否处于上架状态、是否被运营强制下架。安全的做法是用es.mget批量查询这些 ID 的基础状态,而不是走完整的过滤链路。
为什么要保留这层轻过滤?因为直接返回已经删除的商品,用户点进去会看到 404 或"商品已下架",体验更差。保留基础状态校验,能避免大多数投诉。
透传时的代码大概是这样:
private List<Item> transparentFallback(List<String> candidateIds) { // 只查基础状态,不查复杂过滤条件 Map<String, EsItem> docs = esClient.mget(candidateIds, "status", "deleted"); return candidateIds.stream() .map(docs::get) .filter(Objects::nonNull) .filter(doc -> doc.status == 1 && !doc.deleted) .collect(toList()); }透传后,排序默认按 Redis 列表里的顺序来。因为 Redis 列表往往是按热度、运营权重排好的,直接返回比随机排序要好。
4.2 二级兜底:多路召回,别把鸡蛋放在Redis一个篮子里
只靠 Redis 一路召回,本身就是架构缺陷。Redis 里的数据再多,也只是一个视角——热度/运营视角。用户真实意图可能藏在 ES 的倒排索引里、用户行为序列里、向量语义空间里。
我强烈建议把召回做成多路:
- Redis 路:热销榜、运营推荐、用户最近浏览;
- ES 路:基于 query 的 BM25 全文召回;
- 向量路:基于 embedding 的语义召回;
- 协同过滤路:基于用户行为相似度的"看了又看"。
在 ES 过滤之前,先把多路召回结果做一次合并去重,再统一交给过滤层。这样即使 Redis 路召回的数据被全部过滤掉,其他路召回的 ID 也可能通过过滤,不会一空俱空。
多路合并时要注意权重设计。我的实践是:不提前硬切,把每路召回的 ID 都带上来源标签和分数,合并后先按"是否满足硬条件"过滤,再按综合分排序。同一批候选,Redis 路分数高不代表一定排前面,ES 路的语义匹配分数也要参与比较。
4.3 三级兜底:配置化兜底池,让空结果直接变推荐位
多路召回也可能全部被过滤掉,特别是用户筛选条件特别苛刻时。这时就需要"人为干预"的兜底池。
我在系统里维护了一个运营配置兜底池,本质是一张表/一个配置项:当某个 query 或某个筛选组合过滤后为空,就返回运营预先配置好的推荐商品。比如搜索"iPhone 15 蓝色 256G"过滤为空,兜底池配置了"iPhone 15 全系热销 Top 10",用户看到的就不再是空页面。
这个兜底池有两个实现要点:
- 必须可动态更新,用配置中心(如 Apollo、Nacos)管理,紧急时刻运营能改,不需要重启服务、不需要发版;
- 要有优先级,先匹配 query 级兜底配置,再匹配类目级兜底配置,最后落到全局兜底配置。
很多人会犹豫:"这不是作弊吗?用户搜的是蓝色,我给推黑色?"——但你要想清楚,空结果对用户的伤害远大于推荐不完全匹配的商品。用户搜冷门组合,本身就说明 ta 的需求不常见,你给一个相关但不完全匹配的结果,再加上"已为您展示更多相似商品"的提示,用户能接受。
4.4 四级兜底:全局热榜,永不落空的最后防线
配置化兜底池需要人去维护,不可能覆盖所有 query。最后一层兜底,是准备一个永不落空的候选池,比如全局热榜 Top 500。
这个池子的特点是:
- 覆盖全品类,不分 query,任何时候都有数据;
- 只做安全过滤,不做用户意图匹配;
- 从 Redis 和 ES 两边定期同步,保证池子里都是有效商品。
当上面三级兜底都为空时,直接返回全局热榜。雖然用户会感觉结果"不太相关",但至少不是空页面,用户还有继续逛下去的机会。热榜同时要承担新用户冷启动、无结果页推荐这两个场景,所以它是整个兜底链里的最后保险丝。
4.5 兜底方案选型对比
| 兜底级别 | 适用场景 | 优点 | 风险 | 是否推荐 |
|---|---|---|---|---|
| 透传Redis候选 | 候选集小、过滤误杀 | 成本低、速度快 | 可能展示不完全满足条件的数据 | 强烈推荐 |
| 多路召回合并 | 单路召回质量差 | 召回覆盖面广 | 增加接口耗时 | 强烈推荐 |
| 配置化兜底池 | 高频空结果query | 体验可控、运营干预强 | 依赖人工维护 | 推荐 |
| 全局热榜 | 极端冷门场景 | 永不落空 | 相关度低 | 保底必做 |
我见过的很多系统,只做了透传兜底,没有多路召回,也没有配置化兜底池。结果就是"空结果"变成"不太相关的商品"——虽然比空页面好,但离好的体验还差得远。四层都上,才算是完整的兜底链路。
5. 比兜底更重要的是:让Redis和ES别再"打架"
兜底解决的是"出了问题怎么办",但根本问题是"Redis 和 ES 数据经常不一致"。如果两边数据始终一致,塌陷概率会大幅下降,兜底也只是极少数的保险。
5.1 数据同步:从MQ异步到Canal订阅binlog
Redis 和 ES 的数据同步,常见的有两条链路:
- 应用双写:业务代码在写 MySQL 后,同时发 MQ 消息,消费者分别更新 Redis 和 ES。
- Canal 订阅 binlog:让 Canal 监听 MySQL binlog,解析变更后推给下游,下游更新 Redis 和 ES。
两条链路各有优劣。双写逻辑简单,但业务代码侵入强,容易漏发消息;Canal 对业务无侵入,能保证 MySQL 与 ES 的最终一致,但它只管 MySQL,Redis 里的榜单、热度这类衍生数据还得单独处理。
我目前比较推荐的组合是:MySQL 作为唯一数据源,Canal 同步到 ES;Redis 里的榜单/推荐列表由单独的定时任务从 ES 或 MySQL 重建,不直接接收业务写操作。这样 Redis 的数据不是"实时写"的,而是"周期重建"的,反而更容易保证干净。榜单缓存过期时间设为 5~10 分钟,即使重建期间有数据变化,最多也就是几分钟的延迟。
5.2 定期对账:把Redis里的"幽灵ID"清理掉
再好的同步链路,也会因为网络抖动、消息丢失、消费者报错而出现不一致。所以必须有对账任务。
对账的思路不复杂:扫描 Redis 候选列表中的所有 ID,批量去 ES 里查这些 ID 是否存在、状态是否有效,把"幽灵 ID"清理掉。频率可以每天一次,放在凌晨低峰期。
注意,对账任务不要把 ES 当作唯一真相。有些商品在 ES 里可能因为索引重建暂时查不到,简单粗暴地删除 Redis 里的 ID,反而会把好数据误杀。建议先标记为"待确认",连续多次对账都不存在再删除。
抓过一次线上事故后,我把清理策略从"不存在就删"改成了"连续三次不存在才删",误杀率从 3% 降到 0.01% 以下。
5.3 空结果率、透传率、兜底率的立体监控
最后是监控。很多人只监控接口报错,不监控"返回空结果"这种业务层异常,导致塌陷事故一直在发生,却没有任何告警。
我建议至少监控三个指标:
| 指标 | 定义 | 告警建议 |
|---|---|---|
| 空结果率 | ES 过滤后为空的请求数 / 总请求数 | 超过 5% 触发 P2 告警 |
| 透传率 | 走透传兜底的请求数 / 总请求数 | 超过 10% 说明过滤或召回有问题 |
| 兜底率 | 走任意兜底的请求数 / 总请求数 | 超过 15% 需要排查数据一致性 |
这三个指标要按 query 维度、渠道维度、筛选条件维度拆开看。比如"华东区 + 仅看有货"这个组合的空结果率特别高,可能不是技术问题,而是业务上华东区确实缺货,需要运营补货或调整筛选选项。
6. 落地时最容易被忽略的几个细节
前面把兜底链路讲完了,但真正落地时还有几个小坑,我单独拎出来讲讲。
6.1 透传后的排序、分页、去重怎么处理
透传 Redis 候选时,最容易出现的问题是分页错乱。比如第一页走了正常过滤,用户翻到第二页时 ES 过滤为空,走了透传,两页的数据来自完全不同的集合,用户会觉得"越翻越奇怪"。
最好的办法是:一旦本次请求走了兜底,整个分页都用兜底数据源。前端要感知这个状态,在翻页时带上"兜底模式"的参数,后端统一从 Redis 候选里分页,而不是普通结果一页、兜底结果一页。
去重也别忘了。Redis 候选列表本身一般是去重的,但如果做了多路召回合并,不同路的 ID 可能重复。合并时用 Set 去重,同时保留第一路的分值。
6.2 放宽过滤条件,放到什么程度才算合理
放宽条件不是无脑放。我的建议是排一个"放宽顺序",按业务影响从小到大排:
- 配送区域、配送时效;
- 价格区间(比如用户选了 3000-4000,放宽到 2500-4500);
- 品牌偏好(用户只是默认偏好,不是硬筛选);
- 库存状态(这个要谨慎,用户如果主动选了"仅看有货",放宽后体验很差);
- 安全合规条件(永远不放宽)。
每次只放宽一级,不要一次把所有条件全砍掉。放宽后如果还有结果,就在前端提示"已为您放宽部分筛选条件";如果放宽到最宽松仍然为空,才走 4.3 和 4.4 的配置化兜底和热榜。
6.3 兜底结果要埋点,更要在前端"说人话"
做兜底不是为了糊弄监控,是为了让用户体验不塌方。所以兜底发生时要埋点,记录:
- 触发兜底的是哪一级(透传/多路/配置池/热榜);
- 用户当时的 query 和筛选条件;
- 兜底结果用户点击率、停留时长、下单率。
有了这些数据,你才能持续优化。比如发现某个 query 经常触发配置化兜底,说明 ES 召回本身有问题,应该去优化召回而不是一味靠兜底撑着。
前端也要配合。透传或放宽条件时,要在页面上明确告诉用户"已为您放宽筛选条件"或"已为您展示更多相关商品"。用户不怕结果不完全匹配,怕的是系统"装死"——明明过滤条件把他想要的东西全滤掉了,还假装什么都没发生。
我在实际项目中还发现一个规律:空结果页如果加上"清除筛选条件"按钮,用户点击率非常高。这说明用户并不是不需要结果,而是筛选条件组合得太死了。与其让用户手动清除,不如后端主动放宽一级条件,再用 UI 告知用户,转化率会明显更好。
最后说一句我自己的体会:后过滤召回塌陷这个问题,单纯加兜底能治好标,但治不了本。真正要长期做的是三件事——召回量体检、数据一致性对账、空结果监控。这三件事和兜底链路加在一起,才能让你的系统在 Redis 和 ES 偶尔"打架"的时候,依然稳稳地给出用户想看的东西。