ES 挂了、搜索页白屏、导航栏的搜索框变成装饰——根因通常不在集群,而在搜索被当成不可失败的强依赖。降级要回答的不是"怎么让 ES 更稳",而是"它不稳时,这个页面还能给什么有用的东西"。
1. 四种失败,四套兜底
把"搜索坏了"拆开看,四种失败的处置完全不同:
| 失败类型 | 表现 | 兜底手段 | 用户感知 |
|---|---|---|---|
| 集群不可用 | 连接拒绝、全节点超时 | 缓存 / DB 模糊查 / 分类浏览 | 从"搜索"变"逛" |
| 查询超时 | 部分请求超预算 | 截断结果 + 提示 / 走缓存 | 结果变少但可用 |
| 结果为空 | 正常返回 0 条 | 不一定降级,可能要改写查询 | 关键:可能是正确答案 |
| 结果质量差 | 有结果但排序离谱 | 换排序策略 / 规则干预 | 最隐蔽,最难兜 |
前两类是可用性问题,后两类是相关性问题。混成一个"降级开关"是最常见的错:可用性要立刻兜底,相关性要观察评估,触发条件与回滚代价都不同。
2. 降级的三层与体验落差
| 层级 | 手段 | 延迟量级 | 体验落差 | 适用 |
|---|---|---|---|
| L1 | 缓存结果(同 query 近期结果) | 毫秒 | 小,可能过期 | 热点查询、短暂抖动 |
| L2 | DB 模糊查询(LIKE/前缀) | 数十到数百毫秒 | 中:召回骤降、无分词 | 数据量小、字段少 |
| L3 | 分类/榜单浏览 | 毫秒 | 大:从"找"变"逛" | 兜底,页面不空 |
选哪层不该由代码里的 catch 决定,而由查询的业务价值决定:搜商品名,L2 的 LIKE 有意义;搜一句自然语言,LIKE 基本等于空结果,L3 更诚实。
L2 的隐藏成本:LIKE '%x%' 用不上索引,全表扫会把搜索故障放大成数据库故障。降级路径必须限流,否则兜底就是二次事故。
3. "空结果"要不要降级
这处最容易做错。空结果经常是正确答案——搜了不存在的型号、拼错词、查已下架条目。此时降级去"猜他想搜什么",会把噪声塞进相关性统计。
判断要不要处理空结果,看四个条件:
| 条件 | 判据 | 处置 |
|---|---|---|
| 拼错 | 有纠正候选且置信高 | 提示"是否想搜 X",不自动替换 |
| 同义未覆盖 | 词典有映射、索引没覆盖 | 修词典与索引,不是降级 |
| 范围过窄 | 过滤条件清空结果 | 提示"去掉某条件有 N 条" |
| 确实无此内容 | 上述都不成立 | 保持空,给可浏览入口 |
判据:降级只该发生在"系统坏了",不该发生在"确实没有"。混在一起短期指标好看,长期是相关性数据被系统性污染。
4. 超时预算怎么分
搜索页有总预算(如用户能等的 800ms),要在"检索 + 聚合 + 渲染"之间分配,而不是每层各设一个超时:
long deadline = System.nanoTime() + budgetNanos; // 例如 600ms
SearchResponse r = client.search(req, RequestOptions.DEFAULT.toBuilder()
.setTimeout(TimeValue.timeValueMillis(300)) // 单次上限
.build());
long left = deadline - System.nanoTime();
if (r.isTimedOut() && left > retryFloorNanos) { /* 剩余够才重试 */ }
| 预算项 | 典型占比 | 超支后果 |
|---|---|---|
| 单次查询 | 总预算一半左右 | 没余量做重试或兜底 |
| 重试 | 仅在剩余足够时 | 总延迟翻倍,用户先走了 |
| 兜底(DB/缓存) | 预留固定额度 | 兜底比主链还慢,等于没兜 |
| 渲染与网络 | 固定开销 | 前面省的时间被序列化吃掉 |
重试必须有下限判据:剩余时间跑不完一次查询时,重试只把超时变成双倍超时。宁可立刻降级。
5. 熔断与慢查询保护
拖垮集群的很少是"流量大",而是少量慢查询占满队列:线程池被占住,正常查询排在后面,整体雪崩。
{
"search.default_search_timeout": "1s",
"search.max_concurrent_search": 200,
"thread_pool.search.queue_size": 1000
}
| 机制 | 作用 | 误配的后果 |
|---|---|---|
| 请求级超时 | 到点返回部分结果 | 太长则慢查询堆积 |
| 并发上限 | 限制同时查询数 | 太松等于没保护 |
| 队列长度 | 排队容量 | 太长则延迟失控 |
| 慢查询日志 | 事后定位 | 阈值太高等于不开 |
保护的目标不是让所有查询成功,而是让快查询保持快:宁可拒绝一批,也不让队列一起变慢。限流按请求数,这里按执行时间。
6. 缓存结果的时效标注
缓存兜底最大的风险不是"旧",而是用户不知道它旧。命中缓存时响应必须带得出时效:
{
"degraded": true,
"degraded_reason": "search_cluster_unavailable",
"result_cached_at": "2026-11-09T14:02:11Z",
"stale_seconds": 1260
}
| 字段 | 为什么必须有 |
|---|---|
degraded + 原因 |
前端可提示、监控可聚合、事后可归因 |
| 缓存时间与新鲜度 | 判断"能否接受"的依据 |
| 提示文案 | 容忍度取决于用户知道它旧 |
没有这些字段,降级是不可观测的:监控上是 200,体验上是错答案,没人知道发生过。
7. 降级的观测:降级率与降级后点击率
| 指标 | 定义 | 用途 |
|---|---|---|
| 降级率 | 降级请求 / 总请求 | 兜底是否在被大量使用 |
| 分层分布 | L1/L2/L3 占比 | L3 高则兜底质量差 |
| 降级后点击率 | 降级请求中有点击的比例 | 核心:兜底有没有用 |
| 降级后零点击率 | 降级后无任何交互 | 兜底等于白屏 |
| 降级时长 | 单次持续时长 | 抖动还是持续故障 |
只看降级率会得出反向结论:降级率 100% 但点击正常说明兜底有效;降级率 5% 但零点击翻倍说明少量用户在受重伤。兜底价值要用行为指标衡量,不是可用性指标。
降级还会污染相关性分析,所以每条搜索日志都要带 degraded 标记并按它拆维度:
| 污染方式 | 表现 | 修正 |
|---|---|---|
| CTR 下降 | 被归因为"排序变差" | 按 degraded 拆维度 |
| A/B 失真 | 实验组恰逢降级窗口 | 剔除降级样本 |
| 训练集污染 | 降级点击进了样本 | 打标,训练时排除 |
| 空结果率虚降 | 兜底把空结果填满 | 口径区分"有结果/兜底" |
没有这个标记,故障周的相关性结论无法回溯剔除。
8. 开关:人工迟滞与自动防抖
| 方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 人工开关 | 判断准 | 夜里没人按,迟滞数十分钟 | 计划内维护 |
| 自动开关 | 秒级响应 | 抖动导致反复切换 | 集群不可用 |
| 半自动 | 兼顾 | 仍需人 | 多数生产场景 |
自动开关必须防抖,否则会在临界点来回切:
// 连续 N 次失败才开,连续 M 次成功且持续 T 才关
if (failStreak.incrementAndGet() >= OPEN_THRESHOLD) state = DEGRADED;
if (state == DEGRADED && okStreak.incrementAndGet() >= CLOSE_THRESHOLD
&& now - lastFailAt > QUIET_PERIOD) state = NORMAL;
| 参数 | 含义 | 误配后果 |
|---|---|---|
OPEN_THRESHOLD |
连续失败几次降级 | 太小则抖动即降级 |
CLOSE_THRESHOLD |
连续成功几次恢复 | 太小则恢复又被打回 |
QUIET_PERIOD |
恢复前静默观察 | 太短则回切后再挂 |
防抖是用一点延迟换稳定。参数由"探测间隔 × 阈值"折算成响应时间,写进值班手册。
9. 多副本与多集群:切换不等于一致
| 拓扑 | 切换代价 | 一致性问题 |
|---|---|---|
| 同集群多副本 | 低(自动) | 副本刷新延迟,读到略旧 |
| 多分片 + 副本 | 低 | 主分片不可用时短暂不可写 |
| 双集群(同城) | 中,需切读路径 | 写入不同步则结果不一致 |
| 双集群(异地) | 高 | 延迟差异让排序与召回都变 |
关键判据:切换后用户会不会觉得"换了一个搜索"。双集群写入不同步时,切换会让热门结果消失、长尾冒出来——用户看到的是相关性崩塌,不是"稍旧"。
切换前要答:两集群滞后多少、切后能否只读、恢复后怎么补数据。答不上来就别做双活,单集群 + 缓存兜底更可控。
10. 恢复后的回切:避免来回抖
回切比降级更危险,因为此时流量已经习惯兜底路径:
| 策略 | 做法 | 风险 |
|---|---|---|
| 全量回切 | 探测成功即 100% | 集群未预热,二次雪崩 |
| 灰度回切 | 1%→10%→50%→100% | 需按比例路由能力 |
| 双读比对 | 回切期双读并比对 | 成本翻倍,能提前发现差异 |
| 延迟回切 | 恢复后静默观察再切 | 兜底期延长,但最稳 |
curl -s "http://es:9200/_cluster/health?wait_for_status=yellow&timeout=5s"
curl -s -XPOST "http://es:9200/app/_search" -d '{"size":1,"query":{"match_all":{}}}'
只探活不探查询是常见错误:节点起来了、分片还在恢复、查询走空分片,回切后立刻又是一轮降级。
11. 排错:五种降级相关故障
| 现象 | 原因 | 处理 |
|---|---|---|
| 开关开了页面仍空 | 兜底也失败,或结构不兼容 | 兜底要独立演练 |
| 降级率 100% 无人发现 | 无降级指标,只盯 5xx | 降级率纳入 SLI |
| 反复开关抖动 | 阈值太低、无静默期 | 加连续判定与静默期 |
| 兜底比主链还慢 | L2 全表扫描、未限流 | 限流 + 只允许前缀匹配 |
| 恢复后指标仍异常 | 降级样本混入相关性统计 | 按 degraded 拆分剔除 |
12. 边界:这些场景不值得投入兜底
| 场景 | 做法 | 理由 |
|---|---|---|
| 内部搜索、可接受白屏 | 直接报错 + 提示 | 用户能等 |
| 非核心路径(页脚搜索) | 隐藏入口 | 兜底成本高于收益 |
| 数据量极小 | 客户端过滤 | 不需要搜索集群 |
| 精确查(订单号) | 直接走数据库 | 搜索引擎不是唯一真相 |
| 兜底无法保证正确性 | 宁可不兜 | 错答案比没答案代价高 |
判据:兜底投入应与这条路径的业务权重成比例。主链搜索值得缓存 + 双集群 + 灰度回切;帮助中心的小搜索框,隐藏它比维护三层降级更合理。
13. 自测
- 可用性失败与相关性失败为什么要用不同的兜底策略?
- L2 退化查询最大的隐藏成本是什么?
- 什么情况下"空结果"不应该被降级填充?误判的长期代价是什么?
- 为什么重试必须带"剩余预算下限"这个判据?
- 熔断的保护目标为什么是"让快查询保持快",而不是"让所有查询成功"?
- 回切前只做端口探活会漏掉什么?