深入 Play 25:生命周期与后台任务,停止应用时还剩什么
测试进程收到 SIGTERM 时,PostgreSQL 中仍有一个 PgSleep 查询。应用先拒绝新任务,等待已经接受的任务提交,再关闭执行器、连接池和文件。两个故意失败的 stop hook 被记录下来,后面的清理仍然执行。
退出码 143 只说明进程因 SIGTERM 结束。任务是否提交、线程是否终止、文件和池是否关闭,需要各自的终态证据。本实验同时保留生命周期日志和停止后由新连接读取的数据库结果。
自建资源需要明确的持有者
app/lifecycle/ManagedDatabaseJob.java 是单例的有限实验任务持有者。它按需创建一个 scheduler、一个 worker、一个最多两个连接的数据库池和一个 FileChannel,每次只允许一个任务在途。
scheduler 负责安排任务开始,worker 执行真正的同步 JDBC。两者分开后,任务执行不会占住定时器线程,但这个结构仍然需要容量、拒绝和关闭规则;创建两个执行器本身不提供可靠任务系统。
flowchart LR
Start[接受提交] --> Scheduler[有限调度器]
Scheduler --> Worker[JDBC worker]
Worker --> DB[事务与唯一业务键]
Stop[开始停止] --> Reject[拒绝新提交]
Reject --> Drain[等待已接受任务]
Drain --> Close[关闭池与文件]
数据库功能默认关闭时,持有者不会为普通 health 请求创建数据库池。资源初始化失败时也要关闭已经创建的部分;只有完全初始化后才注册依赖这些资源的清理逻辑。
初始化和销毁构成同一个所有权问题:谁创建资源,谁记录它是否已创建,并在错误和正常退出路径都尝试关闭。把清理散落在某个 HTTP controller 的 finally 中,会遗漏没有走到该 controller 的应用停止路径。
stop hook 逆序执行,并等待每个返回值
固定 Play 3.0.6 源码提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6 的 ApplicationLifecycle.scala 使用双端队列 push 注册 hook,再逐项 poll 执行。因此后注册的 hook 先运行。
执行过程把同步调用 hook 的异常转换为失败 Future,并对返回 Future 的失败记录日志后继续。下一个 hook 接在前一个的完成之后;一个永不完成的 Stage 仍可能阻止后续 hook,异常会被记录不等于无限等待会自动解除。
实验的资源 hook 按下面顺序注册:最终关闭、异步失败、同步抛出、停止调度与等待在途任务。运行顺序则是等待在途任务、同步失败、异步失败、最终关闭。构造器还注册停止标记 hook,覆盖资源从未初始化的情况。
下面的完整 Java 类只演示注册与观察,不创建任何外部资源。把它注册到应用的 ApplicationLifecycle 后,停止应用可读取事件列表;它本身不负责发起应用关闭。
1 | |
该类只表达顺序,真实资源验证由 ManagedDatabaseJob 执行。它的 drain hook 返回真实等待结果,最终关闭 hook 则在池关闭失败时仍进入文件关闭的 finally。不能把记录一个 cleanup 字符串当成资源已关闭。
停止标记与提交必须共享边界
只在最后关闭 scheduler 会留下竞态:新请求先把 inFlight 从 0 改为 1,随后 scheduler 拒绝 schedule,计数却没有恢复。此时没有任务真正开始,状态却永久显示一个在途任务。
实现让 start 与 beginStop 共享 synchronized 边界。停止开始先设置 stopping,再等待旧任务;新提交在访问或创建资源之前检查这个标记。已经跨过接收边界的任务属于等待范围,后来的任务得到拒绝。
1 | |
除了 schedule 本身,scheduler 向 worker 提交任务也可能失败。实现分别处理两处拒绝,恢复 inFlight 并释放开始等待的门闩,避免把提交失败伪装成长期运行中的业务任务。
Java 回归测试先接受一个 700 ms 的真实 SQL 任务,再并发停止应用。观察到 stopping 后,新提交被拒绝;应用停止后再次提交也被拒绝。旧任务最终状态为 committed-inserted-1,inFlight 回到 0。
controller 将调度拒绝映射为 503 DB_STOPPING,但上述竞争由 Java 持有者的直接调用测试。它没有证明已经关闭监听端口的服务器还能接收 HTTP 请求,更不能把连接拒绝与应用返回 503 混为一谈。
在途数据库任务遇到真实 SIGTERM
HTTP 驱动通过 /db/background/start?key=...&millis=1500 提交任务,并使用独立 PostgreSQL 查询确认 PgSleep 正在执行,然后才向该 stage 进程发送 SIGTERM。睡眠范围限制为 0–2000 ms,所有等待均有期限。
实际日志顺序如下,业务键每次运行都独立生成:
1 | |
应用进程随后退出 143,新数据库连接查到该业务键对应的一条已提交记录。事务成功不是由 shutdown 日志推测出来的;外部查询补上了数据库终态。
这个结果只覆盖本次协作停止和有限 SQL。SIGKILL、机器掉电、数据库断网或超过停止预算的任务没有同样的保证;stop hook 无法在进程已经被强制终止后继续执行。需要持久任务恢复的业务,应另外设计任务记录、重试和幂等协议。
清理失败仍应继续释放其他资源
close(scheduler); close(worker); 放在同一个 finally 中仍然不够。第一步在被中断时可能抛出异常,后面的 worker 关闭就被跳过。池和文件也存在相同的串联失败风险。
实验对每层独立资源使用嵌套 finally:关闭 scheduler 失败仍尝试关闭 worker,关闭池失败仍尝试关闭 file。主操作已经失败时,把清理错误作为 suppressed 保留;临时清除中断以执行必要清理后恢复中断位,避免无声吞掉停止信号。
第 22 篇的实际中断场景还执行了 SELECT 1 后再触发清理中断,观察到主异常保留、suppressed=1、中断位为 true、池关闭和执行器终止。它验证了累计实现采用的清理结构,不能据此声称所有驱动都能在任意中断状态下立即关闭。
停止路径的等待驱动使用独立线程,不能让它排队到正在被等待、已经堵住的工作池内。否则任务和关闭过程可能互相等待,有限停止预算也难以按设计兑现。
两个实例重复调度同一业务键
另一个场景启动两个真实 stage 进程,各自拥有 scheduler、worker 和连接池,提交相同 business_key。数据库表以 business_key 为主键,事务使用 INSERT ... ON CONFLICT DO NOTHING。
两个任务都执行到了数据库,一次记录 committed-inserted-1,另一次记录 committed-inserted-0,最终只有一行。进程内的单例只限制一个应用实例,跨进程的重复效果由数据库唯一约束处理。
这组结果证明了合成写入的幂等效果,没有证明任务只执行一次。两边都消耗了线程和 SQL 时间;若在插入前发送邮件或调用外部支付接口,唯一键不能撤销已经发生的外部副作用。
同样,使用 Actor 或定时器不会自动保存业务任务。崩溃后是否重投、重试是否有界、任务何时算完成,需要持久化状态和恢复协议;本实验没有实现 Actor Persistence 或 exactly-once 调度。
复现和检查终态
从仓库根目录配置 JDK 21 与专用 PostgreSQL 17.6。驱动 42.7.5、HikariCP 5.0.1 来自实际 stage jar;公开合成口令和 loopback 地址只服务于本地实验。
1 | |
脚本只终止自己启动并记录 PID 的应用进程。实验结束后,资源持有者、进程退出状态和新连接数据库查询都需要检查;数据库容器的销毁属于建立该 fixture 的流程,不应使用会影响其他容器的批量停止命令。
examples/play-lab/evidence/batch22-25/isolated/http-04/database-main.jsonl 保留真实 SIGTERM 顺序,background-first.jsonl 和 background-second.jsonl 保留双实例终态。summary.json 的 sigterm 与 twoBackgroundInstances 提供对应数据库行数。
历史隔离38项JUnit及37次HTTP观察保留;共享累计工程数据库启用后46项JUnit、stage和37次HTTP观察通过。停止竞态在shared-junit/TEST-DatabaseLabTest.xml,SIGTERM143、drain与清理顺序、独立数据库终态在shared-http/。关闭DB时原有39项实际执行、7个数据库方法跳过;接口产生重复通知的原始53条XML记录按方法归并,不能把Passed46当作46个无数据库实验。单一测试通过不能替代进程与SQL终态。
两个改动练习
- 把最终关闭 hook 的注册位置移到 drain 后面,在独立合成 fixture 观察逆序执行如何改变关闭时点。恢复正确顺序后,断言在途任务终态、池关闭和文件关闭,不只断言 stop 返回。
- 让两个实例在数据库插入前分别增加一个独立的尝试计数,再提交相同业务键。验收尝试次数为两次而业务行数为一,并据此说明需要外部副作用时还缺少哪些原子性约束。
上一篇:Evolutions 与模式升级 · 下一篇:认证与授权 · 系列起点:最小应用
