浅色网格上显示文章标题,旁侧是依次标注 Observe、Decide、Act 的三个相连方框。

优雅降级保留部分价值

优雅降级是在服务某一部分失效时,继续提供事先明确支持的功能子集。例如,文章页面可以暂时省略推荐区域,同时仍然显示用户请求的正文。关键是这种部分答案必须符合产品契约,既有用又如实说明限制。返回一点内容,并不天然比明确失败更安全;如果剩余内容会误导用户,就不能仅为了保持成功状态码而继续提供它。 先决定哪些东西可以缺少 编写回退代码之前,应分清可选区域和必须满足的结果。侧栏推荐消失可能可以接受,但默默跳过权限检查、把结果未确认的支付说成成功,或用未经验证的价格替代当前报价,都可能破坏核心承诺。这些情况需要明确失败或另行批准的处理流程,不能套用一个“页面总要保持绿色”的统一开关。降级范围应来自业务语义,而不是依赖清单上哪个接口最容易被捕获异常。 可选依赖应尽可能避免占住核心响应的关键路径。它的调用需要有界预算,但调用方等待超时,不代表底层工作已经停止。还应限制并发和排队,避免大量已经失去价值的任务继续占用线程或连接。占位说明应告诉用户缺少什么能力,同时避免暴露内部错误细节,也不能暗示已经取得当前数据。核心页面能否正常工作,应在强制关闭可选区域时单独检查。 把陈旧数据的可接受条件写清楚 最后一次良好数据只在明确范围与年龄政策内有用。需要记录数据取得时间,保留所属对象和授权范围,并规定年龄未知、超过限制或缓存为空时如何处理。一个时间戳本身不能证明数据正确:某些状态发生变化后,即使缓存只有很短时间,也不能继续用于作出授权或交易决定。因此,是否可以复用,是契约问题,不是看到缓存还有内容就可以决定。 上游失败后提供旧数据,与 stale-while-revalidate 并不相同,后者可以在发起验证的同时提供符合条件的陈旧响应。两者都需要有界的新鲜度与资源政策。成功刷新时,也必须真正替换缓存内容并重置年龄;否则系统可能不断声称提供“最近一次成功结果”,实际却永远返回更早的那份快照。显示年龄有助于观察,但不能代替这些状态更新与过期判断。 坏掉的样子 回退可能扩大事故。AWS 关于避免回退的文章描述了缓存故障后流量直接涌向数据库,最终超出数据库承受能力的事件。这份来源是在警告难以测试、很少运行的备用路径,并不是无条件支持“有部分答案就一定更好”。应该优先改善可预测的主要路径,并经常演练恢复行为,而不是把希望寄托在资源需求不明、只有事故时才启用的复杂分支上。 缓存同时到期、所有节点同时试探恢复,都可能制造突发负载。需要分散刷新工作,限制重试与并发,并检查备用路径是否还在争用已经耗尽的同一资源。熔断器决定是否继续调用依赖,降级政策决定剩下的答案是否可以接受。这是两个不同问题:熔断器打开,并不能证明缓存仍然有效,也不能证明某个静态占位符合业务承诺。 恢复探测失败后,普通请求准入必须继续关闭。如果还没验证恢复就先闭合熔断器,流量可能过早回到故障依赖。生产实现还需要同步、探测调度和有界恢复并发,串行示例不能验证这些性质。降级响应也应有独立计数与数据年龄观测,避免 HTTP 200 掩盖持续的功能缺失;是否需要告警,还要结合影响范围、持续时间和实际可采取的动作。 快速验证 保存为 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
绿色网格上的标题为‘退避需要重试预算’,旁侧是观察、判断、行动三个步骤。

带抖动与退避的重试

重试意味着再增加一份工作,不是自动提高可靠性的操作。它可能帮助恢复暂时失败,也可能重复一个不安全的业务行动,或把额外需求送到已经过载的依赖。选择延迟公式之前,应先确定哪些操作允许重复、总共能投入多少次尝试与多长时间,以及这些额外工作由哪一层负责。 先约束尝试次数,再安排时间 需要区分尝试和重试:总共三次尝试,是第一次调用加最多两次重试。还要确定恢复责任属于哪一层。如果嵌套的三层各自允许三次尝试,一次外层行动就可能对持续失败的最底层发起二十七次调用。抖动只能改变这些调用的时间,不能消除次数相乘的结果。 Google SRE 同时讨论单请求预算与更广范围的重试预算。即使每个请求都有限次尝试,大量失败的客户端仍可能制造相当可观的额外工作。应把重试流量与原始需求分别统计,并核对 SDK、代理和应用层已经存在的政策,避免每一层都独立认为自己只增加了少量调用。 指数退避增长的是上限 封顶指数退避让等待上限逐步增大,直到达到最大值。没有抖动时,同时失败的客户端可能算出相同的下次时间,甚至在到达上限后继续保持同样间隔。等待更久,并不必然打破同步,不能把周期变长当成请求已经分散的证据。 完全抖动会在零与当前上限之间采样。因为取值随机,即使上限正在增长,下一次实际等待仍可能比上一次短。因此,“每次失败后一定比之前多等一会儿”不是这种算法的准确描述;需要区分退避上限的变化与本次真正取到的延迟。 AWS 的文章比较了完全抖动、等量抖动和去相关抖动,并报告特定竞争模拟的结果。这些结果能帮助理解取舍,却不保证某种政策在所有工作负载下都具有最低延迟,也不保证完全没有突发。相同随机种子、定时器粒度和大量客户端,仍可能形成聚集。 整条操作共用一个截止期限 预算必须同时包括执行、等待和后续尝试。每次调用都重新拿到完整超时时长,会悄悄扩大原调用方允许的生命周期。应把剩余预算传给下游,并在准备进行更多工作之前再次检查,而不是只限制两次调用之间睡多久。 进程内计算经过时间,应使用单调时钟。客户端超时,也不证明服务器已经停止或从未提交;取消与远端截止期限需要真实的传输层支持,业务仍然需要重试或幂等契约。不能因为本地函数返回错误,就把未知结果当成未执行。 Retry-After 等服务端等待提示会增加另一项约束。不能因为本地抖动公式允许较小值,就采样出早于该边界的请求时间。如果必须等待的时间已经无法放进剩余期限,应在当前操作中放弃重试,而不是为了凑预算缩短服务端要求。 坏掉的样子 永久性的参数验证错误,不会因为重复发送就变得合法。非幂等操作在结果不明时重放,可能再次产生同一业务效果。应明确选择可重试的失败类别;捕获所有异常后循环重试,既会掩盖编程错误,也可能把一次局部故障推成持续过载。 只有退避上限,没有次数或总耗时限制,仍然允许无限工作;但期限过短,又可能让所有昂贵却合法的操作一直失败。需要一起观察完成工作量、重试次数、总延迟和放弃原因。仅看到错误率下降,可能忽略为了获得成功而越来越高的重试成本。 快速验证 保存为 retry_check.py,用 Python 3.10 以上版本执行 python3 retry_check.py。本轮以 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
标题为「故障切换是一把演练过的开关」的封面,下方是观察、判断与行动三个面板。

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

备用节点只有在提升路径可用、旧写者不能破坏新历史时才有价值。故障切换既是决策,也是流程与演练。副本看起来健康,不代表客户端能连上它,也不代表所有已确认写入都已经复制过去。先确定恢复时间目标与可接受的数据损失,再决定何时切换,而不是等报警响起才现场讨论。 提前设计好决定 确定谁有权批准提升、什么证据足以说明故障,以及由自动化还是操作员执行。心跳丢失可能是网络分区,而不是主库已经停止。故障检测本身不能授权两个独立写者。系统需要真正执行所有权边界的机制,例如经过确认的节点隔离,或存储端校验的 fencing,并使它与复制协议的保证相匹配。 单写者服务的安全目标是「最多一个被接受的写者」,并非每一瞬间都恰好有一个可用写者。隔离旧节点后、提升新节点前,暂时没有写者可能正是正确的停机选择。操作手册应写明候选节点、复制位置、可接受的丢失范围、客户端如何重连,以及前提不成立时由谁叫停。自动切换能缩短决策时间,却不能凭空补回尚未复制的数据。 演练真实经过的路径 根据风险决定演练频率,并在相关变更后重测。演练只能证明实际覆盖到的路径:绕过 DNS 的数据库提升不能验证地址缓存;测试客户端使用另一套身份,也不能证明生产权限正确。在安全前提下,加入真实的重连流程和已确认写入核对。记录范围、耗时、复制延迟、拒绝条件及恢复证据,不要把最近一次成功的日期当成完整的就绪评分。 开始前先安排安全停止点和恢复步骤。新主库一旦接受写入,切回旧节点往往就不再是撤销一项配置那么简单。旧主库可能需要对账或重建为副本,不能因为进程重新启动就恢复写权限。备份提供另一条恢复路径,但「有备份」本身也不等于已经验证能恢复。 坏掉的样子 旧主库在提升之后仍可写,就会产生脑裂。控制器里记录一句「已隔离」不够,存储或隔离机制必须真正阻止旧写入。落后的候选节点也可能在切换成功后丢失已确认写入。延迟阈值必须有明确单位,并与数据损失目标相连;允许非零延迟,不能同时宣称零数据丢失。 健康检查反复抖动会触发来回切换,应设置明确的故障判定和恢复条件,不要在重新同步之前自动切回。DNS 修改只影响后续查询:已有连接可能持续使用旧地址,按旧 TTL 缓存的回答也不会立刻消失。事故发生时调低 TTL,不能追溯缩短已经缓存的寿命。除了权威 DNS 的响应,还要验证真实客户端的重试、缓存和重连行为,否则控制台显示成功,用户路径仍可能失败。 快速验证 将下面内容保存为 failover_drill.py,在临时目录运行 python3 failover_drill.py。它只修改内存对象。健康状态和复制延迟是模拟值,fence() 只是更新集合,并不执行真实隔离。模型保留一个被指定的主节点,但隔离期间允许没有可写节点。候选节点通过检查后会被明确解除隔离,因此切回旧节点时不会出现「已经成为主库,却还留在隔离集合中」的自相矛盾状态。 """Failover drill: single-primary switch with health and lag guards (stdlib only).""" from dataclasses import dataclass MAX_LAG = 128 # Synthetic units, not seconds or a zero-data-loss guarantee. class FailoverRefused(Exception): pass @dataclass class Node: name: str healthy: bool = True lag: int = 0 is_primary: bool = False class Cluster: def __init__(self): self.nodes = {"a": Node("a", is_primary=True), "b": Node("b")} self.fenced: set[str] = set() @property def primary(self) -> str: primaries = [n for n in self.nodes.values() if n.is_primary] assert len(primaries) == 1, f"must keep exactly one primary, saw {[p.name for p in primaries]}" return primaries[0].name def can_write(self, name: str) -> bool: node = self.nodes[name] return node.is_primary and node.healthy and name not in self.fenced def health_check(self, name: str) -> bool: node = self.nodes[name] return node.healthy and node.lag <= MAX_LAG def fence(self, name: str) -> None: self.fenced.add(name) assert not self.can_write(name) def failover(self, target: str, *, fence_old: bool) -> str: old = self.primary if target == old: raise FailoverRefused("target is already primary") if target not in self.nodes: raise FailoverRefused(f"unknown target {target}") candidate = self.nodes[target] if not fence_old or old not in self.fenced: raise FailoverRefused(f"refuse: old primary {old} is not fenced; fence first to avoid split brain") if not candidate.healthy: raise FailoverRefused(f"refuse: standby {target} is unhealthy") if candidate.lag > MAX_LAG: raise FailoverRefused(f"refuse: standby {target} lags by {candidate.lag} (> {MAX_LAG})") self.nodes[old].is_primary = False # Model explicit reactivation after the old writer is fenced. self.fenced.discard(target) candidate.is_primary = True assert self.primary == target and self.can_write(target) assert sum(self.can_write(name) for name in self.nodes) <= 1 return target def expect_refused(fn, label: str) -> None: try: fn() except FailoverRefused as exc: print(f"refused as expected ({label}): {exc}") return raise AssertionError(f"should have refused: {label}") def main() -> None: # 1. Clean drill: healthy standby, caught-up replica, old primary fenced. cluster = Cluster() assert cluster.primary == "a" assert cluster.health_check("b") cluster.fence("a") assert cluster.failover("b", fence_old=True) == "b" print("clean switch: a -> b") # 2. Refuse lag beyond this policy; even accepted positive lag may lose writes. cluster = Cluster() cluster.nodes["b"].lag = 900 assert not cluster.health_check("b") cluster.fence("a") expect_refused(lambda: cluster.failover("b", fence_old=True), "stale replica") assert cluster.primary == "a" # 3. Unhealthy standby must not promote. cluster.nodes["b"].lag = 0 cluster.nodes["b"].healthy = False expect_refused(lambda: cluster.failover("b", fence_old=True), "unhealthy standby") assert cluster.primary == "a" # 4. Unfenced old primary must block promotion (split-brain guard). cluster.nodes["b"].healthy = True unfenced = Cluster() # nobody fenced yet expect_refused(lambda: unfenced.failover("b", fence_old=False), "split brain") expect_refused(lambda: unfenced.failover("b", fence_old=True), "split brain without fence record") assert unfenced.primary == "a" # 5. Failback: resynchronize the candidate, then fence the CURRENT writer. cluster = Cluster() cluster.fence("a") cluster.failover("b", fence_old=True) cluster.nodes["a"].healthy = True cluster.nodes["a"].lag = 0 cluster.fence("b") assert cluster.failover("a", fence_old=True) == "a" print("failback: b -> a") print("PASS: at most one writable node; 4 refusals + clean switch + failback passed") if __name__ == "__main__": main() 输出包括一次正常切换、分属三类的四次拒绝,以及一次切回,最后为 PASS: at most one writable node; 4 refusals + clean switch + failback passed。拒绝提升会保留原有的主节点标记,但已经被隔离的旧主库仍不可写,并不代表服务自动恢复可用。正常切换与切回场景的延迟都是零;策略另外允许最多 128 个模拟单位的正延迟,这包含可能丢失数据的取舍,不应误读为真实数据库的推荐阈值。 ...

二月 25, 2027 · 3 分钟 · XB Field Notes
观察、决策与行动三块面板,旁边是关于队列投递保证的标题。

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

队列的投递承诺只描述工作流程中的一段边界。消息到达消费者,与业务副作用已经提交,是不同事件。至少一次允许重复投递,至多一次则允许在不确定时丢失而不继续重试。仅凭这两个标签,不能保证订单、通知或数据库更新在所有故障下都恰好完成一次。可靠性需要沿真正的数据和确认路径逐步检查。 找到发布、确认与提交的位置 首先明确生产方何时知道 broker 已经接受消息、保留多久,以及持久化政策覆盖哪些故障。消息从未成功发布、超过保留期或被明确删除,都不会因为消费者自称至少一次就自动恢复。恢复前提必须与投递标签一起记录,否则“永不丢失”会遮住多个根本没有交给队列负责的环节。 消费方若先确认,再提交业务结果,就会形成丢失窗口:broker 停止重试时,应用可能还没完成工作。先提交又留下另一种窗口:结果已成功,但确认丢失,工作于是重新出现。这个重复仍然符合投递契约,并不意味着最初操作失败。排查时要分别检查业务结果与确认结果,不能用其中一个推断另一个。 可见性超时与确认期限控制的是何时允许下一次尝试,不证明第一个工作进程已经停止。慢处理者可以与重投后的处理者同时运行,因此延长期限与防止重复副作用解决的是不同问题。应同时记录投递尝试和已经提交的业务结果,而不是只统计消费者调用次数,便把它当作成功执行次数。 在真正的事务边界内消除重复效果 若副作用都位于同一个事务数据库,可以把唯一操作身份与业务结果放进同一事务。唯一性检查必须能够裁决并发尝试,单独读取再无保护地写入并不够。还需要保存足够的载荷信息,识别“同一身份却携带不同指令”的情况,不能看到编号重复就无条件接受。这既是去重问题,也是操作身份的契约问题。 数据库事务不会自动包住外部邮件、支付接口或另一套独立存储。这些目标需要自己的幂等或协调契约。本地标记先写、远端调用后做,可能丢失效果;标记后写,则可能留下重复窗口。因此,本地去重表不能被说成端到端恰好一次保证。只有明确哪些动作一起提交、哪些动作仍独立失败,设计才有可检查的边界。 有些服务确实提供带明确范围的恰好一次投递能力,例如 Google Pub/Sub 说明了地域、订阅类型等限制,也区分投递重复与发布侧重复。应阅读具体契约,不应宣称这个能力不可能存在,也不能把它推广成所有下游副作用都已解决。多个已发布消息代表同一业务意图时,稳定业务身份仍然有独立价值。 坏掉的样子 持续失败的消息可能阻塞有序进度,过短期限则可能让本来正常的任务出现重叠尝试。重投计数能提供调查线索,却不能唯一判断是毒数据、慢依赖、确认丢失还是工作进程停滞。应关联消息身份、载荷、异常、处理时长和确认记录,再决定怎样修改重试政策。只根据“一条还是多条计数升高”断言根因,很容易修错位置。 需要限制重试并说明隔离失败如何审阅或重新执行。进入死信队列只是移动未完成工作,并没有完成它。去重记录的保存期限必须覆盖计划容忍的重放范围,删除身份记录之后,旧请求可能再次生效;永远保存又有存储和隐私代价。这里没有适用于所有任务的统一选择,应把保留与重放规则一起设计。 快速验证 保存为 delivery_guarantee_probe.py,使用 Python 3 运行。本次在 Python 3.12.14 与 SQLite 3.53.1 上通过。实验验证真实事务消费落点,而不是模拟 broker:临时数据库同时保存操作身份和一个无害数字总计,不会对任何账户扣款,也不向外发送消息。只有本程序自己的临时文件会被创建和清理。 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。实验已在 macOS、Python 3.14.4 上运行。每种情况都创建自己的子进程和回环 HTTP 服务,SIGTERM 只发给该子进程;POST release 路由仅用于测试协调,不是建议上线的管理接口。 ...

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