程云来 / 杭州
返回文章·计算机网络
·约 14 分钟

网络面试(十四):TCP 可靠,为什么业务仍需超时与幂等?

追踪创建订单成功却收不到响应的场景,区分传输确认与业务结果,用同一请求键管理重试,再判断超时预算、连接复用和 UDP 是否适合当前需求。


超时之后,订单到底建没建?

客户端 A 向订单服务 S 发送“创建一份书籍订单”。S 已经提交订单 O204,却在回复途中失去连接。A 等到请求期限结束,只看到超时。

如果 A 再发送一遍,S 会创建 O205,还是返回 O204?TCP 不认识“订单”,无法替应用决定。

TCP 确认字节接收;业务协议确认操作结果。超时可能意味着结果未知,重试安全需要业务契约。 本章按一个设定的订单流程推导,不提供真实下单服务,也没有故障注入实验。

四个到达位置,对应四种不同证据

A 与 S 之间的字节传输是业务处理的一部分,不能把局部完成当成全链路完成:

链路中的证据能支持的结论不能直接证明
本地 send 成功本次调用接受了返回值所指示的字节字节已到 S,更不能证明订单已提交
S 的 TCP 累计 ACK 覆盖请求字节对端 TCP 已确认相应序列范围S 应用已读取、校验或执行请求
S 的业务处理完成按约定完成相应操作,例如订单事务提交A 已收到结果
A 收到完整且有效的业务响应A 能按响应契约判断此次结果所有业务响应都承诺持久化或外部流程完成

第三行通常是服务器内部证据,客户端未必看得到。最后一行仍取决于语义:“已接收任务”与“订单已经提交”不是同一种成功。TCP 的保证也以连接和协议状态为边界,连接最终仍可能失败。RFC 9293:TCP

本例明确规定,S 返回 O204 表示订单已提交。即便这样的成功响应丢失,已提交状态也不会因为 A 不再等待而自动撤销。

超时应该覆盖哪些等待?

超时(Timeout)限制某个等待过程,截止时间(Deadline)则规定整次操作最晚何时结束。实现中的名字可能不同,先核对 API 实际覆盖什么:

等待位置本例可能发生的事超时意味着什么
获取池中连接其他请求占满连接额度可能尚未发送这次请求
解析、建立连接及安全握手新连接尚未可用要核对具体超时是否覆盖这些步骤
写请求与等待响应请求可能只发出部分,也可能已提交不能仅靠超时推断 S 没有执行
读完整响应已收到部分结果,后续停滞半份响应不能当作完整结果

TCP 的 RTO 用来决定何时重传未确认报文,不等于订单请求允许等待多久。读取超时也可能只约束单次读取;对方持续慢慢发字节时,整份响应仍可拖很久。这与上一章实验的逐次 socket 超时边界一致。

设本例整体预算为 2000ms,第一次已经消耗 1300ms。剩下 700ms;若重试前等待 200ms,下一次最多只剩 500ms。不能给重试重新分配完整的 2000ms,否则一层层调用会把用户等待无限延长。这些数值是预算推导,不是推荐的通用默认值。

A 到期停止等待、甚至关闭连接,也不保证 S 的事务停下。若业务支持取消,还需定义取消与提交竞争时的结果;不能把“客户端取消”解释成远端已经回滚。

TCP 重传与业务重试,为什么不一样?

TCP 重传沿用同一连接中的相应序列位置,接收端处理重复字节。业务重试则是再发一份应用请求,即使沿用原连接,也会占新的字节位置;重新连接更有新的一套传输状态。

因此 S 的 TCP 会把第二份合法请求交给应用,它并不知道两份请求代表同一个业务意图。内容看起来相同也不足以判断:用户可能确实要买第二份相同的书。

幂等(Idempotency)关注的是按契约重复同一操作,预期业务效果是否与一次相同;不是要求每次响应文字、日志或时间戳都完全一致。HTTP 也按预期效果定义幂等,具体方法和接口的重试条件会在下一章展开。RFC 9110 §9.2.2

为同一个业务意图保留同一个请求键

给本例增加一个应用约定:A 在开始创建订单前生成请求键 K17,范围限定为“用户 U42 的创建订单操作”;这个键随同书籍标识与数量一起传给 S。它不是 TCP 字段,也不是本章宣称存在的标准 HTTP 头。

S 记录该键对应的输入和订单结果。图中假设订单和请求键结果在同一事务内提交,重复请求到达时首个事务已经完成:

正在绘制图表…
查看 Mermaid 源码
sequenceDiagram
    participant A as A(客户端)
    participant S as S(订单服务)
    participant D as D(订单与请求键存储)
    A->>S: 创建订单:U42 / K17 / 同一份输入
    S->>D: 原子提交订单 O204 与 K17 的结果
    D-->>S: 提交成功
    S--xA: 成功响应 O204 未到达 A
    Note over A: 等待到期,结果未知
    A->>S: 重试:仍使用 U42 / K17 / 原输入
    S->>D: 读取 K17 对应的已提交结果
    D-->>S: 返回 O204
    S-->>A: 返回已有结果 O204
    Note over D: 创建效果只发生一次

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

文字回退:第一次已提交 O204,但 A 没收到成功结果;重试携带原键,S 返回已有 O204。这里没有再次创建订单的分支,关键依据是应用请求键与存储结果,而非 TCP 去重。

请求键与操作效果的一致保存是服务端职责,键本身并不会让重试自动安全。AWS 的官方设计说明也强调请求标识、参数匹配和原子保存。Making retries safe with idempotent APIs

沿着本例,可以检查四种输入:

到达 S 的请求本例契约创建效果
K17 / 原输入,首次提交 O204 并保存结果一次
K17 / 原输入,再次返回已有 O204不增加
K17 / 数量改成 2拒绝键与输入不匹配不按原键创建另一份
K18 / 原输入视为新的创建意图可以创建另一份订单

所以 A 每次重试都换新键,会破坏这份约定;用输入内容哈希替代用户意图,也会把两次合法的相同购买混成一次。键还必须绑定调用身份/操作范围,不能让其他用户通过猜键拿到别人的结果。

动手选择一次结果未知后的重试

三个分支都从 O204 已提交、响应丢失开始。切换请求键与数量,比较客户端能知道的结果,以及服务端是否新增订单。

响应丢了,再发一次会多一份订单吗?

用户U42首次请求:K17、书籍B1、数量1。服务已原子提交O204和请求键结果,但成功响应没到客户端。

客户端看到的证据

请求超时,结果未知

超时没有证明订单未创建。
服务端已提交订单:1 份

O204 · 数量1 · K17

第一次提交不会因客户端停止等待而撤销。

虚构订单、无真实下单或故障注入。假设首个事务已完成、键绑定用户与操作、参数匹配、保留期未过,订单与键结果原子保存;分支切换不是累计执行。并发、外部副作用与键过期另需契约,不能推成全局恰好一次;安全重试还受次数、整体期限与退避约束。

同一键同时到达,先查后建还安全吗?

单独做“查 K17 不存在 → 创建订单 → 写 K17”存在竞争。两个处理者可能同时查到不存在,各建一份订单;也可能创建完成后进程崩溃,键没保存,重试又创建一次。

对本例同一个事务存储,建议用数据库唯一约束和事务把请求键占用、业务变化、结果保存纳入一致的处理过程。并发请求应按契约等待或返回“仍处理中”,而不是再执行;处理失败或中断时也要能确定后续如何恢复。图里的原子提交是要求,不能只靠写在函数名里就认定实现具备。

若订单还要调用外部服务,同一个本地事务无法自动涵盖外部效果,需为该调用设计相应的幂等、可恢复记录或其他一致性机制。本章不把“本地存一个 key”推导成跨系统的全局恰好一次。

请求键记录也有保留期。过期后再次收到 K17,S 若已不认识它,就可能把请求当新操作。因此客户端重试期限、服务端记录保留期、查询订单状态和业务唯一约束需要共同设计。响应丢失且契约边界不清时,先按业务标识查询/核对,比无条件再次创建更稳妥。

能安全重试,还要问重试是否值得

幂等解决重复效果的一部分风险,却不让服务拥有无限处理能力。参数无效、权限失败等稳定错误通常不会因为原样再试而改善;暂时的连接错误也需考虑请求是否已到达和操作是否允许重试。

建议给本例规定最大尝试次数、整体期限、可重试条件与一个负责重试的层。等待逐渐增大的退避(Backoff)减少连续冲击;加入抖动(Jitter)让大量客户端不要在同一时刻一起再来。它们不能代替幂等,也不保证故障必然恢复。Timeouts, retries, and backoff with jitter

例如本例采用一种全抖动策略:第 n 次重试(从 0 起)先计算上限 min(800ms, 100ms × 2^n),再在 0 到该上限间随机等待。前四次上限为 100、200、400、800ms,随后封顶;实际等待仍受剩余整体预算限制。这是说明策略的设定,不是代码实测或推荐所有请求都重试四次。

再看一个原始请求经过三层调用的简化上限:每层最多尝试三次(首次加两次重试),若失败分支完全展开、没有预算提前终止,底层最多收到 3 × 3 × 3 = 27 次调用。它说明嵌套重试会放大负载,不说明实际每次都发生 27 倍流量。

复用连接节省成本,为什么仍可能第一次就失败?

A 创建多个订单或查询订单时,可以复用合适的连接,减少反复建立 TCP、以及使用安全通道时反复握手的成本。连接池还会限制并发并管理等待者;连接池属于客户端/运行库管理,TCP 并没有通用的“订单连接池”。

空闲连接可能已被 S、代理或其他中间设备清理,而 A 暂未感知。池里还有对象不等于远端仍可用;借出前检查也无法消除“检查之后、使用之前”发生关闭的竞争。

此时读取或写入失败,仍回到本例的问题:请求有没有提交?应丢弃已失效连接,按订单契约决定是否在新连接上重试 K17,而不是因为“换了连接”就当成新业务意图。RFC 9112 §9.3.1

复用还要求当前应用协议允许、并且消息边界清楚。以 HTTP/1.1 为例,客户端想复用连接就必须读完当前响应体;超时后若边界不确定,把连接直接还回池可能让下一请求读到旧响应的剩余字节。HTTP/2、HTTP/3 的多路复用规则另有机制,不能照搬成所有版本必须关闭整条连接。RFC 9112 §9.3

池的容量与获取等待也要进入预算。无上限扩池可能把压力推给服务端;池太小或连接未及时归还则会在请求发出前排队。应分别观察池等待、连接建立、服务响应和响应体读取,而不是把它们全叫“网络慢”。

如果业务自己处理重试,直接选 UDP 行不行?

这并不是从“TCP 不能证明订单成功”推出的结论。换成裸 UDP 后,应用还要面对数据报丢失、重复与乱序;即使补上这些机制,响应丢失后的业务歧义仍然存在。

选择传输方式,应先看实际需求:

需求可考虑的路径还需解决的事情
有序传完整命令与结果TCP 上的成熟应用协议分帧、安全、业务期限与幂等
迟到数据已无价值,例如部分实时状态更新UDP 或合适的实时协议序号、过期丢弃、发送速率与安全
小型查询/响应,允许明确处理丢失UDP 上的既定协议请求标识、超时、有限重试、报文大小与回退
需要可靠流但希望采用 UDP 作为底层QUIC 等成熟传输仍遵守其恢复、安全与拥塞控制规则

UDP 不替应用提供 TCP 的可靠有序字节流,也不意味着可以无节制地重发。UDP 应用同样需要合适的拥塞控制与报文大小策略。RFC 8085

QUIC 以 UDP 为底层,但自身建立可靠流、安全连接等机制;“底层 UDP”不等于“上层业务必然不可靠”。具体流与 HTTP/3 的关系在后续章节说明。RFC 9000

本例订单更关注完整命令、安全与可核对结果,没有“旧数据不必补齐”的需求。建议复用成熟协议并做好业务契约,而不是为规避幂等设计再手写一套可靠 UDP。

30—60 秒参考回答

TCP 的可靠性针对连接中的字节传输,ACK 不代表订单提交。请求超时可能发生在服务器提交之后,所以客户端应区分明确失败与结果未知。允许重试的操作要有幂等契约,同一意图保留同一请求键,服务端保证键、效果和结果的一致性,还要处理并发、参数变化和过期。重试受整体预算、次数与退避约束。连接复用减少建连成本但可能借到失效连接;改用 UDP 也不会消除业务结果歧义。

四个追问及解析

1. TCP 会去重,为什么 S 还能收到重复订单请求?

TCP 去重对应序列空间中的重复字节,业务重试是再次发送的应用消息,使用新字节位置或新连接。相同订单意图需要业务键识别,不能依赖传输层猜测。

2. 请求键保存好了,就能保证所有副作用只执行一次吗?

要看保存过程与范围。如果先创建后单独存键,崩溃仍留下重复窗口;并发也可能竞态。即使本地事务正确,外部调用仍需相应契约。键的作用域、输入匹配与保留期也是保证的一部分。

3. 读超时后关闭 socket,服务器是不是必定不再创建订单?

不是。S 可能已经提交,也可能继续处理已收到的请求。连接关闭不是事务回滚确认;需通过业务响应、状态查询或有明确定义的取消流程判断。

4. 连接池里的连接通过 Keepalive,是否可以放心无限复用?

不能。探测至多提供当时的传输响应,应用和连接随后仍可能失败。还要管理连接寿命、消息完整性、容量、归还与失败清理;重试安全依赖操作契约,和是否复用是两条不同判断。

两个易错辨析

“幂等就是每次返回完全相同的响应。” 核心是约定的预期效果与一次相同。本例选择重放已有 O204 方便客户端确认,但时间、日志或资源后续状态仍可能变化,不能拿全部字节一致作为通用定义。

“超时是网络没送到,继续重试直到成功就行。” 超时可能发生在事务提交之后,无限重试还会延长等待并放大负载。先确认操作可重试,再约束预算;结果未知时保留查询与核对路径。

我们已经能解释“收到字节”“提交结果”和“客户端知道结果”的差别。接下来需要一种应用协议表达操作、状态与消息内容:下一章从原始 HTTP 请求和响应讲起。

本章核验资料

核验日期:2026-10-09。订单、请求键、预算与次数均为教学设定,没有执行订单服务、网络故障注入或可靠 UDP 实验。