《Java 编程实战》16.3 发布与回滚:配置、迁移与灰度

镜像能启动不等于能安全发布。本节用 libraryd 的真实启动失败、Flyway checksum 冲突和 /health 503 输出,讲清环境变量配置、只前进的迁移、扩展-收缩改造,以及灰度发布、回滚判据和连接池容量的具体计算方法等。

本节目标:让 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_URLjdbc:postgresql://localhost:5432/librarydJDBC URL,包含库名和参数
LIBRARYD_DB_USERlibraryd数据库用户
LIBRARYD_DB_PASSWORDlibraryd数据库密码,生产必须覆盖
LIBRARYD_HTTP_PORT8080HTTP 端口,0 表示随机
LIBRARYD_POOL_SIZE10HikariCP 每个实例的连接数
LIBRARYD_LOG_LEVELINFOLogback 根日志级别

另外还有 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 里落地:一次发布检查清单

把上面的机制收成一份发布清单:

  1. 构建:mvn -B -ntp verify 通过,33 个测试全绿,fat jar 生成。
  2. 镜像:用 git commit SHA 打 tag,例如 libraryd:git-abc1234,同时保留上一个版本的 digest。
  3. 迁移:确认新增的是 V3__...sql,没有修改 V1/V2;在预发环境跑一次,检查 flyway_schema_history。
  4. 配置:核对六个环境变量;LIBRARYD_POOL_SIZE 乘以副本数不超过 PostgreSQL max_connections 的安全范围。
  5. 启动:观察日志里的 libraryd started on port ... 和 Flyway 的 Successfully applied N migrations。
  6. 探活:/health 返回 200 且 checks.db 为 UP;容器健康检查为 healthy。
  7. 灰度:5% → 25% → 50% → 100%,每阶段看 5xx、p95、连接池和日志。
  8. 回滚准备:上一个镜像、上一份配置、发布前备份都已确认可用。

启动日志的关键行:

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 配置速查 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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