一段升级脚本先创建表,再向不存在的表插入。自动提交开启时,第一张表留在数据库中,play_evolutions 留下一条 applying_up 记录;自动提交关闭时,这次实验的表创建和版本记录都回滚了。

应用启动失败只是共同的表面结果。下一步应先检查实际 schema 和迁移元数据,确定哪些语句已生效,再决定修复方案。把版本记录直接改成成功,无法补上缺失的数据或撤销已经执行的 DDL。

脚本版本、元数据和数据库事务

Evolutions 按版本组织 up/down 脚本,比较待应用脚本与数据库中的迁移记录,再执行差异。Play 的机制负责版本与执行流程;DDL 是否能够回滚、语句会取得哪些锁,仍由具体数据库及语句决定。

实验固定 Play 3.0.6,源码 SHA 为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。入口是 ApplicationEvolutions.scala 的启动应用逻辑,以及 Evolutions.scala 中的脚本计算和执行。

flowchart LR
    Files[版本脚本] --> Diff[比较版本与 hash]
    Meta[play_evolutions] --> Diff
    Diff --> Policy[运行模式与应用开关]
    Policy --> Lock[可选启动锁]
    Lock --> SQL[执行迁移 SQL]
    SQL --> State[更新执行状态]
    SQL --> Failure[失败后的 DDL 与元数据]

这一流程有两种测试入口。EvolutionLab 直接调用 Java API,在不同随机 schema 中观察 SQL 与元数据;database_checks.py 启动真实生产 stage 进程,验证生产开关、重新启动和两个启动者。直接 API 成功不证明生产启动默认允许执行迁移。

累计构建新增依赖符号为 evolutions,不是 jdbcEvolutions。实际 stage 使用 PostgreSQL JDBC 42.7.5、HikariCP 5.0.1,数据库为 PostgreSQL 17.6;这些由 jar manifest 和服务器查询确认,没有把依赖声明当作最终解析结果。

先扩展字段,再回填和收紧

revision 1 创建 items 表并插入一条旧数据。revision 2 添加可空 label,把已有 name 回填到 label,再添加 NOT NULL 约束。

1
2
3
4
5
6
7
# --- !Ups
ALTER TABLE items ADD COLUMN label text;
UPDATE items SET label = name;
ALTER TABLE items ALTER COLUMN label SET NOT NULL;

# --- !Downs
ALTER TABLE items DROP COLUMN label;

# --- !Ups 和 # --- !Downs 是 Evolutions 文件分段标记,上面的整段应放在迁移文件中,不应原样交给 psql。当旧数据存在时,先直接添加无默认值的 NOT NULL 列不能完成同样的升级过程。

本实验把三步放进同一 revision,目的是观察模式和数据终态。真实滚动部署可能需要跨版本完成扩展、双写或兼容读取、回填、最后收紧;旧进程仍写入空值时,提前收紧约束会使旧写路径失败。

检查点 迁移记录 业务数据或约束
空 schema 应用 revision 1 一条 applied 1, old
同脚本再次启动 仍为一条 applied 没有再次插入
增加 revision 2 两条 applied 1, old, old
查询 information_schema revision 2 已应用 label 的 is_nullable 为 NO

重启不重复执行的结论来自相同脚本和相同数据库状态。修改已经应用的旧脚本会影响差异计算,不能把“版本号没变”当成忽略内容变化的保证。

下面的完整 Java 类通过内存脚本演示两个 revision。调用者提供已存在的空 schema 和所拥有的 Database;类校验 schema 标识符再拼入 DDL,并把元数据 schema 显式传给 API。它不关闭调用者持有的池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.List;
import java.util.Map;
import play.db.Database;
import play.db.evolutions.Evolution;
import play.db.evolutions.Evolutions;

public final class SchemaUpgrade {
public static void apply(Database database, String schema) {
if (!schema.matches("[a-z][a-z0-9_]{0,40}")) {
throw new IllegalArgumentException("invalid schema name");
}
Evolution first = new Evolution(1,
"CREATE TABLE " + schema
+ ".items(id integer PRIMARY KEY, name text);"
+ "INSERT INTO " + schema
+ ".items VALUES(1,'old');",
"DROP TABLE " + schema + ".items;");
Evolution second = new Evolution(2,
"ALTER TABLE " + schema
+ ".items ADD COLUMN label text;"
+ "UPDATE " + schema + ".items SET label=name;"
+ "ALTER TABLE " + schema
+ ".items ALTER COLUMN label SET NOT NULL;",
"ALTER TABLE " + schema + ".items DROP COLUMN label;");
Evolutions.applyEvolutions(database,
Evolutions.fromMap(Map.of(database.getName(),
List.of(first, second))), false, schema);
}
}

这里的 false 是 applyEvolutions 的 autocommit 参数。它只控制这次 API 调用,不能代替生产应用的 autoApply 配置,也没有自动取得应用启动流程中的分布式锁。测试 API 和启动流程要分开验证。

失败脚本留下了什么

失败实验分别创建新 schema,执行 CREATE TABLE partial(...) 后访问 missing 表。两次都会抛出 InconsistentDatabase,但数据库终态不同。

autocommit partial 表 play_evolutions 行数 记录状态
true 存在 1 applying_up
false 不存在 0 空

这些数据来自 PostgreSQL 17.6 和本次两条语句,不能泛化为所有数据库、所有 DDL 都能整体回滚。迁移元数据表本身可以存在而没有记录,因此“metadataRows=0”也不等于整个 schema 不存在任何迁移设施。

故障恢复应同时保存失败日志、脚本内容与 hash、实际表结构、版本表状态。只有确认数据库已经处于目标状态,才讨论如何使迁移状态与之对齐;仅清空版本表可能导致已有 DDL 被再次执行。

生产启动必须显式决定是否应用

固定版本的 reference.conf 默认 autoApply=false、useLocks=false、autocommit=true。这些值控制不同问题,不能因打开其中一个而推定其他能力同时生效。

1
2
3
4
5
6
7
play.evolutions {
autoApply = true
useLocks = true
autocommit = false
}
db.default.hikaricp.maximumPoolSize = 2
db.default.hikaricp.minimumIdle = 2

这是实验的关键开关片段,完整配置还需要显式 db.default、JDBC URL、迁移目录与 schema。真实生产 stage 使用默认 autoApply 时退出码为 255,应用表数为 0;显式打开后,空库应用成功,再次启动维持一条 applied,追加 revision 2 后两条记录均为 applied。

在生产环境,执行迁移的身份、窗口和失败恢复流程应提前确定。将 autoApply 开启只提供了启动时执行的权限,不会评估一条 ALTER TABLE 对在线流量的锁等待或复制延迟。

两个启动者与锁表初建竞态

ApplicationEvolutions.withLock 借连接并关闭自动提交,检查锁表,取得锁行的 FOR UPDATE NOWAIT 锁,再运行升级逻辑。当前实现的 attempts 初值为 5,失败后每次等待 1 秒并减一,减到 0 的尝试再失败才抛出,因此最多有首次加五次重试。这些是有限重试,不能承诺所有并发启动都最终成功。

迁移执行本身还需要连接,因此实验把池至少设为 2。若只有一个连接被锁流程占用,第二次借用就可能等待自身持有的资源;“池越小越容易控制并发”在这里需要考虑嵌套借用。

版本迁移、锁行与失败状态

两个 stage 进程各有独立端口、PID 文件和日志,使用相同数据库 schema、useLocks=true。迁移中加入有限 pg_sleep(0.6) 扩大交叠窗口,分别测锁表不存在和预建锁表两种情况。

初始条件 两个进程进入 ready 最终版本与业务行数
锁表尚不存在 true、false 一条 applied、一条业务行
锁表及锁行已预建 true、true 一条 applied、一条业务行

首次创建锁表的失败进程记录了 PostgreSQL pg_type_typname_nsp_index 唯一冲突,涉及 play_evolutions_lock 的并发创建。行锁必须在锁表存在后才能生效,不能反向保护创建锁表这一步。

已有锁表时两个进程本次均启动成功,但没有重复应用业务脚本。两组成功进程由测试驱动发送 SIGTERM 后退出 143;初建失败进程退出 255。这是一次有限并发实验的真实差异,既不能承诺首次并发创建必失败,也不能声称 useLocks 覆盖所有初始化竞态。

运行与原始记录

从仓库根目录使用 JDK 21;容器名和端口替换为第 22 篇建立的专用合成数据库。脚本会生成独立 schema 与临时迁移配置,真实启动多个 stage 进程并保存其退出状态。

1
2
3
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
PLAY_LAB_DB_ENABLED=true PLAY_LAB_JDBC_URL='jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' bash examples/play-lab/sbtw test stage
python3 examples/play-lab/lab/database_checks.py --container play-db-local --jdbc-url 'jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' --evidence examples/play-lab/evidence/local-evolution-run

隔离记录位于 examples/play-lab/evidence/batch22-25/isolated/http-04/。summary.json 包含 evolutions、productionDefault、migrationRevisionTwo 和 lockRaces;production-default-rejected.log、lock-initial-b.log、lock-existing-a.log 与 lock-existing-b.log 保留对应进程输出。

历史隔离整批38项JUnit、37次HTTP观察保留在isolated;共享累计工程数据库启用后46项JUnit及stage通过,37次真实HTTP观察保留在shared-http/。生产迁移另有退出码和PostgreSQL查询:默认autoApply拒绝启动为255、应用表0;新建锁表竞态本轮仍是一端255、一端143,预建锁表后两端143、元数据和业务写入各1。不同轮次谁先成功可变化,不能把所有终态压缩成接口200。

两个改动练习

  1. 在 revision 1 中加入一条 name 为 null 的旧数据,直接执行现有 revision 2。保存失败后的 label 数据、约束和版本状态,再为回填制定明确的空值规则;不要把删除旧数据当成默认修复。
  2. 将两个启动者的迁移 SQL 延长到有限的 8 秒,保留独立观察连接和超时。比较初建锁表与预建锁表的结果,记录重试耗尽或成功的实际日志,不预设两者都会启动。

上一篇:事务与幂等 · 下一篇:生命周期与后台任务 · 系列起点:最小应用