从局部故障到全局雪崩:分布式系统如何避免渐进式坍塌

2026-08-19 30 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

分布式系统最危险的故障,往往不是某个组件直接宕机,而是一个局部问题沿依赖关系持续扩散:请求开始排队,线程池被占满,重试放大流量,共享资源耗尽,最终让原本健康的服务也失去响应。Sam Newman 借用土木工程中的“渐进式坍塌”概念,把 1968 年 Ronan Point 大楼事故与 AWS 等软件系统故障放在同一个分析框架中:局部损伤之所以演变成灾难,关键在于系统没有把破坏限制在足够小的范围内。

软件系统为什么会逐级坍塌

渐进式坍塌不是“所有组件同时坏掉”,而是一段因果链。假设订单服务依赖库存服务,库存服务又依赖数据库:

  1. 数据库响应变慢,库存请求开始堆积。
  2. 订单服务等待库存结果,占满工作线程和连接池。
  3. 客户端或网关自动重试,进一步提高库存服务的负载。
  4. 健康检查因超时而失败,实例被频繁移出或加入流量池。
  5. 订单服务无法处理与库存无关的请求,故障边界继续扩大。

这里真正致命的通常不是最初的慢查询,而是没有上限的等待、共享资源和重试。每一项局部“容错”机制都可能单独合理,但组合起来却形成正反馈回路。

因此,分析故障时不能只问“哪个服务坏了”,还要追踪三个问题:故障消耗了哪些共享资源、哪些调用者会重试,以及依赖图中还有多少传播路径。

三类防线:加固、隔离与减少连接

土木工程会通过加强关键构件、提供替代承重路径以及分隔结构来控制坍塌范围。映射到软件系统,可以得到三类直接的工程动作。

加固关键组件:为核心依赖设置明确的容量目标、资源余量和退化行为。超时必须小于调用方的总截止时间;重试需要指数退避、随机抖动和次数上限;服务过载时应快速拒绝,而不是接收所有请求后一起超时。

隔离故障:不同租户、功能和下游依赖不要无限共享同一批线程、连接或队列。舱壁模式、独立连接池、分区队列、单元化部署和故障域隔离,都在回答同一个问题:一个分区失效时,最多会带走多少业务?

减少不必要的连接:依赖越多,故障传播路径越多。同步调用链尤其敏感,因为上游资源会在等待期间持续被占用。能够异步处理的工作,可以用有界队列解耦;能够通过缓存、快照或预计算读取的数据,不必每次穿透到源服务;没有明确业务价值的跨服务调用,应当直接删除。

这三类手段存在取舍。冗余会增加成本,隔离会降低资源利用率,异步化会引入最终一致性。韧性设计不是免费获得高可用,而是提前决定在压力下牺牲什么,并阻止系统替你做出最坏的决定。

可以这样实践:用舱壁、截止时间和快速拒绝保护下游

下面是一个仅依赖 Python 标准库的最小示例。它模拟 30 个并发请求访问不稳定的下游,并为该依赖设置最多 3 个并发调用、50 毫秒排队预算和 200 毫秒执行截止时间。可以将参数替换为生产环境的容量测量结果后,再把相同模式应用到 HTTP 客户端或数据库访问层。

import asyncio
import random
from collections import Counter

MAX_IN_FLIGHT = 3
QUEUE_BUDGET_SECONDS = 0.05
CALL_DEADLINE_SECONDS = 0.20

bulkhead = asyncio.Semaphore(MAX_IN_FLIGHT)

async def unstable_dependency(request_id: int) -> str:
    # 固定随机种子后,这里仍会产生快请求、慢请求和少量故障。
    delay = random.choice([0.03, 0.05, 0.08, 0.40])
    await asyncio.sleep(delay)
    if random.random() < 0.10:
        raise RuntimeError("dependency failed")
    return f"ok:{request_id}"

async def protected_call(request_id: int) -> str:
    try:
        await asyncio.wait_for(
            bulkhead.acquire(), timeout=QUEUE_BUDGET_SECONDS
        )
    except TimeoutError:
        return "shed"

    try:
        await asyncio.wait_for(
            unstable_dependency(request_id),
            timeout=CALL_DEADLINE_SECONDS,
        )
        return "ok"
    except TimeoutError:
        return "timeout"
    except RuntimeError:
        return "fallback"
    finally:
        bulkhead.release()

async def main() -> None:
    random.seed(7)
    results = await asyncio.gather(
        *(protected_call(i) for i in range(30))
    )
    print(Counter(results))

if __name__ == "__main__":
    asyncio.run(main())

运行方式:

python3 bulkhead_demo.py

这里的 shed 不是缺陷,而是受控降级:系统拒绝一部分超过容量的工作,保住处理健康流量所需的事件循环、连接和内存。实际服务中,快速拒绝可以映射为 HTTP 429503,并配合 Retry-After。不要让所有层级都独立重试;通常应由最了解业务截止时间和幂等语义的边界层负责重试。

还需要注意,信号量只限制当前进程的并发量,不能替代全局限流。多实例部署时,应同时控制入口流量、消息队列长度、数据库连接数和自动扩缩容速度,并为每项限制建立监控。

上线前检查故障是否真的被限制住

可以用一份简短清单检查系统是否具备抗渐进式坍塌能力:

  • 每个同步调用是否都有截止时间,而不只是底层套接字超时?
  • 每个重试是否有次数上限、退避、抖动和幂等保证?
  • 下游变慢时,线程池、连接池和队列是否存在硬上限?
  • 单一租户、区域或功能是否可能耗尽全局共享资源?
  • 核心业务能否在推荐、搜索或分析等非核心依赖失效时继续运行?
  • 熔断、限流和降级路径是否经过故障注入或负载测试?
  • 告警是否覆盖队列等待时间、拒绝率和资源饱和度,而不只关注错误率?

避免渐进式坍塌的核心,不是保证组件永不失败,而是让每次失败都拥有明确边界。加固关键路径、隔离资源并缩短依赖链之后,局部故障仍然会发生,但它不再天然拥有拖垮整个系统的权力。


相关推荐