分布式系统最危险的故障,往往不是某个组件直接宕机,而是一个局部问题沿依赖关系持续扩散:请求开始排队,线程池被占满,重试放大流量,共享资源耗尽,最终让原本健康的服务也失去响应。Sam Newman 借用土木工程中的“渐进式坍塌”概念,把 1968 年 Ronan Point 大楼事故与 AWS 等软件系统故障放在同一个分析框架中:局部损伤之所以演变成灾难,关键在于系统没有把破坏限制在足够小的范围内。
软件系统为什么会逐级坍塌
渐进式坍塌不是“所有组件同时坏掉”,而是一段因果链。假设订单服务依赖库存服务,库存服务又依赖数据库:
- 数据库响应变慢,库存请求开始堆积。
- 订单服务等待库存结果,占满工作线程和连接池。
- 客户端或网关自动重试,进一步提高库存服务的负载。
- 健康检查因超时而失败,实例被频繁移出或加入流量池。
- 订单服务无法处理与库存无关的请求,故障边界继续扩大。
这里真正致命的通常不是最初的慢查询,而是没有上限的等待、共享资源和重试。每一项局部“容错”机制都可能单独合理,但组合起来却形成正反馈回路。
因此,分析故障时不能只问“哪个服务坏了”,还要追踪三个问题:故障消耗了哪些共享资源、哪些调用者会重试,以及依赖图中还有多少传播路径。
三类防线:加固、隔离与减少连接
土木工程会通过加强关键构件、提供替代承重路径以及分隔结构来控制坍塌范围。映射到软件系统,可以得到三类直接的工程动作。
加固关键组件:为核心依赖设置明确的容量目标、资源余量和退化行为。超时必须小于调用方的总截止时间;重试需要指数退避、随机抖动和次数上限;服务过载时应快速拒绝,而不是接收所有请求后一起超时。
隔离故障:不同租户、功能和下游依赖不要无限共享同一批线程、连接或队列。舱壁模式、独立连接池、分区队列、单元化部署和故障域隔离,都在回答同一个问题:一个分区失效时,最多会带走多少业务?
减少不必要的连接:依赖越多,故障传播路径越多。同步调用链尤其敏感,因为上游资源会在等待期间持续被占用。能够异步处理的工作,可以用有界队列解耦;能够通过缓存、快照或预计算读取的数据,不必每次穿透到源服务;没有明确业务价值的跨服务调用,应当直接删除。
这三类手段存在取舍。冗余会增加成本,隔离会降低资源利用率,异步化会引入最终一致性。韧性设计不是免费获得高可用,而是提前决定在压力下牺牲什么,并阻止系统替你做出最坏的决定。
可以这样实践:用舱壁、截止时间和快速拒绝保护下游
下面是一个仅依赖 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 429 或 503,并配合 Retry-After。不要让所有层级都独立重试;通常应由最了解业务截止时间和幂等语义的边界层负责重试。
还需要注意,信号量只限制当前进程的并发量,不能替代全局限流。多实例部署时,应同时控制入口流量、消息队列长度、数据库连接数和自动扩缩容速度,并为每项限制建立监控。
上线前检查故障是否真的被限制住
可以用一份简短清单检查系统是否具备抗渐进式坍塌能力:
- 每个同步调用是否都有截止时间,而不只是底层套接字超时?
- 每个重试是否有次数上限、退避、抖动和幂等保证?
- 下游变慢时,线程池、连接池和队列是否存在硬上限?
- 单一租户、区域或功能是否可能耗尽全局共享资源?
- 核心业务能否在推荐、搜索或分析等非核心依赖失效时继续运行?
- 熔断、限流和降级路径是否经过故障注入或负载测试?
- 告警是否覆盖队列等待时间、拒绝率和资源饱和度,而不只关注错误率?
避免渐进式坍塌的核心,不是保证组件永不失败,而是让每次失败都拥有明确边界。加固关键路径、隔离资源并缩短依赖链之后,局部故障仍然会发生,但它不再天然拥有拖垮整个系统的权力。