文章受控样例中,同一键的十二个重叠调用,不合并时执行十二次加载,一个共享 Group 执行一次,两个独立 Group 执行两次。这轮结束后,下一轮仍会重新加载。

缓存集中失效会放大回源工作

缓存雪崩或击穿所造成的一个关键风险,是并发未命中对同一依赖发起大量重复工作。同时到期只是触发条件之一:容量淘汰、刚部署的空缓存、缓存服务不可用,都可能造成类似突发。即使只有一把热门键,也足以压垮后端。判断风险时应数清楚:一份结果尚未计算完的这段时间内,到底有多少次重建真正抵达数据源。 文章的重叠调用样例在明确的 Group 边界内统计工作量。一个 Group 合并一轮在途工作,两个独立 Group 各自加载,下一轮仍重新执行。完成结果的存储仍由应用负责。 在真实边界内合并工作 Go 的 singleflight Group 对同一键,在这个 Group 内最多保持一次在途函数执行;重复调用者共享结果,也共享错误。不同 Group 之间不会协调,即使它们位于同一个进程;不同服务实例通常也各有自己的 Group。因此它既不是分布式锁,也不是保存已完成结果的缓存。把 Group 放在每个请求内部重新创建,无法实现跨请求合并。 实际使用时,应让请求共享同一个 Group,并使键准确表示等价工作。不同租户、权限、币种或响应表示如果错误地使用同一个键,可能拿到不属于自己的结果。对于缓存回填,在共享函数内部应再次检查缓存,确实仍缺失才访问数据源,并在函数返回前完成成功结果的缓存写入。这些存储操作都不是 singleflight 自动提供的能力。 错开时间不能改变新鲜度承诺 随机抖动可以打散不同条目或独立副本的到期时间,却不能阻止访问同一过期热键的人一起未命中。如果最大 TTL 是业务规定的新鲜度上限,在有界范围内随机缩短寿命,不会偷偷越过这个上限;任意增加正向抖动则改变了原有承诺。不能脱离请求量、计算成本和允许陈旧程度,宣称几个百分点或几秒钟就普遍足够。 ...

十一月 22, 2027 · 2 分钟 · 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
按比例展示 30 秒新鲜期与随后 60 秒 stale-if-error 许可。历史离线模型在年龄 35 先验证,遇到符合条件的错误才保留 A;年龄 91 返回错误。失败不会重置 Age。

陈旧缓存需要有界策略

陈旧响应可以在源站故障时维持公开手册的可读性,也可能保留过时价格、已撤销权限或误导性的状态。可用性并不天然高于正确性。启用这种行为之前,应先确定哪些信息允许继续使用、什么条件下可以复用,以及最多能旧多久,而不是看到一次错误响应就无条件拿旧成功结果代替。 历史 Node/http-cache-semantics 实验用虚构响应核对有界策略与调用方所选正文。年龄 35、89 可覆盖符合条件的错误,91 不行;失败不会重新开启窗口。离线通过不等于真实网络或故障测试,本图没有新增执行。 用可用性换新鲜度 过期和淘汰是两件事。过期响应仍可留在缓存里,用于条件验证或受到许可的陈旧响应;但冷缓存或已经淘汰条目的缓存,没有旧正文可返回。陈旧指令无法凭空创造副本,保存着字节也不会自动授予复用权限。选中的响应还必须匹配当前请求,包括相关 Vary 维度和访问边界,不能拿另一个请求留下的成功内容填补故障。 如何表示 stale-while-revalidate 允许在有界窗口内返回旧响应,同时尝试不阻塞该响应的验证。stale-if-error 则允许在尝试访问源站遭遇符合条件的错误时使用旧响应。两者触发条件不同,都不强制缓存保存内容,也不能保证每种缓存实现都支持。请求到达时若已越过允许窗口,不能因为之前有过一次许可,就继续无限期使用旧内容。 对于 max-age=30, stale-if-error=60,额外六十秒从新鲜期结束后开始计算,不是从每一次失败尝试重新开始。候选响应的总年龄窗口约为九十秒。RFC 5861 指定了会形成 500、502、503、504 的错误场景。认证失败或资源不存在,不能随手重新包装成可用性故障,再用旧的成功响应掩盖真正需要客户端看到的结果。 成功的条件验证不一定重新下载正文。三零四响应可以依据验证元数据更新存储响应,同时保留原来的表示字节。应把这种结果与替换正文的二百响应、故障期间的陈旧命中分别记录;三种情况可能显示相同页面,却对应不同的新鲜度依据,不能仅靠正文是否改变判断验证是否发生。 坏掉的样子 每次重验证失败后都把 Age 清零,会把有界例外变成无限陈旧。保留旧正文却刷新存储时间戳,也会造成同类错误。失败时应保留年龄;只有真正的新响应或成功验证,才提供更新新鲜度的依据。后台验证通常由流量触发,所以一个长期无人访问的缓存,不一定会在下一次用户访问之前主动把内容更新好。 只读或幂等并不代表可以安全返回旧内容。一个 GET 也可能涉及对时效敏感的权限或价格。RFC 9111 禁止在适用的 no-cache、must-revalidate 等指令不允许时复用陈旧响应。应避免互相矛盾的策略,并测试实际部署的缓存如何处理这些限制。另外,RFC 5861 中关于旧 Warning 字段的建议,已被废弃该字段的 RFC 9111 更新,不能把它当作当前验收必需项。 ...

二月 1, 2027 · 2 分钟 · XB Field Notes