那些年我们踩过的HTTP超时坑:一次线上事故的深度复盘
事情是这样的,那是一个风和日丽的下午,线上监控突然报警——订单支付接口99.9%响应时间从200ms飙升到15秒。我当时正在喝奶茶,看到这个消息差点把珍珠呛进气管里。
你以为我要讲一个"我们怎么快速定位问题并解决"的爽文故事?不,今天我要讲的是我们如何在三天内踩遍了HTTP超时相关的所有坑。这篇文章没有任何滤镜,都是实打实的血泪史。
坑一:以为connectTimeout就是一切
事故发生后,团队第一个反应是:"是不是连接超时太长了?"于是我们把connectTimeout从5秒调成了10秒。
结果:完全没用。
后来才知道,我们用的HTTP客户端是OkHttp,这家伙默认是长连接的。当你的client连接池里有个连接是坏的(服务端已经关闭了socket但client不知道),第一次请求会等socket读超时。这个超时根本不是connectTimeout控制的,而是readTimeout。
// 错误示例:以为只配connectTimeout就够了
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) // 这只管建立连接
.build();
// 正确做法:两个都要配,而且readTimeout要足够大
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) // 建立TCP连接
.readTimeout(30, TimeUnit.SECONDS) // 等待响应
.writeTimeout(30, TimeUnit.SECONDS) // 发送请求
.callTimeout(60, TimeUnit.SECONDS) // 整个调用生命周期
.build();
教训:HTTP超时是个体系,不是某一个参数能搞定的。
坑二:线程池和超时时间的糟糕耦合
我们当时用的是Tomcat,默认200线程。事故发生时,这些线程全部阻塞在调用支付网关的HTTP请求上。
问题来了:为什么200个线程会同时hang住?
因为我们的超时配置是这样的:
// 某个同事的"优化"配置
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
这个配置意味着:最多5秒建连,15秒等响应。加起来20秒。200线程,每线程20秒,理论最大阻塞时间是200×20=4000秒。而支付网关当时响应慢(他们也出问题了),结果就是所有线程全部卡住,新请求进不来,Tomcat直接假死。
正确的做法是:超时时间要独立于线程池大小。
// 方案一:给每个请求设置独立的超时
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000); // 5秒
factory.setReadTimeout(10000); // 10秒
// 关键:设置超时后,线程会在超时后释放,而不是无限等待
return new RestTemplate(factory);
}
// 方案二:使用信号量限制并发
Semaphore semaphore = new Semaphore(50); // 最多50个并发外网调用
public String callPaymentGateway() throws InterruptedException {
semaphore.acquire();
try {
return httpClient.call();
} finally {
semaphore.release();
}
}
坑三:重试机制——救命的稻草还是索命的绳索
加上超时配置和线程池隔离后,我们以为稳了。结果支付网关恢复后,出现了更诡异的问题:订单出现大量重复支付。
原因:我们开启了重试,而且用的还是默认的GET重试。支付接口是POST。
// Apache HttpClient的默认重试配置
CloseableHttpClient client = HttpClients.custom()
.setRetryHandler(new DefaultHttpRequestRetryHandler()) // 默认重试3次
.build();
// 问题:POST请求不是幂等的!
// 当第一个请求成功但超时(服务端已经处理),重试会再发一次
// 结果:用户被扣了4次钱
// 正确做法:POST请求不重试,或者用转账专用的幂等key
CloseableHttpClient client = HttpClients.custom()
.setRetryHandler((exception, executionCount, context) -> {
// 只在特定情况下重试
if (executionCount > 3) return false;
// 只重试GET/HEAD等幂等请求
return false; // 对于支付这种操作,永远不自动重试
})
.build();
教训:重试是把双刃剑,用不好就是自杀。对于支付这种操作,要么做好幂等设计,要么就别重试。
坑四:熔断器和超时配置的相爱相杀
最后我们引入了Sentinel/Hystrix来做熔断保护。配置大概是这样的:
@SentinelResource(value = "paymentGateway",
blockHandler = "fallback",
fallback = "fallback")
public String callPaymentGateway() {
return httpClient.call();
}
// 熔断器配置
// 10秒内至少5次请求失败才熔断
// 熔断后5秒内直接跳到fallback
问题来了:熔断器的超时时间(比如10秒)和HTTP客户端的超时(15秒),哪个先触发?
答案是:谁先到谁触发。如果HTTP超时是15秒,熔断器超时是10秒,那熔断器先触发。这意味着即使服务端还在苟延残喘(只是慢),你的请求也会被熔断器截断。
更好的做法是:熔断器的超时要略大于HTTP客户端的超时,避免误判。
// 推荐配置逻辑
// HTTP客户端:connect=3s, read=10s
// 熔断器:超时时间 = HTTP超时 × 1.5 = 15s
// 这样只有真正超时(>10s)才会触发熔断统计
终极解决方案:超时治理清单
经过这波折腾,我们总结出一套完整的超时治理方案:
- 分层超时:DNS解析、连接建立、SSL握手、读、写,每个环节都要单独控制
- 读写分离:读操作可以适当延长超时(用户愿意等),写操作要严格控制(快速失败)
- 线程隔离:外部调用和内部逻辑分开,避免一家出事全家遭殃
- 熔断保护:超时配置要配合熔断器使用,形成两级保护
- 监控告警:超时次数、超时分布、QPS,这些指标一定要监控
// 最终生产配置参考(OkHttp + Sentinel)
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS) // 保活检测
.connectionPool(new ConnectionPool(50, 5, TimeUnit.MINUTES))
.eventListenerFactory(EventListener.Factory { request ->
new MetricsEventListener() // 埋点监控
})
.build();
// Sentinel规则
FlowRuleManager.loadRules(Arrays.asList(
new FlowRule("paymentGateway")
.setCount(100) // QPS限制100
.setGrade(RuleConstant.FLOW_GRADE_QPS)
));
总结
回过头看这次事故,我们犯了所有能犯的错误:超时配置不全、线程池无隔离、盲目重试、熔断器误配。但正因为踩过这些坑,才对这些"基础知识"有了更深的理解。
很多开发者在写HTTP调用代码时,都是"能用就行"。但线上环境复杂得多——网络抖动、服务端GC、负载均衡故障,任何一个环节出问题,你的代码如果没有正确的超时处理,就会变成一颗定时炸弹。
所以,下次写HTTP调用代码时,请先问自己三个问题:
- 连接超时和读取超时都配了吗?
- 这个请求可以重试吗?如果不能,确保不会重试了吗?
- 如果这个服务挂了,我的线程池会全被堵住吗?
如果任何一个问题你回答不上来,那这篇文章就是为你写的。
祝你的线上服务稳如老狗,奶茶安心喝。