数据库变更的分步策略:先扩展,再切换

为什么停机改表越来越难用

表结构要跟着产品变:加一列状态、把金额从整数改成带币种的结构、把一个大字段拆成独立表。服务若只有一个实例,又可以接受维护窗口,直接改表、改代码、一起上线,往往还能收场。一旦滚动发布同时跑着新旧两个版本,数据库就成了它们唯一的共享契约。新代码写入的形状,旧代码必须仍能读;旧代码写入的形状,新代码也要能读。任何一边假设对方已经不存在,滚动过程中就会出现错误数据或启动失败。

数据库变更的分步策略,先扩展,再切换
数据库变更的分步策略,先扩展,再切换放大图解 ↗

分步策略把一次逻辑上的变更拆成多次可独立发布的小变更。顺序始终是先扩展数据库,让新旧代码都能工作;再切换读写路径;最后才删除旧结构。这个顺序常被叫做扩展、切换、收缩。它慢一些,但每一步都可以单独停下,也不要求所有节点在同一秒更换二进制。

先看变更属于哪一类

不是每一种变更风险相同。动手前先归类,避免用重流程处理无锁的小改动,也避免把危险改动伪装成「只加一列」。

加可空列、加新表、加索引,通常是扩展。旧代码不认识新列时,数据库仍会接受它的插入,前提是新列有默认值或允许空。这是后面一切切换的地基。

收紧约束是收缩。把列改成非空、加上唯一约束、缩小字段长度、删除列,都会让仍在使用旧写法的进程立刻失败。这类动作放在所有应用节点都不再依赖旧写法之后。

改写语义最容易出事。同一列从前存「分」,现在存「元」;同一状态值从前表示取消,现在表示退款。列还在,含义变了,新旧代码会互相污染。语义变化要当成新列或新表来做,不要原地复用旧列。

锁和耗时是另一维度。给大表加带默认值的非空列,在有的引擎上会重写整表;建索引可能堵住写入。扩展阶段也要选在线做法:能并发创建的索引就并发创建,默认值若能只改元数据就不要回填每一行。具体能力以你用的引擎和版本为准。上线前在同等量级的副本上演练,记录耗时和锁等待,而不是靠文档里的「一般很快」。

扩展:让新旧代码同时正确

以「订单增加取消原因」为例。旧版本只知道状态是取消,没有原因。新版本希望写入原因编码,并在后台展示。

扩展阶段只做加法。新增可空列 cancel_reason,或新增一张 order_cancel_detail 表,用订单 id 关联。新表更适合原因本身还会长出备注、操作者和附件的情况。不修改旧列的含义,也不在这一步改掉状态取值。若需要索引,创建能在线完成的索引,避免在业务高峰拿长时间的写锁。应用仍未依赖新列。

数据库先于应用发布。若应用先发布,新代码引用不存在的列,滚动一开始就会报错。发布顺序反过来是常见故障:迁移脚本和二进制绑在同一次部署里,调度器先拉起新容器,迁移还在排队。启动探针失败,旧容器也被换掉,于是出现空窗。把迁移做成独立步骤,完成并校验对象已经存在之后,才允许应用版本前进。

扩展步骤的校验要具体。列的类型、是否可空、默认值、新表的唯一键,都用脚本读数据字典来确认,而不是只看迁移命令的退出码。退出码为零,有时只说明语句被跳过。对生产和预发使用同一套校验,避免「预发多了一列,生产没有」。

双写:新数据开始进入新结构

下一版应用同时写旧结构和新结构。读仍以旧结构为准,这样即使新列写错,用户路径还走旧逻辑。

双写要幂等。请求重试、消息重复投递时,两次写入应得到同一结果。对新表使用订单 id 这类唯一键做更新或插入,而不是盲目插入导致重复行。

双写会失败在一半。旧表成功、新表失败时,不能让用户请求直接失败就把订单卡死,除非新结构已经是强一致的一部分。在切换完成前,策略可以明确写成:旧写失败则整个请求失败;新写失败则记录可补偿的错误,由作业重试。这个选择要写进设计,而不是在异常分支里随手吞掉。补偿作业按主键扫描「旧侧已取消、新侧无原因」的行,补齐数据。补偿要有上限和告警,避免静默积压。

双写时期不要在请求线程里做重查询来顺便洗历史。请求路径只负责当前这一笔。历史数据交给回填。也不要在双写里调用外部接口补全原因,否则支付取消的延迟会绑到一个无关依赖上。缺的原因用显式的「待补全」状态,留给作业处理。

回填要分批,并且可重入

新列对旧行是空的。产品若要求旧订单也能显示原因,就需要回填;若只要求此后取消的订单有原因,可以不回填,但读路径要容忍空值。多数后台会要求两边一致,所以回填几乎总会出现。

回填作业按主键范围分片,每次处理固定行数,记下游标。作业中断后从游标继续,而不是从头再扫一整遍大表。更新语句要带条件,只改仍为空或仍为旧值的行,这样重复执行不会覆盖双写之后产生的新数据。

批量更新会撑大事务日志,顶高复制延迟。控制批次大小,批次之间留出空隙,并观察复制延迟和锁等待。不要在一个事务里更新百万行。批次大小以演练时的延迟曲线来定,不照搬别的系统的数字。

回填规则要明确。历史取消订单没有原因时,写入 unknown 这类显式枚举,比留下空值更利于后面加非空约束。unknown 和「用户没填」如果不是同一种意思,就分成两个值,避免报表把它们算在一起。规则如果中途要改,先停作业,记下已处理的主键上界,再按新规则处理未覆盖的范围。已经按旧规则写入且业务确认无误的行,不要无条件推倒重来。

切换前先证明两边一致

在把读路径切走之前,用可以重复跑的校验比较新旧。

计数要排除在途数据。旧状态为取消的行数,应等于新结构里有取消记录的行数,但双写进行中的最近几分钟会有时间差。比较条件加上「更新时间早于五分钟」,把在途行留在下一轮。

抽样不能只看行数。随机抽取一批主键,逐列比较金额、时间、枚举和关联 id。行数相同,也可能全是默认值。对文本字段比较规范化之后的结果,避免空格差异造成虚假报警,但不要把真正的截断当成空格问题放行。

不变量单独查。有原因的订单,状态必须是取消或退款;原因枚举必须落在已知集合里。违反不变量的行输出到一张旁路表,并触发告警。不要只打散在日志里,否则没人知道要修哪些主键。

校验失败就停止切换。修数据,再跑校验。切换不是「差不多就可以」的发布窗口。校验脚本本身进版本库,和生产迁移放在一起评审。

切换读路径,再停写旧结构

校验通过后,发布只改读路径的版本:优先读新结构,新结构缺失时回退旧结构,并打点计数回退次数。回退计数在观察期内应降到接近零。若回退持续发生,说明回填或双写有洞,此时回到修复,而不是继续删旧列。

观察期要覆盖业务周期。工作日的批处理、周末的高峰、月结任务,至少让相关作业都跑过一轮。只有白天的请求流量是不够的,因为有的读发生在夜间对账。对账作业若仍读旧列,你在接口指标里看不到它。

读切换可以放在特性开关后面,按租户或按比例打开。开关不要做成「必须发版才能退回」。打开之后仍保留旧读代码一个版本,确认指标稳定再删除回退分支。按租户打开时,选一个有真实取消量、又愿意配合核对的租户,不要选几乎没有数据的空租户,那会让校验失去意义。

写路径稍晚一步。当读者都从新结构读,并且回退计数接近零,再发布只写新结构的版本。若仍有旧节点在跑,它们还会写旧列。只写新结构的节点要能接受旧节点留下的数据,所以停写旧列必须晚于旧节点全部下线。滚动发布的尾部节点是这里最常见的遗漏。发布系统里应能回答「现在还有没有旧版本的进程」,而不是靠人记得。

收缩要晚于你的记忆

旧列不再被任何运行中的版本读写,校验也证明新结构完整之后,才进入收缩。

收缩仍分小步。先删应用代码里的旧字段映射,发布,确认没有 SQL 再访问旧列。数据库的审计日志或慢查询日志可以帮上忙。然后才删除列、旧索引和旧约束。删除列在大表上也可能重写或长时间锁表,同样要演练。

若旧列里有尚未迁移的信息,删除就是不可逆的业务损失。收缩前做一次可恢复的快照,并确认恢复流程演练过。备份放着但没人会恢复,等于没有。快照的保留时间要长于你发现「删错了」所需要的时间,不是只留到发布当天结束。

非空约束、外键和唯一索引都属于加固。加非空之前,先用查询证明不存在空值,再加约束。有的引擎支持先加约束但不校验历史、再单独做校验,这样不会在加约束时用长时间的阻塞锁扫整表。唯一索引要先清理重复行,否则创建会失败,失败位置若卡在发布中间会很难看。清理重复行之前,先写出保留哪一行的规则,例如保留更新时间较新的那一行,并把被删除行的主键清单存下来。

几种容易抄近路的变更

重命名列看起来像小事,对同时在线的两个版本却是破坏性的。做法仍是扩展:加新列,双写,回填,切换读,再删旧列。不要用数据库的重命名当作单步上线,除非你能保证没有任何旧进程、报表和订阅任务引用旧名字。视图若被外部报表使用,重命名还会打断报表,即使应用本身已经改完。

修改枚举要分清「加值」和「认识这个值」。有的数据库允许给枚举类型加一个新值,这并不表示应用已经准备好接收它。先加值,再发布认识新值的代码。删除枚举值更危险,因为旧行可能还引用它。先停止写入,清洗旧行,再考虑类型变更。在有的引擎里修改枚举会重写表,把它当成可能锁表的操作来排期。

拆分表时,解析规则就是迁移的一部分。例如把 users.address 文本拆成地址表。扩展阶段建地址表;双写时解析文本写入新表,解析失败写入待处理队列,而不是丢掉地址;读路径在观察期仍可回退文本;确认解析质量之后再停止写文本。解析规则要版本化,否则回填一半改规则,前后行会不一致。无法解析的比例要有阈值,超过就停,而不是用一个宽泛的默认地址填满。

大表索引会抢 I/O。为报表加的索引若和在线交易共用磁盘,会表现为接口变慢而代码没改过。索引放在扩展步骤里单独发布,便于和后续应用变更区分责任。能用部分索引覆盖真实查询条件时,不要给整表建更宽的索引。创建期间观察写入延迟,必要时降低创建速度或换到低峰。

回滚不是把迁移倒放

向下迁移脚本常常被当成安全感的来源。真正滚动发布时,向下脚本可能删掉新版本已经写入的列,造成数据丢失。更可靠的回滚是应用退回到仍能读写旧结构的版本,数据库保持扩展后的形状不动。新列多出来,旧代码忽略它,这是安全的;旧列被删掉,旧代码却还在读,这是不安全的。

因此收缩要推迟到不再需要应用回滚之后。若政策要求发布后若干小时内可回滚应用,收缩就不要排进同一天。数据修复也要可重入。写更新语句之前先写查询,证明它命中的行集是对的,在批次里更新,并保留被改主键的清单。没有清单的修复无法复查,也无法在修错时把这些行挑回来。

切换期间要看的信号

双写失败率、回填滞后行数、读回退次数、复制延迟、锁等待时间,这五个数比「应用已发布」更能说明变更是否健康。把它们放在同一块看板上,阈值按演练时的基线来定。

读切换打开后,对比新旧路径的错误码分布。若新路径出现旧路径没有的空原因,说明回填条件漏了某种状态。立刻把读比例调回零,修完再开。比例开关若不能在几分钟内生效,就不要用它做风险控制,先改配置的生效速度。

从库和报表往往被忘掉。主库已经双写,从库延迟很高时,报表仍会看到旧形状。切换公告要写明依赖从库的任务何时可以切。订阅数据变更的下游若按列名解析,扩展阶段多出来的列应被忽略,而不是导致消费失败。在切换前用一份样例事件做契约测试。

人和发布记录

迁移文件进版本库,按顺序编号,生产只向前应用尚未执行的那些。禁止手工在生产改一列却不补脚本,否则下一套环境会对不上,灾难恢复也会回到旧形状。

每次变更附带短说明:属于扩展、切换还是收缩;是否可能锁表;如何校验;如何回滚应用;对报表和从库的影响。评审时看这张说明,而不是只看语句的语法。

值班人要能回答三个问题:生产现在跑到了哪一步,新旧代码是否还并行,回填游标在哪里。把这些状态放进发布记录。出了差错时,人们失败在「以为上一步已经完成」,而不是失败在不会写语句。

分步策略的代价是版本窗口变长,代码里会暂时存在双写和回退。这是有意的临时复杂度。用任务或代码注释标明删除条件,写清哪一个版本之后可以拆掉双写,避免临时逻辑变成永久分叉。注释里写版本号和日期,不写「以后再清理」这种没有触发条件的句子。

可以放进变更单的顺序

把取消原因的例子收成发布序列:

1. 发布数据库扩展:新列或新表,以及必要的在线索引。用数据字典校验对象已存在。 2. 发布双写版本。读仍走旧路径。观察双写失败率,并让补偿作业跑起来。 3. 跑回填,直到滞后指标归零。跑一致性校验和不变量检查。 4. 按比例切换读路径,保留回退。看错误率、延迟和回退次数,覆盖至少一个业务周期。 5. 确认旧版本进程已全部退出后,发布只写新路径的版本。 6. 经过观察窗口,删除应用里的旧代码路径。 7. 再经过一个发布周期,执行收缩:删列,或加上非空和唯一约束。

任何一步的指标异常,都停在该步修复,不把后面的删除提前做完。数据库变更怕的不是步骤多,而是把不可逆的删除和仍在滚动的应用绑在一起。扩展已经加上的列,在收缩之前都不是负担,而是你还能退回应用版本的理由。

见字如晤

图解