本节目标:让 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
谁依赖谁,谁就先停。具体顺序是:
| 顺序 | 资源 | 为什么在这个位置 |
|---|---|---|
| 1 | Javalin | 先停止接收新请求,并等待在途请求完成 |
| 2 | OverdueScanner | 不再提交新的定时查询 |
| 3 | RedisBookCache | 缓存客户端不再被请求线程使用 |
| 4 | HikariDataSource | 没有 HTTP 和定时任务后再关闭连接池 |
| 5 | Telemetry | 最后关闭,保证停机日志和指标还能被导出 |
反过来,如果先关 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 与手写规则 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。