优雅降级是在服务某一部分失效时,继续提供事先明确支持的功能子集。例如,文章页面可以暂时省略推荐区域,同时仍然显示用户请求的正文。关键是这种部分答案必须符合产品契约,既有用又如实说明限制。返回一点内容,并不天然比明确失败更安全;如果剩余内容会误导用户,就不能仅为了保持成功状态码而继续提供它。

先决定哪些东西可以缺少

编写回退代码之前,应分清可选区域和必须满足的结果。侧栏推荐消失可能可以接受,但默默跳过权限检查、把结果未确认的支付说成成功,或用未经验证的价格替代当前报价,都可能破坏核心承诺。这些情况需要明确失败或另行批准的处理流程,不能套用一个“页面总要保持绿色”的统一开关。降级范围应来自业务语义,而不是依赖清单上哪个接口最容易被捕获异常。

可选依赖应尽可能避免占住核心响应的关键路径。它的调用需要有界预算,但调用方等待超时,不代表底层工作已经停止。还应限制并发和排队,避免大量已经失去价值的任务继续占用线程或连接。占位说明应告诉用户缺少什么能力,同时避免暴露内部错误细节,也不能暗示已经取得当前数据。核心页面能否正常工作,应在强制关闭可选区域时单独检查。

把陈旧数据的可接受条件写清楚

最后一次良好数据只在明确范围与年龄政策内有用。需要记录数据取得时间,保留所属对象和授权范围,并规定年龄未知、超过限制或缓存为空时如何处理。一个时间戳本身不能证明数据正确:某些状态发生变化后,即使缓存只有很短时间,也不能继续用于作出授权或交易决定。因此,是否可以复用,是契约问题,不是看到缓存还有内容就可以决定。

上游失败后提供旧数据,与 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")

成功调用会刷新缓存。接下来依赖失败时,如果注入年龄恰好为一百二十秒,回退返回的确实是刚才取得的最新成功值;改为一百二十一秒,就选择明确简化的答案。连续三次失败使调用闸口打开,下一次普通请求继续得到回退,但实际调用计数不增加。这里验证的是旧数据仍然符合约定,而不仅仅是旧数据仍然存在。

失败探测只执行一次真实的模拟调用,并保持熔断器打开;随后普通请求依然不能增加调用数。成功探测才会关闭闸口并刷新缓存。独立的空缓存和负年龄对照也都选择简化响应。最后的 PASS 只覆盖这些确定性状态选择,没有测量真实服务的可用性,也没有证明其他机器会在同一时刻看到相同的恢复结果。

部署前,应在受控负载下对真实依赖故障和恢复验证已选择的契约,并分别检查截止时间、取消、授权范围、缓存年龄计算和可见的降级指标。示例中的静态推荐回退有意保持廉价,不需要访问另一个系统。它不能证明一个备用远程服务在相关性故障中仍然健康,更不能用来替代真实恢复并发与资源争用的验收。

延伸阅读

来源