bigywater.tech
Java · 384

发布回滚测试:确认旧版本真的可恢复

回滚测试要确认的不是“能不能再部署旧版本”,而是旧版本能不能正确读写新版本留下的数据。这篇用真实数据库和 Java 序列化量出:哪些迁移旧代码扛得住,回滚迁移丢什么,以及滚动窗口里两个版本同时写会发生什么。

8 种迁移
其中改名、删列、NOT NULL 无默认值、改类型会让 v1 的语句失败,新增可空列与 int→bigint 不会
VIP-99 → null
回滚到 v1 读出再写回,再升级回 v2 时 coupon 字段的值
100 / 200
v2 只写新列时 v1 读 name 看到 NULL 的行数(双写时 0)

旧代码 vs 新表结构

实测 · 本机执行输出
  [M1 新增可空列 email] INSERT 成功;SELECT 成功;UPDATE 成功
  [M2 新增 NOT NULL 列(表里有 2 行,无默认值)] 迁移本身失败:column "email" of relation "users" contains null values
  [M3 新增 NOT NULL 列(空表,无默认值)] INSERT 失败(null value in column "email" of relation "users" violates not-null constraint);SELECT 成功;UPDATE 成功
  [M4 新增 NOT NULL 列并带默认值] INSERT 成功;SELECT 成功;UPDATE 成功
  [M5 列改名 name → full_name] INSERT 失败(column "name" of relation "users" does not exist);SELECT 失败(column "name" does not exist);UPDATE 成功
  [M6 删除列 age] INSERT 失败(column "age" of relation "users" does not exist);SELECT 失败(column "age" does not exist);UPDATE 失败(column "age" does not exist)
  [M7 age 改成 text] INSERT 成功;SELECT 成功;UPDATE 失败(operator does not exist: text + integer)
  [M8 age 从 int 改成 bigint] INSERT 成功;SELECT 成功;UPDATE 成功

回滚迁移的数据损失

实测 · 本机执行输出
  up:status(文本)→ status_code(1 = active,2 = inactive,其它 = 0)并删除 status 列
  down 之后表结构与迁移前相同(列 id/name/age/status),但数据校验和 不一致(前 135f5381… / 后 3ee62a91…);status 现在是:1 active ,2 inactive ,3 unknown ,4 unknown ,5 active(原来 c = pending、d = banned)
  v2 上线后写入了 3 行 email;回滚(DROP COLUMN email)再重新升级(ADD COLUMN email)后有 email 的行:0

序列化往返

实测 · 本机执行输出
  [没有 serialVersionUID(v2 新增字段 coupon)]
    v2 写文件;v1 读:Exception in thread "main" java.io.InvalidClassException: Order; local class incompatible: stream classdesc serialVersionUID = -7164839286763380304, local class serialVersionUID = 6748852704420637489
    v1 写文件;v2 读:Exception in thread "main" java.io.InvalidClassException: Order; local class incompatible: stream classdesc serialVersionUID = 6748852704420637489, local class serialVersionUID = -7164839286763380304
  [固定 serialVersionUID = 1(v2 新增字段 coupon)]
    v2 写文件;v1 读:读到 Order{id=7, name=widget}
    v1 写文件;v2 读:读到 Order{id=7, name=widget, coupon=null}
  往返:v2 写入 coupon = VIP-99 → v2 自己读:读到 Order{id=7, name=widget, coupon=VIP-99}
  回滚到 v1,v1 读出再写回(不改任何内容)→ 再升级回 v2 读:读到 Order{id=7, name=widget, coupon=null}(v1 的类里没有 coupon,字段被静默丢弃)

滚动发布窗口

实测 · 本机执行输出
  [v2 只写新列 full_name] 共 200 行:v1 读 name 时看到 NULL 的行 100;v2 读 full_name 时看到 NULL 的行 100;回填 UPDATE ... SET full_name = name 之后 v2 看到 NULL 的行 0
  [v2 双写 name 与 full_name] 共 200 行:v1 读 name 时看到 NULL 的行 0;v2 读 full_name 时看到 NULL 的行 100;回填 UPDATE ... SET full_name = name 之后 v2 看到 NULL 的行 0

分步演示

发布回滚测试
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。真实数据库是本机 Homebrew 的 PostgreSQL 16.15(独立数据目录、端口 9501);v1 的“代码”是 3 条 SQL(用 psql 执行),序列化部分是 JDK 21.0.12 的两个版本的 Order 类
    • 没有验证:Flyway / Liquibase 的 undo 与 baseline、Kubernetes 的滚动更新与回滚命令、金丝雀与蓝绿发布、消息队列里的消息格式、真实的 v1 / v2 应用进程(这里的“应用”是 SQL 与最小的 Java 类)、PostgreSQL 之外的数据库对 DDL 的锁与回滚行为
    京ICP备2026067502号-1京公网安备11010502063487号