AI 建议直接删除旧字段,为什么灰度发布时新旧实例会同时报错:从数据库 Schema 迁移的双向兼容说起
数据库字段迁移经常被误认为是一件很简单的事。订单表里原本有一个字段customer_nameVARCHAR(128)团队后来决定把它拆成customer_first_nameVARCHAR(64)customer_last_nameVARCHAR(64)开发者把表结构和实体片段交给 AI 后很容易得到类似建议ALTERTABLEordersADDCOLUMNcustomer_first_nameVARCHAR(64),ADDCOLUMNcustomer_last_nameVARCHAR(64);ALTERTABLEordersDROPCOLUMNcustomer_name;代码里也顺手改成publicrecordCustomerName(StringfirstName,StringlastName){}本地开发环境中这种修改通常顺利通过数据库结构最新、本地应用最新、测试数据可重新生成、没有旧实例继续运行、没有异步任务在处理旧格式也没有老版本接口继续读旧字段。但真实发布环境通常不是“旧版本瞬间消失、新版本瞬间全部上线”。更常见的状态是旧实例仍在处理请求新实例开始灰度上线数据库结构已经变化异步任务还在消费旧格式消息定时任务仍按旧字段读写管理后台可能还没更新补偿任务和数据修复脚本仍依赖旧结构。此时直接删除旧字段可能出现新实例写新字段、旧实例查询旧字段失败老代码仍执行 INSERT customer_name新代码只读新字段、历史数据却尚未回填回滚到旧版本后旧代码无法读取新格式数据消息消费者收到旧事件时字段解析失败导出任务因字段缺失中断部分节点刷新而部分节点仍旧错误只在少数实例出现。根源通常不是 SQL 语法写错而是把数据库 Schema 迁移当成一次“结构修改”忽略了它本质上是一场跨版本协作。一、最常见的错误把数据库结构当成只服务当前代码版本开发环境中的流程常常是改实体、改 SQL、改表结构、本地启动、测试通过。但生产环境中数据库可能同时服务灰度中的旧实例、已发布的新实例、延迟执行的异步任务、消息消费者、定时任务、导出与统计任务、审计程序、运维脚本、异常补偿逻辑以及需要紧急回退的旧版本。假设当前有两个应用版本版本 V1读 customer_name写 customer_name 版本 V2读 customer_first_name customer_last_name写 customer_first_name customer_last_name如果数据库先删掉 customer_nameV1 立刻失去运行能力。但如果数据库只新增新字段V2 也可能无法正确读取历史数据因为历史记录里旧字段有值而新字段为空。所以 Schema 迁移最先需要回答的不是“最终字段长什么样”而是在新旧版本并存期间谁读什么、谁写什么、历史数据在哪里、回滚后还能不能运行。二、不要直接“替换字段”先把变更拆成扩展和收缩更稳定的 Schema 演进通常遵循Expand ↓ Migrate ↓ Contract也就是先扩展、再兼容、后迁移、最后收缩。第一阶段扩展结构先只新增字段ALTERTABLEordersADDCOLUMNcustomer_first_nameVARCHAR(64)NULL,ADDCOLUMNcustomer_last_nameVARCHAR(64)NULL;不删除旧字段。新旧版本都还能运行V1 继续读写 customer_nameV2 至少仍能读 customer_name数据库同时存在旧字段和新字段。第二阶段让新代码具备兼容能力新版本读取时使用兼容策略publicCustomerNameresolveCustomerName(Orderorder){if(order.getCustomerFirstName()!null||order.getCustomerLastName()!null){returnnewCustomerName(order.getCustomerFirstName(),order.getCustomerLastName());}returnsplitLegacyName(order.getCustomerName());}这意味着新代码既能处理新数据也能读取历史旧数据。第三阶段双写或转换写入新版本写入时在兼容窗口内同时写两套字段publicvoidapplyCustomerName(Orderorder,CustomerNamecustomerName){order.setCustomerFirstName(customerName.firstName());order.setCustomerLastName(customerName.lastName());order.setCustomerName(joinLegacyName(customerName));}这样即使系统中还有 V1 实例旧实例仍能读取 customer_name。第四阶段回填历史数据新增字段后历史记录并不会自动拥有新值。需要明确回填策略UPDATEordersSETcustomer_first_name...,customer_last_name...WHEREcustomer_nameISNOTNULLANDcustomer_first_nameISNULLANDcustomer_last_nameISNULL;真实回填不一定适合一次性全表执行。还要考虑数据量、锁等待、批次大小、回填进度、异常中断、是否可重复执行、数据解析规则是否存在歧义以及回填后是否需要人工抽样验证。第五阶段停止旧字段写入只有当旧实例全部下线、旧消费者全部完成兼容、回填验证完成后才能停止写旧字段。第六阶段收缩结构最后才考虑ALTERTABLEordersDROPCOLUMNcustomer_name;这不是一次普通删除而是团队确认系统中已经没有任何仍然依赖旧字段的运行路径。三、最难的问题不是新增字段而是新旧版本如何同时写字段迁移里常见兼容策略策略写入方式优点风险旧写新读继续写旧字段新代码兼容读取改动小新字段长期为空双写同时写旧字段和新字段新旧实例兼容性强两套字段可能不一致写新读旧兜底新字段优先旧字段作为回退逐步完成迁移历史数据需要回填只写新字段新版本完全切换最终结构干净旧实例和回滚风险高最容易出问题的是双写。它看似解决了兼容性但也可能导致新字段写成功、旧字段写失败旧字段格式可以表达而新字段不能完整表达新旧代码采用不同拼接规则读取结果不一致。因此双写必须明确哪个字段是最终事实来源两边不一致时谁优先旧字段是否只是兼容副本双写失败时是否整体回滚是否需要定期校验双写持续多久什么条件满足后可以停止旧写。例如可以定义新字段是权威数据旧字段是兼容字段不一致时以新字段为准并记录异常旧字段写失败时按业务决定是否阻断。四、错误但常见的做法数据库先改完再让代码慢慢追上有些团队的顺序是先执行 ALTER TABLE直接删除旧字段再发布新代码。这在停机发布、没有异步链路、没有多实例、可以瞬间切换的环境里或许成立但对大多数在线系统风险很高。因为数据库变更一旦先发生旧代码可能立即失败V1 仍在运行查询 customer_name字段被删除SQL 异常请求失败。更麻烦的是回滚V2 发布后发现问题想回滚到 V1但旧字段已删除V1 仍依赖旧字段回滚版本也无法运行。所谓回滚不可用。判断 Schema 迁移是否安全不能只看新版本能不能运行还要看旧版本能不能继续运行、旧版本能不能作为回滚版本运行、历史数据能不能被新版本读取、新数据能不能被旧版本兼容处理。这就是双向兼容。五、让 AI 先整理新旧版本读写矩阵而不是直接输出DROP COLUMN如果只问 AI“我想把 customer_name 拆成 first_name 和 last_name帮我改数据库”它可能直接给出 ADD COLUMN 和 DROP COLUMN。这些 SQL 在最终阶段不一定错误但如果没有上下文AI 无法判断系统是否灰度发布、是否还有旧实例、是否有消息消费者依赖旧字段、是否有延迟任务处理历史消息、是否支持快速回滚、历史数据能否无损拆分、是否需要双写、哪个字段是权威来源旧字段是否存在导出、审计或脚本依赖。更有效的提示方式你是 Java 数据库 Schema 迁移与灰度发布评审助手。 场景订单表中的 customer_name 需要拆分为 customer_first_name 和 customer_last_name。系统有多个应用实例采用灰度发布存在异步任务、消息消费者、导出任务和定时任务上线后必须保留可回滚能力。 请不要直接输出 DROP COLUMN。 请输出 1. 新旧版本的读写矩阵 2. 扩展、兼容、回填、停旧写、收缩各阶段的顺序 3. 双写时哪个字段应作为权威来源 4. 历史数据回填如何分批、重试和核验 5. 新旧实例并存时的错误场景 6. 回滚到旧版本需要保留哪些结构和数据 7. 至少 10 个兼容性、回填和发布测试场景 8. 哪些命名、解析和业务规则必须由产品或运营确认。当团队把 ChatGPT Plus 用于迁移方案审查、SQL 评估、发布清单整理和故障复盘时工具接入准备不只是能否快速生成脚本还包括是否能完整描述变更范围、明确新旧版本关系、保留异常证据并让回滚在真正需要时仍然可用。团队把 AI 工具纳入日常流程时除了权限与脱敏规范也应提前确认使用规则、周期和异常处理路径需要集中核对时可参考gpt0424com六、回填任务必须可重复、可暂停、可核验历史数据回填通常是 Schema 迁移里最耗时、最容易被低估的一段。订单表有数千万条记录时一次性 UPDATE 可能带来大量行锁或事务压力、主从复制延迟扩大、业务写入等待、磁盘和日志增长、长事务难以回滚、执行中断后无法判断处理到哪里以及在线请求受影响。更稳妥的方式通常是按主键范围或时间范围分批处理第 1 批id 1 - 10000 第 2 批id 10001 - 20000 第 3 批id 20001 - 30000每批都应记录批次编号、处理范围、开始时间、结束时间、成功数量、失败数量、重试次数、错误摘要和校验结果。回填逻辑最好满足“已经回填完成的数据再次执行时不会产生副作用”。例如UPDATEordersSETcustomer_first_name:firstName,customer_last_name:lastNameWHEREid:idANDcustomer_first_nameISNULLANDcustomer_last_nameISNULL;回填完成后不应只看任务跑完了还要核对旧字段有值但新字段为空的记录数、新旧字段解析后不一致的记录数、无法自动拆分的异常记录数以及新字段为空但业务仍依赖的数据量。七、至少覆盖这些兼容与回滚测试测试场景预期结果V1 写旧字段V2 能正确读取V2 写双字段V1 仍能正常读取旧字段V2 读取历史数据能通过旧字段兼容展示历史回填中断可从断点继续不重复破坏数据双写某一侧失败按规则阻断、告警或进入修复队列灰度实例混合运行新旧接口均可用老消息延迟到达新消费者能兼容旧字段新消息被旧消费者处理旧消费者不会因字段缺失崩溃新版本回滚到 V1V1 仍可运行停止旧写后旧字段不再被依赖收缩前扫描没有任何代码、任务和脚本依赖旧字段字段删除后验证数据、导出、统计和审计均不受影响示例TestvoidshouldReadLegacyNameWhenNewFieldsAreEmpty(){OrderordernewOrder();order.setCustomerName(张三);order.setCustomerFirstName(null);order.setCustomerLastName(null);CustomerNameresultcustomerNameResolver.resolve(order);assertEquals(张,result.firstName());assertEquals(三,result.lastName());}TestvoidshouldKeepLegacyFieldDuringCompatibilityWindow(){CustomerNamenamenewCustomerName(张,三);OrderordernewOrder();orderWriter.applyCustomerName(order,name);assertEquals(张三,order.getCustomerName());assertEquals(张,order.getCustomerFirstName());assertEquals(三,order.getCustomerLastName());}八、上线后要观察什么建议至少记录legacy_field_read_total legacy_field_write_total new_field_read_total new_field_write_total dual_write_mismatch_total backfill_processed_total backfill_failed_total backfill_unparseable_total schema_compatibility_error_total old_version_query_error_total重点观察旧字段是否仍被读取或写入、双写是否出现不一致、回填进度是否停滞、是否有无法解析的历史数据、是否有旧实例因字段缺失报错、异步任务是否仍依赖旧格式、是否存在新旧字段同时为空或冲突以及是否已经满足删除旧字段的前置条件。真正执行收缩前最好能明确回答旧字段最近一次被读取和写入是什么时候是否还有活跃实例运行旧版本是否有延迟消息或补偿任务可能使用旧字段是否存在未经检查的脚本和导出任务回滚版本是否仍需要旧字段如果这些问题没有答案删除旧字段通常还太早。九、结语数据库字段迁移的风险不在于 ALTER TABLE 写错。真正困难的是让新旧版本、历史数据、异步任务和回滚路径在同一段时间里仍能彼此兼容。可靠的 Schema 演进需要先扩展后收缩明确新旧字段在兼容窗口内如何读写明确双写时谁是最终事实来源明确历史数据如何回填和核验确认新旧实例并存时是否仍可运行确认回滚是否真可执行扫描旧字段是否仍被脚本、任务、消费者或导出逻辑使用明确哪些异常必须阻断发布哪些边界需要人工确认。AI 可以帮助整理读写矩阵、生成迁移脚本、补齐测试场景和梳理发布清单。但真正需要团队决定的是这次字段演进是否允许旧版本继续存在、旧版本要兼容多久以及什么时候才能证明旧字段已经真正没有任何运行依赖。可靠的 Schema 迁移不是最快删掉旧字段而是在新旧版本同时存在、异常发生、需要回滚时系统依然能够稳定运行。