连接池、超时、重试与幂等:控制不确定性与故障放大
超时不是失败原因,而是调用方停止等待的决定。它发生时,请求可能尚在连接池排队,可能只发出一部分,可能已经到达服务端但未提交,也可能已经提交而响应丢失。这个“不知道原操作是否生效”的窗口,是分布式调用最危险的状态。重试若没有幂等契约,会把一次不确定写入变成两次真实写入;所有层同时重试,又会把局部变慢放大成全链雪崩。
超时不等于未执行,取消不等于回滚,重试也不等于恢复;三者都必须由业务状态和证据闭环。
先画出一笔时间预算
一次调用的时间不只包含 socket read:连接池借用、DNS、TCP/QUIC 建连、TLS、请求发送、服务端排队与执行、响应读取都消耗预算。每层独立设置 1 秒并不等于总共 1 秒,串行阶段会累加,重试还会复制整条路径。
入口生成绝对 deadline,向下游传播剩余时间;每一层从同一终点扣减,而不是重新获得完整超时。下游要保留清理、返回错误和上游处理的安全余量。连接池等待时间必须单独计量,否则线程会在“还没发请求”时耗尽整体预算,却被误报为下游 read timeout。
取消等待不等于事务回滚。客户端关闭连接时,服务端线程可能继续运行,数据库提交也不会因 socket 消失自动撤销。服务端应在排队、调用依赖、批处理循环等可取消点检查 deadline,但一旦进入不可中断的提交阶段,就要以状态查询或幂等记录暴露最终结果。
连接池要同时限制每目标并发、全局连接数和等待队列。无限等待队列隐藏过载并制造过期请求;无限连接数把压力推给下游。容量近似来自允许并发 L ≈ λ × W,但尾延迟、突发、连接复用和下游硬上限要求留下余量。监控至少分开:leased、idle、pending、acquire timeout、connect timeout、连接年龄与空闲年龄。
重试是有成本的再执行
只有同时满足四个条件才应重试:错误被判定为瞬时;操作可证明重放安全;还有总 deadline;还有重试预算。DNS 永久不存在、证书身份错误、参数校验失败、授权失败通常不该重试;连接拒绝、部分 5xx、限流和超时是否可重试,要结合幂等与服务端信号。
指数退避降低持续冲击,随机抖动避免大量客户端同步醒来。服务端 Retry-After 可提供恢复时间,但客户端仍要施加本地上限。最大次数不是唯一预算:更有效的是限制重试流量占正常请求的比例,系统越接近饱和,越不允许重试抢走健康请求容量。
假设 A 调 B、B 调 C,各自最多重试三次。若每层都用“初次 + 3 次重试”,C 最坏可看到 4 × 4 = 16 次尝试;再多一层就是 64 次。重试应尽量由最了解业务幂等与总 deadline 的边界统一拥有,内部层只在能证明请求未离开本地或协议明确允许时做有限重试。
examples/backend-development/network-web/timeout-retry-idempotency/RetryAmplificationDemo.java 把三层各自重试与共享预算做成计数实验:
uncappedAttempts=12 budgetedAttempts=3 amplification=4x这里的 12 是模型中三条上游尝试各触发四次下游尝试;共享预算只允许总共三次。它揭示的不是某个框架默认值,而是重试所有权分散后的乘法关系。
HTTP 方法幂等不等于业务写入自动幂等
PUT /orders/42 重复把资源设为同一表示,在 HTTP 意图上可幂等;但实现若每次都重复扣库存、发送短信,就破坏了预期效果。POST 默认不能推断幂等,却可以通过业务幂等键建立“同一创建意图只执行一次”的契约。方法语义是重试判断的一层证据,最终仍要由业务状态机保证。
Idempotency-Key 可作为应用协议字段,但相关 IETF 文档仍是工作组草案,不应宣称它已经成为发布 RFC。无论字段叫什么,服务端必须定义:作用域是租户、调用方还是全局;有效期多长;同键不同内容如何拒绝;并发到达谁执行;成功与失败是否缓存;响应能否原样重放;记录何时清理。
仅在执行前 SETNX key 不够。进程拿锁后崩溃会留下永久 IN_PROGRESS,锁过期后旧执行者又可能继续提交。更可靠的记录包含请求指纹、所有者租约、状态、业务资源标识、响应摘要和版本;业务提交与幂等状态尽量处在同一事务。跨存储无法原子提交时,需要 outbox、唯一约束、可对账业务键或补偿流程收敛 UNKNOWN。
同键不同请求必须返回冲突,而不是复用旧响应。请求指纹应基于规范化后的语义字段,排除 trace id、时间戳等非业务变化,并用租户身份参与作用域。记录完整敏感请求和响应会形成新的数据泄露面,通常保存哈希、结果资源 id、状态码和必要响应摘要即可。
可执行实验:响应丢失,业务只执行一次
examples/backend-development/network-web/timeout-retry-idempotency/IdempotentRetryDemo.java 启动本地 HTTP 服务。首次请求完成业务写入后故意延迟响应,客户端超时;第二次携带相同键与指纹,服务端返回已保存结果而不再次执行:
javac --release 17 --add-modules jdk.httpserver -Xlint:all -Werror examples/backend-development/network-web/timeout-retry-idempotency/IdempotentRetryDemo.java examples/backend-development/network-web/timeout-retry-idempotency/RetryAmplificationDemo.java
java --add-modules jdk.httpserver -cp examples/backend-development/network-web/timeout-retry-idempotency IdempotentRetryDemo
java --add-modules jdk.httpserver -cp examples/backend-development/network-web/timeout-retry-idempotency RetryAmplificationDemofirstTimedOut=true retryStatus=201 executions=1 response=order-1
uncappedAttempts=12 budgetedAttempts=3 amplification=4x关键证据是 firstTimedOut=true 与 executions=1 同时成立:调用方超时并未证明服务端未执行,幂等记录使重试变成结果重放。生产实现还需加入并发同键请求、进程崩溃、租约过期、事务回滚、同键异参和记录过期测试。
失败分类必须保留“结果未知”
很多错误模型只有成功与失败,迫使系统把超时错误归到任一侧。更准确的外部状态至少有:明确未执行、明确成功、明确业务失败、结果未知。结果未知时向用户返回可查询的 operation id,让后台对账收敛;不要直接展示“失败”又在稍后生成订单。
批量操作要定义幂等粒度。整批一个键适合全有或全无;每项独立键适合部分成功和精确重试。消息消费则通常依赖业务唯一约束、inbox 记录或幂等状态迁移,因为“恰好一次投递”无法自动等同于端到端业务只生效一次。
指标应同时覆盖原始请求率、尝试率、重试率、重试成功率、预算耗尽、幂等命中、并发等待、指纹冲突、IN_PROGRESS 年龄、UNKNOWN 数量与对账收敛时间。只看“重试后成功率”会掩盖下游已经承受数倍流量。trace 中每次尝试有独立 span,但共享同一个逻辑 operation id,才能还原一次用户意图跨越多少次网络执行。
安全上,幂等键必须有长度和字符限制,按身份隔离,不能让攻击者猜中他人键并读取旧响应;缓存响应要遵守授权变化与数据保留策略;重试器不能记录完整 Authorization、Cookie 或请求敏感内容。稳定性上,所有重试都必须可关闭、可限流、可观测,并在过载时优先保护首次请求。
一套成熟调用策略不是“超时 3 秒、重试 3 次”。它是一条可证明的控制链:绝对 deadline 限制总时间,有界连接池限制并发,错误分类决定是否重试,退避与预算限制额外流量,幂等状态机收敛重复执行,查询与对账处理无法立即确定的最终结果。
hedging 不是免费的尾延迟优化
对只读且可安全并行的请求,可以在首个尝试超过某个延迟分位后向另一副本发送 hedge,谁先完成就取消另一个。它用额外负载换尾延迟,若阈值过低或副本共享同一瓶颈,反而加剧拥塞。hedge 必须纳入同一尝试预算,限制并发副本,并优先选择故障域不同的后端。
取消落败尝试仍不保证服务端停止。读请求可能继续消耗数据库连接和 CPU,因此服务端要接收 deadline、支持可取消查询并记录“客户端已放弃但后端仍执行”的时长。写请求除非具有严格幂等和提交协调,通常不应做并行 hedge。
幂等记录本身也有一致性等级
若幂等表使用最终一致副本读取,两次并发请求可能都看不到记录并同时执行。首个所有权获取必须落到能提供原子条件写或唯一约束的权威节点。读取旧成功结果可以走副本,但在 miss 时要回源确认,不能把复制延迟解释成“从未执行”。
记录 TTL 不能只按客户端最长重试时间设置。消息积压、人工补偿、移动端离线和上游网关重试都可能延长重复到达窗口;TTL 太短会让旧意图再次执行,太长则增加存储和隐私成本。应按业务不可重复的风险选择,并在过期后仍由业务唯一键提供最后防线。
用故障矩阵验证而不是只测 happy path
在请求写入前断连,预期可明确判定未执行;写入一半时断连,预期拒绝不完整消息;业务提交前杀进程,预期事务回滚或进入可重试;业务提交后、幂等记录前杀进程,预期由唯一约束或对账收敛;保存响应后丢包,预期重放同一结果;同键异参并发,预期稳定冲突。
每个场景都要验证四类资源:业务效果次数、幂等状态、连接与线程是否释放、重试总尝试数。若只断言客户端最终拿到 201,重复扣款可能已经发生,连接池也可能仍在泄漏。
过载演练则逐步增加延迟而不是直接让服务崩溃,观察池等待、deadline、重试率和下游尝试率的相位关系。健康设计会先限制排队和重试,保持首次请求的一部分吞吐;错误设计会在延迟上升后产生更多尝试,进一步推高延迟,形成正反馈。
