4.1 版本发布:灰度按租户分批
场景:升级是 SaaS 的心跳时刻
我们最狼狈的一次发布发生在第十五个租户上线后仅仅两周。那天晚上八点,一版包含了三十六个文件变更的更新发布到全部服务器,十五分钟后客户群开始陆续亮灯:A 户的导出不工作了,B 户的登录页报错五次才能进去,C 户倒是没事——但他们的老板正在群里问系统是不是坏了。运维同学回滚以后,所有人都瘫在椅子上问了一个问题:为什么我们要让所有客户陪着一小撮人冒险?
定制交付时代,升级是双方约好的仪式:客户知道要停机,要出问题也是一家的事。SaaS 的升级则必须是「客户无感」的常规操作——周五晚上八点到周一早上九点之间,任何时刻都可能有大租户的员工在加班用系统,你找不到真正安全的停机窗口。所以除了把发布本身做稳,更根本的解法是让 blast radius(爆炸半径)可控:同样一份代码,先让少数租户用上,确认没事再放大。
这就是按租户灰度的起点。它把「升级」从一个全平台的心跳时刻,变成了一段可以在任何节点喊停、且只有少数人受影响的滚动过程。一年后,我们的发布日志里写着:两百三十四次发布,十一次回滚,全部回滚都在第一批五户之内被发现并完成,没有任何一次事故蔓延到二十户以外。这份成绩不是我们多厉害,而是这套流程的结果——流程立起来之后,即使新人操作,也出不了大圈。
灰度策略我们也比较过几种:
| 策略 | 风险 | 复杂度 | 我们的采用 |
|---|---|---|---|
| 按服务器分批 | 中 | 低 | 早期使用 |
| 按租户分批(路由层) | 低 | 中 | 现在的方案 |
| 按特性开关逐户开启 | 最低 | 高 | 关键功能使用 |
配置:路由层分流与规则表
实现的关键在网关与配置中心的配合。代码部署到全量机器,但每个请求进来时,网关先查这个租户是否在当前版本的白名单里,不在就路由到旧版实例:
class GrayRouter
{
// 返回该请求应路由到的版本号
public function version(int $tenantId): string
{
$gray = $this->config->get('gray.release');
// 命中回滚名单的租户始终走上一稳定版
if (in_array($tenantId, $gray['pinned'], true)) {
return $gray['stable'];
}
// 灰度名单按批次分摊
foreach ($gray['batches'] as $i => $batch) {
if (in_array($tenantId, $batch, true)) {
return $i < $gray['current_batch'] ? $gray['next'] : $gray['stable'];
}
}
return $gray['stable'];
}
}
这段代码的精妙不在逻辑本身,而在它配合的发布清单。每次发布前必须填一张灰度计划表,作为发布工单的一部分,工单里没有这张表的发布会直接被流水线拒收:
| 批次 | 租户数量 | 观察时长 | 自动回滚条件 |
|---|---|---|---|
| 第一批 | 五户 | 六小时 | 错误率超过一个百分点 |
| 第二批 | 二十户 | 十二小时 | 错误率超过千分之五 |
| 第三批 | 全量 | 二十四小时 | 任何核心指标异常 |
每批之间必须留够观察窗口,窗口内由监控看板代替人工盯盘(监控的搭建见 5.1)。核心指标包括接口错误率、登录成功率、慢请求增加量与客服工单新增量——最后这项接的是客服系统的 webhook,有时候系统指标还没动,客户的抱怨先到了。我们内部有句玩笑:客服工单是最灵敏的 APM,听着像段子,但它在至少三次故障里确实比我们的告警快了五分钟。
发布清单里还有一项是与回滚预案配套的「数据可逆性检查」:发布涉及的任何数据写入变更,必须先写好回滚 SQL 并附在工单里。写 SQL 的人常常觉得多余,但真到回滚的两分钟里,现想 SQL 是来不及的,预案的价值只在用上的那一刻兑现。
回滚的操作也做了简化:一个命令把灰度名单清空并把 stable 版本指回上一版本,整个过程两分钟。回滚发生后,失败的原因必须写成复盘归档,下一次发布前先回顾上次的复盘——这条规矩立起来之后,同类原因的故障再没重复过。
量化:发布健康度
我们把发布当作一个可度量的产品功能跟进了半年,得到了一组很有意思的数字:
| 指标 | 数值 |
|---|---|
| 全年发布次数 | 两百三十四次 |
| 回滚次数 | 十一次 |
| 回滚均在第一批发现 | 十一次 |
| 回到稳定耗时 | 平均两分四十秒 |
| 客户主动感知到的故障 | 两次 |
倒数第二行是我们的成绩单:从决定回滚到全量恢复,平均不到三分钟,这是过去想都不敢想的速度。而最后那两次客户感知到的故障,都不是发布本身的问题,而是发布过程中配置中心缓存刷新延迟导致的串区(见 2.3 配置中心),后续通过发布流水线加一步强制刷新解决了,也让大家重新认识了配置中心在发布链路里的位置。
顺带说一个观察:灰度发布对开发同学的心理改造比技术改造更大。以前发布是「赌一把」,大家周五晚上都紧张得不敢走;有了灰度之后,发布变成了可以随时喊停的常规动作,反而敢在白天发版了。胆量问题被工程化消解掉之后,团队的迭代速度肉眼可见地加快——这件事没法在代码里度量,但它真实发生了。
演进:三代发布流水线
我们的发布方式也经过三代演进,每一代都是被一次狼狈推动的:
| 代际 | 做法 | 触发升级的事件 |
|---|---|---|
| 第一代 | 全量发布,人工盯盘 | 十五户全部亮灯 |
| 第二代 | 按服务器分批 | 代码与服务器分布不齐 |
| 第三代 | 按租户路由加回滚 | 热修与暗数据事故 |
第三代的关键认识是:机器粒度的分批解决不了代码与数据的耦合问题,租户才是与业务边界对齐的天然单位。一旦想通这一点,路由层、配置中心、监控看板三件事就被串了起来,发布体系也真正成熟,新员工入职只需要学会这套流程就能安全地完成发布动作。
踩坑:灰度发布的四个雷
第一个坑是第一批选错了租户。最初我们按注册时间选最早的五户,结果全是小户,流量低、用法单一,根本覆盖不到真实的生产模式。后来第一批固定包含一户内部团队、一户愿意配合的大客户、三户典型业务形态的中小客户,并且每次轮换。第一批必须是「最有代表性」而不是「最不重要」的,这个认知反直觉但极其关键,我们的灰度质量在改选标准后立刻上了一个台阶。
第二个坑是灰度期间的会话问题。用户在灰度边界上活动的瞬间,请求记住了新版本、会话留在旧版本,导致部分功能行为不一致。我们的处理是灰度切换在租户粒度整体生效,且切换前给该租户的活跃会话做一次平滑迁移,要求十分钟内无关键操作时切换。
第三个坑是数据迁移与代码发布的顺序。Schema 变更必须先于代码上线、且兼容新旧两个版本,这个老生常谈的道理我们在灰度上吃了亏:新版本的代码往一张新字段里写数据,灰度到一半回滚,旧版本不认这个字段但也不报错,导致这批数据成了暗数据(dark data)。之后立了规矩:任何 Schema 变更必须经历「兼容旧代码的中间版本」这一步,绝不允许一步到位。
第四个坑是特性开关的滥用。关键功能可以用开关逐户开启,但开关一旦多了,组合数会爆炸,等于每个人都在用不同的系统。我们的约束:全局同时打开的实验类开关不超过五个,每个开关有明确的关闭日期,超过三个月的开关进入清理流程。开关是解药也是负债,用的时候想清楚什么时候摘,想不清楚的干脆别打开。
小结
灰度发布把升级从心跳时刻变成了工程化流程,核心是敬畏每一次变更:发布前必须填分批计划、发布中靠监控代替人眼、出问题靠两分钟回滚兜底。它也是多租户架构的一个隐藏红利——租户本身就是天然的灰度分桶,做单机应用的公司想灰度还得想办法切流量,我们天然的拥有一百多个互不干扰的实验组,这是多租户架构送给发布流程最好的礼物。下一节看计量计费:每一次发布背后,都得有钱在转。再补两条发布纪律的实战细节。第一是发布窗口与业务高峰错开:我们把可发布时间固定在每周二到周四的上午十点,周五不发布、大促前一天不发布。这不是迷信——周五发布意味着出问题要拖到周末回滚,而周一又是流量尖峰,两头都赌不起。制度张贴之后,周末告警肉眼可见地少了。第二是每次发布都要带一份一句话风险摘要:这次改了哪个模块、影响哪些租户、回滚开关在哪。摘要不超过五行,发布前五分钟在群里过一遍。刚开始大家嫌形式主义,执行两个月后发现真正有价值的是「发布前五分钟」这个停顿——好几次因为要走这个流程,发布人自己突然想到了遗漏的边界条件,自己喊停了发布。制度真正的价值从来不是约束,而是给人一个理直气壮停下来的理由。
灰度路由的实现思路与 PHP API 实战 的网关层设计可以互相对照。
