一个页面调用推荐服务,推荐服务再读画像。两层都配置“最多尝试三次”,画像在故障期间可能收到九次请求。页面最后仍然失败,资源消耗却扩大了九倍。限制故障扩散需要同时约束请求的存活时间、额外尝试次数和进入下游的并发量;三个开关不能各自按正常流量独立设置。

这是第 10 篇消息重投问题在同步调用链中的延伸。场景限定为商品详情中的可选推荐卡片,商品价格与交易状态不允许用旧推荐结果替代。所有数字是教学假设,实验使用虚拟时间,不把模型的毫秒数当作机器实测延迟。

先约定失败时还能交付什么

GET /products/{id}?recommendations=true 返回商品详情和推荐列表。商品不存在返回 404;推荐不可用时主体仍返回 200,响应带 recommendation_status=unavailable 和空列表。客户端必须能区分“计算结果为空”与“未计算”,否则分析系统会把服务故障误判成用户没有兴趣。

候选目标是从入口收到合法请求到发出响应,滚动五分钟窗口内 p99 不超过 300 ms。这里先给同步链路 250 ms,预留入口解析、序列化和传输 50 ms。预算必须包括连接池等待、DNS、建连、服务端排队与读取,不能把每个阶段都配置成 250 ms。超时是调用方不再等待,不意味着对端事务自动撤销;写接口仍需要业务幂等键,超时后查询原请求状态,不盲目生成新键。

调用上下文至少有 request_id、remaining_budget_ms、attempt、priority 和 idempotency_key。跨机器传绝对时间戳会受时钟偏差影响,可以传剩余预算并扣除本地已花时间,或使用经过同步且明确偏差容忍度的截止时间。进程内测时用单调时钟,不能用会被校时回拨的墙上时钟计算已等待时长。

错误分类直接决定是否重试。参数错误和无权限不会因再发一次变正确。临时连接失败可能恢复;请求已发出但没有收到响应则存在结果未知。一个明确的“过载拒绝”也不应立即原地重试。服务端拒绝时携带可理解的原因,调用方按退避与剩余预算决定等待还是交付降级结果。

九倍放大如何耗尽连接池

假设入口每天 864 万次请求,平均为 8640000 / 86400 = 100 requests/s,峰值系数 5,峰值 500 requests/s。每次正常访问画像一次;若两层都至多三次尝试,故障窗口内下游请求量上界为 500 × 3 × 3 = 4500 attempts/s。这是所有请求都触发全部重试时的上界,不是正常流量预测。

假设下游平均占用连接 100 ms,稳定运行时在途量约 500 × 0.1 = 50;尝试率放大到 4500 后,同样服务时间对应 450 条在途连接。若故障又使占用时间升至 800 ms,则需求达到 3600 条。连接池上限 200 时系统不可能保持稳态,这时不能用 Little 定律宣称实际在途无限增长:多余请求会排队、超时或被拒绝,必须观察排队长度及存活时间。

只在一个调用层允许至多一次额外尝试,最坏上界降为 1000 attempts/s,仍然可能压垮只有正常余量的下游。因此再设置重试令牌预算,例如五分钟窗口内额外尝试不超过成功首次请求的 10%,并给服务端一个独立的并发上限。重试预算限制增量压力,准入限制总压力,截止时间限制浪费持续多久,三者解决不同问题。

每次返回 8 KiB 时,500 次/秒的有效载荷约 4.10 MB/s。把推荐降为 2 KiB 可以减小网络与序列化开销,却不会自动减少画像查询次数;只有降级路径真正绕开昂贵计算,才能缓解该瓶颈。容量敏感性分析应沿实际调用路径计算,不用“响应小了四倍”推导数据库负载也小四倍。

让预算沿调用链递减

初版只需在现有 HTTP 客户端封装中统一超时和重试策略,不必先引入一套熔断平台。入口负责总截止时间和准入,推荐客户端是唯一重试所有者,下游收到预算后不再开启独立的长重试循环。连接池采用有界等待;已经超时的工作尽可能取消,服务端在开始昂贵计算前再次检查预算。

flowchart LR
    U[详情请求] --> G[入口准入与250ms总预算]
    G --> P[商品事实读取]
    G --> R[推荐客户端 唯一重试层]
    R -->|剩余预算 足够才尝试| D[画像服务 有界并发]
    D --> R
    R --> F[推荐结果或明确降级状态]
    P --> O[响应组装]
    F --> O
    G -->|超过准入上限| X[快速拒绝]

预算检查放在睡眠前和请求前。第一次调用花 100 ms,退避 20 ms,第二次预计还需 100 ms,总计 220 ms,可以放进 250 ms;第三次放不下。若剩余预算不足最小有用执行时间,直接降级比发出注定迟到的请求更合理。实际运行无法准确预知每次耗时,所谓最小有用时间应来自测量,单次调用仍设置 min(单次上限, 剩余预算)。

指数退避加随机抖动能分散重试时刻,但不会减少一个请求允许的总尝试数。客户端越多,同步定时器造成的脉冲越明显。断路器在错误持续时减少探测,恢复期必须限制半开探测数量;如果每台实例都同时半开,仍会形成新的突发。低流量服务的统计窗口可能样本不足,不能直接照搬高流量阈值。

sequenceDiagram
    participant C as 客户端
    participant R as 推荐调用层
    participant D as 慢画像
    C->>R: 剩余预算250ms
    R->>D: 第一次尝试 单次上限100ms
    D-->>R: 超时 结果未知
    Note over R: 消耗100ms 后退避20ms
    R->>D: 第二次尝试 单次上限100ms
    D-->>R: 再次超时
    Note over R: 已用220ms 禁止第三次
    R-->>C: unavailable 推荐为空

用反例验证边界,不把模型当压测

仓库 examples/system-design/labs/11/retry.py 固定输入 100 个同批请求,慢下游每次虚拟占用 100 ms,重试间隔 20 ms。无保护时两层各三次尝试,产生 900 次调用,每个请求虚拟耗时 1020 ms。受保护组只准入 20 个请求,80 个快速拒绝;准入者最多尝试两次,共 40 次,最大虚拟耗时 220 ms。把总预算缩短为 150 ms 后只允许首次尝试,共 20 次。

1
2
python3 examples/system-design/labs/11/retry.py
python3 examples/system-design/labs/11/retry.py --unsafe

默认命令逐项断言并退出 0。负例要求无保护结果符合 40 次预算,检测到 900 次后输出 EXPECTED_REJECTION 并退出 2。原始输出、命令和解释器版本保存在 examples/system-design/evidence/11/。该模型明确采用“固定突发最多准入 20 个”,不是生产限流算法,也没有模拟 TCP 取消、连接池竞争、真实抖动分布或重试令牌桶。

真实上线前应在隔离测试环境让下游实际延迟,确认请求取消是否传播、超时后工作是否继续、线程与连接是否及时归还。模型通过仅说明计数和截止预算公式正确;服务端已经接受的副作用、异步取消失败以及内核队列不在本次证据范围。

恢复顺序与面试追问

故障恢复不能先同时解除所有限制。先给少量探测请求确认下游可用,再逐步提高并发,观察首次请求成功率与等待队列;重试占比仍高时不要急着放开入口。日志区分 original_requests、downstream_attempts、budget_exhausted、admission_rejected 和 degraded,否则 99% HTTP 200 可能掩盖推荐全量降级。降级成功率和完整响应成功率分别统计,SLO 分母提前定义。

另一个方案是完全不重试。对推荐这种可以缺席的功能,如果下游过载是主要故障而非偶发网络抖动,不重试可能更省资源,也更容易预测尾延迟。对离线批任务,较长退避与持久队列更合适,因为用户没有同步等待;但队列仍要有积压上限、任务过期和恢复吞吐预算。

面试可用五分钟定义商品事实与推荐的不同承诺,八分钟算放大率和在途量,十二分钟画预算传递,十五分钟推演慢下游、超时结果未知及恢复,最后五分钟核对指标。追问“将三次改为两次就安全了吗”,答案取决于剩余容量;追问“断路器能代替超时吗”,答案是否定的,已开始执行的请求仍需要结束边界。

可迁移规则是把失败控制绑定到资源与业务承诺:截止时间保护等待者,重试预算保护额外负载,准入保护总负载,降级保护可用的业务部分。任意一项缺失都可以用具体故障时间线检验。

参考资料