我给一个上线已久的服务改了下消息格式、把一个字段重命名顺手就发版了,自测和预发都好好的,可滚动发布刚推到一半,生产就开始零星报错、有些消息处理失败有些又正常,等全部实例滚完反而又恢复了,排查很久才反应过来滚动发布的那几分钟里新旧两个版本是同时在线的、旧实例根本不认识新字段的深度复盘
这次踩的坑,事后想想后背发凉——因为它出问题的窗口只有滚动发布的那几分钟,过了就自愈,以至于我第一反应是"偶发抖动吧",差点就放过了一个会反复啃咬我们每一次发版的结构性隐患。
故障现场:发布到一半报错,滚完又自己好了
我维护一个跑了很久的服务,它通过消息队列和另一个消费方协作。这次我做了个看起来无伤大雅的改动:把消息体里一个字段从 userName 改名成 username(统一命名风格),生产者和消费者的代码我都改了,本地自测、预发环境联调全都正常,于是我就走正常流程滚动发布上线了。
结果发布过程中,监控开始抽风:
- 发布到一半才开始报错:刚开始推新版本时一切正常,等滚动发布推进到一半左右,消费方开始零星报错——有的消息处理失败(取不到用户名、字段为 null),有的消息又处理得好好的,成功和失败夹杂着出现。
- 错误比例随发布进度变化:发布刚过半时错误最多,随着越来越多实例被换成新版本,错误比例反而慢慢下降。
- 全部滚完,自己就好了:等所有实例都换成新版本,错误彻底消失,系统又恢复了岁月静好,仿佛什么都没发生过。
- 回滚时又来一遍:我一开始慌了想回滚,回滚的过程中居然又出现了一波同样的报错,等回滚完成才又恢复。
这个"只在发布/回滚过程中出错、过程一结束就自愈"的特征太鲜明了,鲜明到指向一个我平时压根没放在心上的事实:滚动发布不是一瞬间把所有实例从旧版本切到新版本的,在发布的那段时间里,集群里是新旧两个版本的实例同时在线、同时干活的。而我的报错,恰恰只在这个"新旧共存"的窗口里发生。我得顺着这个事实,去想清楚新旧版本共存时,我那个"改字段名"的操作到底闯了什么祸。
第一件事:搞懂滚动发布期间,新旧版本必然有一段共存窗口
带着这个线索,我重新审视了"发布"这件每天都在做、却从没细想过的事,这才意识到一个被我严重低估的事实——任何不停机的发布(滚动发布、灰度发布),本质上都做不到"瞬间全量切换",中间必然存在一段新旧版本共存的过渡期。
为了不中断服务,滚动发布是这么干的:不是一下子把所有实例都停掉换新版,而是一批一批地换——先把一两个实例换成新版本、确认健康了,再换下一批,如此推进,直到全部换完。这中间的每一刻,集群里都同时存在着:
- 已经换好的新版本实例(发新格式、认识
username); - 还没轮到的旧版本实例(发旧格式、只认识
userName)。
关键就在这里:在这段共存窗口里,新旧两个版本的实例是混在一起对外服务、并通过同一个消息队列、同一份数据库、同一套缓存互相协作的。消息队列里,这一刻可能有新版本生产者发的"新格式消息",下一刻就被一个还没升级的旧版本消费者拿到;反过来,旧版本生产者发的"旧格式消息",也可能被已经升级的新版本消费者拿到。谁发的消息被谁消费,在共存窗口里是完全混合、不可控的。
我那个"改字段名"的祸根,这下彻底清楚了。我把 userName 改成 username,是一个破坏性变更(breaking change)——它不向后兼容:
- 新版本生产者发出的消息里字段叫
username,可还没升级的旧版本消费者代码里读的是userName,它在新消息里找不到userName,取到 null,处理失败; - 反过来,还没升级的旧版本生产者发的消息里字段还是
userName,已经升级的新版本消费者读username也取不到,同样失败。
而"发布刚过半时错误最多",正是因为那时新旧实例数量最接近、跨版本错配的消息比例最高;等全滚成新版本,大家都说 username 了,自然就一致了。我把这个跨版本错配画成一目了然的对照:
# 共存窗口里, 谁发给谁消费, 决定了成败(字段名不兼容)
生产者(新, 发 username) -> 消费者(新, 读 username) ✅ 成功
生产者(新, 发 username) -> 消费者(旧, 读 userName) ❌ 失败(旧的不认识 username)
生产者(旧, 发 userName) -> 消费者(新, 读 username) ❌ 失败(新的不认识 userName)
生产者(旧, 发 userName) -> 消费者(旧, 读 userName) ✅ 成功
# 发布过半时, 新旧各半 -> 上面 4 种组合各约 1/4 -> 约一半消息失败, 和监控吻合
真相大白:错根本不在某一个版本的代码本身(单看新版本是对的、单看旧版本也是对的),而在于我做了一个破坏新旧版本之间契约兼容性的变更,却忘了发布期间新旧必然共存、它们必须能互相听懂对方的话。我一直以为发布是"旧的下、新的上"的瞬间切换,把过渡期当成了不存在,这才埋了这颗只在发布窗口引爆的雷。
第二件事:正解——用"扩展-收缩(expand-contract)"分两步发布,保证过渡期兼容
根因是"破坏性变更撞上新旧共存窗口",那正解的核心就一句话:任何会影响新旧版本之间契约(消息格式、API、DB schema、缓存结构)的变更,都不能一步到位,要拆成保证过渡期双向兼容的多步发布。业界成熟的做法叫 expand-contract(扩展-收缩)/ 平行变更(parallel change),以我这次改字段名为例:
// 错误做法:一步到位改名(破坏性, 共存窗口必炸)
// 旧: {"userName": "alice"} -> 新: {"username": "alice"}
// 正解:expand-contract 分三步, 每一步都向后兼容
// 第 1 步(expand 扩展):生产者同时写两个字段, 新旧字段并存
{"userName": "alice", "username": "alice"}
// 此时无论消费者读 userName 还是 username 都取得到 -> 新旧消费者都不报错
// 第 2 步(迁移):等所有消费者都升级成读 username 之后...
// (这一步可能跨好几次发布、好几天, 确认旧消费者彻底没了再走下一步)
// 第 3 步(contract 收缩):生产者去掉旧字段 userName, 只留 username
{"username": "alice"}
这套做法的精髓,是把"破坏性的一步"拆成"每一步都兼容的多步":第一步只增加新字段、保留旧字段(扩展),这一步对谁都兼容;中间从容地把所有读方都迁移到新字段;最后一步等旧字段彻底没人读了,才删掉旧字段(收缩)。每一步发布时,新旧版本共存都不会出问题,因为这一步本身是向后兼容的。核心原则:在新旧共存的过渡期,变更必须保证"新的能听懂旧的、旧的也能听懂新的",做不到就拆步骤,而不是赌发布快没人撞上。
这个原则适用于一切跨版本契约:加字段(消费方要能容忍不认识的新字段、别用严格模式直接报错);删字段(先确认没人读了再删);改类型/改语义(等价于先加新的、迁移、再删旧的);DB schema(加列要可空或有默认、删列分两步、改列拆成加新列迁移删旧列);API(用版本号或只做向后兼容的扩展)。
第三件事:同一类"忽略过渡期、把切换当瞬间完成"的坑,我后来又撞见好几个
这次踩坑让我看清了一个更普遍的盲区:我们做"切换"(发布、迁移、升级)时,脑子里默认它是瞬间、原子地完成的,于是完全忽略了那个"新旧并存、半新半旧"的过渡期,而真正的事故往往就藏在这个被忽略的过渡期里。这种坑到处都是:
- 数据库迁移加了非空无默认列:加一个 NOT NULL 没默认值的列,还没升级的旧版本代码 INSERT 时不带这列、直接报错——共存期旧代码就挂了。
- 客户端 App 强制依赖新接口:服务端下掉旧接口,可用户手机里还装着没更新的旧版 App,旧 App 一调就 404。
- 缓存数据结构变更:改了缓存里存的结构,新代码写新结构、旧代码读出来解析不了(或反之),共存期缓存读取炸裂。
- 双写迁移期读到不一致:数据从老存储迁新存储,迁移期间双写/灰度读,没处理好就读到新老不一致的数据。
- 协议/序列化版本升级:升级 protobuf/序列化格式,新旧节点互发对方解不开的包。
它们的内核是同一个:任何在运行中的、分布式的系统上做的"切换",都不可能是瞬间原子完成的,中间必然有一段"新旧并存"的过渡期;在这段过渡期里,新旧两部分要互相协作、共享同一份数据和通道,所以任何破坏二者之间兼容性的变更,都会在过渡期暴露出来。所以做切换时,不能只设计"切换前"和"切换后"两个稳态,更要设计好那个"切换中"的过渡态——保证过渡期里新旧并存也能正常工作,做不到就拆成多个各自兼容的小步骤。我把这套判断画成了一张图(见后文)。
| 切换场景 | 被忽略的过渡期问题 | 兼容做法 |
|---|---|---|
| 消息格式改字段 | 新旧消费者互不认识字段 | expand-contract 先加后删 |
| DB 加非空列 | 旧代码 INSERT 不带该列报错 | 先加可空/带默认, 分两步 |
| 下线旧 API | 旧客户端还在调 | 保留旧版本/灰度淘汰 |
| 缓存结构变更 | 新旧代码读写结构不兼容 | 换 key 或兼容读两种结构 |
| 序列化协议升级 | 新旧节点互发解不开的包 | 先全升能读新格式, 再发新格式 |
第四件事:破坏性变更 vs 兼容性变更——一张对照表
这次事故逼我把"什么变更能一步发、什么必须拆步骤"摆成一张表,改契约前先对照一下:
| 变更 | 是否破坏共存兼容 | 该怎么发布 |
|---|---|---|
| 加一个可选新字段 | 兼容(旧方忽略它即可) | 可一步发, 但消费方需容忍未知字段 |
| 重命名字段 | 破坏(=删旧+加新) | expand-contract 分三步 |
| 删除字段 | 破坏(还有人读就炸) | 先确认无人读再删 |
| 改字段类型/语义 | 破坏 | 加新字段→迁移→删旧字段 |
| DB 加 NOT NULL 无默认列 | 破坏(旧代码 INSERT 报错) | 先加可空/带默认, 分两步收紧 |
| DB 加可空列 | 兼容 | 可一步发 |
看清这张表,改契约的判断就有了准绳:凡是会让"旧版本读不懂新数据"或"新版本读不懂旧数据"的变更,都是破坏性的,绝不能一步发,必须拆成 expand-contract 这种每一步都兼容的多步;只有纯增量、可选、向后兼容的变更才能一步到位。改名看起来只是个小动作,实则等于"删一个加一个",是妥妥的破坏性变更。
第五件事:我曾经对"发布/切换"想当然的几个误区
这场"发布到一半才报错"的事故,把我对"发布"这件事的一堆想当然照得清清楚楚:
| 我以为 | 实际上 |
|---|---|
| 发布是旧的下新的上、瞬间切换 | 滚动/灰度发布有一段新旧共存的过渡期 |
| 只要新版本代码自洽就没问题 | 共存期新旧要互相协作、还得跨版本兼容 |
| 预发联调过了生产就稳了 | 预发是纯新版本、压根没有新旧共存场景 |
| 改个字段名是无害的小改动 | 改名=删旧加新、是破坏性契约变更 |
| 发布快一点就能避开问题 | 窗口再短也有消息/请求撞上、是赌运气 |
| 回滚总是安全的退路 | 回滚同样制造共存窗口、可能再炸一次 |
这些误区的根子是同一个:我把"发布/切换"在脑子里想象成了一个瞬间的、原子的、非此即彼的动作——某一刻之前全是旧的,某一刻之后全是新的,中间没有过渡。正因为我的心智模型里根本不存在那个"新旧并存"的过渡态,我才会只检查"新版本对不对",而完全没去想"新旧版本在过渡期能不能和平共处、互相听懂"。把一个在运行系统上渐进发生的切换,当成一个瞬间完成的原子动作、从而无视那个新旧并存的过渡期,是这类"切换过程中才出事"问题的共同根源。
第六件事:改契约、做发布/迁移时,我现在的自检习惯
现在每当我要改一个跨服务/跨版本的契约、或排查"只在发布过程中出错",我都会先在脑子里把那个被忽略的过渡期请回来。先看清滚动发布期间到底发生了什么:
然后按这张自检图决定一个契约变更怎么发:
配套地,在改 DB schema 这种最容易在过渡期翻车的地方,我把"分两步收紧"固化成了模板:
-- 反例:一步加 NOT NULL 无默认列, 旧版本代码 INSERT 不带这列直接报错
-- ALTER TABLE orders ADD COLUMN channel VARCHAR(20) NOT NULL; -- 共存期炸
-- 正解:分两步, 过渡期对新旧代码都兼容
-- 第 1 步(发布前):加可空列(或带默认值), 旧代码不带它也能 INSERT
ALTER TABLE orders ADD COLUMN channel VARCHAR(20) NULL;
-- 部署新代码: 新代码开始写 channel; 回填历史数据 UPDATE ... WHERE channel IS NULL;
-- 第 2 步(等所有实例都是新版本、且历史已回填后, 下一次发布再收紧):
ALTER TABLE orders MODIFY COLUMN channel VARCHAR(20) NOT NULL;
而排查一个"疑似发布期问题"时,我固定先确认它的时间线是不是和发布窗口吻合:
# 把错误出现/消失的时间, 和发布(及回滚)的开始/结束时间对齐
# 若错误恰好起于发布开始、止于发布结束(回滚又复现)→ 几乎实锤是共存期兼容问题
kubectl rollout history deploy/my-svc # 看发布的版本和时间
kubectl get rs -o wide # 看新旧 ReplicaSet 各有多少副本在跑
# 错误比例 ~ 新旧副本数的乘积关系(过半时最高)→ 跨版本错配的典型特征
这套习惯的精髓,是"改契约前先问向后兼容吗、破坏性就拆 expand-contract、发布期问题先对齐时间线和新旧副本数"。它让我从"改完自测过就发",变成了"先想清楚过渡期新旧能不能共处"——核心始终是:任何在运行中的分布式系统上进行的发布升级或迁移(滚动发布、灰度发布、数据迁移、协议升级),本质上都无法做到瞬间原子地把所有实例从旧版本切换到新版本,中间必然存在一段新旧两个版本同时在线、同时对外服务、并通过同一个消息队列同一份数据库同一套缓存互相协作的共存过渡期;在这段过渡期里,新版本发出的数据可能被尚未升级的旧版本处理、旧版本发出的数据也可能被已经升级的新版本处理,谁的数据被谁处理是完全混合不可控的;所以任何会破坏新旧版本之间契约兼容性的变更(重命名或删除字段、改字段类型或语义、加 NOT NULL 无默认列、改缓存结构、下线旧 API、升级序列化协议)都会在过渡期里因为一方读不懂另一方的数据而报错,且错误只在发布过程中出现、全部滚完就自愈、回滚时还会再现一次;正解是任何跨版本契约变更都不能一步到位做破坏性变更,而要采用 expand-contract 扩展-收缩(又叫平行变更 parallel change)分多步发布——第一步 expand 只增加新的同时保留旧的(对新旧双方都兼容)、中间从容地把所有读方都迁移到新的、第三步 contract 等确认旧的彻底没人用了再删掉旧的,每一步本身都向后兼容所以每一步发布时新旧共存都不出问题;DB schema 变更同理要分两步(先加可空列或带默认值、回填、等全升级后再收紧为 NOT NULL),删列下线 API 都要先确认无人使用再动;更一般地,做任何切换时不能只设计切换前和切换后两个稳态、而必须设计好那个被普遍忽略的切换中过渡态,保证过渡期里新旧并存也能正常工作,做不到就把破坏性的一步拆成多个各自向后兼容的小步骤,绝不靠发布快来赌没有请求或消息撞上那个共存窗口。
我立下的几条规矩
这场"发布到一半才报错"的事故,换来了我改契约、做发布时,刻进骨子里的几条铁律:
- 滚动/灰度发布有一段新旧版本共存的过渡期,不是瞬间切换。
- 共存期新旧通过同一队列/DB/缓存协作,必须能互相听懂。
- 改名/删字段/改类型都是破坏性变更,绝不能一步发。
- 破坏性契约变更用 expand-contract:先加→迁移→再删。
- DB 加列先可空/带默认,等全升级再分步收紧为 NOT NULL。
- 下线旧 API/删字段前,先确认确实没人用了。
- 只在发布过程中出错,先对齐时间线和新旧副本数,别当偶发。
附:一段可直接照抄的 expand-contract 发布检查清单
最后留一段我自己改契约、发版时照着走的 expand-contract 检查清单(以加/改一个字段为例):
# === 判定:这个契约变更是否破坏新旧共存兼容 ===
def is_breaking(change):
# 破坏性:旧版本读不懂新数据, 或新版本读不懂旧数据
return change in {"rename_field", "delete_field", "change_type",
"change_semantic", "add_not_null_no_default",
"drop_api", "change_cache_struct", "bump_proto"}
# 兼容:纯增量、可选、旧方可忽略(add_optional_field / add_nullable_column)
# === 破坏性变更 -> 强制走 expand-contract 三步, 严禁一步发 ===
PLAN = [
"第1步 expand:只【加】新的, 同时保留旧的(双写/双字段), 本步对新旧都兼容",
" 部署: 全集群滚成新版本, 期间新旧共存但都能读懂两种形态",
"第2步 migrate:把所有【读方】都切到新的, 回填历史数据",
" 验证: 确认旧的形态已无人产出、无人读取(看监控/埋点)",
"第3步 contract:确认旧的彻底没人用了, 才【删】旧的(本步同样独立发一次)",
]
def release(change):
if is_breaking(change):
for step in PLAN:
print("[必须分步]", step) # 破坏性: 拆成每步都兼容的多步
else:
print("[可一步发]", change, "(但消费方需容忍未知字段, 别用严格模式)")
release("rename_field") # -> 打印三步计划, 而不是一把梭
这段清单的顺序就是结论本身:先判定变更破不破坏共存兼容 → 破坏性的一律拆成 expand(只加)→ migrate(迁读方)→ contract(再删)三步、每步独立发且都向后兼容 → 兼容性的才可一步发。把"改完一把梭发上去"换成"先加后删分步走",那些"滚到一半就报错"的发版夜,就再也不会找上门了。
写在最后
回头看,这场由"改了个字段名"引发的"发布期报错"事故,真正教给我的,远不止"改契约要用 expand-contract"这一个技巧。它让我对"在一个活着的、持续运行的系统上做改变,和在一张白纸上从头设计,是两件截然不同的事",有了一次刻骨的体会。我栽跟头,是因为我把"发布"想象成了一个瞬间的、原子的、非此即彼的开关——仿佛有那么一个时刻,啪一下,整个系统就从"全是旧的"变成了"全是新的",中间没有任何过渡;在这个想象里,我只需要保证"新版本是对的"就够了,因为旧版本马上就会消失;可现实狠狠纠正了我:为了不停机,系统是一批一批、渐进地从旧换到新的,中间有一段明明白白的、新旧两个我同时活着、还得共用一套家当过日子的过渡期,而我那个"无害的改名",恰恰让这两个我在过渡期里听不懂彼此说的话。这让我领悟到一个关于"变更与过渡态"的深刻认知:我们在思考一个"改变"时,天然地只关心两个稳定状态——改之前是什么样、改之后是什么样;我们把改变本身想象成一个连接这两个状态的、不占时间的瞬间跳变;可是在任何一个真实的、运行中的、由许多部分组成的系统里,改变从来不是瞬间完成的——它需要时间来传播、来逐个生效,于是必然存在一个"一部分已经变了、另一部分还没变"的过渡态;而这个过渡态,恰恰是最危险、也最容易被忽略的:因为它不在我们的"改前/改后"的二元想象里,我们从不为它做设计,而那些"已变的"和"未变的"部分却必须在这个没人设计过的过渡态里继续协作;所以,真正成熟的变更思维,是把"过渡态"也当成一个需要被认真设计、必须保证其正确性的一等公民:不是只让"终态"是对的,而是让从初态到终态的每一个中间步骤都是对的;当一步跨过去会让过渡态崩溃时,就把这一大步,拆成若干"每一步的过渡态都安全"的小步。这给了我一种看待"一切'在运行的系统上做改变'之事"时的敬畏:每当我要在一个活着的系统上做任何改变(发版、迁移、改配置、调结构),我都提醒自己"这不是一个瞬间的开关,中间有一段新旧并存的过渡期;在那段时间里,变了的和没变的得一起把活干好——我的这个改变,在过渡态里安全吗"——为过渡态而设计、让每一个中间步骤都向后兼容,而不是只盯着终态、把过渡期赌给运气;"把切换当成有过渡期的渐进过程、为过渡态设计兼容",是安全发布、也是在任何运行系统上从容演进的关键。认清发布有新旧共存的过渡期、破坏性变更会在过渡期翻车、要用 expand-contract 让每步都兼容——这,是我用一次"改个字段名,发布到一半就报错"的事故,换来的、关于架构、也关于如何在活着的系统上安全地做出改变的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次改一个跨服务的字段时,先停下来想一句"发布那几分钟新旧并存,旧的认得这个改动吗?要不要先加后删分两步走",那我对着那段"滚到一半就报错"的监控曲线熬的那个发版夜,就值了。
—— 别看了 · 2026