ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

灰度发布时如何兼容新旧表结构——在线系统中的双写、双读与事务边界

灰度发布时如何兼容新旧表结构——在线系统中的双写、双读与事务边界 文章目录每日一句正能量前言1. 背景与问题2. 环境与数据3. 复现过程3.1 复现只写新字段的问题3.2 复现读新字段过早3.3 复现双写不在同一事务4. 方案实施4.1 第一步Expand只增加兼容字段4.2 JDBC 双写4.3 多语句双写事务4.4 MyBatis 双写4.5 JPA/Hibernate 双字段兼容4.6 双写必须监控失败4.7 历史数据回填4.8 为什么回填不能覆盖非空新字段4.9 每批独立事务4.10 双读read-new-fallback-old4.11 SQL 层 fallback4.12 MyBatis 双读4.13 回退率是重要灰度指标4.14 灰度期间 V1 仍然会制造“旧写”4.15 能不能用数据库触发器双写4.16 灰度流量不要只看 HTTP 指标4.17 一致性校验 SQL4.18 切读前先做影子读4.19 回滚策略4.20 Contract最后收口4.21 事务边界双写失败必须整体回滚4.22 旧写与新写冲突4.23 ORM 缓存也要小心4.24 风险矩阵5. 结果对比不做灰度兼容做双写双读兼容6. 风险与复盘6.1 双写不是永久架构6.2 不要把双写拆成异步6.3 回填不是一次跑完就结束6.4 fallback 不能无限期存在6.5 旧字段删除前要查所有消费者6.6 灰度是数据灰度不只是流量灰度6.7 Contract 是不可逆风险最高的一步结语每日一句正能量深海不会因为一杯沸水而升温。真正的坚韧源于深邃与广阔而非坚硬的对抗。 当你的认知、胸襟与自我认知如深海般丰沛外界的偶然攻击、短暂风波便如一杯沸水倾入瞬间被包容、消融无法改变你恒定的温度与流向。前言灰度发布真正难的地方不是让新版本先跑 5% 流量而是让新旧版本在同一套数据库上同时工作。如果 V1 只认识旧字段V2 已经开始使用新字段那么灰度阶段数据库就必须承担一个特殊角色同时兼容两个应用版本。很多线上事故都发生在这个窗口。新应用本身没有 Bug数据库迁移脚本也能单独执行但两者放在灰度环境里却互相不兼容。例如V2 写了新字段V1 仍然只写旧字段 V2 开始读新字段但历史数据还没回填 数据库提前删了旧列V1 Pod 立刻报错 双写分成两次事务其中一次失败后留下不一致数据。因此灰度发布中的 Schema 兼容不是一个 DDL 技巧而是一套“双版本同时在线”的设计方法。本文用“收货人姓名拆列”为例说明如何通过双写、双读、回填、切流和最终 Contract让 V1 与 V2 安全共存并补充 JDBC、MyBatis、JPA/Hibernate 的适配、异常传播与事务边界。1. 背景与问题旧版本订单表CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULLUNIQUE,receiver_nameVARCHAR(64)NOTNULL,statusVARCHAR(32)NOTNULL,created_atTIMESTAMP(6)NOTNULLDEFAULTCURRENT_TIMESTAMP(6));V1 应用只认识receiver_name新版本希望拆成receiver_first_name receiver_last_name如果直接执行ALTERTABLEordersDROPCOLUMNreceiver_name;灰度环境中仍然存活的 V1 会马上失败。如果新版本只写新列receiver_first_name receiver_last_name而旧版本仍然读receiver_name那么 V1 读到的就是旧值甚至空值。所以灰度发布的第一原则是数据库结构必须先同时兼容新旧版本。2. 环境与数据示例环境JDK 21 Spring Boot 3.3 MySQL 8.0 PostgreSQL 15 HikariCP MyBatis 3.x Hibernate 6 / JPA Flyway / Liquibase初始数据INSERTINTOorders(order_no,receiver_name,status)VALUES(O-1001,Zhang San,CREATED),(O-1002,Li Si,CREATED);灰度目标阶段 1 V1 100% 阶段 2 V1 95% V2 5% 阶段 3 V1 50% V2 50% 阶段 4 V2 100% 阶段 5 删除旧字段真正危险的是阶段 2 ~ 阶段 4因为新旧应用会同时操作同一张表。3. 复现过程3.1 复现只写新字段的问题假设数据库已经增加ALTERTABLEordersADDCOLUMNreceiver_first_nameVARCHAR(32)NULL,ADDCOLUMNreceiver_last_nameVARCHAR(32)NULL;V2 代码改成UPDATEordersSETreceiver_first_name?,receiver_last_name?WHEREid?;但不再更新receiver_name此时 V1 读取SELECTreceiver_nameFROMordersWHEREid?;仍然会看到旧数据。也就是说V2 写成功 不等于 V1 能看到新值。3.2 复现读新字段过早如果 V2 直接SELECTreceiver_first_name,receiver_last_nameFROMorders;历史订单还没有回填结果可能是NULL / NULL因此读切换必须晚于新写稳定 历史回填 一致性校验3.3 复现双写不在同一事务错误代码orderRepository.updateOldName(id,fullName);try{orderRepository.updateNewName(id,firstName,lastName);}catch(Exceptione){log.warn(new fields write failed,e);}结果可能变成旧字段成功 新字段失败此后 V1 与 V2 读到不同结果。灰度双写必须具备同一事务成功 或一起回滚4. 方案实施4.1 第一步Expand只增加兼容字段DDLALTERTABLEordersADDCOLUMNreceiver_first_nameVARCHAR(32)NULL,ADDCOLUMNreceiver_last_nameVARCHAR(32)NULL;不要删除旧字段 修改旧字段含义 强制新字段 NOT NULL这一步的目标只是让 V2 有地方写。4.2 JDBC 双写推荐一条 SQL 同时写新旧字段publicvoidupdateReceiver(longid,StringfullName,StringfirstName,StringlastName){introwsjdbcTemplate.update( UPDATE orders SET receiver_name ?, receiver_first_name ?, receiver_last_name ? WHERE id ? ,fullName,firstName,lastName,id);if(rows!1){thrownewIllegalStateException(order not found);}}一条 SQL 的好处是原子 简单 不会出现第二次 UPDATE 失败如果业务结构复杂确实需要多条 SQL也必须放进同一事务。4.3 多语句双写事务TransactionalpublicvoidupdateReceiver(longid,Receiverreceiver){orderRepository.updateLegacyName(id,receiver.fullName());orderRepository.updateNewName(id,receiver.firstName(),receiver.lastName());}如果第二条失败第一条也必须回滚。不要把新字段当成“可选写入”。4.4 MyBatis 双写MapperupdateidupdateReceiverUPDATE orders SET receiver_name #{fullName}, receiver_first_name #{firstName}, receiver_last_name #{lastName} WHERE id #{id}/update业务introwsorderMapper.updateReceiver(cmd);if(rows!1){thrownewOrderNotFoundException();}灰度期间一个 Mapper 同时维护两个 Schema 表达。这样比在多个调用点分别写旧列和新列更安全。4.5 JPA/Hibernate 双字段兼容EntityEntityTable(nameorders)publicclassOrderEntity{Column(namereceiver_name)privateStringreceiverName;Column(namereceiver_first_name)privateStringreceiverFirstName;Column(namereceiver_last_name)privateStringreceiverLastName;publicvoidchangeReceiver(Stringfirst,Stringlast){this.receiverFirstNamefirst;this.receiverLastNamelast;this.receiverNamefirst last;}}迁移期 Entity 暂时显得“重复”是正常的。这段兼容代码的生命周期应该有明确删除计划。4.6 双写必须监控失败建议记录{event:schema_dual_write,orderId:1001,legacyWritten:true,newWritten:true,version:v2}如果出现legacyWritten ! newWritten应该视为严重一致性问题。4.7 历史数据回填只更新新字段为空的记录。UPDATEordersSETreceiver_first_nameSUBSTRING_INDEX(receiver_name, ,1),receiver_last_nameSUBSTRING_INDEX(receiver_name, ,-1)WHEREid?ANDid?ANDreceiver_first_nameISNULL;这样回填任务可重入。4.8 为什么回填不能覆盖非空新字段假设 V2 已经把订单改成receiver_name Wang Wu receiver_first_name Wang receiver_last_name Wu回填任务如果无条件根据旧值计算可能把新写入的数据覆盖掉。所以WHERE receiver_first_name IS NULL是迁移安全的重要保护。4.9 每批独立事务Transactional(propagationPropagation.REQUIRES_NEW)publicintbackfillBatch(longstartId,longendId){returnorderRepository.backfill(startId,endId);}外层for(...){backfillService.backfillBatch(start,end);}不要把几百万行迁移放进一个事务。4.10 双读read-new-fallback-oldV2 读取时publicStringgetReceiverName(OrderRowrow){if(row.receiverFirstName()!nullrow.receiverLastName()!null){returnrow.receiverFirstName() row.receiverLastName();}returnrow.receiverName();}也就是优先读新字段 新字段缺失时回退旧字段这比直接切读新字段安全得多。4.11 SQL 层 fallback也可以SELECTCOALESCE(CONCAT(receiver_first_name, ,receiver_last_name),receiver_name)ASreceiver_displayFROMordersWHEREid?;但要注意CONCAT 遇到 NULL 的具体行为应根据数据库方言确认。4.12 MyBatis 双读selectidfindOrderresultTypeOrderViewSELECT id, order_no, receiver_name, receiver_first_name, receiver_last_name, status FROM orders WHERE id #{id}/selectService 层统一做 fallback便于统计到底还有多少请求命中旧字段回退。4.13 回退率是重要灰度指标建议记录schema_read_fallback_count schema_read_fallback_ratio如果回填完成后fallback_ratio仍不为 0说明还有漏写 旧版本写入 回填遗漏这时绝不能删旧列。4.14 灰度期间 V1 仍然会制造“旧写”即使回填已经完成只要 V1 还在线它后续仍然只写 receiver_name。所以回填不是一次性任务。在 V1 完全下线前V2 的双读 fallback 仍然有价值。4.15 能不能用数据库触发器双写可以例如更新 receiver_name - trigger 拆分新字段但通常不建议把迁移兼容逻辑长期藏在触发器里。原因规则隐式 ORM 不容易感知 迁移完成后容易忘记删除 复杂姓名拆分逻辑不适合数据库触发器如果只是非常简单、短期的兼容兜底可以评估。更推荐应用显式双写 数据库监控校验4.16 灰度流量不要只看 HTTP 指标常规灰度看错误率 P95 CPUSchema 灰度还必须看新旧字段一致率 NULL 新字段数量 fallback 读比例 双写失败数 回填剩余量因为 HTTP 200 并不能证明数据一致。4.17 一致性校验 SQL例如SELECTCOUNT(*)FROMordersWHEREreceiver_first_nameISNULLORreceiver_last_nameISNULL;再检查SELECTCOUNT(*)FROMordersWHEREreceiver_nameCONCAT(receiver_first_name, ,receiver_last_name);结果应逐步收敛。4.18 切读前先做影子读V2 可以先真实业务仍用旧字段 后台同时计算新字段结果 比对但不影响返回这叫shadow read例如Stringlegacyrow.receiverName();StringcandidatebuildNewName(row);if(!Objects.equals(legacy,candidate)){metric.incrementMismatch();}returnlegacy;验证稳定后再正式切读。4.19 回滚策略在新字段已经加 旧字段仍保留期间V2 出问题时可以切流回 V1因为数据库仍兼容 V1。如果已经执行DROP COLUMN receiver_name再回 V1 就会失败。所以旧字段删除必须晚于灰度结束。4.20 Contract最后收口只有满足V1 实例 0 新字段缺失 0 fallback 比例 0 双写错误 0 所有消费者已迁移才能进入 Contract。第一步停止旧字段写入观察一段时间。第二步删除兼容代码最后才ALTERTABLEordersDROPCOLUMNreceiver_name;4.21 事务边界双写失败必须整体回滚假设TransactionalpublicvoidupdateReceiver(...){updateLegacy();updateNew();audit();}如果audit()失败前两个更新也应该回滚。迁移期的兼容写本质上仍然是一个业务动作。不能因为字段来自两个版本就拆成两个事务。4.22 旧写与新写冲突灰度期 V1 和 V2 可能同时修改同一订单。这时要依赖正常并发控制version 乐观锁 条件 UPDATE 悲观锁Schema 双写不能替代业务并发控制。例如UPDATEordersSETreceiver_name?,receiver_first_name?,receiver_last_name?,versionversion1WHEREid?ANDversion?;如果影响 0 行说明版本冲突。4.23 ORM 缓存也要小心如果 Hibernate 二级缓存里还有旧 Entity 结构切版本期间要确认缓存 Key 序列化格式 字段兼容不要只关注数据库。4.24 风险矩阵最危险的不是加新字段。而是切读过早 停旧写过早 删旧列过早因为这些操作会直接破坏回滚能力。5. 结果对比不做灰度兼容流程改表 部署 V2问题V1 无法继续运行 不能真正灰度 应用回滚失败 历史数据可能缺失做双写双读兼容流程Expand - V2 双写 - 回填 - 双读 fallback - 影子校验 - 切读 - V1 下线 - 停旧写 - Contract收益V1/V2 可并存 回滚窗口长 历史数据可渐进迁移 数据质量可监控 切换风险可分阶段暴露代价代码短期复杂 需要额外指标 发布周期更长对于高可用在线系统这种复杂度是有价值的。6. 风险与复盘6.1 双写不是永久架构兼容代码越留越久越容易变成技术债。迁移开始时就应该创建删除旧字段 删除 fallback 删除双写对应任务。6.2 不要把双写拆成异步如果旧字段和新字段表达的是同一业务事实不建议用 MQ 异步双写。否则会引入暂时不一致。最稳妥的是同 SQL 或 同本地事务。6.3 回填不是一次跑完就结束需要持续检查V1 是否还在写旧数据 是否有失败批次 新字段是否再次出现 NULL6.4 fallback 不能无限期存在如果read-new-fallback-old永远不删除就会掩盖数据迁移问题。应该设定fallback_ratio 必须归零作为 Contract 前置条件。6.5 旧字段删除前要查所有消费者包括报表 BI ETL 脚本 数据仓库同步 存储过程 触发器不只是在线应用。6.6 灰度是数据灰度不只是流量灰度5% 流量进入 V2不代表只有 5% 数据受影响。一次批处理、一个热点用户、一个共享订单都可能让新旧版本交叉操作同一数据。所以必须监控数据一致性而不是只看流量比例。6.7 Contract 是不可逆风险最高的一步只要旧字段还在V1 可以回来。旧字段删掉以后应用回滚能力显著下降。因此 Contract 必须是最后一步。结语灰度发布中的数据库兼容核心不是“让 V2 支持新字段”而是让V1 V2 历史数据 新数据在同一段时间内都能正确工作。一套成熟的灰度迁移顺序通常是先加新结构 再双写 再回填 再双读 再切读 最后停旧写和删旧列。可以把核心原则总结为一句话灰度期间兼容优先 切换期间验证优先 清理旧结构永远最后。只要把双写、双读、回填和事务边界设计成可观察、可回退的阶段数据库 Schema 就能真正参与持续交付而不是成为灰度发布里最难回滚的那一环。转载自https://blog.csdn.net/u014727709/article/details/165243425欢迎 点赞✍评论⭐收藏欢迎指正
返回列表