《Java 编程实战》7.3 优雅停机与连接管理

进程收到 SIGTERM 时,libraryd 正在处理的借阅请求会不会被切断,连接池和定时任务会不会来不及收尾。本节用两次真实停机实验对比 stopTimeout=0 与 10 秒的差别,再给出 LibraryApplication 的关闭顺序、幂等关闭、启动失败回滚和 shutdown hook 的完整写法。

本节目标:让 libraryd 在收到停止信号后先停止接收新请求,再等在途请求完成,最后按依赖顺序释放连接池、缓存和遥测资源。
适用版本:JDK 25(Temurin 25.0.5)、Javalin 7.2.4、Jetty 12.1.14、HikariCP 7.1.0。

承接的问题

7.1 和 7.2 让 libraryd 能接请求、能记日志、能返回统一错误。现在换一个视角:进程要退出时会发生什么?

Kubernetes 滚动发布、docker stop、开发者按 Ctrl+C,发的都是 SIGTERM。JVM 收到信号后开始执行 shutdown hook,但默认行为不会自动替你排空 HTTP 连接。如果直接 System.exit(0) 或让 JVM 自然退出,可能出现三种情况:

  • 正在执行的借阅请求被切断,客户端收到连接重置,但它不知道借阅有没有成功。
  • 定时任务还在跑,刚拿到数据库连接就被关闭,日志里出现 HikariDataSource has been closed。
  • 连接池、Redis 客户端、OpenTelemetry 的缓冲区没有关闭,指标和 trace 丢失最后一段。

这一节要回答的是顺序问题:谁先停,谁后停,每一步失败会不会拖垮后面的步骤。

核心机制一:Javalin.stop() 与 Jetty 的 stopTimeout

Javalin 的 stop() 没有超时参数,它把停止动作交给 Jetty。Jetty 的 Server 有一个 stopTimeout,单位毫秒;默认值是 0。0 的含义不是「无限等待」,而是「不等待」。

先看默认值下的真实行为。启动一个 /slow 端点,睡 3 秒再返回;请求发出 0.6 秒后向进程发 SIGTERM:

Javalin app = Javalin.create(cfg -> {
    cfg.routes.get("/slow", ctx -> {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
        }
        ctx.result("done");
    });
});
Runtime.getRuntime().addShutdownHook(new Thread(app::stop));
app.start("127.0.0.1", 18083);

本机输出:

READY 18083
HOOK-begin
18:06:37.321 INFO org.eclipse.jetty.server.Server -- Stopped Server{STOPPING}[12.1.14,sto=0]
18:06:37.335 INFO org.eclipse.jetty.server.AbstractConnector -- Stopped ServerConnector{HTTP/1.1}{127.0.0.1:18083}
18:06:39.860 INFO io.javalin.Javalin -- Javalin has stopped
curl: http=000 total=0.551272

sto=0 出现在 Jetty 的日志里。连接器在 14 毫秒内就被停掉,客户端的 curl 在 0.55 秒时失败,HTTP 状态码是 000(没有拿到响应)。服务端线程池后来才慢慢退出,但客户端已经断了。

再看设置了 10 秒停止超时的版本:

Javalin app = Javalin.create(cfg -> {
    cfg.jetty.modifyServer(server -> server.setStopTimeout(10_000));
    cfg.routes.get("/slow", ctx -> {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
        }
        ctx.result("done");
    });
});

同样的请求和信号,本机输出:

READY 18083
HOOK-begin
18:05:40.425 INFO org.eclipse.jetty.server.Server -- Stopped Server{STOPPING}[12.1.14,sto=10000]
18:05:43.153 INFO org.eclipse.jetty.server.AbstractConnector -- Stopped ServerConnector{HTTP/1.1}{127.0.0.1:18083}
18:05:43.174 INFO io.javalin.Javalin -- Javalin has stopped
curl: http=200 total=3.175043
body: done

连接器直到 43.153 才停止,和请求开始时间相差约 3 秒;客户端拿到 200 和 done。差别不是「Javalin 会不会等」,而是「Jetty 拿到的停止超时是多少」。

cfg.jetty.modifyServer(...) 是 Javalin 7 暴露 Jetty 配置的入口,必须在 start 之前调用。stopTimeout 给的是上限,不是固定等待:没有在途请求时,stop() 会立刻返回;有请求时,最多等到超时,超时后仍未完成的请求会被中断。

这里有一个容易误解的点:stopTimeout 不会取消正在执行的 JDBC 查询。它只是让 Jetty 多等一会儿。如果查询本身要跑 30 秒,而 stopTimeout 是 10 秒,10 秒后连接仍然会被关闭。数据库侧的语句超时和连接池回收才是最后的兜底,见第 4、5 章。

核心机制二:关停顺序由依赖方向决定

停机顺序不是「随便关」,而是启动顺序的逆序。libraryd 的依赖方向是:

HTTP 层 → LibraryService → Repository → HikariDataSource → PostgreSQL
   ↓           ↓
定时任务      Redis 缓存
   ↓
Telemetry

谁依赖谁,谁就先停。具体顺序是:

顺序资源为什么在这个位置
1Javalin先停止接收新请求,并等待在途请求完成
2OverdueScanner不再提交新的定时查询
3RedisBookCache缓存客户端不再被请求线程使用
4HikariDataSource没有 HTTP 和定时任务后再关闭连接池
5Telemetry最后关闭,保证停机日志和指标还能被导出

反过来,如果先关 HikariDataSource,正在处理的请求会从池里拿连接失败,客户端看到 500;如果先关 Telemetry,停机过程中的错误日志可能丢失 trace 和指标。顺序错误的代价不是「关得慢一点」,而是「关的时候还在报错」。

OverdueScanner 用的是 ScheduledExecutorService,它的 close() 先 shutdown(),再等 5 秒,超时才 shutdownNow():

@Override
public void close() {
    scheduler.shutdown();
    try {
        if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
            scheduler.shutdownNow();
        }
    } catch (InterruptedException exception) {
        Thread.currentThread().interrupt();
        scheduler.shutdownNow();
    }
}

shutdown() 只是不再接收新任务,已经提交的扫描会继续跑。awaitTermination 给 5 秒,是为了让正在执行的查询有机会结束。捕获 InterruptedException 后要恢复中断标志,否则上层无法知道线程被中断过。

核心机制三:幂等关闭与启动失败的回滚

close() 可能被调用两次:一次是 shutdown hook,一次是测试或上层框架。第二次调用不能抛异常,也不能重复关闭已经关闭的资源。用一个 AtomicBoolean 做闸门:

private final AtomicBoolean closed = new AtomicBoolean();

@Override
public void close() {
    if (!closed.compareAndSet(false, true)) {
        return;
    }
    stopQuietly(javalin);
    closeQuietly(scanner);
    closeQuietly(redis);
    dataSource.close();
    closeQuietly(telemetry);
    log.info("libraryd stopped");
}

compareAndSet(false, true) 是原子的:只有一个线程能把状态从「未关闭」改成「已关闭」,另一个线程直接返回。不要用 if (closed) return; closed = true;,两个线程可能同时通过检查。

关闭单个资源时用两个辅助方法,把「失败不阻断后续步骤」写进代码:

private static void stopQuietly(Javalin application) {
    if (application == null) {
        return;
    }
    try {
        application.stop();
    } catch (RuntimeException exception) {
        log.warn("javalin stop failed", exception);
    }
}

private static void closeQuietly(AutoCloseable closeable) {
    if (closeable == null) {
        return;
    }
    try {
        closeable.close();
    } catch (Exception exception) {
        log.warn("close failed for {}", closeable.getClass().getSimpleName(), exception);
    }
}

null 检查是给启动失败路径用的。start 方法按顺序创建资源,任何一步失败时,后面还没创建的对象是 null:

public static LibraryApplication start(AppConfig config) {
    return start(config, AppConfig.redisUrlFromEnvironment());
}

public static LibraryApplication start(AppConfig config, String redisUrl) {
    Clock clock = Clock.systemDefaultZone();
    HikariDataSource dataSource = null;
    Telemetry telemetry = null;
    RedisBookCache redis = null;
    Javalin javalin = null;
    OverdueScanner scanner = null;
    try {
        dataSource = DataSourceFactory.create(new DatabaseConfig(
                config.dbUrl(), config.dbUser(), config.dbPassword(), config.poolSize()));
        DatabaseMigrator.migrate(dataSource);

        ThreadLocalTransactionManager transactions = new ThreadLocalTransactionManager(dataSource);
        JdbcBookRepository jdbcBooks = new JdbcBookRepository(dataSource, transactions);
        JdbcMemberRepository members = new JdbcMemberRepository(dataSource, transactions);
        JdbcLoanRepository loans = new JdbcLoanRepository(dataSource, transactions);

        telemetry = new Telemetry();
        redis = RedisBookCache.connect(redisUrl, JsonSupport.objectMapper());
        BookRepository cachedBooks = new CachingBookRepository(jdbcBooks, redis);
        LibraryService service = new LibraryService(
                cachedBooks, members, loans, transactions, new FinePolicy());

        scanner = new OverdueScanner(service, telemetry.registry(), clock);

        HikariDataSource pool = dataSource;
        HealthProbe healthProbe = () -> {
            try (Connection connection = pool.getConnection()) {
                return connection.isValid(2);
            } catch (SQLException exception) {
                return false;
            }
        };

        javalin = new LibraryApi(service, telemetry.registry(), telemetry::scrape,
                healthProbe, telemetry.tracer(), clock).create();
        javalin.start("0.0.0.0", config.httpPort());
        scanner.start();

        log.info("libraryd started on port {} (db={}, poolSize={})",
                javalin.port(), config.dbUrl(), config.poolSize());
        return new LibraryApplication(dataSource, javalin, scanner, telemetry, redis);
    } catch (RuntimeException failure) {
        stopQuietly(javalin);
        closeQuietly(scanner);
        closeQuietly(redis);
        if (dataSource != null) {
            dataSource.close();
        }
        closeQuietly(telemetry);
        throw failure;
    }
}

失败清理的顺序和 close() 一致,也是逆序。这里有两个容易漏的点:

  • javalin 可能已经 start 成功,但 scanner.start() 失败。此时必须 stopQuietly(javalin),否则端口已经被占用,进程却退出了。
  • dataSource 是 HikariDataSource,它的 close() 不抛受检异常,但可能抛运行时异常。示例里直接调用,是因为它在最后一步;如果要更保守,也可以包进 closeQuietly。

清理完成后把原异常重新抛出,交给 AppMain 记录并 System.exit(1)。不要吞掉启动失败,否则进程会以 0 退出,编排系统以为发布成功。

在 libraryd 里落地:LibraryApplication

完整的关闭逻辑如下。LibraryApplication 持有所有需要关闭的资源,构造函数私有,只通过 start 工厂方法创建:

package com.plume.library;

public final class LibraryApplication implements AutoCloseable {

    private static final Logger log = LoggerFactory.getLogger(LibraryApplication.class);

    private final HikariDataSource dataSource;
    private final Javalin javalin;
    private final OverdueScanner scanner;
    private final Telemetry telemetry;
    private final RedisBookCache redis;
    private final AtomicBoolean closed = new AtomicBoolean();

    private LibraryApplication(HikariDataSource dataSource,
                               Javalin javalin,
                               OverdueScanner scanner,
                               Telemetry telemetry,
                               RedisBookCache redis) {
        this.dataSource = dataSource;
        this.javalin = javalin;
        this.scanner = scanner;
        this.telemetry = telemetry;
        this.redis = redis;
    }

    public int port() {
        return javalin.port();
    }

    @Override
    public void close() {
        if (!closed.compareAndSet(false, true)) {
            return;
        }
        stopQuietly(javalin);
        closeQuietly(scanner);
        closeQuietly(redis);
        dataSource.close();
        closeQuietly(telemetry);
        log.info("libraryd stopped");
    }
}

implements AutoCloseable 让测试可以写 try (LibraryApplication app = LibraryApplication.start(config)) { ... },退出代码块时自动关闭。注意 close() 不声明 throws Exception,因为所有资源关闭失败都已经被 stopQuietly/closeQuietly 消化,调用方不需要为清理失败写 try/catch。

dataSource.close() 放在 redis 之后、telemetry 之前。HikariCP 关闭时会关闭池中的空闲连接;如果还有连接被借出,行为取决于池的实现和版本。前置的 HTTP 排空和定时任务停止,就是为了让借出的连接在该时刻已经归还。

telemetry.close() 最后执行,它内部依次关闭 OpenTelemetry SDK、TracerProvider 和 MeterRegistry。停机日志本身还会产生指标,因此遥测必须活到最后一行日志之后。

核心机制四:shutdown hook 与进程信号

AppMain 负责把 close() 挂到 JVM 的 shutdown hook 上:

public static void main(String[] args) {
    AppConfig config = AppConfig.fromEnvironment();
    applyLogLevel(config.logLevel());
    Logger log = LoggerFactory.getLogger(AppMain.class);

    LibraryApplication application;
    try {
        application = LibraryApplication.start(config);
    } catch (RuntimeException failure) {
        log.error("libraryd failed to start", failure);
        System.exit(1);
        return;
    }

    Runtime.getRuntime().addShutdownHook(
            new Thread(application::close, "libraryd-shutdown"));
    log.info("libraryd is ready: http://localhost:{}", application.port());
}

shutdown hook 在两种信号下会执行:SIGTERM(docker stop、Kubernetes 删除 Pod)和 SIGINT(Ctrl+C)。SIGKILL(kill -9)不会执行,JVM 直接被内核杀掉,任何 Java 层的清理都没有机会运行。这也是为什么生产环境不应该用 kill -9 停服务。

hook 线程的名字取成 libraryd-shutdown,是为了在 jstack 和线程 dump 里能一眼看出停机卡在哪一步。hook 里不要打印大量日志、不要做网络重试、不要等待用户输入;它应该只做资源释放,并且有明确的上界。

JVM 会等待所有 shutdown hook 结束后才退出,但编排系统不会无限等。Kubernetes 的 terminationGracePeriodSeconds 默认是 30 秒,超过就发 SIGKILL。因此:

  • stopTimeout 要小于 terminationGracePeriodSeconds。
  • OverdueScanner 的 5 秒等待要算进总预算。
  • 连接池关闭和遥测 flush 也要留时间。

一个可用的预算是:stopTimeout=10s、scanner 等待 5s、其余资源 2s,总计小于 30s。如果请求可能跑很久,应该把长任务移出请求线程,而不是把 stopTimeout 调到 60 秒。

容器里还有一个信号问题:如果 Java 进程不是 PID 1,docker stop 发的 SIGTERM 可能被 shell 吞掉。Dockerfile 要用 exec form(ENTRYPOINT ["java", "-jar", ...]),或者用 tini 做 init。这个细节在第 16 章容器化时再展开。

常见错误与边界

错误一:只调用 System.exit(0),不先停 HTTP。 JVM 会执行 hook,但如果 hook 里没有 javalin.stop(),Jetty 的连接器不会排空,在途请求直接被切断。System.exit 不是优雅停机的替代品。

错误二:先关连接池,再停 HTTP。 请求线程还在跑,dataSource.getConnection() 会抛「池已关闭」,客户端收到 500。顺序必须是 HTTP → 定时任务 → 缓存 → 连接池 → 遥测。

错误三:close() 不幂等。 shutdown hook 和测试的 try-with-resources 可能各调一次。第二次 dataSource.close() 可能抛异常,掩盖第一次关闭的结果。用 AtomicBoolean 做一次性闸门。

错误四:stopTimeout 大于编排系统的宽限期。 Kubernetes 等到 30 秒就 SIGKILL,Jetty 还在等第 40 秒的请求,结果一样是被强杀,而且日志里看不到「已停止」。停机预算要按最小宽限期设计。

错误五:在 hook 里做业务补偿。 hook 的执行时间不可控,网络可能已经不通。补偿逻辑应该放在正常的业务事务或消息队列里,hook 只负责释放本地资源。

边界:长事务和长查询。 stopTimeout 不会取消 JDBC 查询。一个 30 秒的报表查询会让停机等到超时,然后被强制关闭。正确做法是给语句设置 statement_timeout,或者把长查询移到后台任务,并让任务支持取消。

边界:健康检查的摘流时间。 收到 SIGTERM 后,负载均衡器可能还要几百毫秒才把实例从端点列表里摘掉。这段时间新请求仍可能进来。Kubernetes 的 preStop 睡眠、就绪探针先失败,都是为了让摘流发生在 stop() 之前;第 13 章会把这个顺序补完整。

小结

  • Jetty 的 stopTimeout 默认是 0,含义是不等待;不设置时在途请求会被切断,客户端看到连接重置。
  • 用 cfg.jetty.modifyServer(server -> server.setStopTimeout(10_000)) 给停机一个上界,它必须在 start 之前配置。
  • 关停顺序是启动顺序的逆序:Javalin → 定时任务 → Redis → HikariCP → Telemetry。
  • AtomicBoolean.compareAndSet 保证 close() 幂等,单个资源关闭失败不能阻断后续步骤。
  • 启动失败时按相反顺序清理已创建的资源,再把原异常抛给 AppMain,让进程以非 0 退出。
  • shutdown hook 只处理 SIGTERM/SIGINT,不处理 SIGKILL;停机预算要小于编排系统的宽限期。

服务能安全停止了,但客户端传进来的数据还没有被认真检查。一个空标题、一个 1800 年的出版年、一个多传的字段,目前要么靠领域层兜底,要么直接变成 500。下一章从参数校验开始,把「请求能不能进来」和「业务是否允许」分开。

阅读导航:上一节:7.2 中间件:请求 ID、访问日志与错误处理 · 下一节:8.1 参数校验:Jakarta Validation 与手写规则 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发