连接池、超时、重试与幂等:控制不确定性与故障放大
订单接口已经保存了订单,返回消息却没有在客户端的等待时间内到达。用户看到超时,再点一次提交,服务器会接着创建,还是返回刚才的订单?答案取决于请求是否保留同一个业务意图,以及服务端怎样保存和查找执行结果。
超时控制等待时间,连接池分配通信资源,重试发起新的尝试,幂等处理重复意图。这几个机制需要协同设计。
一次调用的时间花在哪里
总预算与阶段超时
一次远程调用可以经历以下阶段,复用连接时会跳过部分建连工作:
调用开始
├─ 等待客户端并发名额 / 连接池资源
├─ DNS、建立 TCP 或 QUIC 连接、TLS
├─ 写入请求
├─ 服务端排队、业务执行、下游调用
└─ 读取响应头与响应正文
调用结束各客户端的计时起点并不相同。读配置时应查清“何时开始计时、什么事件会重置计时器、超时后关闭什么”,再选择参数。
| 时间限制 | 主要约束 | 仍需另外处理 |
|---|---|---|
| 连接池获取超时 | 等待可用连接或请求名额 | 后续连接和请求处理 |
| connect timeout | 新连接建立,是否涵盖 DNS/TLS 要查实现 | 已建立连接上的业务等待 |
| read / idle timeout | 一段时间没有新的读取进展 | 持续少量返回、但很久才结束的响应 |
| 单次尝试超时 | 一次请求允许占用的时间 | 退避与后续尝试 |
| 总 deadline | 一次逻辑操作剩余可等待时间 | 服务端已经产生的副作用 |
例如总预算为 2 秒,第一轮已经花掉 1.2 秒,退避又用了 0.1 秒,后续最多只剩 0.7 秒,还要给错误处理和返回保留余量。这是一组预算算例,不是所有接口通用的推荐值。
进程内计算经过时间可以使用 System.nanoTime(),避免日历时钟调整。其数值只适合在同一进程内比较,不能当跨机器时间戳发送。跨服务传递剩余时长或协议定义的 deadline 时,要扣除已花时间,限制外部传入的最大预算,并考虑传输和时钟误差。Java 单调时间 API
连接复用与池等待
创建连接通常涉及握手和缓冲分配。长生命周期客户端可以复用连接;每次业务请求都新建客户端,会降低复用率,并使连接建立和清理成为额外成本。Java HttpClient
HTTP/1.1 客户端常以连接作为并发资源,HTTP/2 与 HTTP/3 还需要分配同一连接上的流。连接数少的时候,请求仍可能很多。池配置至少要与以下对象对齐:
客户端资源
├─ 每个目标的连接 / 并发流限制
├─ 全局在途请求限制
├─ 等待队列长度与获取超时
├─ 空闲连接淘汰、连接最大寿命
└─ 响应正文的消费、关闭与取消响应头已经返回,而 InputStream 没有读完或关闭,会占用后续传输资源。使用流式 BodyHandler 时,调用方必须管理正文流的生命周期;如果只约束“拿到响应对象”的时间,正文读取可能继续等待。Java 响应处理器
扩容连接池前先看 pending、已借出连接、在途请求和下游处理能力。把连接上限从 50 改为 500,可以解除一处排队,也可能把等待转移到数据库连接池。较长的等待队列会保留大量即将过期的请求,过载时应尽早拒绝,给还能完成的工作留下资源。
连接池的 idle、max lifetime 与服务器、代理的 keep-alive 策略也要配合。复用到已被对端关闭的连接时,客户端可能遇到 EOF 或 reset;能否重新发送,要结合请求是否已经发出及操作语义判断。
实验:客户端超时,服务器已经保存结果
环境、身份与文件
下载完整实验,在 Linux Bash 解压为 timeout-retry-idempotency 并进入目录。宿主需要 Docker Engine、curl 7.76+ 和 Docker 操作权限。Java 编译目标为 17,实验可用 Temurin 17 或 25;下面固定使用 eclipse-temurin:25.0.4_7-jdk。
timeout-retry-idempotency/
├─ RetryLab.java 真实 HTTP 服务、重复写入及并发断言
├─ DeadlineClient.java 总预算内的有限 GET 重试
├─ DeadlineProbe.java 慢正文、总预算耗尽与中断检查
├─ run.sh 严格编译并运行主实验与超时探针
└─ README.md业务效果用进程内订单编号和计数表示,不连接真实支付或订单系统。幂等记录保存在内存,重启会清空;这个实现用于观察请求时序,不能直接充当生产去重存储。
先运行自动实验:
docker run --rm --user 10001:10001 --read-only --network none \
--cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,exec,size=128m -v "$PWD:/src:ro" \
eclipse-temurin:25.0.4_7-jdk bash /src/run.sh运行身份为 10001,源码只读,编译结果写入临时目录。network none 保留容器自身回环,测试客户端与服务器在同一容器通信。首次拉取镜像需要联网;内网可导入受信镜像归档后执行。正常结果为:
committedBeforeTimeout=true timedOut=true retry=201 effects=1 result=order-1 conflict=409
unsafeTimedOut=true repeatedPostEffects=2
concurrentRequests=12 effects=1 sameResult=true
boundedGetAttempts=3 result=ready
slowBodyTimeout=true bodyStarted=true
overallDeadlineExhausted=true attempts=2 lateSuccessRejected=true
interruptedWait=true interruptFlagRestored=true第一组通过服务端同步信号确认内存更新已经完成,再让客户端等待超时,避免把“还没发出请求”误当响应丢失。第二组去掉去重处理,重复 POST 得到两个效果。第三组发送 12 个并发同键请求,检查所有响应和最终计数。第四组访问一个前两次返回 503、第三次返回 200 的真实接口。后面的三个探针分别检查正文已经开始却未结束、两次处理消耗同一个总预算,以及等待中被中断后返回异常并恢复中断标记;不要求日志中的耗时精确等于某个毫秒值。
用 curl 分开观察提交与响应
启动可手动访问的服务,宿主仅发布回环端口:
CONTAINER=retry-lab
docker run -d --name "$CONTAINER" --user 10001:10001 --read-only \
--cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,exec,size=128m -v "$PWD:/src:ro" \
-p 127.0.0.1:18092:18092 \
eclipse-temurin:25.0.4_7-jdk bash /src/run.sh serve
docker logs "$CONTAINER"
curl -q --noproxy '*' --fail-with-body --max-time 3 \
http://127.0.0.1:18092/stats日志出现 LISTENING 18092 后,初始计数应为 effects=0。若端口已经被占用,停止新增步骤,先查 docker ps 与宿主 ss -lnt;不要删除其他服务。容器名已存在时也先确认归属。
给这次创建选择固定键 demo-42。X-Lab-Hold 是实验专用字段:首次创建后暂停返回,最多等待 5 秒。下面有意把 curl 总等待限制为 1 秒:
BASE=http://127.0.0.1:18092
KEY=demo-42
RC=0
curl -q --noproxy '*' --fail-with-body --max-time 1 \
-H "Idempotency-Key: $KEY" -H 'X-Lab-Hold: 1' \
-H 'Content-Type: text/plain' --data-binary 'amount=10' \
"$BASE/orders" || RC=$?
test "$RC" -eq 28curl 退出码 28 表示超时。-q 必须是第一个选项,禁用默认 curlrc;--noproxy '*' 排除代理;--fail-with-body 使非预期 HTTP 错误也体现为失败。curl 手册
接着独立查询结果,再用同一键和相同正文重试:
curl -q --noproxy '*' --fail-with-body --max-time 3 \
"$BASE/results/$KEY"
curl -q --noproxy '*' --fail-with-body --max-time 3 \
-H "Idempotency-Key: $KEY" -H 'Content-Type: text/plain' \
--data-binary 'amount=10' -i "$BASE/orders"
curl -q --noproxy '*' --fail-with-body --max-time 3 "$BASE/stats"查询应返回 order-1,重试返回 201 与同一个 order-1,计数仍为 effects=1。只有把“超时、已记录结果、重试结果、效果计数”放在一起,才能确认本轮发生了什么。
若结果查询为 404,说明本轮还没有观测到记录。先看服务日志和目标地址,短暂等待后有界复查;不要因此换一个键马上重发,原请求可能仍在传输或处理。手动实验受机器调度影响,自动实验的同步信号提供了更严格的时序控制。
改变正文、保留键,预期冲突:
STATUS=$(curl -q --noproxy '*' -sS --max-time 3 \
-o /tmp/retry-conflict.txt -w '%{http_code}' \
-H "Idempotency-Key: $KEY" -H 'Content-Type: text/plain' \
--data-binary 'amount=11' "$BASE/orders")
TRANSPORT=$?
test "$TRANSPORT" -eq 0
test "$STATUS" = 409
cat /tmp/retry-conflict.txt
curl -q --noproxy '*' --fail-with-body --max-time 3 "$BASE/stats"正文为 same key with different payload,效果仍为 1。此处主动测试 409,所以不使用 --fail-with-body;分别检查传输完成与 HTTP 状态,避免读到响应头后又超时仍被算作成功。
完成后释放可能还在等待的首个请求,再关闭本实验容器:
curl -q --noproxy '*' --fail-with-body --max-time 3 -X POST "$BASE/release"
docker stop -t 10 "$CONTAINER"
docker rm "$CONTAINER"服务端怎样识别同一意图
实验的关键操作在同一个 synchronized 方法里完成:
synchronized Claim claim(String key, String hash) {
Entry existing = results.get(key);
if (existing != null) return new Claim(existing, false);
if (results.size() >= 1000) throw new IllegalStateException("lab capacity");
Entry created = new Entry(hash, "order-" + effects.incrementAndGet());
results.put(key, created);
return new Claim(created, true);
}已有记录返回旧结果,新键才增加效果并保存记录。调用方随后比较请求指纹;指纹不一致返回 409,不执行新写入。锁只保护这一进程的 Map,多实例之间没有共享互斥。
请求键限定为 1–64 个字母、数字或连字符,正文限制 1024 字节。作用域固定为 demo,指纹为原始正文的 SHA-256,因此 amount=10 与包含额外空格的正文会被视为不同内容。生产若按 JSON 业务字段计算指纹,应先定义规范化、默认值和字段排除规则,不能简单假设字节不同就一定是业务不同。
重试策略与持久幂等怎样配合
先决定哪些尝试值得发送
| 观察 | 通常先做什么 | 是否适合重试 |
|---|---|---|
| 参数错误、权限拒绝、证书名称不匹配 | 修正请求、身份或信任配置 | 原样反复发送通常无益 |
| 429 或明确临时过载 | 读取服务端节奏提示,减少流量 | 契约允许且预算充足时有限尝试 |
| 502、503、504 | 判断是谁生成、下游是否可能已执行 | 按操作幂等与具体服务契约决定 |
| 响应等待超时或连接中途关闭 | 保留原键,查询或重放既有意图 | 写入结果可能未知 |
| 已知业务冲突 | 展示或重新读取业务状态 | 不把冲突当网络瞬时错误 |
HTTP 的安全、幂等方法语义是判断起点。PUT 按指定表示更新资源,DELETE 重复删除同一目标,所要求的服务器效果具有幂等意图;响应状态可以不同。POST 的重放安全需要另外约定。客户端不能仅根据一个 5xx 状态,把所有 POST 自动再发一次。HTTP 方法幂等语义
Retry-After 可以表示等待秒数或 HTTP 日期。客户端应解析对应格式,再与总预算比较;如果服务端要求的等待已经超出预算,可以向调用者返回稍后再试,而不是把等待强行压短后继续冲击服务。Retry-After、429 状态
重试还要求请求体能够重新生成。一次性输入流、正在读取的上传文件、会随时间变化的签名都需要单独处理;复制一个请求对象并不总能重新产生相同字节。
次数、退避与总预算
最大尝试数应明确是否包含首次发送。例如 maxAttempts=3 表示最多发三次,其中最多两次重试。若 A 调 B、B 调 C,两层都允许四次尝试,极端情况下 C 可接到 4 × 4 = 16 次调用。这是调用关系的计数上限,不是某个框架的默认行为。
集中在了解业务和总预算的一层做重试,其他层核查是否还启用了代理、SDK 或连接恢复重试。服务过载时限制额外尝试比例;否则延迟越高,重试越多,剩余资源更少。
指数退避逐轮增加等待上限,随机抖动把大量客户端的下一次尝试分散开。具体基数与上限应结合服务恢复时间选择,不照搬示例毫秒数。AWS 退避模式
实验的 DeadlineClient 仅对 GET 的 503 重试,最多三次,总预算 2 秒,单次上限 800 毫秒。退避上限依次为 40、80 毫秒,实际从零到上限之间随机选择;超时直接结束,不继续重试。核心扣减逻辑如下:
long remaining = total - (System.nanoTime() - started);
if (remaining <= 0) throw new HttpTimeoutException("overall deadline");
long perAttempt = Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(800));每次发送前、等待异步结果前、退避前都重新计算剩余时间。sendAsync 使用 ofString,等待的 Future 包含正文聚合;外层 get(timeout) 约束这一等待,超时或中断时取消 Future。Java 请求超时 API
这个客户端只连接实验的小文本接口,没有实现 Retry-After、一般错误分类和正文大小上限。接入未知远端时还需有界 BodySubscriber 或受控流式读取,限制响应内存;将输入流交给调用者后,也要把剩余预算交给实际读取代码。Future 取消是尽力终止本地工作,线程调度和清理会影响实际返回时刻,不能把毫秒预算当作硬实时承诺。
把记录与业务更新放进同一事务
多实例服务需要在共享存储中裁决第一个执行者。一个常见设计是为“租户、操作类型、幂等键”建立唯一约束,在同一个数据库事务内保存业务更新、请求指纹与可重放结果。并发同键请求由唯一约束和事务隔离协调;发生冲突后读取已提交记录并校验指纹。PostgreSQL 提供唯一约束配合 ON CONFLICT 的处理方式,具体可见 INSERT 与冲突处理。
同一存储事务
├─ 取得本键的唯一记录
├─ 校验请求指纹
├─ 写入订单 / 变更业务状态
└─ 保存结果标识及必要响应 → 提交
↓
重试:按原身份与原键读取 → 比较指纹 → 返回既有结果如果事务回滚,业务修改与幂等记录一起撤销;如果提交后响应断开,下一次可以取到已保存结果。对于长任务,可先建立 IN_PROGRESS 记录,再由持久任务推进状态,但必须处理执行者崩溃、租约过期和旧执行者迟到提交。租约只是时间约定,必要时还要用版本或 fencing token 拒绝过期执行者。
调用外部支付、发送消息或写另一个数据库时,一个本地事务无法覆盖全部效果。可向下游传递稳定的业务键,结合 outbox、消费去重或结果查询;具体持久状态和对账流程见 从写入到一致状态。
不同产品保存结果的规则也不同。例如 Stripe 会保存已开始执行请求的状态与正文,后续同键可以重放包括 500 在内的结果;参数校验失败和与执行中请求冲突的情况有另外规则。使用产品 API 时按它的约定实现,不能把“失败一定不缓存”当成通例。Stripe 幂等请求
有效期、权限与结果查询
同一逻辑操作重试时保留原键;用户真的发起另一笔订单时才生成新键。服务端要先验证调用者身份,再在该身份允许的范围内查找结果,避免猜测键值读取别人的订单。
记录有效期应覆盖可能的重复到达窗口,包括客户端离线、消息积压和人工补偿。记录被清理后,同一个旧键可能被当作新请求,重要业务仍需订单号、支付流水号等长期唯一约束。指纹和结果摘要也要按敏感信息管理,不把 Authorization、Cookie 或完整个人资料写进去重日志。
查询接口可以返回处理中、已成功、明确拒绝或结果待确认,并附稳定的操作标识。处于待确认状态时,由查询、回调或对账继续更新;前端保留这个操作,避免向用户展示一次“失败”后又无提示地产生订单。
超时、重试失控与结果未知怎样排查
从等待位置寻找原因
| 现象 | 先收集什么 | 下一步 |
|---|---|---|
| 请求还没发出就超时 | 池 pending、获取耗时、在途数量 | 查资源未释放或过载,限制排队 |
| 首次请求慢,复用后正常 | DNS、连接与 TLS 分段耗时 | 检查解析、握手和连接复用 |
| 持续有少量数据却很久不结束 | 已收字节、空闲计时、总耗时 | 增加总预算约束,按业务限制正文 |
| 重试后成功率很高,下游却更拥堵 | 原始请求率与尝试率 | 降低额外尝试,查多层重复重试 |
| 同键产生两个效果 | 身份作用域、唯一约束、事务与记录过期 | 找出绕过去重的提交路径 |
| 幂等记录长期 IN_PROGRESS | 执行者存活、租约、实际业务结果 | 查询或对账后恢复,禁止盲目解锁重做 |
每次尝试记录逻辑操作 ID、attempt 序号、目标、剩余预算、开始阶段和结果。分布式追踪可以给各尝试独立 span,同时关联同一操作;这样能分清一个用户点击产生了多少次网络请求。URL、请求头和正文按字段脱敏,不能为了排障扩大秘密收集范围。
怎样设计故障实验
自动实验覆盖了“效果已保存、响应尚未返回”这一窗口。生产实现还应分别验证:
| 故障或并发条件 | 应检查的结果 |
|---|---|
| 同键同内容并发到达 | 最终业务效果与所有响应一致 |
| 同键不同内容 | 稳定返回冲突,旧结果没有被覆盖 |
| 提交前进程退出 | 恢复后没有半笔业务 |
| 提交后、响应前断连 | 原键能查到或重放结果 |
| 执行者租约过期后恢复 | 旧执行者不能越过新的所有权提交 |
| 记录过期、消息迟到 | 业务唯一键仍保护关键效果 |
每个场景同时检查效果次数、持久记录和资源释放。只有最终响应为 201 的断言,看不出是否已经创建两笔订单。
实验出现异常时,先保留输出:
docker ps -a --filter name=retry-lab
docker logs --tail 100 retry-lab
docker inspect retry-lab --format '{{.State.Status}} {{.State.ExitCode}}'若第一次手动请求没有超时,检查是否已经使用过该键、是否去掉了 X-Lab-Hold,或是否连接到旧实例。已有键会直接返回保存的结果,不再等待。需要重新观察首次创建时,先确认旧实验容器已结束,再从空状态启动;不要在共享环境里清空真实去重表。
并行竞速的使用条件
Hedging 在首个尝试较慢时向另一个副本发送并行请求,接受先完成的结果。它适合能够安全重复、不同副本有独立延迟来源的操作,但每个副本都会消耗资源,应计入同一个并发和尝试预算。
落败请求被客户端取消后,服务器可能仍在查询数据库。写入请求若没有完整的并发幂等和提交协调,不宜用并行竞速掩盖尾延迟。先定位慢请求来源,确认额外负载不会加剧同一个瓶颈,再决定是否采用。
权威资料与规范地址
超时 API、HTTP 重试语义与持久去重的官方定义如下。
