XMLjava.net.SocketTimeoutException: Read timed out
一、故障现象(情景描述)
某日,运营反馈:ES服务查询页面点击后直接报IO异常 ,随后无论点击任何菜单,页面均不再跳转,Tomcat访问日志无新增,刷新浏览器无效,唯一恢复手段只有重启Tomcat。
查看应用日志,最后一条错误停留在:
XML
java.net.SocketTimeoutException: Read timed out
之后日志"静默",再无任何输出,但进程仍在(未OOM)。
二、初步怀疑与排查方向
一开始,我们怀疑是 ES 中日志的时间片索引不存在(即查询时索引名写死,到了新月份索引未创建),导致 ES 返回 404,进而引发了某种锁死。
为什么 404(索引不存在) 不是元凶?
-
ES 返回 404 是 极快的(通常 < 10ms),只是一个正常的 HTTP 状态码。
-
我们的代码在收到 404 后会立即抛出异常,同步锁会在方法退出时释放。
-
单次 404 占用的锁时间极短,不可能导致线程池长期阻塞。
排除掉这个"烟雾弹"后,我们开始深挖 ESClientHolder.getRestClient() 的实现。
三、真正的"罪魁祸首"------锁内网络健康检查
查看核心代码(简化版):
java
public class ESClientHolder {
private static RestClient restClient;
private static final Object lock = new Object();
public static RestClient getRestClient() {
synchronized (lock) {
if (restClient == null) {
restClient = buildClient();
}
// ⚠️ 问题所在:锁内执行网络IO
if (!isConnectionAlive(restClient)) {
restClient.close();
restClient = buildClient();
}
return restClient;
}
}
private static boolean isConnectionAlive(RestClient client) {
try {
// 发送 HEAD / 请求,若ES不可达,会阻塞直到超时(默认60s)
client.performRequest(new Request("HEAD", "/"));
return true;
} catch (IOException e) {
return false;
}
}
}
isConnectionAlive() 内部发送一个 HEAD / 请求到 ES 服务端,用来检查连接是否存活。
致命点 :这个检查被包裹在 synchronized 锁内,且没有设置合理的超时(或超时时间过长,如 60s)。
四、服务卡死完整推演(含阻塞堆栈)
前置条件
-
Tomcat 最大线程数 = 200(默认)
-
ES 服务突然不可用(宕机 / 网络分区 / 防火墙拦截)
-
isConnectionAlive()的超时设为60s
推演过程
| 时刻 | 事件 |
|---|---|
| T0 | 请求A进入 getRestClient(),获得锁 |
| T1 | 请求A执行 isConnectionAlive(),此时 ES 不可达,阻塞在 Socket read,并一直持有锁 |
| T2 ~ T5 | 请求B、C、D......陆续进入,它们也想获取锁,但锁被A占用,全部进入等待队列 (BLOCKED 状态) |
| T6 | 随着并发量增加,Tomcat 工作线程逐渐都被"锁等待"占用,线程池满(200个线程全在等锁) |
| T7 | 新的请求(包括页面切换、刷新、心跳)到达,但无空闲线程处理,被拒绝或排队等待,但队列也满,Tomcat 开始拒绝连接 |
| T8 | 60秒后,请求A的 isConnectionAlive() 超时抛出异常,锁释放,A返回(但此时ES仍不可用) |
| T9 | 请求B获得锁,再次执行 isConnectionAlive(),又阻塞60秒......如此循环 |
阻塞堆栈示意图(文字描述)
html
"http-nio-8080-exec-1" # 请求A
at java.net.SocketInputStream.socketRead0(Native Method)
at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(...)
...
at org.elasticsearch.client.RestClient.performRequest(...)
at com.xxx.ESClientHolder.isConnectionAlive(ESClientHolder.java:45)
- locked <0x00000000abcdef> (a java.lang.Object)
at com.xxx.ESClientHolder.getRestClient(ESClientHolder.java:30)
"http-nio-8080-exec-2" # 请求B
at com.xxx.ESClientHolder.getRestClient(ESClientHolder.java:29)
- waiting to lock <0x00000000abcdef> (a java.lang.Object)
...
"http-nio-8080-exec-200" # 请求N
- waiting to lock <0x00000000abcdef>
结论 :不是内存泄漏,而是 锁未及时释放 + 网络阻塞 → 线程池被"饿死",系统无法处理任何新请求,日志也因此中断。
五、解决方案(可直接落地)
方案一:将健康检查移出同步锁(推荐)
java
public static RestClient getRestClient() {
// 1. 快速读取(无锁或读锁)
RestClient client = restClient;
if (client != null && isConnectionAlive(client)) {
return client;
}
// 2. 加锁重建
synchronized (lock) {
if (restClient == null || !isConnectionAlive(restClient)) {
if (restClient != null) {
restClient.close();
}
restClient = buildClient();
}
return restClient;
}
}
注意 :此写法仍存在双重检查的并发问题,但至少锁内只执行重建逻辑,而健康检查在锁外进行,即使阻塞也只会阻塞当前线程,不影响其他请求。
方案二:使用独立的健康检查线程(更优雅)
-
启动一个后台线程,每隔几秒 ping ES,维护一个全局的
healthy原子标志。 -
getRestClient()中只读取该标志,不执行网络IO。 -
若标志为 false,则返回降级结果或快速失败。
方案三:tryLock 带超时(不阻塞太久)
java
private static final ReentrantLock lock = new ReentrantLock();
public static RestClient getRestClient() {
try {
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// 执行重建逻辑(仍建议避免网络IO)
} finally {
lock.unlock();
}
} else {
throw new RuntimeException("获取锁超时,服务繁忙");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
方案四:结合 Spring @Async 或异步 HTTP 客户端
将 ES 查询改为异步,使用 CompletableFuture,并设置 timeout,避免阻塞 Web 容器线程。
六、如何监控锁持有时间(生产必备)
1. 通过 JMX 查看线程状态
-
使用
jstack或 JVisualVM 导出线程栈,搜索BLOCKED和locked关键字。 -
关注
isConnectionAlive相关的堆栈,统计阻塞时长。
2. AOP 打印锁耗时警告
java
@Aspect
@Component
public class LockMonitorAspect {
@Around("execution(* ESClientHolder.getRestClient(..))")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
long duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
if (duration > 500) {
log.warn("getRestClient 锁持有时间过长: {} ms", duration);
}
}
}
}
3. 使用 Micrometer / Prometheus 记录锁等待指标
- 通过
Lock的getQueueLength()定期采集,接入监控告警。
七、并发编程的"铁律"(经验总结)
| 规则 | 说明 |
|---|---|
| 锁内禁止网络IO | 同步块 / 锁内只能操作内存和本地资源,任何可能阻塞的操作(DB、RPC、HTTP、文件IO)都必须在锁外执行。 |
| 网络IO必须设超时 | 所有网络调用(包括健康检查)必须设置 connectTimeout 和 socketTimeout,且超时值要合理(如 1~3s)。 |
| 线程池隔离 | 将 ES 查询与业务主线程池分离,使用独立的线程池,避免相互影响。 |
| 快速失败优于长时间等待 | 当外部依赖不可用时,优先抛出明确异常并降级,而不是让调用者无限阻塞。 |
| 健康检查不要走业务链路 | 健康检查应由独立组件(如 Spring Boot Actuator)完成,不要混入业务初始化路径。 |
| 使用读写锁或乐观锁 | 对于读多写少的场景,ReentrantReadWriteLock 可提升并发度。 |
八、复盘与启示
本次事故的根源是对同步锁的"无意识"使用------开发者本能地认为健康检查只是轻量操作,忽略了网络IO的不确定性。教训深刻:
-
锁的粒度要尽量小,只保护共享变量的修改,而不是整个方法。
-
永远不要在锁内执行远程调用,哪怕你认为它"很快"。
-
故障恢复能力:代码应当能正确处理外部依赖的间歇性故障,而不是一挂全挂。
最终,我们将健康检查改为独立定时任务 + 原子标志,并设置了连接超时(3s),同时引入了熔断机制,自此该问题再未出现。
写在最后:希望这篇文章能帮你少踩一个坑。如果你也遇到过类似问题,欢迎留言交流~