专业书籍精读 · 持续交付 · 第 12 章
Continuous Delivery · Ch 12 · Jez Humble & David Farley · 2010
你用的每个 App 背后都有一个数据库,存着订单、聊天记录、余额。前面几章讲「怎么把新版本推上线、推错了怎么撤回来」,这一章讲那件撤不回来的事:新版本往往要改数据的存放方式,而数据不像代码,改错了没法「换回上一版」。
把数据库想成一条街的门牌号。发新版应用像给店换招牌——挂错了摘下来就行,五分钟的事。改数据库则像给整条街重新编号:快递员的地址簿、居民的身份证、外卖平台的记录,全都对着旧号码。你要是今天半夜把旧号牌一次拆光换上新号,第二天整条街的快递都会送错,而且已经按新号发出去的包裹,退不回来了。
老办法是:上线那晚,找个懂数据库的人登上去,照一张便条手工敲几条命令改结构。出了事怎么办?答案通常只有一个——拿昨晚的备份把整个数据库还原回去。听着安全,其实是把从昨晚到现在所有人的下单、付款、留言一起抹掉。于是团队宁愿不改,半年攒一次大版本,然后在某个周六凌晨提心吊胆地干一整夜。
第一,把每次数据库改动写成一张编号的施工单,和代码一起存进版本库。数据库自己记着「我已施工到第 27 号」,上线时自动比对、把 28 到 31 号依次做完。谁改的、改了什么全有记录;同一批施工单在测试环境演练过很多遍才轮到线上。
第二,施工单只添不拆。要挪走一个字段,先加新的、把数据抄过去,旧的先留着——万一要退回去,东西还在原地。
第三,也是最关键的一招:两块门牌并挂一阵子。不再有「切换的那一瞬间」,而是拆成好几小步——先挂上新号牌,让新旧两块牌子并存一段时间,等所有人的地址簿都换完了,再从容摘掉旧牌。任何一小步出问题,都能原地停下、退回上一步,而不是整条街推倒重来。
很多团队图省事,把线上数据库整个拷一份到测试环境。看着最真实,其实最坑:太大、跑不动,全是真人的隐私,而且谁都能改——今天有人删了一条记录,明天别人的测试就莫名其妙红了。更稳的办法是让每个测试自己造那一小撮数据,用完清掉。
「并挂两块牌子」意味着一次简单的改动要分成好几次上线,而且中间那段时间,代码得同时伺候新旧两套结构——更啰嗦、更费事,换来的是每一步都能停能退。
代码可以换回上一版,数据不行。所以:把每次数据库改动写成编号的、版本化的、在各环境反复演练过的脚本;只添不拆;再把一次结构变更拆成新旧并存的几小步,让每一步都可以停下、可以退回。
想看迁移脚本怎么编号、扩展-收缩六步具体怎么走、各阶段的测试数据到底该怎么造? → 切到精读版
你以为发布的难点是代码,其实真正撤不回来的是数据。这一章把前面十一章建立起来的「构建一次、随处部署、随时可回滚」拿到数据库面前重做一遍,发现有一半假设不成立:应用是无状态的、可以整包换掉,数据库带着状态、只能被增量地改、而且时间只朝一个方向走。本章给出的答案是三件事——把数据库纳入版本控制并以编号脚本增量迁移、把迁移做成非破坏性的、并与应用部署解耦、为流水线的每个阶段准备恰当而最小的测试数据。
CREATE / ALTER / DROP)叫 DDL;改数据的语句(INSERT / UPDATE / DELETE)叫 DML。up 和撤销用的 down。ALTER TABLE 会长时间持锁,等于服务不可用。RPO。本章属于《持续交付》Part III「交付生态」,紧接 Ch11(管理基础设施与环境)。它接住 Ch10「部署与发布」明确留下的那个缺口:蓝绿和金丝雀让代码的上线变得可撤回,可两套版本共用的那个数据库怎么办?同时它是 Ch2「配置管理」「一切纳入版本控制」的最后一块拼图——代码进去了、环境进去了,数据库也必须进去。下启 Ch13(组件与依赖)。现实对应物是今天的 Flyway、Liquibase、Rails / Django migrations、gh-ost、以及所有「零停机 schema 变更」的实践。
先看清楚这道题为什么和前面十一章都不一样。应用是无状态的,数据库是有状态的,这一个差别毁掉了三条你已经习以为常的性质:
RPO 直接扔出去)。ALTER TABLE,传统做法要重建整张表并持锁——量级是几十分钟到几小时,期间写入全部阻塞。于是「加个字段」这种小事,被排进了半年一次的停机窗口。不解决会怎样,答案很具体:数据库成了整条流水线上唯一还靠手工、靠一个人的记忆、靠一张便条的环节。前十一章把构建、测试、环境全自动化了,上线那晚仍然是某位 DBA 登上生产库敲几条 SQL,敲完没人知道他到底敲了什么。团队于是不敢频繁发布,回到大批量、长间隔、高风险的老路——而这正是全书第一章要拆掉的东西。
起点是 Ch2 那条规矩的延伸:数据库的一切也要能从版本库重建。分两半——初始化:建库、建 schema、灌入参照数据的脚本,让任何人在任何机器上一条命令得到一个空白但可用的库;增量变更:此后每次结构改动都不是「上去改一下」,而是新增一个编号的迁移脚本并提交到版本库。
机制很朴素,但正是它把数据库拉进了流水线:库里存一张版本表,记着自己当前处在第几号。部署工具读出这个号(比如 27),对照版本库里已有的脚本(到 31 号),把 28 到 31 依次执行,再把版本号写成 31。每个脚本配一个反向的 down 用于降级。本书成书时的代表工具是 dbdeploy 与 LiquiBase,Rails migrations 是同一思路最广为人知的实现,今天的 Flyway 亦然。
关键收益不在「自动化」四个字,而在于:同一批迁移脚本,在到达生产之前已经在开发机、提交阶段、验收环境、预生产上跑过许多次。上线那晚执行的不是一段没人见过的 SQL,而是今天已经成功跑过二十遍的脚本——数据库变更就此从「一次性事件」变成「被反复演练的例行动作」。
这是全章最难、也最有价值的一节。本书给回滚数据库列了三条路,代价差别极大。
路 A:迁移前备份,出事恢复。最朴素,也是多数团队的默认答案。硬伤有二:丢数据——恢复到备份点意味着这段时间所有用户写入蒸发;慢——TB 级库的恢复通常是数小时量级,远超任何像样的 RTO。它只在「有停机窗口、库不大、且能接受丢这段数据」时成立。
路 B:写反向(down)脚本。能撤销结构,却撤不回信息:028_up 删掉了 legacy_addr 列,028_down 能把这一列加回来,却填不回里面原本的内容。这就逼出了本章最该记住的规矩——
迁移必须是非破坏性的(non-destructive)。要移除一个列,不要在同一次迁移里删掉它:先把数据复制到一张备份表(或干脆保留原列不用),确认新结构稳定运行若干个发布周期之后,再用一个单独的迁移真正删除。这样反向脚本才有东西可恢复。同一规矩的另一面是:改名等于删加——把 addr 重命名为 address,在数据库看来就是一次破坏性变更,旧版应用会当场找不到列。
路 C:不制造需要回滚的那一刻。这是本章最有远见的一段,也是下一节的主题。
Ch10 留下的那道题在这里作答:蓝绿的两套环境通常共用同一个数据库,所以切流量那一刻,新旧两版应用必然同时对着同一个 schema 说话。想让部署可撤回就只有一个办法——让 schema 同时兼容新旧两版应用。本书的表述是「让数据库变更与应用变更相互独立、彼此兼容」;这套做法后来被 Danilo Sato 与 Martin Fowler 命名为 ParallelChange / expand-contract(扩展-收缩),今天已是零停机变更的默认解法。
核心是把「一步到位的结构切换」拆成一串各自可停可退的小步,中间保留一个兼容窗口——窗口里新旧两版应用都能正常跑。以「把地址从一个字段拆成结构化的几列」为例:
六步里最该注意的是它们是分开上线的:每一步都是一次独立、可回滚的部署,两步之间可以隔几小时也可以隔两周。①②③ 全是加法,代价只是磁盘和一点写放大;⑥ 是唯一不可逆的操作,而它发生在新路径已被真实流量跑过很久之后。另外注意 ① 里那句「可空、无默认」——在许多数据库上,给大表加一个带默认值的非空列会触发全表重写(PostgreSQL 直到 11 版才对不变默认值做了优化),一个看似无害的迁移就此变成长时间持锁。
本章还提到一条更重的解耦手段:用视图或存储过程给数据库包一层「公共接口」,应用只对接口编程,底层表结构改动时由接口层吸收差异。它确实降低耦合,代价是逻辑跑进了数据库、测试与版本管理都更难,本书对此保持克制。
上面所有招数都假设「这个库是我一个人的」。现实里最难的场景是多个应用共用一个数据库——本书称之为需要编排(orchestration)的变更:改一个列,得同时改三个团队的代码,还得让三次发布同时落地。这直接违背持续交付的地基(小批量、独立发布),于是本章的建议是把编排的需要减到最少:让每个应用拥有自己的数据,跨应用访问走明确定义的接口(服务或数据库视图),而不是让别人直接读你的表。实在拆不掉的,就必须把兼容窗口开到足够长——长到最慢的那个团队也完成了迁移。这正是 Martin Fowler 所说的集成数据库(integration database)反模式,也是后来微服务「每服务一库」主张的直接来源。
本章后半转向一个日常得多、却同样常年做错的问题:测试用的数据从哪来。先分清三类——参照数据(币种、国家、状态码,跟 schema 一起版本化);测试专属数据(某个测试为验证自己而造的那几条记录);应用一致性数据(为满足外键与业务约束而必须一并存在的周边记录)。混为一谈,就会得到那种「谁都不敢删」的巨型共享测试库。
最重要的一条反模式是:不要把生产库整个拷到测试环境。四条理由条条致命——太大(验收测试从几分钟涨到几十分钟,直接冲垮 Ch7 的十分钟提交阶段与 Ch8 的反馈节奏);含真实个人数据(合规风险);没人拥有它(一个人改了一条记录,别人的测试隔天莫名其妙变红);它会腐化(生产数据里混着历史遗留的脏记录,测试却假设它们合法)。正解是每个测试造出自己需要的最小数据集,并尽可能通过应用自己的 API 来造——这样 schema 演化时测试数据自动跟着演化,不会退化成一堆写死的 INSERT。
让测试与数据解耦,本章给了三种办法,优劣分明:测试隔离(每个测试用自己独有的键前缀 / 账号,互不可见,最推荐);自适应测试(先查询当前状态,再据此断言或自建前置数据);测试排序(让测试 B 依赖测试 A 留下的数据)——最后这种本书明确反对:无法并行、无法单独重跑,一个失败引发一串失败。
表 1 · 数据库出了问题怎么退回来:三条路的真实代价
| 恢复备份 | 执行反向(down)脚本 | 兼容窗口 + 非破坏性迁移 | |
|---|---|---|---|
| 丢数据吗 | 丢——备份点之后的全部写入 | 丢被删列里的内容,除非事先留了副本 | 不丢——旧结构还在原地 |
| 耗时 | TB 级库常在数小时量级 | 分钟级,视脚本而定 | 秒级——只回滚应用,库不动 |
| 需要停机吗 | 需要 | 通常需要 | 不需要 |
| 前提 | 备份可用、恢复流程被真正演练过 | 每个迁移都配了正确的 down;迁移非破坏 | 把一次变更拆成多次发布,代码要能同时伺候新旧结构 |
| 主要代价 | 丢的是用户的钱和信任 | 反向脚本几乎从不被测试,真出事时未必能跑通 | 流程更长、更啰嗦;旧列会多留几周 |
| 什么时候用 | 兜底,永远要有;不是主力方案 | 低风险的纯结构变更、非生产环境 | 默认方案,尤其对高流量、不可停机的系统 |
表 2 · 常见 schema 变更的危险等级与安全做法(含具体量级参考)
| 变更 | 危险在哪 | 安全做法 |
|---|---|---|
| 加可空列 | 基本无害,元数据操作 | 直接做。扩展-收缩的第 ① 步就该长这样 |
| 加非空带默认值的列 | 可能触发全表重写并持锁——亿行表上是小时量级(PostgreSQL 11 之前尤为典型) | 先加可空列 → 分批回填 → 再加非空约束 |
| 建索引 | 要扫全表;默认方式会阻塞写入 | 用在线 / 并发建索引(如 CREATE INDEX CONCURRENTLY),慢但不挡路 |
| 改列类型 | 重写数据;旧版应用可能立即解析失败 | 当成加新列处理,走完整的扩展-收缩 |
| 重命名列 / 表 | 本质是一次破坏性变更——旧版应用当场找不到它 | 加新名 → 双写 → 切读 → 隔几个发布周期再删旧名 |
| 删列 / 删表 | 不可逆;且常在别处还有隐藏引用 | 先停止写入并观察若干周期,确认零访问后再删;删前留副本 |
| 大表数据搬迁 | 一条 UPDATE 扫全表会撑爆事务日志并长时间持锁 | 分批回填(如每批 1000~10000 行、可暂停可续跑),别一条语句干完 |
表 3 · 让测试不再互相踩:三种解耦策略
| 测试隔离 | 自适应测试 | 测试排序 | |
|---|---|---|---|
| 做法 | 每个测试用独有的键 / 账号,只碰自己那一撮数据 | 先查当前状态,再据此断言或自建前置数据 | 让后面的测试依赖前面的测试留下的数据 |
| 能并行吗 | 能 | 能 | 不能 |
| 单个用例能重跑吗 | 能 | 能 | 不能,必须按顺序整套跑 |
| 失败诊断 | 清楚——只可能是它自己的问题 | 较清楚 | 一个失败引发一串失败,得反查是谁污染了数据 |
| 代价 | 要设计好键的生成方式与清理 | 断言写起来更绕,容易写松 | 看起来最省事,实际最贵 |
| 本书态度 | 首选 | 可用,尤其对遗留系统 | 明确反对 |
表 4 · 数据库归谁:独占 vs 共享(编排式变更的成本)
| 应用独占自己的库 | 多应用共享一个库(集成数据库) | |
|---|---|---|
| 改一个列要协调几方 | 一方 | 所有读这张表的团队,且要同时发布 |
| 发布节奏 | 各团队独立,可以每天发 | 被最慢的一方拖住,退回大批量发布 |
| 兼容窗口要开多长 | 自己说了算,通常几天到几周 | 长到最慢的团队完成迁移为止,可能数月 |
| 代价 | 跨应用查询要走接口,数据有冗余 | 持续交付的地基被抽掉——小批量、独立发布都不成立 |
| 本书建议 | 默认:让每个应用拥有自己的数据,跨应用走明确接口 | 能拆则拆;拆不掉就把编排成本显式摆到台面上 |
四张表之外,本章真正的总判据同样只有一句,可以当场自查:你上一次生产发布,如果十分钟后决定撤回,数据库这一侧需要做什么?答案是「什么都不用做,只换回旧版应用」,说明你走在这一章的路上;答案是「叫 DBA 起来恢复备份」,那么无论用了多先进的部署工具,你的发布仍然是不可逆的。
这一章是全书里被后续十五年验证得最彻底的一章。Flyway 与 Liquibase 把「编号迁移脚本 + 库内版本表」做成了行业标配,Rails、Django、Entity Framework 把它内置进了框架;gh-ost、pt-online-schema-change、pg_repack 这类在线变更工具,解决的正是本章点名的障碍——大表 ALTER 会长时间持锁;扩展-收缩则成了任何一份「零停机发布」清单上的第一条。反过来,本章预警的两个坑也原样留存至今:共享数据库仍是微服务拆分中最难的一刀,「拷一份生产库当测试数据」仍是多数团队的默认起手式。
ACCESS EXCLUSIVE 级别的锁、从而在高流量下等同于停机,并把这套判断固化成开源的 pg_ha_migrations,在代码评审阶段就拦下不安全的迁移。前提是他们「主支付处理服务不接受任何计划内停机」。Coleman「PostgreSQL at Scale: Database Schema Changes Without Downtime」 ↗ braintree/pg_ha_migrations ↗面试与架构评审里这一章的问法很固定,而能拿分的答案从来不是工具名:数据库变更进版本库吗,还是发布当晚现敲?上一次发布若要回滚,数据库这边怎么办?给一张一亿行的表加个非空字段,你打算怎么加?测试环境的数据是从生产拷的吗?最后一问尤其能分辨人——答「是」的团队,几乎必然同时有着慢而不稳的验收测试。
ALTER,在生产的两亿行表上可能锁两小时。迁移的执行时长本身就是一项必须在类生产数据量上验证的指标,这也是容量测试环境需要真实量级数据的原因之一。① 一句话:代码是无状态的、可以整包换回上一版;数据不是。发布的可逆性最终卡在数据库这一侧。
② 数据库必须进版本控制,且分两半:初始化脚本(建库 + 参照数据)与编号的增量迁移脚本;库内一张版本表记录当前号,部署时只补差的那几号。
③ 最该记住的规矩:迁移必须是非破坏性的。删列前先留副本;重命名等于删加——在数据库看来它就是一次破坏性变更。
④ 回滚三条路的代价:恢复备份丢数据且要数小时;down 脚本撤得回结构、撤不回内容,且几乎从未被测试;兼容窗口不丢数据、秒级、不停机——默认选第三条。
⑤ 核心手法是扩展-收缩:加新列 → 双写 → 回填 → 切读 → 停旧写 → 删旧列,六步分开上线。中间那段兼容窗口里新旧两版应用都能跑,所以每一步都可停可退,唯一不可逆的删除被推迟到风险出清之后。这也是蓝绿部署共用一个数据库时唯一的正解。
⑥ 危险变更清单要背下来:加带默认值的非空列、建索引、改类型、重命名、大表整表 UPDATE——亿行量级上都可能是小时级持锁。安全做法一律是「拆成加法 + 分批回填 + 事后收缩」。
⑦ 测试数据分三类(参照 / 测试专属 / 一致性);别把生产库整个拷过来——太大、含隐私、无人拥有、会腐化。让每个测试经应用 API 造出自己的最小数据集,并用测试隔离而非测试排序来解耦。沿流水线则有梯度:提交阶段用假库保住十分钟预算,验收阶段用最小专属数据换隔离,容量阶段用生成的大数据量换真实性。
⑧ 共享数据库是持续交付的隐形天花板:它把独立发布变成需要多方编排的大批量发布。默认应当让每个应用拥有自己的数据、跨应用走明确接口。