一次依赖超时的故障推演

用一场明确虚构的订单查询事故,追踪回退误判、推荐降级与恢复退出,区分能算清的损失、能执行的动作和仍待演练的整改。

·17 minDevOpsSystem Design
把应用部署到土耳其|BRNCHOST · 土耳其 VDS
云服务器,积分可续期|雨云 · 国内外节点 · 积分兑换权益
低价年付,大流量 VPS|RackNerd · SSD 存储 · 1Gbps 端口
香港轻量,搭个小站|晚安云 · 香港云服务器
香港 VPS,大带宽可选|野草云 · BGP 直连
大陆访问,精品线路|搬瓦工 · CN2 GIA / CTGNet 套餐
资料归档,交给 AI 整理|WorkBuddy · 本地文件处理
建站起步,先看应用镜像|腾讯云 · 轻量应用服务器
CN2 GIA,中国方向优化|DMIT · Premium 网络
双 ISP 原生住宅 IP|丽萨主机 · 美国 9929 精品线路
高频 CPU,多地部署|Evoxt · 云服务器 · 每周异地备份
京东云轻量云主机:129元/年,新人专享,限购1台

09:00,订单查询开始变慢。用户打开订单页,要等推荐商品加载完成才能看到订单状态。数据库仍能返回结果,应用进程也活着,值班工程师最先看到的却是连接占用和依赖超时。十分钟前,团队刚把新版本放到两成实例上。

以下是一场虚构事故推演。时间、流量、版本比例和操作结果均为编写案例时设定的输入,不代表任何公司的真实事故。配套脚本只验证错误预算、重试倍数和退出判定的算术,未执行故障演练。读者可以替换这些输入,检查自己的手册是否还支持同样的选择。

案例中的查询路径先做鉴权,再读订单,最后同步等待推荐。推荐与订单读取共用应用侧的请求并发名额,但各自访问不同依赖。产品约定:用户在一秒内看到正确订单即算完成,推荐可以缺席;鉴权、归属判断和订单数据仍按原语义处理。这个约定决定了后面的降级边界。

稳定性工作的三个层次,在这场事故里各有一个交付物:发现影响的人交出受损用户旅程和范围,指挥止损的人交出有代价与撤销条件的动作,复盘负责人交出能重现失效条件的验收材料。同一团队可以并行做这些工作,值班现场则要按证据顺序推进。

09:02:先算丢掉了多少次查询

值班工程师拿到第一个完整分钟的结算:进入入口的有效查询有六千次,三千六百次在一秒内返回正确订单,另有两千四百次超时或超过约定时限。有效查询包括本来有资格完成、后来遭系统限流的请求;健康探测、无效凭证单列。本例暂不讨论客户端重试去重,六千指请求尝试数,无法据此宣称有六千名用户。

有人先看进程健康,判断“服务还在,影响可能有限”。这次判断遗漏了同步推荐等待。进程存活能解释机器仍可接收请求,却解释不了用户能否完成查询。值班工程师把事故通报改成“订单查询及时完成比例从设定基线的百分之九十九点九降到百分之六十”,并把推荐缺席和订单失败分开报告。

假设一个已经结算的评估窗口有一百万次有效查询,目标为百分之九十九点九,容许的不良请求数是一千。事故这一分钟的两千四百次不良请求,相当于该预算的百分之二百四十。这里拿固定分母做复算,实际滚动窗口的分母会随流量变化,值班者要同时标明评估窗口和事故窗口。

错误预算是一种损失尺度。它帮助负责人解释为什么眼下值得中断发布、调用产品负责人,至于是否已经触发冻结发布,则取决于团队事先约定的预算政策。把算例中的一千当作可随意花费的配额,会把用户受损当成常态。预算用尽以后,团队仍有恢复义务。

Google 在 SLO 告警章节用错误比例相对目标容许比例的倍数表示燃烧率,并讨论长短窗口组合。按这个定义,本例百分之四十除以千分之一得到四百倍。它没有告诉我们还能撑几分钟:繁忙程度会变,未来请求总量也未知。这里不展开告警配置,事故现场先保留这笔能够复算的损失。

09:03:回退有理由,归因还差一步

发布刚发生,回退是合理候选。值班工程师发现新版错误很多,于是把“新版造成故障”写进初始判断。事故负责人批准停止扩量,并把已发布的两成实例退回旧版。这项动作有一个前提:发布记录显示没有数据格式迁移,旧版仍能读当前数据,回退不涉及覆盖用户写入。

推演给出的下一条观测是:回退后又过了一个完整分钟,六千次有效查询中仍只有三千六百次及时完成。工程师再按版本拆开故障前的请求,发现旧版与新版在可比流量组中的失败比例接近。刚才的回退没有改善用户结果,“发布时间接近”不足以支持原来的因果判断。

这仍无法证明新版本毫无关系。两组实例可能共用推荐依赖,新版也可能先把共享资源拖入过载,随后连累旧版。负责人保留发布记录和两组样本,把结论降为“应用回退未产生可见恢复,推荐等待值得优先处理”。在事故中修正判断,比替第一个判断寻找更多解释更有用。

回退本身也有成本。替换实例可能重建连接、清空本地缓存,旧实例退出还可能中断在途请求。即使本例没有设定这些副作用,负责人也给回退安排一个观察周期;第一轮效果不明时,继续反复回退重启会搅乱证据。记录员写下动作完成时间,供后续区分自然恢复和操作效果。

此时把角色压到最小即可:事故负责人决定下一项动作,应用值班者执行,另一人记录时间与业务影响。产品负责人只确认推荐缺席的展示语义和损失容忍范围,无须在群里审批每条查询。小团队可以兼任角色,但操作期间由谁盯着退出条件,要在开始前说清。

09:05:三个动作,三种不同的损失

依赖侧的下一条虚构观测显示,推荐调用开始超时;订单读取在取得执行机会后仍能完成,但许多查询已在应用等待队列里消耗了一秒预算。调用记录还有一次初始调用之后的两次重试。工程师提出扩大并发,另一人提出把入口砍半,产品负责人提出先隐藏推荐。

扩大并发可以把更多等待中的请求推进去,也可能把更多超时调用压到推荐依赖上。当前缺少推荐容量余量的证据,负责人没有选择扩容应用并发。把连接数或线程数调大通常很快,回收新增在途工作却没那么快;动作的可逆性包含资源何时释放,而不只是配置值能否改回。

入口砍半有可见的损失上限,却也会拒绝原本可以完成的查询。设想它拒绝三千次,让余下三千次里有两千九百九十七次成功。只看准入后的错误率,报表会显示千分之一;按最初六千次有效尝试结算,成功比例只有百分之四十九点九五,比先前百分之六十还低。这个反例解释了为什么拒绝量留在同一张损失账里。

限流仍有适用场景。如果核心写入已受拖累、可选依赖没有独立开关,拒绝低优先级查询可以保护更重要的工作。此时负责人应明确受保护的旅程与被牺牲的旅程,分别观察完成量。若瓶颈集中在单个租户,身份识别和公平性规则又足够可靠,定向限制可能比全站砍半更合适。本例的证据尚未指向租户倾斜。

停用推荐的直接代价是订单页少了一块商品展示,推荐点击和转化可能下降。好处是它切断了已知正在等待的可选路径,不再让核心订单查询占着名额等待推荐。负责人选择这个动作,因为产品契约事先允许缺席,应用也已有经过兼容检查的空推荐响应。若开关只隐藏前端组件,后台仍继续调用,预期的止损就不会发生。

这次取舍不适用于权限服务。权限未知时显示别人的订单属于正确性事故,成功率升高也无法抵消泄露。也不适用于依赖计算的是应付金额、库存承诺或订单归属的系统。把“某项依赖超时就跳过”做成通用中间件,会把业务语义藏进基础设施。动作表里要写具体字段和客户端行为。

09:07:开关切下去,旧请求还在跑

应用值班者先在一个小流量组停用推荐,确认订单字段与鉴权结果一致,再扩到全部查询。记录员记下实际覆盖范围。推演中第一分钟的及时完成量升到五千九百九十四次,六次失败;推荐返回明确的缺席结构,客户端不再为补齐推荐重试整个订单接口。它是核心查询恢复、可选功能仍降级的状态。

开关生效只影响新请求。已经发出的推荐调用还可能占着并发名额,应用放弃等待也不保证下游停止执行。负责人观察在途量是否下降,把“新调用停止”“旧工作排空”作为两项不同结果。如果旧请求持续悬挂,他才进一步处理取消传播或隔离实例,避免一看到开关变色就宣布止损结束。

重试让这个排空过程更难判断。三层各允许最多三次尝试,在每次失败都触发下一层完整重放的假设下,最底层会收到二十七次尝试。把重试集中到一个层级、仍允许最多三次,才得到三次;故障期间暂时关闭重试则为一次。这些数值包含初始尝试,措辞成“三次重试”会多算一轮。

Amazon Builders' Library 的超时与重试文章解释了多层重试的乘法放大,也讨论退避与抖动的作用。本例据此优先暂停推荐重试,但没有宣称抖动能解决容量不足。若依赖持续无法服务,延迟到达的重试仍会占用容量;恢复前留下的大量重试还可能形成第二轮冲击。

订单查询是只读操作,这场推演不用处理重复扣款。迁移同样的止损动作到写接口时,调用方超时只说明没有按时拿到结果,服务端可能已经提交。操作者先查业务结果或幂等记录,再决定重放。把查询手册直接套到支付接口,会跨过本例没有证明的安全边界。

09:12:核心恢复和撤销降级分两次批准

成功比例回升之后,负责人没有马上恢复推荐。他先检查三个连续五分钟窗口:每个窗口至少三万次有效尝试,及时正确完成比例不低于百分之九十九点九,积压不增长,最老等待不超过两秒,且没有新增的归属或鉴权错误。采集异常会使这次判定失败。这是本案例的演练退出门槛,流量较低的团队可换成更长窗口和明确样本下限。

退出门槛的数值也有用途边界。三万次里允许三十次不良结果,刚好达到目标,只能说明这些已观察窗口符合规则,无法证明未来达标或月度目标已经恢复。三次连续通过降低了被短暂回落误导的机会,仍不是统计上的长期可靠性证明。故障结算保留先前损失,恢复动作没有把它抹掉。

在推演时间线上,09:12只拿到从09:07开始的第一个五分钟窗口。如果后续两个窗口也通过,最早到09:22才能完成核心恢复验收。负责人在这十分钟里继续通报“核心改善,观察中”,避免把第一分钟回升误报成全部恢复。推荐重启的分阶段观察还要另算时间,值班交接不会因此省略剩余步骤。

假如流量从每分钟六千掉到一百,百分之百成功也不满足上述门槛。用户可能已离开,也可能入口误挡请求。负责人把入口有效尝试、成功完成、拒绝和在途积压放在一起看;若观测系统停止收集,就暂停确认,转看独立业务结果。这样能识别“错误消失,但工作也消失”的假恢复。

推荐重启另有一个操作条件:依赖负责人确认服务恢复,应用侧旧请求排空,并先以小比例恢复调用。本例约定百分之一流量持续五分钟且至少三百次推荐尝试,随后到百分之十、百分之五十,最后全量;每级都检查核心完成比例和等待积压。阶段失败就退回停用状态,不接着放量。这里保留的是计划,脚本没有实施这个流程。

负责人还给降级开关设定下一次复查时间和接手人。自动到期恢复虽然省去一次操作,却可能在依赖尚未恢复时重新触发事故;无限期留着又可能让缺失功能成为无人负责的常态。这里选择到期提醒、人工复核后恢复,代价是交接时多保留一项未完成工作。

次日:把“推荐慢了”拆成两个可验收任务

复盘从原先的错误判断开始。发布接近事故时间,让值班者先回退;分版本数据和回退后的窗口才修正了归因。主持人保留这一过程,追问当时为何看不到可比旧版样本,而不把“判断失误”改写成某个人经验不足。若材料不足以确定起点,就标出未知区间,避免按回忆补出精确秒数。

第一项整改交给推荐依赖负责人,目标是消除本例设定的触发条件:一次批处理任务长期占用推荐服务资源。验收材料包含同样的批处理输入、资源占用上限,以及交互查询与批处理并发时的完成情况。负责人可以缩小批次、限制后台并发或迁移执行窗口,选择取决于批处理时效要求。把任务改到凌晨会降低碰撞概率,却仍保留资源争抢机制。

第二项交给订单应用负责人,目标是缩小再次发生时的影响:推荐使用独立并发预算和有界等待,超限按已约定结构缺席。验收人员让推荐持续不返回,保持订单查询输入不变,检查核心旅程、名额释放和客户端展示。然后恢复依赖,确认调用能按计划重启且不靠重启应用排空。鉴权失败与订单读取失败另做负例,防止降级分支误把它们吞掉。

两个任务可以分开验收。修好后台批处理,保护不了下一次网络故障;隔离推荐路径,则没有消除推荐自身的可用性损失。前者的交付物是触发条件不再成立的对照记录,后者是触发仍存在时核心旅程的结果。把两项都写成“提高稳定性”,执行者会不知道哪种输入才算完成工作。

验收窗口还要覆盖依赖恢复。只测持续失败,容易遗漏恢复时积压集中释放、半开探测放量过快或旧连接无法复用的问题。这里计划在持续超时、部分恢复、全量恢复三个阶段记录资源变化;这是测试输入的区分,没有把架构画成会永久免疫故障的系统。

把同步推荐改成异步获取,也值得讨论。订单可先显示,推荐随后补上,代价是客户端状态增加、页面出现空档,并且要处理过期结果覆盖新页面的问题。团队已能用现有开关止损,所以本次不把客户端重构塞入紧急发布。是否承担这项改造,留给正常产品评审,用推荐的真实收益与维护成本判断。

留给接班人的材料

前面的动作前提、执行角色、停止线和撤销条件可以按本团队人员填写成交接卡。下面是完整算术脚本,验证损失分母、二十七次尝试和退出判定。将代码放入独立的 check.mjs,使用 Node 运行即可,不安装项目依赖,也不会启动服务。

import assert from 'node:assert/strict';
const total = 1000000;
const allowedBadFraction = 1 / 1000;
const budget = total * allowedBadFraction;
assert.equal(budget, 1000);
const during = { offered: 6000, good: 3600, failed: 2400, rejected: 0 };
const naiveThrottle = { offered: 6000, good: 2997, failed: 3, rejected: 3000 };
const bypass = { offered: 6000, good: 5994, failed: 6, rejected: 0 };
const goodRatio = x => x.good / x.offered;
const badRatio = x => (x.failed + x.rejected) / x.offered;
for (const row of [during, naiveThrottle, bypass]) {
  assert.equal(row.offered, row.good + row.failed + row.rejected);
}
assert.equal(goodRatio(naiveThrottle), 0.4995);
assert.equal(badRatio(naiveThrottle), 0.5005);
assert.equal(badRatio(during) / allowedBadFraction, 400);
assert.equal(during.failed / budget, 2.4);
const attempts = depths => depths.reduce((n, attemptsAtLayer) => n * attemptsAtLayer, 1);
assert.equal(attempts([3, 3, 3]), 27);
assert.equal(attempts([1, 1, 3]), 3);
assert.equal(attempts([1, 1, 1]), 1);
console.log(JSON.stringify({ evidence_kind: 'fictional_scenario_arithmetic',
  budget, incident_bad: during.failed, budget_fraction_consumed: during.failed / budget,
  burn_rate: badRatio(during) / allowedBadFraction,
  bad_only_on_admitted: naiveThrottle.failed / (naiveThrottle.good + naiveThrottle.failed),
  all_offered_bad_ratio: badRatio(naiveThrottle),
  all_offered_good_ratio: goodRatio(naiveThrottle),
  bypass_good_ratio: goodRatio(bypass), retry_attempts: [27, 3, 1] }));
// Proposed exit gate for a drill, not an SLO estimator or a production controller.
const exits = windows => windows.length === 3 && windows.every(w =>
  w.offered >= 30000 && w.good / w.offered >= 0.999 &&
  w.backlog_end <= w.backlog_start && w.oldest_seconds <= 2 &&
  w.correctness_violations === 0 && w.collection_healthy === true);
const healthy = { offered: 30000, good: 29970, backlog_start: 3,
  backlog_end: 0, oldest_seconds: 1, correctness_violations: 0, collection_healthy: true };
assert.equal(exits([healthy, healthy, healthy]), true);
assert.equal(exits([healthy, healthy, { ...healthy, offered: 1, good: 1 }]), false);
assert.equal(exits([healthy, healthy, { ...healthy, backlog_end: 8 }]), false);
assert.equal(exits([healthy, healthy, { ...healthy, collection_healthy: false }]), false);
assert.equal(exits([healthy, healthy, { ...healthy, correctness_violations: 1 }]), false);
console.log(JSON.stringify({ check: 'exit_gate', result: 'PASS', cases: 5,
  production_exercise_executed: false }));
node check.mjs

本次在 Node.js v26.8.1、Darwin arm64 上运行,得到以下原始标准输出。

{"evidence_kind":"fictional_scenario_arithmetic","budget":1000,"incident_bad":2400,"budget_fraction_consumed":2.4,"burn_rate":400,"bad_only_on_admitted":0.001,"all_offered_bad_ratio":0.5005,"all_offered_good_ratio":0.4995,"bypass_good_ratio":0.999,"retry_attempts":[27,3,1]}
{"check":"exit_gate","result":"PASS","cases":5,"production_exercise_executed":false}

其中预算为一千,事故窗口消耗比例为二点四;只按准入请求计算的不良比例是千分之一,把拒绝计入后为百分之五十点零五。退出函数的五个样例通过断言,覆盖正常窗口、样本不足、积压增长、采集异常和正确性违规。断言通过只说明这些输入的计算符合规则。

事故负责人交班时留下两件尚未完成的事:推荐依赖的真实故障原因仍要靠实际环境中的证据确认,旧客户端是否会在推荐缺席时重试整个页面仍要按版本清点。次日复盘不能借这份虚构推演把两件事勾成完成。对于接班者,最有价值的一句话是:“核心查询已经按约定恢复,推荐仍关闭;下一次复查由谁负责,看到什么结果才重新打开。”