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 实战 的网关层设计可以互相对照。

延伸阅读