一个接口超时,三个视角,三种完全不同的"事实"。
凌晨十一点半,开发群里炸了。
客服甩过来一张截图:用户说"下单失败了,但我被扣了钱"。
客户端同学:"我这边 timeout 了,3 秒就断了,不是我的问题。"
服务端同学翻着日志,一脸懵:"我这单明明成功入库了,耗时 6 秒,清清楚楚。"
用户在旁边补了一刀:"失败了我当然又点了一次啊,结果......"
这就是接口超时最经典的**"三方修罗场"**。今天这篇,我把多年踩坑攒下的经验掰开揉碎讲清楚:超时发生之后,客户端、服务端、C 端用户,各自到底"感受"到了什么?
01 先纠正一个误解:超时 ≠ 失败
很多人(包括不少老开发)心里有个默认假设:超时了,说明对方失败了。
错。超时是调用方的止损行为,不是服务端的执行结果。
打个比方:你给同事打电话,让他帮忙查个数据,说好等 3 分钟。3 分钟到了没回音,你把电话挂了,按"这事没办成"继续干活。但同事呢?他压根不知道你挂了,5 分钟后把结果整理好发过来,或者干脆已经把事办完了。
两个关键词请圈起来:
- 调用方:超时是客户端的单方面决定;
- 放弃等待:客户端只是不等了,没有能力(也没有义务)通知对方"别干了"。
所以任何一次超时之后,世界上同时存在两条平行叙事:客户端视角是"请求失败,抛异常";服务端视角是"请求照常执行,可能成功"。
为什么会这样?根因只有一个:**超时的决定权和执行权是分离的。**放弃等待的是客户端,执行任务的是服务端,两者之间没有可靠的"取消协议"。HTTP 顶多在写响应时通过断连感知到对方走了;Dubbo 甚至连连接都不会断。
你放弃了,但你不知道对方做成没有。
下面分别看 HTTP 和 Dubbo 两种典型场景下,三方各自的感受。
02 HTTP 超时:三方的平行叙事
场景:OkHttp / RestTemplate 调用,读超时 3s;服务端 Spring Boot + Tomcat;中间有 Nginx。
客户端:精确的一刀
客户端的感受非常"精确"------在整整 3 秒的时刻,抛出异常:
text
java.net.SocketTimeoutException: Read timed out
三个细节值得注意:
- 耗时整齐地等于超时配置值。监控里看到清一色的 3000ms、3001ms,别怀疑,就是超时了。
- 连接会被客户端关掉,防止"脏连接"复用后读到残留数据。
- 最关键的是,客户端拿到的是**"未知"**,不是"失败"。HTTP 状态码?没有。响应体?没有。
服务端:最卖力的"孤儿请求"
**Tomcat 的工作线程完全不知道客户端已经放弃等待了。**它会继续执行 Filter、AOP、业务逻辑、SQL 事务、消息发送......一路跑到底。
跑完之后,它要把响应写回 socket,这时才发现连接早没了:
text
org.apache.catalina.connector.ClientAbortException:
java.net.SocketException: Broken pipe (Write failed)
服务端日志里,这条请求的业务逻辑大概率记录为**"执行成功"**------只是最后"寄快递"的时候,收件人搬家了。
这种"客户端已经不要了、服务端还在卖力干活"的请求,我叫它孤儿请求。如果大面积超时,海量孤儿请求会把服务端线程池占死------雪崩就是这么发生的。
中间层:Nginx 的 504,替人背锅
Nginx 有自己的 proxy_read_timeout(默认 60 秒)。到了时间 upstream 还没回话,Nginx 会自己给客户端回一个 504 Gateway Timeout------但 upstream 上的 Tomcat 线程还在继续跑。
排查时要先问一句:**这个超时是谁判的?**是客户端 SDK、网关,还是应用自己?
C 端用户:转圈、报错,以及最危险的"再点一次"
用户不懂 TCP,也不懂线程池。他只有一个朴素的判断:"没成功。"
接下来只有两个选择:放弃走人,或再点一次。如果他不知道服务端其实已经把单下成功了------恭喜,你收获了一个重复订单。
**对用户而言,超时呈现为"失败";对服务端而言,它可能是"成功"。**这种感知错位,是一切重复提交、重复支付事故的源头。
03 Dubbo 超时:多一个"狠角色"
场景:Consumer timeout 3s,Provider 平均耗时 5s。
Consumer:异常之前,先偷偷重试了两把
很多团队上线后才知道的事实:Dubbo 默认超时是 1000 毫秒。多少接口顶着这个默认值上线,高峰期直接被超时淹掉。
text
org.apache.dubbo.remoting.TimeoutException:
Waiting server-side response timeout. start time: ...,
end time: ..., client elapsed: 1 ms, server elapsed: 3002 ms, timeout: 3000 ms
但在此之前,还有更重要的事发生了:Dubbo 默认 retries = 2,也就是 failover 集群容错会自动换一台 Provider 重试,最多调用 3 次。
幂等的读接口,这是自愈能力;非幂等的写接口------比如下单、扣款------就是灾难的种子。Consumer 抛异常的时候,Provider 可能已经把这件事执行了三遍。
Provider:把结果寄给一个已经离开的收件人
Dubbo 的 Consumer 和 Provider 之间是共享的长连接。Consumer 超时后并不会断开连接,连接上的其他请求照常往来。
Provider 侧的感受:
- 工作线程继续把业务逻辑跑完,不因客户端放弃而停下;
- 跑完后照常把结果序列化,写回这条长连接;
- Consumer 侧对应的请求上下文早已因超时被清理,收到"迟到的响应"只能默默丢弃。
补充一个严谨的细节:Dubbo 在部分版本下会对 Provider 工作线程发起中断(interrupt),但这个机制不可依赖 ------如果业务代码不检查中断标志,线程照样跑到底。正确的工程姿势是:假设 Provider 永远不会自动停,把幂等当作唯一可靠的兜底。
重试 × 超时:一道会放大的数学题
把 timeout 和 retries 摆在一起算笔账:
text
上游 1000 QPS 全部超时
↓
failover 自动重试 ×3 次
↓
Provider 实收 3000 QPS
↓
更慢 → 更多超时 → 更多重试
↓
线程池打满,雪崩
没有幂等的话,数据库被写了 3 遍。超时配得太短 + 默认重试,就是给系统埋了一颗放大器。
C 端用户:等了三个超时周期
Dubbo 用户和 HTTP 用户的感受主线一样:转圈、报错、可能重复提交。
但有一个真实又隐蔽的差别:**由于 Dubbo 内部已默默重试了两次,用户等待的总时长是三个超时周期之和。**端上以为在等一次 3 秒的请求,实际上等的是 9 秒。这就是为什么有些"接口超时"用户体感是"特别卡",而监控单次调用耗时却很正常。
重试并非全是坏事------第二次重试成功了,用户全程无感知。但用户永远感受不到成功的那几次重试,只感受到失败的那一次。当重试导致重复执行时,留下的是一条查不清的"幽灵订单"。
04 一张表看懂三方感受
| 视角 | HTTP 超时之后 | Dubbo 超时之后 |
|---|---|---|
| 客户端 | 抛 SocketTimeoutException,耗时≈超时阈值,结果"未知" |
抛 TimeoutException,且默认已自动重试 2 次,结果仍是"未知" |
| 服务端 | 线程继续执行完业务,写响应时报 Broken pipe,日志像"成功" | 继续执行完并把结果写回长连接,响应被 Consumer 丢弃,日志也像"成功" |
| 中间层 | Nginx/网关可能抢先替服务端回 504 | 无中间断开动作,长连接照常 |
| C端用户 | 转圈 → 报错 → 可能重复提交 | 转圈更久 → 报错 → 服务端可能已执行多次 |
规律很清楚:客户端拿到的一律是"未知",服务端表现的一律是"正常",而真正的风险全部转移给了用户和业务数据。
05 老兵经验:让超时变成受控行为
1. 超时预算,从内到外逐层放大
下游超时 < 网关超时 < 端上超时。
只有下游先超时并明确报错,上游才能拿到一个"结构化的失败"。读超时按下游 P99 的 1.5~2 倍配置,Dubbo 记得显式配 timeout,别用 1 秒默认值裸奔。
2. 重试必须配幂等
- 幂等的读接口:放心开 failover 重试;
- 非幂等的写接口:
retries = 0,用上层补偿代替底层重试; - 真要重试:指数退避 + 随机抖动 + 重试预算,防雪崩。
3. 幂等的三道防线
- 唯一业务单号 + 数据库唯一索引:最后防线,多脏的重复请求也插不进第二条;
- 防重 token:进页面领 token,提交时核销,天然挡住连击和重放;
- 状态机校验:订单已是"已支付",再收到支付请求,直接返回原结果;
- 产品侧同步:提交按钮置灰防连击,超时后提示"处理中"而非"失败",用查询接口轮询终态。
4. 别让慢请求拖死服务端
- 线程池隔离 + 熔断降级(Sentinel / Resilience4j):对慢调用比例熔断,快速失败好过慢慢等死;
- 请求准入控制:预计等待超过预算直接拒绝,把孤儿请求扼杀在门口;
- 长流程异步化:同步接口只做"受理",后续走 MQ,体验从"焦灼等待"变成"受理成功,处理中"。
5. 观测和对账,让"半成功"无处藏身
- 同时监控客户端超时率 和服务端 Broken pipe / 迟到响应数,两边一对比,孤儿请求一目了然;
- 支付、订单等强一致业务,必须有定时对账任务兜底,主动发现"客户端失败、服务端成功"的单子。
06 写在最后
回到开头那个凌晨的修罗场:当用户说"失败了我再点一次"的时候,你的系统接得住吗?
超时从来不是错误,它是系统用"有限的耐心"换取可用性的一种设计。真正决定事故大小的,不是那几秒的等待,而是超时之后你有没有准备好:
幂等兜底、克制的重试、熔断降级、异步化,以及最后一道对账防线。
想清楚这些,你才敢在下一个凌晨,平静地在群里回一句:
"放心点,点多少次都只算一单。"
------ 一个踩过超时坑的老开发