优雅降级保留部分价值
当一个依赖失败时,服务不必因此倒掉。优雅降级意味着用你尚能算出的东西回答,只缺掉那坏掉的一块,而不是什么都不给。即使站点的一部分不工作,它仍然有用。 挂掉的是那个局部,不是整页 当侧栏、某块片段或某个资料卡的后端下了线或慢到没法等,页面可以省略这一块并明确标注,而不是把整页变成一张空错误。访客仍然拿到那些还能用的文本和图片,失败的表面从「整页」收缩到「这一个区域」。这是一次刻意的模板决策:每个区块都要能没数据也渲染,并用占位符+一句说明替身那部分。慢依赖是降级里最容易漏的静默杀手,所以每块都要有边界超时,绝不能让一个拖沓的上游把整页都吊在半空。 缓存最后一次良好状态 对变化很慢的数据,把先前那次还算新的结果存起来,等实时源挂掉时还能把它端上来,换来的是可用性,失去的是新鲜度。这里的心态和 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() 六组断言依次走过正常、降级与熔断三态。正常时返回实时数据,推荐失败时返回带年龄的过期缓存,缓存为空时返回明确标注的简化占位,连续三次失败后熔断器打开,第四次不再调用依赖,并用调用计数证明快速失败成立。半开探测成功后关闭熔断,服务回到正常。末尾会打印通过信息与版本。示例只验证回退选择与熔断计数,不验证过期抖动、重试退避与线上看板。上线前,还要用强制失败在真实依赖上重演三态,并确认降级计数可见可告警。 ...