一次锁内网络IO引发的Tomcat线程池“饿死”事故

XML 复制代码
java.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 导出线程栈,搜索 BLOCKEDlocked 关键字。

  • 关注 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 记录锁等待指标

  • 通过 LockgetQueueLength() 定期采集,接入监控告警。

七、并发编程的"铁律"(经验总结)

规则 说明
锁内禁止网络IO 同步块 / 锁内只能操作内存和本地资源,任何可能阻塞的操作(DB、RPC、HTTP、文件IO)都必须在锁外执行。
网络IO必须设超时 所有网络调用(包括健康检查)必须设置 connectTimeoutsocketTimeout,且超时值要合理(如 1~3s)。
线程池隔离 将 ES 查询与业务主线程池分离,使用独立的线程池,避免相互影响。
快速失败优于长时间等待 当外部依赖不可用时,优先抛出明确异常并降级,而不是让调用者无限阻塞。
健康检查不要走业务链路 健康检查应由独立组件(如 Spring Boot Actuator)完成,不要混入业务初始化路径。
使用读写锁或乐观锁 对于读多写少的场景,ReentrantReadWriteLock 可提升并发度。

八、复盘与启示

本次事故的根源是对同步锁的"无意识"使用------开发者本能地认为健康检查只是轻量操作,忽略了网络IO的不确定性。教训深刻:

  • 锁的粒度要尽量小,只保护共享变量的修改,而不是整个方法。

  • 永远不要在锁内执行远程调用,哪怕你认为它"很快"。

  • 故障恢复能力:代码应当能正确处理外部依赖的间歇性故障,而不是一挂全挂。

最终,我们将健康检查改为独立定时任务 + 原子标志,并设置了连接超时(3s),同时引入了熔断机制,自此该问题再未出现。


写在最后:希望这篇文章能帮你少踩一个坑。如果你也遇到过类似问题,欢迎留言交流~

相关推荐
不可求~1 小时前
C++ std::string_view 不是字符串:从悬空引用到安全用法
java·开发语言·c++
Lam Tang1 小时前
APS 系列文章10
java·代理模式
考虑考虑2 小时前
Excel导入时产生特殊字符处理
java·后端·java ee
90后小陈老师2 小时前
PHP 配置默认值失效排查实录:isset 兜底遇上表单空串回填,首页文案是怎么集体消失的
开发语言·php
Scabbards_2 小时前
面试Leetcode - Heap 堆
java·leetcode·面试
程序员清风3 小时前
专业再升级!程序员专属显示器明基RD280UG上手实测!
java·后端·面试
JJJennie7773 小时前
Gemini 3.7 Flash 上线,Google 想让 AI 多干活
开发语言·javascript·ecmascript
wuyk5554 小时前
Python 第三章:列表 List
服务器·开发语言·python
Evand J4 小时前
【MATLAB例程,车联网7】CACC网联车辆协同控制:ACC对比、V2X时延丢包与队列稳定性,附例程下载链接
开发语言·matlab·车联网