后过滤召回塌陷:Redis候选被ES全部过滤掉后的四级兜底方案

发布时间:2026/9/9 4:41:48
后过滤召回塌陷:Redis候选被ES全部过滤掉后的四级兜底方案 先说你最可能在哪儿遇到这个问题:电商搜索、信息流推荐、垂类内容筛选,凡是后端存在先用 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召回候选数过滤条件数单条件平均通过率最终结果数200180%160200280%128200380%102200570%33200450%1220570%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 召回 ListString candidateIds redisRecaller.recall(request); // 2. 召回量体检 if (candidateIds.size() RECALL_MIN_THRESHOLD) { // 候选太少,不做过细过滤,只做安全过滤 ListItem safeItems esFilter.safeFilter(candidateIds); if (!safeItems.isEmpty()) { return buildResult(safeItems, true); } // 安全过滤也空了,才走完整兜底链路 return fallbackChain.fallback(request, candidateIds); } // 3. 正常后过滤 ListItem 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 ListItem transparentFallback(ListString candidateIds) { // 只查基础状态,不查复杂过滤条件 MapString, 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订阅binlogRedis 和 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 偶尔打架的时候,依然稳稳地给出用户想看的东西。