背景
我做了 10 年网络,最近在带一个 6 人小组做内部 RPC 网关(Java,JDK 17,OkHttp 4.12,HTTP/1.1 keep-alive,连接池每服务 500)。下游是 Java 服务,同机房,p50 RTT 稳定 1–2ms。网关到下游的调用配置是:connectTimeout=2s,readTimeout=5s,writeTimeout=5s(最初是 10s,后来调小)。
现象
一次 P1:下游某实例 JVM 出现一次 8s 的 STW GC,期间内核 TCP 栈还活着、socket 是 ESTABLISHED,但应用线程不 read socket。网关在这 8s 里对这台实例发出的请求,实际表现不是 5s 就报错,而是 12–20s 才抛 SocketTimeoutException,抓包看到的画面是:
- 网关侧 socket 状态 ESTABLISHED,send buffer 里堆了 3–5 个未 ACK 的段;
- 没有 RST,没有重传失败,就是 RTO 在指数退避(200ms → 400ms → 800ms → 1600ms …);
- 下游 GC 结束、应用线程恢复 read 后,一次性把积压段全收走,业务响应正常返回——但此时网关已经超时断链。
也就是说,卡住我的不是"读超时",是写阻塞:send() 在内核里等 RTO 退避,应用层的 writeTimeout 根本没机会触发。
已尝试的排查与结果
- 把 OkHttp 的 writeTimeout 从 10s 调到 5s,超时时长没变,还是 12–20s。基本可以确认:writeTimeout 只对"用户态写 socket 到内核 send buffer 非满"这一步有效,一旦 send buffer 满了、内核态在等 ACK,这个参数就失效了。
ss -tin 看到重传段在指数退避,netstat -s 里 TcpRetransSegs 涨、TcpOutRsts 不涨——没有 RST,就是纯粹的等待,和 #523 里"2% 丢包打出 12s"是同一类内核退避行为,但触发条件不同:#523 是链路丢包,我是下游进程活着但不消费。
- 试着把
net.ipv4.tcp_retries2 从默认 15 调到 5,最坏等待从 ~925s 压到 ~15s 左右,能兜住。但这是全局参数,网关还有一堆跨机房链路,怕影响别人;而且调小后,遇到真正的网络抖动(丢包 2% 那种)会更快被判死,反而放大故障面。
- 也想过 SO_SNDTIMEO、TCP_USER_TIMEOUT、tcp_keepalive_* 这几个内核旋钮,但 SO_SNDTIMEO 在 Java 里没暴露、TCP_USER_TIMEOUT 语义是"未被确认数据的最长等待"我还没在生产验证过,keepalive 系列我理解是"空闲探测"而不是"未确认数据超时",不确定能不能顶上来。
想请各位帮忙确认 / 补充的几点(希望尽量给可复现的参数或代码片段)
- OkHttp / Apache HttpClient 的 writeTimeout 在 Linux 下到底是怎么实现的? 是不是只对应 send() 系统调用返回前的用户态等待,一旦 send buffer 满了、内核态等 ACK,这个参数就完全失效?有没有哪个主流客户端库(Netty、Vert.x、Curl、Go net)把"内核态未确认数据等待"也纳入了 timeout?
- 在"单个连接 / 单个请求"粒度上控制内核态 send 阻塞的最长等待,除了 tcp_retries2 全局调小,还有什么可用?TCP_USER_TIMEOUT(setsockopt)在 Java 里怎么设?Nagle 关闭 + TCP_NODELAY 对这个场景有没有影响?
- 生产里对"下游进程活着但不消费"这种半死状态,大家是靠什么兜底的? 应用层心跳(业务 ping 包 / HTTP2 PING / gRPC healthcheck)?TCP keepalive?连接池侧的"空闲探活"?各自的探活频率和判定阈值你们怎么配?
- 如果下游是 gRPC(HTTP/2),这个问题是不是被 flow control 掩盖了? HTTP/2 的 WINDOW_UPDATE 会不会让"下游不消费"这件事在应用层就表现为"window=0",从而避免 send buffer 堆满、绕开内核 RTO 退避?还是说底层 TCP 一样,只是表象不同?
- 有没有人遇到过 "write 阻塞超时"比"read 超时"更难治理的生产案例?最后是怎么收口的?
引用几条我觉得和这个场景同源、但角度不同的内容,供参考:
- 顾墨白《2% 丢包为什么能打出 12 秒超时:TCP RTO 退避机制与生产调参》(id=523)——同为内核 RTO 指数退避,但触发是链路丢包;
- 沈星河-786《tcp_retries2 与 RTO 退避的排查、参数预算与三处反直觉坑》(id=870)——同参数,但角度是"重传率"而不是"下游不消费";
- 程亦舟《凌晨 P99 飙到 30 秒:NAT 静默剪断长连接池的复盘》(id=106)——同为"半死连接",但触发是中间设备剪断、我这边是下游进程活着但不 read。