先给结论:你们踩的不是"expand → backfill → contract"方法论的坑,而是"contract 那一步没有安全门"的坑。 从安全加固的角度看,schema 变更和凭据轮换是同构问题——核心原则是"最小爆炸半径 + 不可逆操作必须有回滚窗口"。下面按你们的四个问题逐条给方案。
1) 兼容窗口怎么切:把"双写期"从"人肉判断"变成"机器判据"
你们的双写期 3 周,期间"任何一次误删新列都是 P0"——这就是没有安全门的典型后果。我的建议是把 contract 条件从"人觉得够了"改成可查询的硬判据:
-- 切读判据:新列非空率连续 7 天 ≥ 99.9%
SELECT
DATE(created_at) AS dt,
COUNT(*) AS total,
SUM(CASE WHEN refund_channel IS NOT NULL THEN 1 ELSE 0 END) AS filled,
ROUND(SUM(CASE WHEN refund_channel IS NOT NULL THEN 1 ELSE 0 END) / COUNT(*) * 100, 3) AS fill_pct
FROM t_order
WHERE created_at >= NOW() - INTERVAL 7 DAY
GROUP BY DATE(created_at)
ORDER BY dt;
contract 前置条件 checklist(写成 CI 卡点脚本):
- ✅
refund_channel非空率连续 7 天 ≥ 99.9%(排除 NULL 业务合法值) - ✅ 老列
refund_type的写入量在灰度全量后归零(查 binlog:SHOW BINLOG EVENTS中无refund_type的 SET) - ✅ 读侧 100% 走新列(应用配置开关
read.source=refund_channel已全量生效) - ✅ 连续 48h 无 P2+ 工单涉及该字段
- ✅ 回滚方案已验证(见第 3 点)
2) ORM 层"非空才 SET":推荐"白名单 + 注解"混合方案,别用全局配置
全局 MyBatis 配置改 updateStrategy=NOT_NULL 太粗——它会让所有表的 UPDATE 都跳过 null 字段,你迟早会踩"想置 NULL 置不了"的坑。我的做法是:
方案 A(推荐):在 MyBatis Mapper 层面用 <if> 做白名单
<!-- OrderMapper.xml -->
<update id="updateOrder" parameterType="Order">
UPDATE t_order
<set>
<if test="amount != null">amount = #{amount},</if>
<if test="status != null">status = #{status},</if>
<!-- 新列:只有显式传了值才 SET -->
<if test="refundChannel != null">refund_channel = #{refundChannel},</if>
<!-- 老列:兼容期保留双写 -->
<if test="refundType != null">refund_type = #{refundType},</if>
gmt_modified = NOW()
</set>
WHERE id = #{id} AND tenant_id = #{tenantId}
</update>
关键原则:新列在 expand 阶段的语义是"只增不改"——老 Pod 的实体里 refundChannel = null,<if test="refundChannel != null"> 天然跳过,不会覆盖新 Pod 写入的值。这就是你第 1 点"已尝试"的方案,它是对的。
方案 B(对"合法置 NULL"的补充):加一个显式标记字段
public class Order {
private String refundChannel;
private Boolean forceNullRefundChannel; // 显式标记:true 表示要置 NULL
}
<choose>
<when test="forceNullRefundChannel == true">
refund_channel = NULL,
</when>
<when test="refundChannel != null">
refund_channel = #{refundChannel},
</when>
</choose>
这样"合法置 NULL"和"未赋值"就分开了,不需要全局改策略。
3) 防止"提前 contract":用数据库触发器 + 应用层双保险
这是我最想强调的安全加固点。打个比方:这就像你拆炸弹,剪错一根线就炸了——你不能只靠"大家都记得别剪"。
保险 1:数据库触发器(硬拦截)
-- 在 backfill 期间,阻止老列被 DROP/ALTER
-- MySQL 8.0 没有 DDL 触发器,但可以用事件 + 权限控制:
-- 1) 回收普通账号的 DDL 权限
REVOKE ALTER, DROP ON your_db.t_order FROM 'app_writer'@'%';
-- 2) 只保留 DBA 角色有 DDL 权限,且 DDL 必须走审批工单
-- 3) 在 CI/CD pipeline 里加一道卡点:
-- 任何包含 "DROP COLUMN refund_type" 或 "ALTER TABLE t_order ... DROP" 的
-- migration 文件,必须附一个 "CONTRACT_APPROVED: <ticket-id>" 注释
-- 且 ticket 状态为 CLOSED
保险 2:应用层"迁移状态机"
// MigrationState.java — 放在配置中心,全服务共享
public enum MigrationState {
EXPAND, // 新列已加,双写中
BACKFILL, // 回填中
READ_CUTOVER, // 读已切到新列
CONTRACT_READY, // 满足 contract 条件,等待执行
CONTRACTED // 老列已删
}
// 在应用启动时校验:如果状态是 EXPAND/BACKFILL,
// 则 ORM 层禁止对老列做 DROP 相关的 migration
// 如果状态是 CONTRACT_READY,允许 contract 但需要二次确认
保险 3:binlog 审计
-- 开启 binlog(你们应该已开),定期跑:
-- 检查过去 24h 内是否有对 refund_type 的 DDL 操作
SELECT event_time, event_type, table_name,
SUBSTRING(info, 1, 200) AS ddl_snippet
FROM performance_schema.events_statements_history
WHERE info LIKE '%t_order%refund_type%'
AND event_type IN ('ALTER TABLE', 'DROP')
AND event_time > NOW() - INTERVAL 1 DAY;
4) 如果重做,我的 checklist(按时间线)
T-7d [准备]
□ 新列 DDL 已 review:ALTER TABLE t_order ADD COLUMN refund_channel VARCHAR(32) DEFAULT NULL;
□ MyBatis Mapper 已改为 <if> 白名单模式(新列 + 老列双写)
□ 读侧开关 read.source=refund_type(默认走老列)
□ binlog 审计脚本已部署
□ 回滚脚本已测试:ALTER TABLE t_order DROP COLUMN refund_channel;(确认 2.3 亿行可接受)
□ 迁移状态机初始化为 EXPAND
T-0 [发布]
□ 灰度 1/6 → 观察 10min(监控 refund_channel 非空率、binlog 无异常)
□ 灰度 1/3 → 观察 30min
□ 全量 6/6 → 观察 2h
□ 迁移状态机 → BACKFILL
T+1d~T+21d [回填]
□ 回填任务:UPDATE t_order SET refund_channel = f(refund_type) WHERE refund_channel IS NULL;
(分批,每批 5 万行,避免长事务锁表)
□ 每日检查非空率,记录到监控看板
□ 读侧灰度切:read.source=refund_channel(先 10% 流量 → 50% → 100%)
□ 迁移状态机 → READ_CUTOVER → CONTRACT_READY
T+21d+ [contract]
□ 跑 contract 判据脚本(见第 1 点)
□ DBA 审批 + 执行 ALTER TABLE t_order DROP COLUMN refund_type;
□ 回收老列相关的索引/视图
□ 迁移状态机 → CONTRACTED
□ 通知所有下游服务
最后补一个安全视角的"红线":
你们 6 副本 × 40 分钟灰度窗口内互相覆盖,本质上是两个版本对同一行有写权限。这和"两个运维同时用 root 改生产库"是同构风险。如果你们后续还有类似"新老逻辑共存"的场景,建议在应用层加一个行级写锁标记:
-- 在 t_order 加一列 _schema_version INT DEFAULT 1
-- 新 Pod 写入时 SET _schema_version = 2
-- 老 Pod 的 UPDATE 加条件 WHERE _schema_version <= 1
-- 这样老 Pod 天然不会覆盖新 Pod 写过的行
这不是银弹(有并发竞态),但把"概率性覆盖"变成了"确定性不覆盖",配合 binlog 审计就够了。
总结一句话:expand → backfill → contract 方法论本身没问题,问题出在每一步的"进入条件"和"退出条件"靠人脑记而不是靠机器判。 把判据写成脚本、把权限收到 DBA、把状态显式化,P2 就不会变 P0。