不知道从哪天开始,只要有人跟我说他们在做持续交付,我都会先问一句:你上一次回滚是什么时候,花了多久。
这个问题听起来有点冒犯,但它比「你们用 Jenkins 还是 GitLab CI」有用得多。2019 年 11 月的一个周五,我们发了一版代码,把订单金额字段从 DECIMAL(10,2) 改成用 BIGINT 存分。回滚本身只用了 4 分钟,kubectl rollout undo 一条命令,Pod 状态从 Running 到 Terminating 再到新 Pod Ready,全程没超过 300 秒。但数据库那边,字段类型已经改完了,新代码写进去的数据是分,旧代码读出来当元处理。那天晚上我们花了将近 6 个小时写数据转换脚本,把 200 多万行订单逐批修回去,批大小设的 5000,每批 sleep 200 毫秒防止主从延迟堆高。
从那之后我明白了一件事:回滚能不能 4 分钟完成,取决于你的数据能不能 4 分钟回滚,而不是你的容器编排平台有多丝滑。
先分清楚:持续交付和持续部署不是一回事
这两个词被混用得太厉害了,连一些招聘 JD 里都写混。Jez Humble 在 2010 年那本《Continuous Delivery》里讲得很清楚:持续交付是软件始终处于可发布状态,随时可以按需发布;持续部署是每次通过流水线的变更都自动推到生产。前者是一种能力,后者是这种能力的一个可选策略。
这个区别不是文字游戏。DORA 这几年(Accelerate 那本书以及后续每年的 State of DevOps 报告)反复给出的数据大致是这样的:精英团队的部署频率是按需的、一天多次,变更前置时间小于 1 小时,变更失败率 0-15%,MTTR 小于 1 小时;而低绩效团队部署频率在每月到每半年之间,前置时间 1-6 个月,MTTR 也是 1-6 个月。注意,精英团队的变更失败率并不比低绩效团队低多少——差距主要在恢复速度上。也就是说,持续交付真正在优化的是「失败之后多快能恢复」,而不是「永不失败」。
很多团队把持续部署当成 KPI 来推,逼着业务方接受一天发 20 次,结果每次发的都是无足轻重的小改动,真正的大功能还是攒一个大版本手动上。这种做法我见过不少次,最后流水线沦为摆设,工程师会绕过它——直接 SSH 上机器改配置。
数据库迁移:持续交付里最硬的那块骨头
应用代码回滚是状态无关的,容器一删一建就回去了。但数据库是有状态的,schema 变更一旦执行,就是不可逆的——除非你提前设计好了回滚路径。
我们后来统一采用 expand-contract 模式,任何一次 schema 变更拆成至少三个发布周期:
- 扩展期:加新列,允许 NULL,代码完全不读它。发布一次。
- 双写期:代码同时写旧列和新列,读还是读旧列。发布一次。
- 切换期:读切到新列,写仍然双写。发布一次。
- 收缩期:确认新列数据完全正确后,停掉旧列的写入,再下一次发布删掉旧列。
听起来啰嗦,但这样做的好处是每一个中间状态都是可回滚的,回滚只需要回滚代码,数据库不动。代价是加一个字段要发 4 次,一个月才能彻底完成。所以我们的做法是给慢变更开一条独立通道,跟日常的功能发布解耦,用 Flyway 管理版本,每次迁移脚本都必须写对应的 undo 脚本——虽然 Flyway 社区版本身不直接支持 undo,但我们用 R__ 前缀的 repeatable migration 加上一个手工校验表来兜底。
具体参数上,我有几个经验值供参考:单次 DDL 操作在千万级表上尽量控制在 5 秒内完成,超过这个量级就考虑 pt-online-schema-change 或者 gh-ost;ALTER TABLE 加字段这类操作在 MySQL 8.0 的 instant DDL 支持下基本是毫秒级,但改字段类型基本都要重建表。这些细节不搞清楚,回滚路径就是假的。
功能开关:从救火工具变成债务黑洞
功能开关(feature flag)是持续交付的另一个支柱,它让你把「部署」和「发布」解耦。但这个工具用着用着就变味了。
我见过一个项目,代码库里躺着 340 多个开关,其中 200 多个没人知道是干什么用的,注释写着「临时」两个字,一看提交时间,三年前。开关本身还形成了嵌套:A 开关打开时才检查 B 开关,B 开关打开时才读 C 配置。这种组合空间是指数级的,测试根本覆盖不过来。
我的建议是给开关强制加两个字段:创建时间和预期下线时间,超期就在 CI 里报警,超过 90 天没清理的直接阻断合并。LaunchDarkly 这类商业方案有自己的 flag 生命周期管理功能,但如果你用的是开源自建方案,就得靠流程来兜。另外,永久开关(release toggle)和临时开关(ops toggle)要分开管理,别混在一个系统里。前者是业务配置,后者是应急阀门,生命周期完全不同。
流水线的时间预算:超过 10 分钟,人就绕路了
这一点没什么理论,就是观察。我们统计过内部 4 个团队的数据,流水线全量跑超过 12 分钟之后,开发者开始出现明显的规避行为:把多个小改动攒在一起推、跳过后端集成测试只跑单测、甚至本地起个 mock 直接上预发。
所以我们后来定的预算是:提交触发到反馈 ≤ 8 分钟,其中编译 ≤ 2 分钟,单元测试 ≤ 3 分钟,集成测试 ≤ 3 分钟。超过的部分全部拆成夜间流水线或者按需触发。为此我们做了几件事:把 Docker 镜像构建缓存化(BuildKit 的 cache mount 帮了大忙),集成测试用 Testcontainers 起临时数据库而不是共享实例,测试并行度从 2 调到 8。改完之后中位反馈时间从 17 分钟降到 6 分 40 秒,翻车率(流水线失败但代码没问题)从 12% 降到 3% 左右。
我的一个不太受欢迎的观点
持续交付本质上不是一个技术问题,是一个信任问题。
工具链可以买,流水线可以搭,Kubernetes 和 Argo CD 的文档写得比大部分内部 wiki 好得多。但真正难的是:产品经理愿不愿意接受「先上线再灰度」而不是「完整验收才发布」,测试团队愿不愿意从「把关者」转成「质量顾问」,运维愿不愿意把生产环境的操作权限交给自动化。这些没人愿意先松手,因为松手意味着把风险敞口放大,而没有人愿意在出问题的时候背锅。
所以很多团队做的其实不是持续交付,是持续部署的一层皮——流水线自动跑完了,但最后卡在一道人工审批上。审批的人通常是某个技术负责人,他一天有 20 个审批要看,平均花 8 秒扫一眼 commit message 就点了通过。这道审批提供的安全感是虚假的,成本却是真实的:它让所有人觉得「反正有人把关」,于是测试写得更松,监控配得更少。
如果你现在正卡在持续交付的某个环节,我的建议是别急着换工具。先去问问团队里最近一次生产事故的回滚花了多久,问清楚每一步卡在哪里,再决定要优化什么。通常答案会让你有点意外。