服务假死:进程还在,请求却挂了?用代码复现与线上避坑

服务假死:进程还在,请求却挂了?用代码复现与线上避坑

Scroll Down

有一次线上告警没响,CPU 也正常,用户却在群里说页面转圈。我 curl localhost:8080/api/order,30 秒没反应。进程在、端口也在听——这就是服务假死:JVM 没挂,但已经接不住新请求了。


一、假死和真死,别搞混

状态 进程 端口 业务请求
真死 退出 连不上 直接失败
假死 还在跑 能连上 超时、极慢、时好时坏

假死比真死难查:监控往往还是绿的,重启有时能好、有时不行,最后常被甩锅给「网络问题」。说白了,就是工作线程或连接池这类关键资源被占满了,新请求进来只能干等。


二、四种典型场景,本地能复现

1. Tomcat 线程池被打满

Spring Boot 默认 Tomcat 工作线程 max=200。接口里要是没设超时、傻等下游,200 个线程很快占满,后面的请求全排队。

@RestController
public class FakeDeathController {

    @GetMapping("/slow")
    public String slow() throws InterruptedException {
        // 模拟慢请求:占住一个 Tomcat 线程 30s
        Thread.sleep(30_000);
        return "ok";
    }
}

拿 ab 或 wrk 打 250 并发,很快就能看到:ss -ant | grep 8080 连接堆起来,jstack 里一堆 http-nio-8080-exec-* 卡在 TIMED_WAITING(Demo 里是 Thread.sleep)。真实场景里等下游 HTTP 时,常见是 RUNNABLE(堵在 socketRead0)或 WAITING——别只盯一种状态,看栈顶在哪才是关键。

2. 数据库连接池泄漏

HikariCP 默认就 10 个连接。借出来不还,池子干了,后面所有要查库的线程都堵在 getConnection()

@Service
public class LeakService {
    @Autowired DataSource ds;

    public void leak() throws SQLException {
        Connection conn = ds.getConnection();
        // 没 close,也没 try-with-resources
        conn.prepareStatement("SELECT 1").execute();
    }
}

jstack 里会看到大量线程停在 HikariPool.getConnection,下面跟着 AbstractQueuedSynchronizer.park

3. 死锁

public class DeadlockDemo {
    static final Object A = new Object(), B = new Object();

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
    }

    public static void main(String[] args) {
        new Thread(() -> { synchronized (A) { sleep(100); synchronized (B) {} } }).start();
        new Thread(() -> { synchronized (B) { sleep(100); synchronized (A) {} } }).start();
    }
}

跑完 jstack,会直接看到 Found one Java-level deadlock。注意:两个线程死锁,通常只卡部分请求;全站假死一般是线程池或连接池整体耗尽。线上更绕,业务锁、Redis 锁、DB 行锁搅在一起,我一般用 Arthas 的 thread -b 查。

4. Full GC 间歇性卡死

堆里长期存活对象太多、回收跟不上,Full GC 一 STW 就是好几秒,表现就是「偶尔能访问,偶尔全超时」。下面这段代码持续分配大对象且不释放,能把 Old 区慢慢顶满:

public class GcPressureDemo {
    static List<byte[]> cache = new ArrayList<>();

    public static void main(String[] args) {
        while (true) {
            cache.add(new byte[1024 * 1024]); // 1MB,只增不减
        }
    }
}

配合 jstat -gcutil <pid> 1000 看,OU(Old 使用率)持续走高,FGC 次数往上飙、GCT 占比高,基本就对上了。


三、为什么进程看着还正常?

客户端 → TCP 握手成功 → 进 Tomcat Connector 队列
       → 分配 worker 线程 → 跑业务
       → 线程卡在 I/O / 锁 / getConnection()
       → 线程池或队列满了 → 连接能建,没人处理 → 假死

几个容易误判的点:

  • 端口能连,不代表服务好使。listen 成功只说明 TCP 层 OK,Tomcat 有没有空闲线程是另一回事。
  • 进程在,不代表线程池能用。GC 线程、定时任务还在跑,CPU 可能很低——业务线程全在等。
  • health 返回 200 也可能骗人。没配 DB/Redis 探活,或者没把线程池队列、连接池 active 数放进指标,探活照样绿。

四、开发时我会守的几条线

超时别省。 RestTemplate、Feign、WebClient 都要设 connect + read timeout,别裸调。下面 RestTemplate 示例适用于 Spring Boot 2.x;3.x 需改用 ClientHttpRequestFactory 配超时:

@Bean
RestTemplate restTemplate(RestTemplateBuilder b) {
    return b.setConnectTimeout(Duration.ofSeconds(2))
            .setReadTimeout(Duration.ofSeconds(3))
            .build();
}

线程池分开用。 HTTP 入口、异步任务、MQ 消费各走各的池,别共用一个,一个慢接口拖死全部。

连接一定还。 查库走 try-with-resources,别裸借连接:

try (Connection c = ds.getConnection();
     PreparedStatement ps = c.prepareStatement(sql)) {
    // ...
}

下游慢就熔断。 Sentinel 或 Resilience4j 配一下,快速失败比干等强,至少保住 Tomcat 线程。

探活做深一点。 DB、Redis、核心下游都测;线程池排队数、连接池使用率也打到监控上。

出事了先留现场再重启:

jstack <pid> > thread.dump
jstat -gcutil <pid> 1000
# 有 Arthas 的话
thread -n 10
dashboard

dump 完再重启。直接重启,现场没了,下次还得猜。


五、我自己踩过的坑

同步写日志。 某接口压测 P99 飙到秒级,jstack 里大量 http-nio-* 线程栈顶卡在 FileAppender.doAppend(同步写盘),业务代码反而在下面几层。换成异步日志、调高级别后,同一套压测 P99 从 8s 掉到 200ms 左右。

事务里调 HTTP。 接口超时,jstack 里业务线程在等 Feign 返回,同时 HikariCP active=10、多线程堵在 getConnection()——根因是 @Transactional 包着远程调用,事务占着 DB 连接等下游。后来改成:事务里只动 DB,HTTP 放事务外面。


参考