一个依赖缺失时仍返回部分、降级答案的服务。

优雅降级保留部分价值

当一个依赖失败时,服务不必因此倒掉。优雅降级意味着用你尚能算出的东西回答,只缺掉那坏掉的一块,而不是什么都不给。即使站点的一部分不工作,它仍然有用。 挂掉的是那个局部,不是整页 当侧栏、某块片段或某个资料卡的后端下了线或慢到没法等,页面可以省略这一块并明确标注,而不是把整页变成一张空错误。访客仍然拿到那些还能用的文本和图片,失败的表面从「整页」收缩到「这一个区域」。这是一次刻意的模板决策:每个区块都要能没数据也渲染,并用占位符+一句说明替身那部分。慢依赖是降级里最容易漏的静默杀手,所以每块都要有边界超时,绝不能让一个拖沓的上游把整页都吊在半空。 缓存最后一次良好状态 对变化很慢的数据,把先前那次还算新的结果存起来,等实时源挂掉时还能把它端上来,换来的是可用性,失去的是新鲜度。这里的心态和 stale-while-revalidate 一脉相承,只是作用在组件层:设好明确的最大新鲜时间、后台重新验证,以及缓存本身都空时该给用户什么。要点是你得让那份缓存「足够新到可信」,于是要把你正服务的时间戳记下来,并在页面上如实反映。一张带着略陈旧但正确的数据的页面,每次都能赢过一份措手不及的错误页。 让降级对全队可见 即使降级后的页面看起来一切如常,它也必须对整个系统是大喊的。把降级路径、依赖它的是哪个上游、以及你实际服务的那份数据到底有多旧,都记进日志,这样「活着的降级」和「功能齐全」能被分清。再用一个「降级计数」指标把这件事挪到监控面板里,而不是默默吞进一个永远 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 self.failures = 0 self.threshold = 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 get_recs(self, fail=False, use_cache=True): if self.open: return {"mode": "open", "data": ["popular-picks"], "note": "breaker open"} try: data = self.live_recs(fail) self.failures = 0 return {"mode": "fresh", "data": data} except DependencyDown: self.failures += 1 if self.failures >= self.threshold: self.open = True return {"mode": "open", "data": ["popular-picks"], "note": "breaker open"} if use_cache and self.cached_recs: return {"mode": "degraded-cache", "data": list(self.cached_recs), "note": f"stale age={self.cached_age_s}s"} return {"mode": "degraded-simplified", "data": ["popular-picks"], "note": "simplified fallback"} def half_open_probe(self, fail=False): # 半开只允许一次真实试探。 self.open = False self.failures = 0 return self.get_recs(fail=fail) def check(cond, msg): if not cond: raise AssertionError(msg) def main(): svc = PageService() r1 = svc.get_recs(fail=False) check(r1["mode"] == "fresh" and r1["data"] == ["fresh-pick"], "fresh read must win") print(f"1 fresh: {r1}") r2 = svc.get_recs(fail=True) check(r2["mode"] == "degraded-cache" and "stale" in r2["note"], "must serve stale cache") print(f"2 degraded-cache: {r2}") svc.cached_recs = [] # 缓存为空时只能给简化答案。 svc.failures = 0 r3 = svc.get_recs(fail=True) check(r3["mode"] == "degraded-simplified", "empty cache must simplify") print(f"3 degraded-simplified: {r3}") svc2 = PageService() svc2.get_recs(fail=True) svc2.get_recs(fail=True) r_open = svc2.get_recs(fail=True) check(r_open["mode"] == "open", "third failure must open breaker") calls = svc2.live_calls r_fast = svc2.get_recs(fail=True) check(r_fast["mode"] == "open" and svc2.live_calls == calls, "open must fail fast") print(f"4 open: {r_open} (live_calls={calls}, fast-fail holds)") r_half = svc2.half_open_probe(fail=False) check(r_half["mode"] == "fresh", "probe success must close breaker") print(f"5 half-open probe: {r_half}") print("PASS: fresh, stale-cache, simplified, open fail-fast, and probe all hold") import sys print(sys.version.split()[0]) if __name__ == "__main__": main() 六组断言依次走过正常、降级与熔断三态。正常时返回实时数据,推荐失败时返回带年龄的过期缓存,缓存为空时返回明确标注的简化占位,连续三次失败后熔断器打开,第四次不再调用依赖,并用调用计数证明快速失败成立。半开探测成功后关闭熔断,服务回到正常。末尾会打印通过信息与版本。示例只验证回退选择与熔断计数,不验证过期抖动、重试退避与线上看板。上线前,还要用强制失败在真实依赖上重演三态,并确认降级计数可见可告警。 ...

四月 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
一边是一块磁盘,另一边是一个带标签的桶。

对象存储与块存储

存储并不都相同。块存储呈现一块你能挂载、可访问任意字节的盘;对象存储呈现一个按键寻址、带元数据的对象存储仓。选得对不对,取决于访问模式,而不是流行词。 块存储是字节寻址的盘 块存储是真正的磁盘:一个卷以宿主或容器能直接认的块设备挂进去,宿主自己有底层块、页与整个文件系统。所以凡是需要低延迟、字节级写入和文件锁的负载——数据库、文件系统、任何要毫秒级 I/O 的家伙——都该用块存储。它的短板也很集中:一个块卷通常同时只给单一写者用,做不了「百万文件被许多客户端并发读」,而且无论你用不用,容量都按整块预付。如果你是库或跑文件系统,基本就是要块存储。 对象存储是 Web 规模的键值大对象 对象存储是 blob 的键值库:内容由「bucket/对象 key」这组扁平地址定位,数以百万的客户端可以同时读写,GET/PUT/DELETE 这三招就能处理。它天生适合共享,也因此是媒体、备份、数据湖这类「写一次、读很多次」的家。要付出的代价是高一些的延迟与「整对象读写」的模型——想做逐块的在线修改,不是它的强项。所以它更适合放大件的、不常变的、要靠成本与访问模式一起撑起来的数据。 让存储匹配访问模式 选存储要跟访问模式走,而不是跟牌子走。需要追加字节、锁行、跑 SQL、或挂一个文件系统——用块。需要存一份写一次、读很多次、并且要分发给许多客户端的大对象——用对象。两者通常是搭档而非对手:一个典型架构里,热的、事务性的状态交给块,冷的、耐久的对象交给对象,中间用明确的迁移策略把数据发往该去的地方。别用「我过去一直这么存」来决定,用量与你读写方式是否匹配来选,这才能让账目与延迟都诚实。 坏掉的样子 把对象存储当成块设备直接用,是最先出事的用法。数据库文件或日志直接放在对象键上,既没有随机字节写,也没有文件锁:一次单字节修改会被放大成整对象的下载再上传,多写者互不知晓地互相覆盖,崩溃恰好发生在下载与回传之间,就会留下一个被截断的新版本,而不是块设备上可恢复的坏页。延迟也会暴露错配,因为改一个字节同样要搬运整个对象,读改写循环越频繁,尾延迟与失败窗口就越大。 第二种坏法是海量小文件。每个PUT、GET尤其是LIST都是一次计费的接口调用,扁平命名空间里并没有真正的目录,枚举一个前缀可能要翻上千个键做分页。如果每个事件写一个对象,再靠列举桶来发现任务,元数据很快变成瓶颈,账单与延迟一起失控。 第三种坏法更隐蔽,是一致性窗口里的旧版本。写入成功后的一小段时间内,读者仍可能拿到上一个版本,两个进程会同时认为自己看到的是当前对象,直到新版本完全扩散。依赖立即读写的协调逻辑会因此出现偶发分歧,排查时很难复现。 第四种是生命周期规则误删。一条前缀写得过宽的过期或删除策略,会悄悄删掉备份与上传中的对象;如果没有开启版本控制,这次删除就是终局。恢复只能靠桶外另一份拷贝,所以重要前缀必须先用小范围演练,再配版本保留与最小权限。 快速验证 把下面代码存为storage_semantics.py,运行python3 storage_semantics.py。它只用标准库,不碰网络与磁盘,所有状态都在内存里的两个对象中。块设备夹具支持原地覆写,对象夹具用dict保存整对象不可变值,每个键带单调递增的版本号。 """块设备原地覆写 vs 对象整对象替换+版本号.""" class BlockVolume: def __init__(self, data: bytes): self._disk = bytearray(data) def write(self, offset: int, chunk: bytes) -> None: self._disk[offset:offset + len(chunk)] = chunk def read(self) -> bytes: return bytes(self._disk) class ObjectStore: """极简对象语义:整对象 PUT,每个键带版本。""" def __init__(self): self._objects: dict[str, tuple[bytes, int]] = {} def put(self, key: str, data: bytes) -> int: version = self._objects[key][1] + 1 if key in self._objects else 1 self._objects[key] = (bytes(data), version) return version def get(self, key: str) -> tuple[bytes, int]: return self._objects[key] def main() -> None: # 1. 块:原地覆写保留相邻字节。 block = BlockVolume(b"ABCDEF") block.write(2, b"XX") assert block.read() == b"ABXXEF", block.read() print(f"block: {block.read()!r} preserved neighbours") # 2. 对象:PUT 替换整个值并递增版本。 store = ObjectStore() v1 = store.put("report", b"ABCDEF") old_data, old_version = store.get("report") v2 = store.put("report", b"XYZ") new_data, new_version = store.get("report") assert (v1, v2) == (1, 2) assert len(old_data) == 6 and len(new_data) == 3 assert old_version == 1 and new_version == 2 assert new_data == b"XYZ", "对象替换不应与旧字节合并" print(f"object: v{v1} -> v{v2} len {len(old_data)} -> {len(new_data)}") # 3. 一致性窗口:持旧版本的读者仍停留在 v1。 stale = (old_data, old_version) current = store.get("report") assert stale[1] != current[1], "版本必须能暴露过期读" print(f"stale reader saw v{stale[1]}, current is v{current[1]}") # 4. 无局部更新:对象接口没有按偏移写入。 assert not hasattr(store, "write"), "对象存储不应提供块写入" print("PASS: block mutates in place, objects replace by version") if __name__ == "__main__": main() 四组断言把语义差异钉死。块写入只改两个字节,相邻字节原样保留,证明原地修改;对象两次写入把长度从六字节换成三字节,版本从一涨到二,证明整对象替换与显式历史;持旧版本的读者与当前版本不一致,复现一致性窗口;对象接口根本没有按偏移写入的方法,说明局部更新只能靠读改写拼出来。 ...

三月 17, 2027 · 2 分钟 · XB Field Notes