8 月 17 日,GitHub 经历了持续 7 小时 47 分钟的服务中断。影响并未停留在 github.com 无法访问:认证系统失效、GitHub Actions 中断,Pull Request、Issue 和 Copilot 等功能也受到波及。根据官方事后分析,事故的关键链路是负载均衡配置错误,以及错误响应触发的客户端重试风暴。
这类事故值得工程团队关注,因为它不是简单的“配置写错导致少量请求失败”。在分布式系统里,一次局部故障会改变客户端行为;如果重试没有退避、抖动和总量限制,原本用于提高可用性的机制反而会持续放大流量,让系统失去恢复窗口。
从错误配置到全站故障的放大链路
可以把这次事故抽象为一条反馈回路:
- 负载均衡器的配置错误使部分请求未被正确处理。
- 客户端或内部服务收到错误响应,按照既定策略重新发起请求。
- 重试流量进一步占用连接、线程、CPU 和下游服务容量。
- 更多正常请求因资源拥塞而失败,继而加入重试队列。
- 即使修正配置,积压请求和持续重试也可能让服务迟迟无法恢复。
假设正常入口流量为每秒 10 万个请求,其中 40% 失败,每个失败请求立即重试两次,那么入口层看到的流量就可能迅速接近每秒 18 万个请求。现实系统还存在多层调用:浏览器重试 API,API 服务重试认证服务,认证服务再重试数据库或缓存。各层重试次数相乘后,放大倍数会远高于单层估算。
这也解释了为什么认证故障特别危险。认证通常位于大量产品功能的公共调用路径上。一旦认证依赖拥塞,代码托管页面、自动化任务、协作功能和 AI 辅助服务都可能同时失去关键依赖。表面上是多个产品一起出问题,底层却可能共享同一条故障链路。
重试不是免费保险
工程代码里常见这样的策略:请求失败就立即重试三次。它在单机测试中通常表现良好,但在线上故障期间会制造同步流量峰值。
可靠的重试策略至少要回答五个问题:
- 哪些错误允许重试?连接中断、超时和部分
5xx可能适合重试,认证失败、参数错误等4xx通常不应重试。 - 请求是否幂等?
GET一般可以安全重试,创建订单、触发部署等写操作需要幂等键或去重机制。 - 等待多久?应采用指数退避,而不是立即循环。
- 如何避免同步?加入随机抖动,防止大量客户端同时在固定时间点重新请求。
- 最多增加多少负载?使用重试预算限制重试请求占总流量的比例,并设置总超时。
尤其需要检查“重试叠加”。如果入口代理、服务 SDK 和业务代码都各自重试三次,一次用户请求理论上可能触发数十次下游调用。重试责任应由明确的一层承担,其他层则快速返回或执行降级。
可以这样实践:实现有边界的 HTTP 重试
下面是一个可直接运行的 Python 示例。它只重试幂等的 GET 请求和有限的瞬时错误,使用指数退避、随机抖动与总时限。运行前安装 requests,并把示例地址替换为自己的健康检查或只读 API。
python -m pip install requests
import random
import time
from typing import Iterable
import requests
RETRYABLE_STATUS = {429, 502, 503, 504}
def get_with_budget(
url: str,
attempts: int = 4,
base_delay: float = 0.25,
total_timeout: float = 5.0,
retryable_status: Iterable[int] = RETRYABLE_STATUS,
) -> requests.Response:
deadline = time.monotonic() + total_timeout
retryable_status = set(retryable_status)
last_error = None
for attempt in range(attempts):
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
response = requests.get(url, timeout=min(2.0, remaining))
if response.status_code not in retryable_status:
response.raise_for_status()
return response
last_error = RuntimeError(
f"retryable HTTP status: {response.status_code}"
)
except (requests.Timeout, requests.ConnectionError) as exc:
last_error = exc
if attempt == attempts - 1:
break
# Full jitter: sleep randomly between zero and the exponential cap.
delay_cap = base_delay * (2**attempt)
sleep_for = random.uniform(0, delay_cap)
if sleep_for >= deadline - time.monotonic():
break
time.sleep(sleep_for)
raise RuntimeError(f"request failed within retry budget: {last_error}")
if __name__ == "__main__":
result = get_with_budget("https://httpbin.org/status/200")
print(result.status_code)
这段代码刻意没有重试所有异常,也没有无限延长单次请求。生产环境还应在进程或服务级别设置重试预算。例如,在一分钟窗口内,当重试请求超过总请求的 5% 时暂时关闭重试,让服务保留处理正常请求和健康检查的能力。
负载均衡层也要限制重试。以下是一个可改造的 Envoy 配置片段,演示如何限定可重试状态、重试次数和单次尝试超时。字段是否适用仍需结合实际 Envoy 版本验证。
route:
cluster: api_backend
timeout: 3s
retry_policy:
retry_on: connect-failure,reset,5xx
num_retries: 2
per_try_timeout: 800ms
retry_back_off:
base_interval: 100ms
max_interval: 1s
这里的重点不是照搬数值,而是确保总请求时限大于单次尝试时限,同时对尝试次数设硬上限。对于非幂等写请求,应关闭代理层自动重试,或者先引入幂等键。
配置发布必须具备刹车能力
负载均衡配置属于生产代码,应经过解析、验证、灰度和自动回滚,而不是一次性推送到全部实例。可以这样设计发布流水线:
set -euo pipefail
# 将命令替换为所用负载均衡器的真实校验工具。
envoy --mode validate -c envoy.yaml
# 先发布到小比例实例,并观察错误率、重试率和饱和度。
./deploy-lb-config.sh --canary-percent 1 envoy.yaml
./check-slo.sh --window 5m
# 只有金丝雀指标正常时才扩大范围。
./deploy-lb-config.sh --canary-percent 10 envoy.yaml
./check-slo.sh --window 10m
./deploy-lb-config.sh --all envoy.yaml
其中 deploy-lb-config.sh 和 check-slo.sh 是示意接口,需要接入团队自己的部署与监控系统。关键控制点包括:
- 在提交阶段执行配置语法与语义校验。
- 用流量回放或集成测试验证路由、超时和失败响应。
- 先让 1% 实例加载新配置,避免同时破坏全部入口。
- 将错误率、重试率、请求量和后端饱和度设为自动回滚条件。
- 保留上一份已验证配置,并确保回滚不依赖已经故障的控制面。
监控也不能只盯可用率。重试风暴早期,最终成功率可能尚未明显下降,但每个成功请求的尝试次数已经上升。建议至少记录 request_attempts_total、retries_total、retry_exhausted_total、后端并发连接数和队列等待时间,并按客户端、路由、状态码和配置版本切分。
落地时检查这几件事
GitHub 的这次长时间中断说明,负载均衡器和重试策略共同构成一个闭环系统。只验证“新配置能否加载”,并不足以证明它在错误响应和流量激增时仍然安全。
上线前可以按以下清单检查:
- 配置变更是否经过静态校验、灰度发布和自动回滚?
- 客户端是否只重试明确的瞬时错误?
- 是否同时设置单次超时、总超时、最大尝试次数和随机抖动?
- 多层代理与 SDK 是否重复执行重试?
- 写请求是否具备幂等键或服务端去重?
- 是否能按配置版本观察错误率和重试放大倍数?
- 修复入口配置后,系统是否有清理积压和限制恢复流量的机制?
- 认证等共享依赖不可用时,非关键功能能否降级而不是持续施压?
重试能掩盖短暂抖动,却不能创造容量。真正稳健的设计会给重试设预算、给配置发布设灰度、给共享依赖设隔离,并在故障发生时主动减少工作量。这样,局部配置错误才不至于扩展成持续数小时的系统性事故。