本节目标:让
libraryd的每次发布都能回答三个问题:配置从哪来、数据库改了什么、出错后怎么退回去。
适用版本:JDK 25(Temurin 25.0.5)、PostgreSQL 17、Flyway 13.11.1、Docker 29.x。
承接的问题
16.2 节做出来的镜像能在本机启动,/health 返回 UP。但发布当天真正会出问题的是另一批东西:
- 生产数据库的密码放在 Secret 里,环境变量名写错一个字母,应用启动就连不上库。
- 新版本需要给
books加一列,旧版本还在跑,直接alter table ... set not null会让旧版本的插入失败。 - 发布后发现 5xx 升高,想回滚镜像,但数据库已经执行了删列迁移,旧镜像也起不来。
- 容器进程还在,
/health已经 503,负载均衡器却还在转发流量。 - 连接池每个实例 10 个连接,扩到 20 个副本后 PostgreSQL 的
max_connections被打满。
这些问题不是“代码 bug”,而是发布流程没有把配置、迁移和流量当成一个整体。本节按这个顺序拆开。
核心机制一:配置只从环境变量来
libraryd 的配置入口是 AppConfig,一个不可变 record:
public record AppConfig(
String dbUrl,
String dbUser,
String dbPassword,
int httpPort,
int poolSize,
String logLevel) {
public AppConfig {
if (dbUrl == null || dbUrl.isBlank()) {
throw new IllegalArgumentException("dbUrl must not be blank");
}
if (dbUser == null || dbUser.isBlank()) {
throw new IllegalArgumentException("dbUser must not be blank");
}
if (dbPassword == null) {
throw new IllegalArgumentException("dbPassword must not be null");
}
if (httpPort < 0 || httpPort > 65_535) {
throw new IllegalArgumentException("httpPort must be between 0 and 65535");
}
if (poolSize < 1 || poolSize > 100) {
throw new IllegalArgumentException("poolSize must be between 1 and 100");
}
if (logLevel == null || logLevel.isBlank()) {
throw new IllegalArgumentException("logLevel must not be blank");
}
}
}
环境变量到字段的映射:
| 变量 | 默认值 | 说明 |
|---|---|---|
LIBRARYD_DB_URL | jdbc:postgresql://localhost:5432/libraryd | JDBC URL,包含库名和参数 |
LIBRARYD_DB_USER | libraryd | 数据库用户 |
LIBRARYD_DB_PASSWORD | libraryd | 数据库密码,生产必须覆盖 |
LIBRARYD_HTTP_PORT | 8080 | HTTP 端口,0 表示随机 |
LIBRARYD_POOL_SIZE | 10 | HikariCP 每个实例的连接数 |
LIBRARYD_LOG_LEVEL | INFO | Logback 根日志级别 |
另外还有 LIBRARYD_REDIS_URL,默认 redis://127.0.0.1:6379。Redis 不可用时 RedisBookCache 会降级到本地 Caffeine,不会阻止启动;数据库不可用则直接启动失败。这个区别要在发布时明确:Redis 是可选依赖,PostgreSQL 是必需依赖。
AppConfig.fromEnvironment() 读取环境变量并做类型转换:
public static AppConfig fromEnvironment() {
Map<String, String> env = System.getenv();
return new AppConfig(
env.getOrDefault("LIBRARYD_DB_URL", "jdbc:postgresql://localhost:5432/libraryd"),
env.getOrDefault("LIBRARYD_DB_USER", "libraryd"),
env.getOrDefault("LIBRARYD_DB_PASSWORD", "libraryd"),
intValue(env, "LIBRARYD_HTTP_PORT", 8080),
intValue(env, "LIBRARYD_POOL_SIZE", 10),
env.getOrDefault("LIBRARYD_LOG_LEVEL", "INFO"));
}
校验失败会在启动时立刻抛出。本机把 LIBRARYD_POOL_SIZE 设为 0,真实输出:
Exception in thread "main" java.lang.IllegalArgumentException: poolSize must be between 1 and 100
at com.plume.library.AppConfig.<init>(AppConfig.java:27)
at com.plume.library.AppConfig.fromEnvironment(AppConfig.java:42)
at com.plume.library.AppMain.main(AppMain.java:14)
这是“快速失败”,不是“优雅失败”。AppMain.main 里的 try 从 LibraryApplication.start(config) 开始,AppConfig.fromEnvironment() 在 try 外面,所以配置错误会直接抛栈,没有 libraryd failed to start 那一行日志。这个行为对本地开发够用,对生产不够友好:运维看到的是 Java 栈,不是一条可读的错误。
更稳妥的写法是把配置解析也包进启动保护:
public static void main(String[] args) {
AppConfig config;
try {
config = AppConfig.fromEnvironment();
} catch (IllegalArgumentException failure) {
System.err.println("invalid configuration: " + failure.getMessage());
System.exit(2);
return;
}
// ... 继续启动
}
退出码 2 表示“配置错误”,和数据库连不上(1)区分开,编排系统可以用不同的告警规则。
配置的三条规则:
- 镜像里不放密码。 镜像层对任何能拉镜像的人可见。密码用运行时
-e、Kubernetes Secret 或挂载文件注入。 - 默认值只用于本地。
libraryd的默认密码也是libraryd,生产必须覆盖。启动日志里不要打印密码;LibraryApplication打印的是dbUrl,不是密码。 - 配置变更也要走发布。 改环境变量等于改运行行为,应该和镜像版本一起记录,不要在生产上手工
export后重启。
核心机制二:迁移只前进
libraryd 在启动时执行 Flyway:
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.cleanDisabled(true)
.validateOnMigrate(true)
.load();
MigrateResult result = flyway.migrate();
三个配置项各有作用:
locations("classpath:db/migration"):迁移文件在libraryd-persistence的 classpath 里,打包进 jar。不要用文件系统路径,否则容器里找不到。cleanDisabled(true):禁止flyway clean。这个命令会删掉 schema 里的所有对象,生产上误执行一次就是事故。validateOnMigrate(true):每次迁移前校验已应用迁移的 checksum。历史文件被改动时直接失败,而不是带着不一致的 schema 继续跑。
校验失败的场景很具体:假设 V1__init.sql 已经应用,后来有人“只是加了一行注释”:
-- changed after release
重新构建并启动,真实输出:
org.flywaydb.core.api.exception.FlywayValidateException: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 1
-> Applied to database : -655564429
-> Resolved locally : -353981913
Either revert the changes to the migration, or run repair to update the schema history.
Flyway 记录的是文件内容的 checksum,不是“语义差异”。加注释、改空格、调整 SQL 顺序都会改变 checksum。正确做法是:
- 已经应用的迁移文件永远不改。
- 需要变更就新增
V3__...sql、V4__...sql。 - 只有在确认数据库实际结构与新 checksum 对应时,才用
flyway repair更新历史表;repair不会改数据库结构,只是改历史记录,误用会掩盖真正的不一致。
本机的迁移历史表:
version | description | success
---------+-------------------+---------
1 | init | t
2 | book search index | t
success=t 表示迁移成功。如果某次迁移失败,Flyway 会留下一条 success=f 的记录,下一次启动会要求先修复。不要手工删这条记录,先看失败原因。
多副本同时启动时,Flyway 会用数据库锁保证只有一个实例执行迁移,其他实例等待。这个行为默认可用,但等待时间受连接超时限制。副本很多时,建议把迁移拆成独立的 Job/Init Container,应用只做 validate,避免每个副本都参与迁移竞争。
核心机制三:扩展-收缩,而不是直接改列
发布期间最常见的事故是“新旧版本同时存在”。滚动发布、蓝绿发布、灰度发布都会有一段时间两个版本同时连同一个数据库。任何破坏性 schema 变更都会让旧版本立刻失败。
以把 books.title 改名为 display_title 为例,直接做:
alter table books rename column title to display_title;
旧版本的 SQL 还在查 title,下一次请求就报:
ERROR: column "title" does not exist
正确做法是分三步,每一步都保证新旧版本都能工作。
第一步:扩展(expand)。 新增列,允许为空,旧版本完全不受影响:
-- V3__add_display_title.sql
alter table books add column display_title varchar(200);
第二步:双写与回填。 新版本同时写 title 和 display_title,读仍然用 title;后台分批把旧数据补上:
with batch as (
select isbn
from books
where display_title is null
order by isbn
limit 1000
)
update books b
set display_title = b2.title
from batch b2
where b.isbn = b2.isbn;
分批回填是为了避免一次 update 锁住整张表。每批 1000 行,提交后再跑下一批,直到 display_title is null 的行为 0。回填期间新旧版本都能读写。
第三步:切换读路径。 新版本改为读 display_title,但仍然双写 title。这时旧版本还在读 title,新版本读新列,两边都能工作。
第四步:收缩(contract)。 确认旧版本全部下线后,再收紧约束、删除旧列:
-- V4__contract_title.sql
alter table books alter column display_title set not null;
alter table books drop column title;
set not null 和 drop column 都是破坏性操作,只能在旧版本完全停止后执行。如果还有旧实例在跑,它的插入会因为缺少 title 失败,或者读到不存在的列。
这个过程的兼容矩阵:
| 阶段 | 旧版本 | 新版本 | schema |
|---|---|---|---|
| 扩展后 | 读写 title | 读写 title,忽略新列 | 两列都在,新列可空 |
| 双写后 | 读写 title | 写两列,读 title | 两列都有值 |
| 切换读后 | 读写 title | 写两列,读 display_title | 两列都有值 |
| 收缩后 | 已下线 | 读写 display_title | 只剩新列 |
不是所有变更都需要四步。加一个可空列、加一个索引、加一个不做外键校验的枚举值,通常一步就够。判断标准是:旧版本的 SQL 在新 schema 上还能不能执行。 能,就是兼容变更;不能,就要扩展-收缩。
核心机制四:灰度发布与回滚判据
发布不是“一次切 100%”,而是“先让一小部分流量验证,再逐步扩大”。libraryd 是无状态 HTTP 服务,灰度可以在负载均衡器、服务网格或 Kubernetes Service 上做,应用本身不需要改代码。
一个可操作的节奏:
| 阶段 | 流量 | 观察时间 | 通过条件 |
|---|---|---|---|
| 金丝雀 | 5% | 10 分钟 | 5xx 不高于基线,/health 全 UP |
| 小流量 | 25% | 15 分钟 | p95 延迟不超过基线 1.5 倍 |
| 半量 | 50% | 15 分钟 | 错误码分布无新异常,连接池无等待 |
| 全量 | 100% | 持续 | 旧版本保留至少一个发布窗口 |
观察的指标要提前定好,不要发布时临时想。libraryd 已经暴露了 /metrics,至少看这些:
libraryd_http_server_requests_seconds的 5xx 比例和 p95。/health的checks.db是否为 UP。- HikariCP 日志里的连接等待、超时和池耗尽告警。
libraryd_loans_overdue是否异常波动(扫描器指标)。- 日志里
E_DB_UNAVAILABLE、E_INTERNAL的出现频率。
回滚判据也要提前写死,例如:
- 5xx 比例连续 5 分钟超过 1%。
/health连续 3 次返回 503。- p95 延迟连续 10 分钟超过基线的 2 倍。
- 连接池等待线程持续大于 0。
- 出现新的错误码,且集中在同一个接口。
满足任意一条就回滚,不要“再观察一下”。灰度窗口的价值是限制爆炸半径,不是给侥幸心理留时间。
回滚分两层:
- 应用回滚:把流量切回上一个镜像 digest/tag。数据库保持扩展后的 schema,旧版本仍然兼容。这是最快、最安全的回滚。
- 数据库回滚:不推荐。Flyway 社区版没有
undo,迁移是单向的。删列、改类型之后,想恢复只能写新的前向迁移,或者从备份恢复。备份恢复会丢失发布后的写入,通常比“前向修复”代价更大。
所以发布前要确认:
- 上一个版本的镜像还在 registry 里,tag 不是
latest。 - 上一个版本的环境变量配置还在 Secret/配置中心里。
- 本次迁移是扩展性的,旧版本能在新 schema 上运行。
- 数据库有发布前的备份,且恢复流程演练过。
应用回滚的命令可以很简单:
docker stop libraryd-current
docker run -d --name libraryd-current \
-p 8080:8080 \
-e LIBRARYD_DB_URL="$LIBRARYD_DB_URL" \
-e LIBRARYD_DB_USER="$LIBRARYD_DB_USER" \
-e LIBRARYD_DB_PASSWORD="$LIBRARYD_DB_PASSWORD" \
registry.example.com/libraryd:1.0.0
前提是 1.0.0 的镜像还在,且它的 schema 兼容性没有被破坏。用 latest 做回滚目标,等于把“回滚到哪个版本”交给 registry 的当前状态,不可靠。
在 libraryd 里落地:一次发布检查清单
把上面的机制收成一份发布清单:
- 构建:
mvn -B -ntp verify通过,33 个测试全绿,fat jar 生成。 - 镜像:用 git commit SHA 打 tag,例如
libraryd:git-abc1234,同时保留上一个版本的 digest。 - 迁移:确认新增的是
V3__...sql,没有修改V1/V2;在预发环境跑一次,检查flyway_schema_history。 - 配置:核对六个环境变量;
LIBRARYD_POOL_SIZE乘以副本数不超过 PostgreSQLmax_connections的安全范围。 - 启动:观察日志里的
libraryd started on port ...和 Flyway 的Successfully applied N migrations。 - 探活:
/health返回 200 且checks.db为 UP;容器健康检查为healthy。 - 灰度:5% → 25% → 50% → 100%,每阶段看 5xx、p95、连接池和日志。
- 回滚准备:上一个镜像、上一份配置、发布前备份都已确认可用。
启动日志的关键行:
Flyway ... Successfully applied 2 migrations to schema "public", now at version v2
Javalin started in 176ms
libraryd started on port 18081 (db=jdbc:postgresql://127.0.0.1:55436/libraryd, poolSize=5)
libraryd is ready: http://localhost:18081
数据库不可用时,/health 的真实响应:
HTTP/1.1 503 Service Unavailable
Content-Type: application/json
X-Request-Id: 5856f939-50da-4aeb-9562-719530177159
{"status":"DOWN","checks":{"db":"DOWN"}}
注意响应头仍然带 X-Request-Id。这让客户端报错日志能和服务端日志对上,即使服务处于降级状态。数据库恢复后,不需要重启应用,HikariCP 会重新建立连接,/health 回到 200:
{"status":"UP","checks":{"db":"UP"}}
容器健康检查也会从 unhealthy 回到 healthy。这说明探活检查的是实时依赖,不是启动时的一次性状态。
常见错误与边界
错误一:修改已经应用的迁移文件。 本地想“顺手修一下 V1 的列名”,CI 构建通过,生产启动直接 FlywayValidateException。已经应用的迁移是历史,不是源码;要改就加新版本。
错误二:用 flyway repair 掩盖结构不一致。 repair 只更新 flyway_schema_history 的 checksum,不会让数据库结构变成新 SQL 的样子。如果数据库实际缺列,repair 之后应用会在运行时报 column does not exist。只有在确认结构一致、只是历史记录需要修正时才用。
错误三:回滚镜像时不考虑 schema。 新版本执行了 drop column title,回滚到旧版本后旧版本查 title 直接失败。破坏性迁移之后,应用回滚不等于系统回滚;要么前向修复,要么从备份恢复。
错误四:连接池总数超过数据库上限。 每个实例 LIBRARYD_POOL_SIZE=10,20 个副本就是 200 个连接。PostgreSQL 默认 max_connections=100,其中还要留给超级用户和运维连接。扩副本前先算总连接数,或者引入 PgBouncer。
错误五:把 latest 当发布版本。 回滚时 latest 已经指向新版本,无法回到旧版本。每次构建用 commit SHA 或语义版本打唯一 tag,latest 只作为便利别名,不作为回滚依据。
错误六:配置错误只打印 Java 栈。 前面看到的 IllegalArgumentException 对开发有用,对运维不友好。把配置解析包进启动保护,输出一条可读错误和明确的退出码。
边界:迁移与副本同时启动。 Flyway 的锁能避免重复执行,但多个副本同时等锁会拉长启动时间。副本多时把迁移放到独立的 Init Container/Job,应用启动时只 validate,失败就退出。这样迁移失败不会表现为“某个副本启动慢”。
边界:灰度期间的缓存。 libraryd 有 Caffeine 和 Redis 两层缓存。新旧版本可能对同一个 key 有不同的序列化格式。发布前确认缓存 key 带版本前缀,或者接受短暂的回源;不要假设“缓存里只有一种格式”。
边界:日志级别与敏感信息。 排查问题时把 LIBRARYD_LOG_LEVEL 调到 DEBUG,可能把 SQL 参数、请求体打进日志。发布后记得调回 INFO,并确认日志里没有密码、token、完整连接串。
小结
- 配置只从环境变量来,
AppConfig在启动时校验;配置错误要快速失败,并给出可读错误和明确退出码。 - Flyway 迁移只前进,
validateOnMigrate会拦住被修改的历史文件;已应用的迁移永不修改。 - 破坏性 schema 变更用扩展-收缩:先加可空列,再双写和回填,切换读路径,最后收紧约束和删旧列。
- 灰度按 5% → 25% → 50% → 100% 推进,回滚判据提前写死,满足任意一条就回滚。
- 应用回滚靠上一个镜像和兼容 schema;数据库回滚不靠 Flyway undo,靠备份或前向修复。
/health返回 503 时仍然带X-Request-Id,数据库恢复后无需重启即可回到 UP。
到这里,libraryd 从多模块代码、PostgreSQL、Javalin、连接池、事务、可观测性,一路走到可测试、可打包、可容器化、可发布的版本。卷三会拆开并发原语和语言机制,卷四会用同一套服务做 JVM 观测与调优。下一节进入附录,把 Maven 配置和依赖选择单独整理成可查阅的参考。
阅读导航:上一节:16.2 容器化:多阶段 Dockerfile 与 Jib · 下一节:附录 A Maven 配置速查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。