优雅降级保留部分价值
优雅降级是在服务某一部分失效时,继续提供事先明确支持的功能子集。例如,文章页面可以暂时省略推荐区域,同时仍然显示用户请求的正文。关键是这种部分答案必须符合产品契约,既有用又如实说明限制。返回一点内容,并不天然比明确失败更安全;如果剩余内容会误导用户,就不能仅为了保持成功状态码而继续提供它。 先决定哪些东西可以缺少 编写回退代码之前,应分清可选区域和必须满足的结果。侧栏推荐消失可能可以接受,但默默跳过权限检查、把结果未确认的支付说成成功,或用未经验证的价格替代当前报价,都可能破坏核心承诺。这些情况需要明确失败或另行批准的处理流程,不能套用一个“页面总要保持绿色”的统一开关。降级范围应来自业务语义,而不是依赖清单上哪个接口最容易被捕获异常。 可选依赖应尽可能避免占住核心响应的关键路径。它的调用需要有界预算,但调用方等待超时,不代表底层工作已经停止。还应限制并发和排队,避免大量已经失去价值的任务继续占用线程或连接。占位说明应告诉用户缺少什么能力,同时避免暴露内部错误细节,也不能暗示已经取得当前数据。核心页面能否正常工作,应在强制关闭可选区域时单独检查。 把陈旧数据的可接受条件写清楚 最后一次良好数据只在明确范围与年龄政策内有用。需要记录数据取得时间,保留所属对象和授权范围,并规定年龄未知、超过限制或缓存为空时如何处理。一个时间戳本身不能证明数据正确:某些状态发生变化后,即使缓存只有很短时间,也不能继续用于作出授权或交易决定。因此,是否可以复用,是契约问题,不是看到缓存还有内容就可以决定。 上游失败后提供旧数据,与 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") 成功调用会刷新缓存。接下来依赖失败时,如果注入年龄恰好为一百二十秒,回退返回的确实是刚才取得的最新成功值;改为一百二十一秒,就选择明确简化的答案。连续三次失败使调用闸口打开,下一次普通请求继续得到回退,但实际调用计数不增加。这里验证的是旧数据仍然符合约定,而不仅仅是旧数据仍然存在。 ...