缓存年龄钟表对应依赖失败后的两种可选推荐结果:120 秒复用最新的合格缓存,121 秒改为简化响应。

优雅降级保留部分价值

优雅降级是在服务某一部分失效时,继续提供事先明确支持的功能子集。例如,文章页面可以暂时省略推荐区域,同时仍然显示用户请求的正文。关键是这种部分答案必须符合产品契约,既有用又如实说明限制。返回一点内容,并不天然比明确失败更安全;如果剩余内容会误导用户,就不能仅为了保持成功状态码而继续提供它。 先决定哪些东西可以缺少 编写回退代码之前,应分清可选区域和必须满足的结果。侧栏推荐消失可能可以接受,但默默跳过权限检查、把结果未确认的支付说成成功,或用未经验证的价格替代当前报价,都可能破坏核心承诺。这些情况需要明确失败或另行批准的处理流程,不能套用一个“页面总要保持绿色”的统一开关。降级范围应来自业务语义,而不是依赖清单上哪个接口最容易被捕获异常。 可选依赖应尽可能避免占住核心响应的关键路径。它的调用需要有界预算,但调用方等待超时,不代表底层工作已经停止。还应限制并发和排队,避免大量已经失去价值的任务继续占用线程或连接。占位说明应告诉用户缺少什么能力,同时避免暴露内部错误细节,也不能暗示已经取得当前数据。核心页面能否正常工作,应在强制关闭可选区域时单独检查。 把陈旧数据的可接受条件写清楚 最后一次良好数据只在明确范围与年龄政策内有用。需要记录数据取得时间,保留所属对象和授权范围,并规定年龄未知、超过限制或缓存为空时如何处理。一个时间戳本身不能证明数据正确:某些状态发生变化后,即使缓存只有很短时间,也不能继续用于作出授权或交易决定。因此,是否可以复用,是契约问题,不是看到缓存还有内容就可以决定。 上游失败后提供旧数据,与 stale-while-revalidate 并不相同,后者可以在发起验证的同时提供符合条件的陈旧响应。两者都需要有界的新鲜度与资源政策。成功刷新时,也必须真正替换缓存内容并重置年龄;否则系统可能不断声称提供“最近一次成功结果”,实际却永远返回更早的那份快照。显示年龄有助于观察,但不能代替这些状态更新与过期判断。 坏掉的样子 回退可能扩大事故。AWS 关于避免回退的文章描述了缓存故障后流量直接涌向数据库,最终超出数据库承受能力的事件。这份来源是在警告难以测试、很少运行的备用路径,并不是无条件支持“有部分答案就一定更好”。应该优先改善可预测的主要路径,并经常演练恢复行为,而不是把希望寄托在资源需求不明、只有事故时才启用的复杂分支上。 缓存同时到期、所有节点同时试探恢复,都可能制造突发负载。需要分散刷新工作,限制重试与并发,并检查备用路径是否还在争用已经耗尽的同一资源。熔断器决定是否继续调用依赖,降级政策决定剩下的答案是否可以接受。这是两个不同问题:熔断器打开,并不能证明缓存仍然有效,也不能证明某个静态占位符合业务承诺。 恢复探测失败后,普通请求准入必须继续关闭。如果还没验证恢复就先闭合熔断器,流量可能过早回到故障依赖。生产实现还需要同步、探测调度和有界恢复并发,串行示例不能验证这些性质。降级响应也应有独立计数与数据年龄观测,避免 HTTP 200 掩盖持续的功能缺失;是否需要告警,还要结合影响范围、持续时间和实际可采取的动作。 仅可选推荐契约允许这种回退。年龄 0–120 秒可复用最新缓存;否则返回注明的静态简化结果,探测失败仍阻止普通调用。 快速验证 保存为 degrade_probe.py,执行 python3 degrade_probe.py。这个标准库样本只模拟一个可选推荐区域,不执行网络或文件系统操作。缓存年龄和依赖失败都是显式注入的输入,一百二十秒上限只是示范性的产品决定。探测由程序明确、同步地调用,没有实现自动计时器或并发请求准入,因此也不声称能够协调多个进程的半开请求。 class DependencyDown(Exception): pass class PageService: def __init__(self): self.cached_recs = ["cached-pick"] self.cached_age_s = 90 # Injected age, not a running clock. self.max_age_s = 120 self.failures, self.threshold = 0, 3 self.open = False self.live_calls = 0 def live_recs(self, fail): self.live_calls += 1 if fail: raise DependencyDown("recs down") return ["fresh-pick"] def fresh(self, data): self.cached_recs, self.cached_age_s = list(data), 0 self.failures, self.open = 0, False return {"mode": "fresh", "data": data} def fallback(self, reason): if self.cached_recs and 0 <= self.cached_age_s <= self.max_age_s: return {"mode": "cached", "data": list(self.cached_recs), "age_s": self.cached_age_s, "reason": reason} return {"mode": "simplified", "data": ["popular-picks"], "reason": reason} def get_recs(self, fail=False): if self.open: return self.fallback("breaker-open") try: return self.fresh(self.live_recs(fail)) except DependencyDown: self.failures += 1 self.open = self.failures >= self.threshold return self.fallback("dependency-failed") def half_open_probe(self, fail=False): # Explicit synchronous trial; not a concurrent admission controller. assert self.open, "probe requires an open breaker" try: data = self.live_recs(fail) except DependencyDown: return self.fallback("probe-failed") # Leave the breaker open. return self.fresh(data) # Close only after success. svc = PageService() assert svc.get_recs() == {"mode": "fresh", "data": ["fresh-pick"]} assert svc.cached_recs == ["fresh-pick"] and svc.cached_age_s == 0 svc.cached_age_s = 120 cached = svc.get_recs(fail=True) assert cached["mode"] == "cached" and cached["age_s"] == 120 assert cached["data"] == ["fresh-pick"] svc.cached_age_s = 121 assert svc.get_recs(fail=True)["mode"] == "simplified" svc.get_recs(fail=True) assert svc.open calls = svc.live_calls assert svc.get_recs()["reason"] == "breaker-open" and svc.live_calls == calls assert svc.half_open_probe(fail=True)["reason"] == "probe-failed" assert svc.open and svc.live_calls == calls + 1 calls = svc.live_calls svc.get_recs() assert svc.live_calls == calls # A failed probe must not reopen normal admission. assert svc.half_open_probe()["mode"] == "fresh" and not svc.open assert svc.cached_age_s == 0 svc.cached_recs = [] assert svc.get_recs(fail=True)["mode"] == "simplified" svc.cached_recs, svc.cached_age_s = ["unknown-age"], -1 assert svc.get_recs(fail=True)["mode"] == "simplified" print("PASS: fresh refresh, bounded cache, empty/invalid cache, open gate, failed/successful probe") 成功调用会刷新缓存。接下来依赖失败时,如果注入年龄恰好为一百二十秒,回退返回的确实是刚才取得的最新成功值;改为一百二十一秒,就选择明确简化的答案。连续三次失败使调用闸口打开,下一次普通请求继续得到回退,但实际调用计数不增加。这里验证的是旧数据仍然符合约定,而不仅仅是旧数据仍然存在。 ...

四月 22, 2027 · 2 分钟 · XB Field Notes
退避上限先增长后封顶,完全抖动的实际等待在区间内变化,也可能变短。次数上限、总期限和更广的重试预算是独立约束。

带抖动与退避的重试

重试意味着再增加一份工作,不是自动提高可靠性的操作。它可能帮助恢复暂时失败,也可能重复一个不安全的业务行动,或把额外需求送到已经过载的依赖。选择延迟公式之前,应先确定哪些操作允许重复、总共能投入多少次尝试与多长时间,以及这些额外工作由哪一层负责。 圆点为示意取样,不是实际到达时刻。抖动不保证容量或逐次延长等待。检查重放资格、次数、总期限、更广预算和 Retry-After;必需等待放不进期限时应放弃。 先约束尝试次数,再安排时间 需要区分尝试和重试:总共三次尝试,是第一次调用加最多两次重试。还要确定恢复责任属于哪一层。如果嵌套的三层各自允许三次尝试,一次外层行动就可能对持续失败的最底层发起二十七次调用。抖动只能改变这些调用的时间,不能消除次数相乘的结果。 Google SRE 同时讨论单请求预算与更广范围的重试预算。即使每个请求都有限次尝试,大量失败的客户端仍可能制造相当可观的额外工作。应把重试流量与原始需求分别统计,并核对 SDK、代理和应用层已经存在的政策,避免每一层都独立认为自己只增加了少量调用。 指数退避增长的是上限 封顶指数退避让等待上限逐步增大,直到达到最大值。没有抖动时,同时失败的客户端可能算出相同的下次时间,甚至在到达上限后继续保持同样间隔。等待更久,并不必然打破同步,不能把周期变长当成请求已经分散的证据。 完全抖动会在零与当前上限之间采样。因为取值随机,即使上限正在增长,下一次实际等待仍可能比上一次短。因此,“每次失败后一定比之前多等一会儿”不是这种算法的准确描述;需要区分退避上限的变化与本次真正取到的延迟。 AWS 的文章比较了完全抖动、等量抖动和去相关抖动,并报告特定竞争模拟的结果。这些结果能帮助理解取舍,却不保证某种政策在所有工作负载下都具有最低延迟,也不保证完全没有突发。相同随机种子、定时器粒度和大量客户端,仍可能形成聚集。 整条操作共用一个截止期限 预算必须同时包括执行、等待和后续尝试。每次调用都重新拿到完整超时时长,会悄悄扩大原调用方允许的生命周期。应把剩余预算传给下游,并在准备进行更多工作之前再次检查,而不是只限制两次调用之间睡多久。 进程内计算经过时间,应使用单调时钟。客户端超时,也不证明服务器已经停止或从未提交;取消与远端截止期限需要真实的传输层支持,业务仍然需要重试或幂等契约。不能因为本地函数返回错误,就把未知结果当成未执行。 Retry-After 等服务端等待提示会增加另一项约束。不能因为本地抖动公式允许较小值,就采样出早于该边界的请求时间。如果必须等待的时间已经无法放进剩余期限,应在当前操作中放弃重试,而不是为了凑预算缩短服务端要求。 坏掉的样子 永久性的参数验证错误,不会因为重复发送就变得合法。非幂等操作在结果不明时重放,可能再次产生同一业务效果。应明确选择可重试的失败类别;捕获所有异常后循环重试,既会掩盖编程错误,也可能把一次局部故障推成持续过载。 只有退避上限,没有次数或总耗时限制,仍然允许无限工作;但期限过短,又可能让所有昂贵却合法的操作一直失败。需要一起观察完成工作量、重试次数、总延迟和放弃原因。仅看到错误率下降,可能忽略为了获得成功而越来越高的重试成本。 快速验证 保存为 retry_check.py,用 Python 3.10 以上版本执行 python3 retry_check.py。2026-10-19 以 Python 3.12.14 完整运行原样程序并通过,只使用标准库。时钟和下游调用都是确定性模型,不真实等待,不启动服务,也不访问依赖。示例政策只允许 GET、HEAD 在超时或 503 后重试,不是所有 HTTP 客户端行为的实现。 from collections import Counter import random class Clock: def __init__(self): self.now = 0.0 def advance(self, seconds): self.now += seconds class ScriptedCall: def __init__(self, clock, steps): self.clock, self.steps, self.timeouts = clock, iter(steps), [] def __call__(self, timeout): self.timeouts.append(timeout) status, duration = next(self.steps) self.clock.advance(min(duration, timeout)) if duration >= timeout: raise TimeoutError("the cooperative fixture used the remaining budget") return status def retry(call, clock, budget, sample, method="GET", max_attempts=3): deadline = clock.now + budget for attempt in range(1, max_attempts + 1): remaining = deadline - clock.now if remaining <= 0: return "deadline", attempt - 1 try: status = call(remaining) except TimeoutError: status = None if clock.now >= deadline: return "deadline", attempt if status == 200: return "success", attempt if status not in (None, 503) or method not in ("GET", "HEAD"): return "not-retryable", attempt if attempt == max_attempts: return "attempt-limit", attempt ceiling = 2.0 ** min(attempt - 1, 3) delay = ceiling * sample() assert 0 <= delay <= ceiling if delay >= deadline - clock.now: return "wait-exceeds-budget", attempt clock.advance(delay) raise AssertionError("positive attempt limit required") clock = Clock() call = ScriptedCall(clock, [(503, 0.25), (503, 0.25), (200, 0.25)]) assert retry(call, clock, 5, lambda: 0.5) == ("success", 3) assert clock.now == 2.25 and call.timeouts == [5, 4.25, 3] print("three attempts finish at 2.25; propagated timeouts shrink from 5 to 4.25 to 3") class EarlyTimeout: def __init__(self, clock): self.clock, self.timeouts = clock, [] def __call__(self, timeout): self.timeouts.append(timeout) self.clock.advance(0.25) if len(self.timeouts) == 1: raise TimeoutError("a transient failure before the deadline") return 200 clock = Clock() call = EarlyTimeout(clock) assert retry(call, clock, 5, lambda: 0.5) == ("success", 2) assert clock.now == 1.0 and call.timeouts == [5, 4.25] print("an early timeout can recover without resetting the shared deadline") for method, status in (("POST", 503), ("GET", 400)): clock = Clock() call = ScriptedCall(clock, [(status, 0.25)]) assert retry(call, clock, 5, lambda: 0.5, method) == ("not-retryable", 1) clock = Clock() call = ScriptedCall(clock, [(503, 0.25)] * 4) assert retry(call, clock, 5, lambda: 0.5) == ("attempt-limit", 3) assert len(call.timeouts) == 3 print("POST, permanent failure, and the total-attempt cap stop further work") clock = Clock() call = ScriptedCall(clock, [(503, 0.25)] * 7) assert retry(call, clock, 100, lambda: 1.0, max_attempts=7) == ("attempt-limit", 7) assert clock.now == 32.75 # Seven 0.25 calls plus waits 1, 2, 4, 8, 8, 8. print("the exponential ceiling stops at 8 rather than growing without bound") clock = Clock() call = ScriptedCall(clock, [(503, 0.1)]) assert retry(call, clock, 0.5, lambda: 1.0) == ("wait-exceeds-budget", 1) assert clock.now == 0.1 and len(call.timeouts) == 1 clock = Clock() call = ScriptedCall(clock, [(503, 99)]) assert retry(call, clock, 0.5, lambda: 0.5) == ("deadline", 1) assert clock.now == 0.5 clock = Clock() call = ScriptedCall(clock, []) assert retry(call, clock, 0, lambda: 0.5) == ("deadline", 0) and call.timeouts == [] print("a wait that cannot fit is abandoned; cooperative execution cannot consume a fresh budget per attempt") rng = random.Random(7) # Repeatable samples for this experiment, not a production seed. delays = [rng.random() for _ in range(100)] bins = Counter(int(delay * 10) for delay in delays) fixed_bins = Counter(int(1.0 * 10) for _ in range(100)) assert all(0 <= delay < 1 for delay in delays) and sum(bins.values()) == 100 assert max(bins.values()) < 100 assert max(fixed_bins.values()) == 100 print("first-retry 0.1-second-bin peak: fixed delay", max(fixed_bins.values()), "; full-jitter sample", max(bins.values())) leaf_calls = 0 class Unavailable(Exception): pass def nested(depth): global leaf_calls if depth == 0: leaf_calls += 1 raise Unavailable for _ in range(3): try: return nested(depth - 1) except Unavailable: pass raise Unavailable try: nested(3) except Unavailable: pass assert leaf_calls == 27 print("three retrying layers, each allowing three total attempts, invoke the failing leaf 27 times") 两次失败后成功,最终模型时间为 2.25。传给各次调用的超时依次缩短为五秒、4.25 秒和三秒,没有每次重新领取一份完整预算。额外用例确认提前超时仍能在同一预算内恢复;指数先封顶再计算,避免总尝试上限很大时先生成过大的幂。其他断言覆盖总尝试次数、八秒退避上限、永久错误、POST 不自动重放、放不进预算的等待,以及开始时就已经耗尽的期限。 ...

四月 10, 2027 · 4 分钟 · XB Field Notes
本地熔断模型连续两次失败后从关闭转为断开,等待五秒,再放行一个探测。成功探测返回关闭;八候选实验得到一个许可和七个拒绝。

熔断器阻止对故障服务的轰炸

熔断器决定的是一个新的调用,是否还应该到达看起来不健康的依赖。它可以立即拒绝,避免继续为很可能失败的请求投入资源,但不会修复依赖、取消已经运行的调用,也不能证明所有被拒工作本来都会失败。它是否有用,取决于真正实现的失败信号、作用域和恢复政策,而不只是有没有三个状态名称。 明确政策,而不只是画出状态 关闭表示允许调用通过,不代表每次调用都会成功;断开表示按照熔断政策拒绝新调用;半开则允许有限的试探工作,先取得一些恢复证据,再决定是否重新放行普通流量。不能把状态名字直接读成依赖的客观健康证明。 检测规则也不一定是滑动窗口内的失败百分比。Fowler 的示例统计失败,并在成功后重置;其他实现可能使用时间窗口、失败比例、慢调用阈值或最小样本数。应该明确当前使用的规则,而不是借用一张状态图,就默认阈值和统计方法已经确定。 下面的实验按完成顺序统计连续两次已分类失败,使用五秒冷却期,并且只允许一个半开探测。一次成功会把连续失败数清零。这些数字方便验证状态转移,不是生产配置建议;实际门槛需要结合故障形态与业务容忍度决定。 分类失败,并限定影响范围 调用方参数错误,不一定表示依赖已经宕机。需要确定哪些异常、状态码和慢结果应该影响熔断判断。本地直接拒绝的请求,也不同于已经到达依赖后失败的请求;如果把二者混在同一个失败统计里,熔断器可能不断用自己的拒绝强化拒绝状态。 状态应覆盖你真正想保护的故障范围。某个分片或操作不可用,不一定意味着其他独立提供方也应该被阻止。反过来,如果有成千上万个进程各自维护熔断器,每个都允许一个探测,总探测量仍可能很大。“只放一个半开请求”可能只是每实例一个,不是全局一个。 超时仍然不可缺少。一个永远不结束的调用,会占住连接或工作线程,却可能始终不产生足以触发熔断的完成结果。准入决定不能替代截止期限、并发上限和资源清理;等待结果所消耗的资源,也应进入实际验收范围。 恢复是一场受控试探 冷却时间结束后,应原子地占用探测名额。如果多个线程都先看见“现在可以试一下”,然后各自放行,所谓单探测就变成了一批突发请求。实验使用锁保护很短的状态修改,不会在依赖操作实际运行期间一直持有这把锁。 已经在途的旧调用,其结果如何处理也需要明确。这里进入新的断开阶段时会增加本地代次,旧许可对应的结果不能关闭这个较新的断开状态。代次只是模型内部的记账,不是分布式 fencing token,也没有能力撤销已经发生的远程工作。 探测失败会开始另一轮冷却,探测成功则关闭这个实验里的熔断器。但是,一次成功不证明依赖已经能够承接所有返回流量。如果需要逐步恢复,应该验证真实的流量准入政策,而不是认为只要存在“半开”状态,就必然实现了平滑放量。 坏掉的样子 阈值过于激进,会拒绝原本可以成功的请求;冷却过长,又会在依赖恢复后继续阻止调用。范围过大容易掩盖局部健康,而重试循环如果不停捕获断开异常,即使没有继续请求下游,也仍可能浪费本地资源,使故障路径消耗超出预期。 应分别观察转发调用、已分类结果、本地拒绝、状态变化和探测结果。测试持续失败、偶尔成功、并发争抢探测以及迟到的完成消息。一个计数器单测通过,并不证明真实依赖会安全恢复;需要沿着请求、资源占用和实际恢复流量继续检查。 模型使用连续两次已分类失败、五秒冷却及一个原子探测许可。旧代次结果不能关闭较新的断开状态;未证明生产恢复吞吐量。 快速验证 保存为 breaker_check.py,用 Python 3.10 以上版本执行 python3 breaker_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。代码模拟结果,并注入表示单调时间的数值,不真实等待,也不访问服务;半开名额则由八个真实线程竞争,检查准入决定是否一起完成。 from concurrent.futures import ThreadPoolExecutor from threading import Barrier, Lock class OpenCircuit(Exception): pass class Breaker: # Explicit fixture policy: two consecutive classified failures, five-second cooldown. def __init__(self): self.state, self.failures, self.until = "closed", 0, 0 self.epoch, self.probe_busy = 0, False self.lock = Lock() def acquire(self, now): with self.lock: if self.state == "open": if now < self.until: raise OpenCircuit self.state = "half-open" if self.state == "half-open": if self.probe_busy: raise OpenCircuit self.probe_busy = True return self.epoch, True return self.epoch, False def complete(self, permit, succeeded, now): epoch, probe = permit with self.lock: if epoch != self.epoch: return # This outcome belongs to an earlier breaker generation. if probe: self.probe_busy = False self.epoch += 1 self.failures = 0 self.state = "closed" if succeeded else "open" if not succeeded: self.until = now + 5 elif self.state == "closed": self.failures = 0 if succeeded else self.failures + 1 if self.failures >= 2: self.state, self.until = "open", now + 5 self.epoch += 1 breaker = Breaker() other_dependency = Breaker() forwarded = [] def invoke(succeeded, now): permit = breaker.acquire(now) forwarded.append(now) # A modeled dependency invocation, not a network call. breaker.complete(permit, succeeded, now) invoke(False, 0) assert breaker.failures == 1 invoke(True, 1) assert breaker.failures == 0 late = breaker.acquire(1) invoke(False, 2) invoke(False, 3) assert breaker.state == "open" and breaker.until == 8 other_permit = other_dependency.acquire(4) other_dependency.complete(other_permit, True, 4) assert other_dependency.state == "closed" before = len(forwarded) try: invoke(True, 7.99) except OpenCircuit: pass else: raise AssertionError("open circuit must reject before dispatch") assert len(forwarded) == before breaker.complete(late, True, 7.99) assert breaker.state == "open" print("success resets consecutive failures; two later failures open until time 8") print("open calls are not dispatched; an older in-flight success does not close a newer open generation") gate = Barrier(8) def probe_attempt(_): gate.wait(timeout=5) try: return breaker.acquire(8) except OpenCircuit: return None with ThreadPoolExecutor(max_workers=8) as pool: permits = list(pool.map(probe_attempt, range(8))) admitted = [permit for permit in permits if permit is not None] assert len(admitted) == 1 and admitted[0][1] is True assert breaker.state == "half-open" and breaker.probe_busy breaker.complete(admitted[0], False, 8) assert breaker.state == "open" and breaker.until == 13 print("eight simultaneous half-open candidates receive one probe permit and seven refusals") print("failed probe opens a new cooldown ending at time 13") try: breaker.acquire(12.99) except OpenCircuit: pass else: raise AssertionError("second cooldown must still apply") probe = breaker.acquire(13) breaker.complete(probe, True, 13) assert breaker.state == "closed" and not breaker.probe_busy invoke(True, 14) assert other_dependency.state == "closed" and other_dependency.failures == 0 print("a successful later probe closes this breaker; another dependency has separate state") 开始时的一次成功会清零失败数;后面两次失败让熔断器断开到时间八。在这之前发起调用,不会增加模拟下游调用记录。先前获准、后来才完成的成功结果,也不能关闭新一代的断开状态;另一依赖早已存在的熔断器仍然可以使用,没有被一并影响。 ...

四月 6, 2027 · 3 分钟 · XB Field Notes
滚动三十天窗口移除最旧一天后,预算从负三百三十变为正九百二十。旁边的红色暂停标志仍然有效:示例策略中,事故未结束仍会暂停非必要功能发布。

错误预算是真预算

错误预算把可靠性约定变成能够参与排期的依据。产品与运维先确定目标,再度量失败是否超出允许范围,并提前约定预算耗尽后如何调整工作。这个数字支持共同决策,却不能预测明天的部署会不会引发事故。发布会引入风险,真正消耗已度量预算的是观察到的不良事件;不能把每次发布机械地扣成固定额度,也不能把余额充足当成变更必然安全的证明。 请求数量与判断分支来自原文合成样例,条形使用同一请求数比例。本图未执行代码,也未批准部署;事故关闭且余额为正的场景仅可进入发布审查。 先定义这本账如何计算 对于基于请求的目标,允许失败数等于符合统计条件的请求总数乘以一减目标值。例如,三百万次请求的目标为百分之九十九点九,便允许三千次不良请求。接入发布规则之前,需要写清请求范围、成功条件、测量位置和观察窗口。按流量加权的失败额度,不会自动变成一个固定的宕机分钟数;高峰和低谷的同样一分钟可能影响完全不同数量的请求。 滚动三十天的统计会持续移除旧记录并加入新记录。流量变化时,分母与允许失败数都可能变化,即使目标百分比没有改动。自然月统计则在约定的日历边界重新开始,两种窗口回答的问题不同。事故处理中临时换窗口,可能让看似精确的余额失去可追溯含义。因此应将计算定义与策略一起版本化,并在余额旁显示实际窗口、请求范围和统计截止时间。 在争论之前约定如何响应 一种示例策略是:预算超支时暂停非必要功能发布,把资源转向可靠性工作,同时允许紧急安全修复和事故修复经过审核后执行。需要明确决策负责人、例外所需材料、记录方式以及恢复普通发布审查的条件。谷歌公开的预算策略针对书中的示例服务,不能不加判断地把所有阈值和升级处理规则照搬到另一项业务。 预算余额也不能独立决定是否具备发布条件。事故仍未结束、监控覆盖不足、回归失败或缺少可靠回退方案,都可能继续阻止变更。反过来,修复当前事故的必要变更,不应只因为该事故已经花光预算而被机械阻挡。把判断及依据写下来,才能让规则服务于用户影响和工程取舍,而不是让仪表盘变红的团队承担未经分析的责任。 坏掉的样子 一个大事故离开滚动窗口后,预算可能突然恢复。计算本身没有错,但这不能说明根因已经消除。悄悄更改排除条件,把无法采集的样本填成成功,或者直接平均不同流量服务的成功百分比,同样会制造虚假的盈余。看到余额改善时,应先检查测量覆盖与定义是否变化,再判断服务状态是否确实改善,尤其不能把未知数据解释为零失败。 短窗口燃烧率提供另一种观察角度:把实际不良事件比例除以目标允许的不良比例,可以看出近期消耗速度是否异常。高燃烧率能够在长窗口预算耗尽前发出警告,但由它推算剩余时间,需要假设流量与错误行为继续保持可比,不能当成确定的倒计时。应结合长窗口余额、短窗口变化和低流量条件设置可执行的告警,而不是每出现一次失败就叫醒值班人员。 快速验证 把下面代码保存为 budget-policy.py,使用 Python 3 执行。数据是人为构造的每日请求统计,用来核对滚动窗口及一个刻意保持简单的策略。程序不会连接发布系统,也没有对任何真实变更的事故概率作出估计;运行输出只证明给定输入下的计算和判断分支。 from fractions import Fraction target = Fraction(999, 1000) # Every synthetic daily bin has 100,000 eligible requests. days = [(100000, 1300)] + [(100000, 70)] * 29 def account(window): eligible = sum(total for total, _ in window) bad = sum(errors for _, errors in window) allowed = eligible * (1 - target) return bad, allowed, allowed - bad def feature_policy(remaining, incident_open): return "hold" if remaining < 0 or incident_open else "eligible for review" before = account(days) after = account(days[1:] + [(100000, 50)]) assert before == (3330, 3000, -330) assert after == (2080, 3000, 920) assert feature_policy(before[2], False) == "hold" assert feature_policy(after[2], True) == "hold" assert feature_policy(after[2], False) == "eligible for review" for label, (bad, allowed, remaining) in [("before", before), ("after", after)]: print(f"{label}: bad={bad}, allowed={float(allowed):.0f}, remaining={float(remaining):.0f}, spent={float(bad / allowed):.2%}") print("after rotation, incident still open:", feature_policy(after[2], True)) print("after rotation, incident closed:", feature_policy(after[2], False)) 第一个窗口包含三千三百三十次不良请求,允许三千次,预算使用率为百分之一百一十一,余额为负三百三十。移除最旧一天、加入新一天后,不良请求降为二千零八十次,使用率约百分之六十九点三三,剩余九百二十次。示例特意让两个窗口都包含三百万次符合条件的请求,以便单独观察旧事故离开窗口的影响;真实系统的分母不一定保持不变。 ...

三月 25, 2027 · 1 分钟 · XB Field Notes
三个顺序状态展示 a 可写、隔离后 a 与 b 均不可写、提升后 b 可写;写者数为 1、0、1,安全过渡允许零写者。

故障切换是一把演练过的开关

备用节点只有在提升路径可用、旧写者不能破坏新历史时才有价值。故障切换既是决策,也是流程与演练。副本看起来健康,不代表客户端能连上它,也不代表所有已确认写入都已经复制过去。先确定恢复时间目标与可接受的数据损失,再决定何时切换,而不是等报警响起才现场讨论。 提前设计好决定 确定谁有权批准提升、什么证据足以说明故障,以及由自动化还是操作员执行。心跳丢失可能是网络分区,而不是主库已经停止。故障检测本身不能授权两个独立写者。系统需要真正执行所有权边界的机制,例如经过确认的节点隔离,或存储端校验的 fencing,并使它与复制协议的保证相匹配。 单写者服务的安全目标是「最多一个被接受的写者」,并非每一瞬间都恰好有一个可用写者。隔离旧节点后、提升新节点前,暂时没有写者可能正是正确的停机选择。操作手册应写明候选节点、复制位置、可接受的丢失范围、客户端如何重连,以及前提不成立时由谁叫停。自动切换能缩短决策时间,却不能凭空补回尚未复制的数据。 演练真实经过的路径 根据风险决定演练频率,并在相关变更后重测。演练只能证明实际覆盖到的路径:绕过 DNS 的数据库提升不能验证地址缓存;测试客户端使用另一套身份,也不能证明生产权限正确。在安全前提下,加入真实的重连流程和已确认写入核对。记录范围、耗时、复制延迟、拒绝条件及恢复证据,不要把最近一次成功的日期当成完整的就绪评分。 开始前先安排安全停止点和恢复步骤。新主库一旦接受写入,切回旧节点往往就不再是撤销一项配置那么简单。旧主库可能需要对账或重建为副本,不能因为进程重新启动就恢复写权限。备份提供另一条恢复路径,但「有备份」本身也不等于已经验证能恢复。 坏掉的样子 旧主库在提升之后仍可写,就会产生脑裂。控制器里记录一句「已隔离」不够,存储或隔离机制必须真正阻止旧写入。落后的候选节点也可能在切换成功后丢失已确认写入。延迟阈值必须有明确单位,并与数据损失目标相连;允许非零延迟,不能同时宣称零数据丢失。 健康检查反复抖动会触发来回切换,应设置明确的故障判定和恢复条件,不要在重新同步之前自动切回。DNS 修改只影响后续查询:已有连接可能持续使用旧地址,按旧 TTL 缓存的回答也不会立刻消失。事故发生时调低 TTL,不能追溯缩短已经缓存的寿命。除了权威 DNS 的响应,还要验证真实客户端的重试、缓存和重连行为,否则控制台显示成功,用户路径仍可能失败。 先隔离,再提升写者。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 使用 Python 3.9 或更新版本,将下面内容保存为 failover_drill.py,在临时目录运行 python3 failover_drill.py。运行时不要使用 -O、-OO 或非零的 PYTHONOPTIMIZE,以免断言被跳过。它只修改内存对象。健康状态和复制延迟是模拟值,fence() 只是更新集合,并不执行真实隔离。模型保留一个被指定的主节点,但隔离期间允许没有可写节点。候选节点通过检查后会被明确解除隔离,因此切回旧节点时不会出现「已经成为主库,却还留在隔离集合中」的自相矛盾状态。 ...

二月 25, 2027 · 3 分钟 · XB Field Notes
重投进入同一事务边界,边界内包含操作身份与业务效果。

保护已提交效果:示意。

队列定义投递,消费者仍须保护副作用

队列的投递承诺只描述工作流程中的一段边界。消息到达消费者,与业务副作用已经提交,是不同事件。至少一次允许重复投递,至多一次则允许在不确定时丢失而不继续重试。仅凭这两个标签,不能保证订单、通知或数据库更新在所有故障下都恰好完成一次。可靠性需要沿真正的数据和确认路径逐步检查。 找到发布、确认与提交的位置 首先明确生产方何时知道 broker 已经接受消息、保留多久,以及持久化政策覆盖哪些故障。消息从未成功发布、超过保留期或被明确删除,都不会因为消费者自称至少一次就自动恢复。恢复前提必须与投递标签一起记录,否则“永不丢失”会遮住多个根本没有交给队列负责的环节。 消费方若先确认,再提交业务结果,就会形成丢失窗口:broker 停止重试时,应用可能还没完成工作。先提交又留下另一种窗口:结果已成功,但确认丢失,工作于是重新出现。这个重复仍然符合投递契约,并不意味着最初操作失败。排查时要分别检查业务结果与确认结果,不能用其中一个推断另一个。 可见性超时与确认期限控制的是何时允许下一次尝试,不证明第一个工作进程已经停止。慢处理者可以与重投后的处理者同时运行,因此延长期限与防止重复副作用解决的是不同问题。应同时记录投递尝试和已经提交的业务结果,而不是只统计消费者调用次数,便把它当作成功执行次数。 在真正的事务边界内消除重复效果 若副作用都位于同一个事务数据库,可以把唯一操作身份与业务结果放进同一事务。唯一性检查必须能够裁决并发尝试,单独读取再无保护地写入并不够。还需要保存足够的载荷信息,识别“同一身份却携带不同指令”的情况,不能看到编号重复就无条件接受。这既是去重问题,也是操作身份的契约问题。 数据库事务不会自动包住外部邮件、支付接口或另一套独立存储。这些目标需要自己的幂等或协调契约。本地标记先写、远端调用后做,可能丢失效果;标记后写,则可能留下重复窗口。因此,本地去重表不能被说成端到端恰好一次保证。只有明确哪些动作一起提交、哪些动作仍独立失败,设计才有可检查的边界。 有些服务确实提供带明确范围的恰好一次投递能力,例如 Google Pub/Sub 说明了地域、订阅类型等限制,也区分投递重复与发布侧重复。应阅读具体契约,不应宣称这个能力不可能存在,也不能把它推广成所有下游副作用都已解决。多个已发布消息代表同一业务意图时,稳定业务身份仍然有独立价值。 坏掉的样子 持续失败的消息可能阻塞有序进度,过短期限则可能让本来正常的任务出现重叠尝试。重投计数能提供调查线索,却不能唯一判断是毒数据、慢依赖、确认丢失还是工作进程停滞。应关联消息身份、载荷、异常、处理时长和确认记录,再决定怎样修改重试政策。只根据“一条还是多条计数升高”断言根因,很容易修错位置。 需要限制重试并说明隔离失败如何审阅或重新执行。进入死信队列只是移动未完成工作,并没有完成它。去重记录的保存期限必须覆盖计划容忍的重放范围,删除身份记录之后,旧请求可能再次生效;永远保存又有存储和隐私代价。这里没有适用于所有任务的统一选择,应把保留与重放规则一起设计。 历史精确 SQLite 落点证据涵盖回滚、重开和去重。总计是虚构夹具值;broker 行为与外部效果仍是独立边界。 快速验证 保存为 delivery_guarantee_probe.py,用启用断言的 Python 3 运行,不能使用 -O 或 PYTHONOPTIMIZE,因为断言中含有必需调用。保留的精确代码块记录报告 Python 3.12.14 与 SQLite 3.53.1 上通过;本轮没有重跑。临时 SQLite 数据库同时保存身份与无害数字总计,检查的是事务落点,不会扣款或发送外部消息,创建及清理的只是本程序自己的临时文件。 from contextlib import closing from pathlib import Path from tempfile import TemporaryDirectory import sqlite3 def apply(db, message_id, amount, interrupt=False): db.execute('BEGIN IMMEDIATE') try: prior = db.execute('SELECT amount FROM inbox WHERE id=?', (message_id,)).fetchone() if prior is not None: if prior[0] != amount: raise ValueError('same identity, different payload') db.commit() return False db.execute('INSERT INTO inbox VALUES (?, ?)', (message_id, amount)) db.execute('UPDATE totals SET amount=amount+? WHERE id=1', (amount,)) if interrupt: raise RuntimeError('controlled failure before commit') db.commit() return True except BaseException: db.rollback() raise with TemporaryDirectory(prefix='queue-sink-') as directory: path = Path(directory) / 'sink.db' with closing(sqlite3.connect(path, isolation_level=None)) as db: db.execute('CREATE TABLE inbox(id TEXT PRIMARY KEY, amount INTEGER NOT NULL)') db.execute('CREATE TABLE totals(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)') db.execute('INSERT INTO totals VALUES (1, 0)') try: apply(db, 'work-1', 100, interrupt=True) except RuntimeError: pass else: raise AssertionError('controlled failure did not occur') assert db.execute('SELECT count(*) FROM inbox').fetchone() == (0,) assert db.execute('SELECT amount FROM totals').fetchone() == (0,) assert apply(db, 'work-1', 100) assert apply(db, 'work-2', 50) # Close and reopen the real database rather than retaining an in-memory set. with closing(sqlite3.connect(path, isolation_level=None)) as db: assert not apply(db, 'work-1', 100) try: apply(db, 'work-1', 999) except ValueError: pass else: raise AssertionError('conflicting payload reused the same identity') assert db.execute('SELECT count(*) FROM inbox').fetchone() == (2,) assert db.execute('SELECT amount FROM totals').fetchone() == (150,) assert not db.in_transaction print('rollback kept marker and effect together; reopened retry changed nothing; total=150') 两项写入之后主动抛出的异常,会让身份标记与总计一起回滚。随后重试,提交两项操作;关闭并重新打开数据库后,重复第一项身份不会改变结果,而用不同数额复用相同身份会报错。最终数据库有两个身份,总计为一百五十,并且连接不在事务中。这个检查比只把编号放进内存集合更能说明持久化边界。 ...

二月 5, 2027 · 2 分钟 · XB Field Notes
携带同一键的三次尝试汇聚到一笔已存订单;多次尝试保持同一行动身份。

幂等键是客户端的承诺

幂等键命名的是一次预期行动,不是一次网络投递尝试。服务支持这项契约时,客户端在结果不明确后重试同一个行动,应该继续发送同一个键。每次重试都生成新键,实际是在告诉服务器“这是另一次行动”,因此完全可能合理地产生第二份业务效果。客户端必须先把行动身份与尝试次数分开。 创建一次,在后续尝试中保留 在第一次发送之前生成键,并把它与操作参数、目标一起保存。如果后台任务可能在客户端重启后恢复,任务记录也需要保留这个身份。仅把键放在临时网络连接或一次调用栈中,无法可靠地把后续尝试关联回原来的意图,恢复程序也不应临时猜一个替代值。 真正的新行动应该使用新键,即使参数与之前的购买完全一样。仅用正文哈希无法区分“再买一次同样的东西”和“重试第一次购买”。键与请求指纹承担不同职责:键标记行动,指纹检查是否发生了不兼容的复用。把二者合并,既可能漏去重,也可能错误阻止合理的新操作。 键的格式和作用域应遵循服务提供方的契约。Stripe 建议使用 UUIDv4 等随机标识,并提醒不要把敏感数据放进键中。键不是认证凭据,拥有某个键也不能代替访问已存结果所需的授权。不要把 bearer 凭据、邮箱或个人信息塞进用于关联请求的标识。 接收方必须真正实现这份承诺 只加一个 Idempotency-Key 请求头,不会自动实现去重。服务端必须在明确契约下识别它,说明比较哪些参数、保留哪些结果、执行尚未完成时如何处理重复请求,以及记录会保存多久。这些都是服务行为,不能从客户端看见一个字段名就推断已经具备。 Stripe API v1 记录的是首次进入执行阶段的请求状态码与正文,包括 500 响应。如果参数验证失败,或与正在执行的请求发生冲突,而端点执行尚未开始,则不会保存对应幂等结果。因此,“重复请求必须等待第一份结果”并不是所有 API 都必须采用的通用规则,调用方要处理实际的在途冲突语义。 Stripe API v2 则可能继续尝试失败或部分失败的工作,并返回更新后的结果;不能把 v1 的原响应保存规则直接套到 v2。不同提供方也不保证逐字重放响应。EC2 文档说明,成功重试不会再次执行行动,但返回结果可以包含更新后的创建状态等信息。RFC 9110 定义的是预期效果;具体服务是否复用原始响应字节,是叠加在其上的另一层契约,不能把两者当成同一要求。 让键的含义保持稳定 同一个键被配上不兼容的参数时,应按照服务明确的错误规则处理,而不是静默创建另一项操作,或覆盖原先保存的结果。JSON 字段顺序、默认值、数字表示和省略字段,都可能影响比较。一个小例子能规范化某种输入,不代表已经解决任意正文的语义等价问题。 下面的实验只接受一个商品字符串和一个正整数量。将这两个字段排序,定义了它自己的比较规则,并不是通用 JSON 等价算法。示例用 409 表示参数冲突,也是测试接口的政策,不是每一家幂等 API 都保证返回的状态码。 ...

一月 20, 2027 · 3 分钟 · XB Field Notes
本地准入闸口拒绝新工作,已接受的一个请求仍被计数。释放后完成,到期则明确放弃。

优雅关闭:停止准入,再排空工作

优雅关闭改变的是服务接收与完成工作的方式,信号只是触发条件。它能够提供的保证比“绝不丢失任何东西”更有限,也更具体:停止接收新的业务工作,记录此前已经接受的任务,在有界时间内等待完成,并在期限耗尽时如实报告剩余工作。仅仅捕获一个信号,并不等于已经实现安全关闭。 信号开始的是协调过程 应用可以处理 SIGTERM,然后启动自己的关闭流程;SIGKILL 则不能捕获、阻塞或忽略。首先应确认谁真正收到了停止信号:如果外层包装进程收到信号却不转发,内部工作进程即使安装了正确的处理器,也可能根本没有机会执行它。 信号处理器应保持简短。Python 在主解释器的主线程执行信号处理函数,并明确警告不要在其中使用同步锁。实验只设置一个标记,把修改就绪、关闭准入和等待任务的工作留给普通控制流程,不在信号处理器里执行网络操作或等待工作线程。 应该尽早撤下就绪,让路由变更开始传播,但本地业务准入仍需要独立闸口,因为已有连接和传播延迟都可能带来后续请求。关闭闸口与登记已经接受的任务,必须使用一致的边界;否则看似已经停止接收,实际上却可能漏记同时进入的新任务。 截止期限约束完整生命周期 任务应一直记账到真正的完成点。调用方等待超时,不证明工作线程已经停止;响应已经发送,也不证明业务事务已经持久化。应根据操作契约选择完成边界,而不是把任意一次连接关闭都解释成成功,更不能仅凭客户端离开就提前回收在途任务。 进程内的经过时间应由单调时钟计算,并在监督器实际允许的时间内为清理留出余量。期限到达后,对剩余工作必须有明确的取消、放弃或可靠移交政策。从等待循环里跳出来,本身不会终止正在运行的线程,也不会撤销已经执行的业务效果。 正常排空和未完整关闭,应在记录中保持可区分。某个非零退出码是服务自身的约定,不是能够保证正确恢复的通用规则。客户端没有拿到响应时,仍可能需要根据幂等契约或业务记录确认结果,不能仅凭服务正在关闭就断言重试一定安全。 与监督器核对同一份契约 systemd 的 TimeoutStopSec 管理配置中的停止命令等待和服务停止等待;KillMode 选择被终止的进程范围,不决定排空能持续多久。mixed 模式首先向主进程发送终止信号,最后的强制终止覆盖控制组内剩余进程;主进程提前退出,也可能触发这一升级。真实 unit 中的停止钩子和通知延长机制必须一起检查。 Kubernetes 在执行已配置的 preStop 钩子之前,就开始计算终止宽限时间,钩子不是额外领取另一份独立预算。EndpointSlice 的终止状态更新与本地关闭同时推进,并不是完成一次全局同步屏障后,从此再也不会有请求到达。因此,端点变更不能替代应用自己的准入判断。 实际停止信号也可能受到镜像或运行时配置影响。应测试部署中的进程树和停止约定,而不是把本地直接执行程序的结果扩大为容器、sidecar 与代理协调全部正确的证据。这些组件各自的关闭顺序和宽限时间,需要沿真实调用链核对。 坏掉的样子 一个只修改就绪状态的关闭标记,可能让已有的持久连接继续不断提交新工作;反过来,立即切断全部活动连接,又会丢掉原本有时间完成的请求。停止新的业务准入,与完成已经获准的工作,是两个需要分别控制和观察的决定。 无界等待线程可能超过监督器允许的时长。守护线程允许进程退出时不等待它们,但这意味着放弃,不等于安全取消或事务回滚。库自身也有关闭契约:Python socketserver 的 shutdown 必须从不同于 serve_forever 的线程调用,否则会造成死锁,关闭路径反而永远无法结束。 应同时观察接受、完成、拒绝和未完成的工作,以及关闭耗时和退出结果。只记录一个“退出成功”,却不区分这些数量,可能掩盖真正丢掉的任务。验收对象应该是完整生命周期,而不是那一行信号注册代码或某个看起来正常的退出状态。 停止新业务准入,并将已接受工作计到真实完成边界。原文区分请求完成与刻意放弃守护线程;不证明部署中的监督器协调。 快速验证 保存为 shutdown_check.py,在类 Unix 系统上用 Python 3.10 以上版本执行 python3 shutdown_check.py,并启用断言。2026-10-18,两个语言版本各在 CPython 3.14.4 / Darwin 25.5.0 arm64 环境运行一次;其他版本未测试。每种情况创建子进程和回环 HTTP 服务,SIGTERM 只发给该子进程;POST release 仅用于测试协调,不是生产管理接口。 ...

十二月 19, 2026 · 4 分钟 · XB Field Notes