1. AB交换的整体设计:先搞清楚为什么不能直接切
做后端和运维的同学应该都见过这种场面:一个核心服务要升大版本,代码review了两轮,测试环境跑了两周,所有人都觉得没问题,结果上了生产还是出事。不是慢查询把数据库拖垮,就是新接口和老数据不兼容,用户那边直接炸锅。我自己的习惯是,凡是要动线上核心链路,一律先做AB交换,也就是把流量在旧版本A和新版本B之间来回切换,而不是一次性把用户全部赶到新版本上。
小梦这个项目,说白了就是一次典型的AB交换实践。小梦是我们内部对某个用户侧服务模块的代号,负责的是日常请求量很大的一个读写接口。那次要上的B版本改动挺大,涉及缓存结构变更和部分接口超时策略调整,属于那种“看着不大、出事很疼”的改动。如果直接原地升级,一旦出问题,回滚成本极高,甚至可能要连夜捞日志手工修数据。所以当时定的方案就是:A、B两套环境同时在线,入口按权重切流量,逐步把用户从A引导到B,观察稳定后再全量切换,整个过程可随时回滚。
这套思路在业界的说法有很多,有人叫灰度发布,有人叫金丝雀发布,有人叫AB测试,但本质都是同一件事:不要在一棵树上吊死,先在旁边种一棵新的,确认没问题再把主体移过去。小梦这次用的是最经典的nginx upstream双upstream配置加权重切换,做法并不复杂,但前置工作一点也不少,每一步都是在给线上的稳定性和自己的睡眠质量加码。
核心原则就一句话:切流量之前,先把所有可能出现的问题都当成一定会出现来准备。接下来我把这次AB交换从设计、配置到复盘完整拆开讲,涉及的代码和配置都是实际可用的版本,你可以直接抄作业改一改就用到自己的项目里。
1.1 为什么AB交换比原地升级更稳
原地升级看起来省事,实际上是把风险全部集中到一个时间点上。你停掉旧进程,启动新进程,这个瞬间如果新版本有隐藏问题——比如初始化参数不对、连接池配置写死、依赖的第三方服务临时抖动——用户请求会立刻报错,而且因为你已经覆盖了所有流量,根本没有缓冲地带。AB交换的好处在于,它把“发布”这个过程从“一个时刻”拉长成了“一个周期”,你可以在周期里的任何一个时间点踩刹车。
为什么这么做不是浪费时间?因为线上环境的真实行为和测试环境永远有差异。测试环境的数据量级、并发模型、用户操作习惯,都跟生产环境差着数量级。只有让少量真实用户先走到新版本上,你才能看到真正的数据库慢日志、真正的第三方超时分布、真正会出现的参数边界问题。而AB交换给了你一个安全的观察窗口,你在这个窗口里做的所有验证,都是用极小的代价换取极大的信息量。
还有一个容易被忽略的点:AB交换其实是给团队一个心理缓冲区。运维不用手心冒汗地等着报警,开发不用在发布窗口里全程盯着手机,测试可以有条不紊地回归核心用例。人一旦紧张就容易操作变形,而AB交换天然降低了这种压力。
1.2 AB交换、蓝绿部署、滚动发布三者怎么选
很多人会把AB交换、蓝绿部署、滚动发布混在一起说,实际上它们解决的是不同层面的问题。蓝绿部署强调的是“两套独立环境随时可以整体切换”,它的核心是有完整的绿色环境和蓝色环境,入口通过路由一次性切到另一边;滚动发布强调的是“同一套环境里按批次替换实例”,每次替换一部分,直到全部替换完成。
AB交换更像是一种流量维度的灰度策略。它不要求你有完全独立的第二套物理环境,也可以通过同一个集群里的不同分组来实现,核心动作是控制入口流量的分配比例和监控新旧版本各自的健康状态。在实际场景里,这三者经常组合使用。小梦这次用的是偏蓝绿的做法:A和B其实是两套独立的服务实例组,域名通过nginx区分,AB交换就是在同一个入口域名下动态调整两组实例的权重。
选型的判断标准其实很朴素:如果改动涉及数据库结构或缓存协议,用独立的B环境更安全;如果只是改了业务逻辑,用同一集群内的滚动发布就够;如果你需要在切换过程中对比新旧版本的性能和错误率,那就必须做真正的AB流量分桶。小梦的改动涉及缓存结构变化,所以我坚持用独立B环境,避免新旧逻辑在同一个实例组里互相干扰。
1.3 小梦项目改造前需要摸清的底细
动手之前先盘清楚几个关键信息:服务的QPS峰值、平均延迟、错误率基线、依赖的下游服务和中间件、还有上线时间窗口内是否有其他团队在动同一套基础设施。这些信息决定了你的切换节奏和回滚预案。小梦的QPS峰值大概在4000左右,平时波动不小,早晚高峰明显,依赖MySQL和Redis,还有两个内部RPC服务。这是个典型的读多写少业务,所以AB交换期间的压力验证重点在Redis连接数、MySQL慢查询和RPC超时率。
另外一个必须盘的底细是配置项。A和B两组实例在启动时会拉取不同的配置,但有些配置是全局的,比如注册中心地址、日志采集端点、监控上报地址、链路追踪的采样率,这些不能做成AB有差异。我见过不止一次因为AB环境里日志上报地址配错,导致切量期间线上日志丢失,排查问题时两眼一抹黑。所以切量前要做一次配置diff检查,把A和B的配置逐项对比,只允许出现你预期中的差异项。
2. 环境准备与关键配置:把AB两套环境搭稳
环境准备阶段最忌讳的就是“差不多就行”。AB交换本身是为了降低风险,如果环境本身搭得模棱两可,比如A和B共用了一个配置文件,或者B环境的依赖库版本和线上不一致,那整个切换过程就是在一堆不确定因素上走钢丝。小梦这次搭环境花了一天半,其中大部分时间不是在写代码,而是在做核对和验证。
先说架构定位。小梦的服务部署在Kubernetes集群里,A环境是当前生产的稳定版本,B环境是要上线的新版本。两组服务各自有独立的Deployment、Service和Pod副本数,互不干扰。入口层用nginx作为统一接入,nginx配置里维护两个upstream,分别指向A和B的Service地址。AB交换的核心操作就是通过修改nginx配置里的权重参数,把流量从A往B迁。
搭独立环境的时候有几点要注意,我踩过的坑直接写出来。服务实例的副本数不要一开始就拉得很大,B环境先用最小可用副本数跑起来,比如两个Pod,够接收小流量就行,避免浪费资源也避免过早暴露容量问题。B环境的JVM参数、连接池配置、线程池参数要和A环境保持一致,除了你要验证的差异项,其余全部都对齐,否则你根本分不清性能差异是代码变化导致的还是配置变化导致的。数据库和Redis则共用生产实例,因为小梦的业务数据是连续的,不能用一套测试数据来模拟生产行为,但必须在代码层面保证B版本对已有数据的读写是兼容的。
2.1 nginx双upstream配置示例
nginx的AB切换配置是这套方案的核心,也是网上能找到很多但往往不完整的部分。我这里直接给出一个可以落地的配置思路。在nginx.conf的http块里,先定义两个upstream:
upstream xiaomeng_a { server 10.0.1.10:8080 weight=100; server 10.0.1.11:8080 weight=100; keepalive 32; } upstream xiaomeng_b { server 10.0.1.20:8080 weight=0; server 10.0.1.21:8080 weight=0; keepalive 32; }然后在server块里,用split_clients或者简单的server指令配置分流。如果你想用最直观的权重方式,可以直接把两个upstream都挂在同一个location下,通过变量来动态选择。不过nginx的upstream不能直接通过变量切换,所以更实用的做法是用一个中间层,比如OpenResty的balancer_by_lua,或者更简单的:直接用两个location加cookie分流。小梦这次用的是OpenResty,因为要支持按用户维度的会话保持,所以配置类似这样:
location /api/ { set $backend 'xiaomeng_a'; if ($cookie_user_version = 'B') { set $backend 'xiaomeng_b'; } proxy_pass http://$backend; }proxy_pass里的变量会触发nginx的resolver解析,所以这里更适合用OpenResty的balancer_by_lua来做动态权重切换。具体做法是在init_by_lua里定义upstream列表和当前权重,然后在balancer_by_lua里根据一个全局变量决定转发到哪组。全局变量可以通过一个简单的管理接口动态修改,这样就实现了不重启nginx热切换权重。这个方案比改配置reload更流畅,也方便程序化控制。
2.2 非OpenResty场景下的权重切换办法
如果你不想引入OpenResty,就用最朴素的nginx reload方式。每次调整权重就改一下upstream里的weight值,然后执行nginx -s reload。它的缺点是reload瞬间可能有少量请求延迟抖动,但实际影响很小,几毫秒级别,在大部分业务里完全可接受。
需要注意的是,reload的时候nginx会重新解析配置文件,如果配置文件里有语法错误,reload会失败。所以每次准备切换前,先执行nginx -t检查语法,再reload。另一个容易踩的坑是,修改权重后要把旧的worker进程完全退掉再确认新配置生效,可以通过nginx -T导出现有配置确认weight已经变了。我曾经遇到过reload之后权重看起来改了,但实际上因为配置文件里写的是相对路径,导致加载了错误目录下的旧配置,白白浪费了半个小时的排查时间。
管理权重的另一个思路是使用consul或etcd配合confd动态生成nginx配置,这样改权重只需要调用接口写一次注册中心,confd检测到变化后自动reload nginx。它的优点是权限可控、操作有日志、适合多人协作的团队,缺点是多引入一套组件,小团队如果运维能力有限,反而会增加维护成本。小梦这次没有上consul,因为时间窗口紧,而且涉及的人不多,直接用了OpenResty的管理接口方案,简单直接。
2.3 数据层兼容性:缓存和数据库怎么处理
AB交换里最容易被忽视但最容易出问题的,是数据层。小梦这次B版本改动了Redis的缓存结构,原来缓存的是一个完整的用户对象JSON,B版本改成了按字段拆分的多个key。如果A和B同时在线,A版本写入的旧结构缓存,B版本读取时就会发现数据格式不对。所以处理方案是:B版本在读取缓存时做一段时间的双格式兼容,读取旧key失败后自动回源数据库并重建新key,同时写入新key和一份旧key的数据,保证A版本还能读到旧结构。
这种双写策略很丑,但非常有效。它本质上是给了数据层一个过渡期,让新旧格式能共存。切换完成后,等旧key的TTL自然过期,再在下个版本里把双写逻辑删掉。这里有一个重要的实操细节:双写期间要把Redis内存监控打开,因为短时间内缓存数据量可能是原来的两倍,如果内存不够会导致Redis淘汰策略触发,反而把热key给淘汰掉。
数据库层面的兼容性更直接。如果B版本涉及表结构变更,原则是只加字段、不改字段、不删字段,用增量迁移代替全量重建。小梦这次加了一个索引和两个冗余字段,上线脚本放在B版本发布前执行,执行完之后A版本继续运行也没有任何影响。这是数据库变更的通用安全姿势:先让数据库结构兼容新旧两种代码,再让代码兼容新旧两种结构,最后才谈流量切换。
3. 实操过程:从小流量试探到全量切换
这次小梦的AB交换大体上分了三步走:先切5%流量观察30分钟,然后切到30%观察一个完整的高峰周期,最后再逐步放大到50%、80%、100%。每一步都设了明确的验证指标和止损线,一旦指标踩线就立即回滚。
切量不是拍脑袋拍出来的,它依赖前面铺垫的监控能力。小梦的监控体系覆盖了服务自身指标(QPS、耗时、错误率、线程池活跃度)、中间件指标(Redis命中率、MySQL慢查询数、连接数)、业务指标(核心接口成功率和数据一致性校验)。在这些指标齐备之前,我不会开始任何切量操作,因为切了也看不清状态,等于盲切。
3.1 什么是可回滚的灰度切换
可回滚是AB交换的生命线。所谓可回滚,不只是说“我保留了旧代码”,而是指在任意切换阶段,你都能在尽可能短的时间内把流量全部导回到A环境,并且整个过程不需要停机、不需要导数据、不需要手改数据库。小梦这次的做法是:nginx或者OpenResty的管理接口里提供两个预设操作,一个是“切到B”,一个是“切回A”,本质上就是把权重参数整体置成100比0或0比100。回滚动作要快到什么程度?我个人定的标准是,从决定回滚到流量全部回到A,最多不能超过5分钟,否则就谈不上快速止损。
回滚的另一个容易被忽视的方面是状态清理。如果B环境在运行期间往数据库里写入了脏数据,这些数据不会因为流量切回A就自动消失。所以切量之前要约定好:哪些表允许B环境写入,哪些是只读的,B环境写入的数据如果依赖A环境的逻辑,会不会造成冲突。小梦这次因为B版本有双写逻辑,所以提前写好了对账脚本,切回A之后跑一遍,把B写入的临时key和冗余字段统一清理掉。没有这步准备,回滚只是把用户流量带回原点,但数据残留问题会持续发酵。
另外,回滚期间的服务发现也很关键。如果你的B环境注册到了注册中心,而A环境也在同一个注册中心里,那么下游服务调用时可能会随机打到B上,导致你明明已经把入口流量切回A了,但下游调用还是有一小部分打在B上。所以切量之前要确认好服务注册的隔离策略,必要时B环境单独用一个注册中心namespace,或者通过标签路由强制下游只走A。这个细节很多人会漏,但我亲眼见过因为漏了这个导致回滚过程中错误率依然居高不下的。
3.2 会话保持与用户分流:保证同一用户始终在一个版本上
AB交换期间的会话保持是很多初做灰度的人容易忽略的问题。如果用户第一次请求落在A环境,第二次请求被负载均衡分到了B环境,而两边的登录态存储机制不一致,那这个用户就会莫名其妙地掉登录,或者操作到一半出现数据错乱。小梦的登录态是存在Redis里的,A和B共用同一个Redis,所以登录态本身不会失效,但如果B版本的session key格式变了,用户从A切到B后就会找不到自己的会话数据。
解决方式有两种。一种是维持session格式完全不变,让AB两套环境共用同一套会话数据结构,优点是不用做用户级分流,缺点是B版本如果要改会话内容就受限。另一种是按用户维度做哈希分流,保证同一用户ID的请求永远落在同一个版本上。小梦用了第二种方式,因为B版本确实改了部分会话中的用户偏好字段结构。具体实现是在nginx层解析用户ID,对ID做hash,然后按比例把hash区间映射到A或B。通过管理接口调整hash区间的划分比例,就能控制AB各自承担的流量占比。
这种做法的好处是切换粒度很细,可以精确到让某一个用户百分百走B,非常适合内部测试人员进行验证。坏处是如果某个用户的请求量非常大,会形成热key压力,需要实时观察单用户维度的QPS分布。另外要注意,按用户ID分流时要排除掉健康检查的请求,否则监控会把健康检查的流量也算进用户流量里,干扰判断。
3.3 切量过程中的核心验证动作
这里我列一下小梦这次在切量中实际执行的验证清单,你可以直接拿去改一改。小流量阶段重点看三类指标:接入层错误率,如果B版本的错误率明显高于A,说明代码在真实流量下有问题;下游RPC超时率和超时分布,B版本如果改动过超时策略,这一阶段会立刻暴露效果;还有就是核心接口的P99延迟,因为小流量阶段并发不高,P99涨一点没关系,但如果涨到超过设定的止损线,就要回退。30分钟观察期不是干等着,要主动制造一些测试动作,比如让产品同学用内部账号在B版本上走一遍核心流程,确认页面交互和接口返回值都符合预期。你指望真实用户来填这些坑是不行的。
30%阶段要盯的是缓存命中率和数据库连接数。B版本如果缓存结构改了,缓存重建初期命中率必然下降,回源数据库的压力会明显上升。我自己定的标准是:如果B版本的缓存命中率降幅超过15个百分点,并且持续超过10分钟,就暂停继续切量,先检查是不是缓存预热没做或者key设计有问题。数据库连接数也有类似原则,如果连接数涨到连接池上限的80%,就说明代码里有连接泄漏或者慢查询拖长了连接占用时间,这时候继续切量会雪上加霜。
50%以上阶段主要看数据一致性。我会同时对AB环境跑同样的查询请求,比对返回结果中的关键字段,确认两边的数据口径一致。这个动作可以用脚本自动化做,也可以在监控面板上手工抽查。数据一致性是AB交换最容易翻车的地方,因为业务逻辑变了可能导致相同输入不同输出,而大部分测试用例覆盖不到这种边界。
3.4 全量切换之后还要做什么
很多人觉得流量切到100%就万事大吉了,其实全量切换只是新版本正式上岗的开始。小梦在全量之后做了三件事。第一件事是把B环境的副本数扩到和A环境一样,然后把A环境的副本数缩到最小,这时候流量实际上是在B环境上跑,但A环境还留着最后一口气,就是为了万一24小时内出现问题能快速回切。第二件事是加强监控频率,在24小时内把报警阈值调低一些,比如错误率报警阈值从0.5%调到0.2%,慢查询报警阈值从2秒调到1秒,让任何小问题都能及时暴露。第三件事是安排值班,把开发和运维拉到一个群里,群里挂着监控面板链接,谁发现问题直接说话,24小时内不许静默。
还有一个细节:全量切换后的日志和链路追踪要重新确认一遍。因为B环境在灰度期间只接收了部分流量,日志量不大,但全量之后日志量会突然放大几十倍,这时候如果日志采集的缓冲区配置太小,就可能丢日志。链路追踪的采样率也要注意,分布式追踪在低流量下可以全采样,全量后如果不降低采样率,会带来额外的存储和性能开销。小梦这次把采样率从100%调到了10%,只对错误请求保留全采样,监控信息基本没丢,存储压力却小了很多。
4. 常见问题与排查技巧实录
这次小梦的AB交换执行得算是比较顺利,但中间也踩了几个小坑,记录一下,给后面做灰度切换的同事提个醒。
4.1 切量后B版本大量出现登录失效
这个问题第一次出现在小流量阶段,切了5%流量后,B版本的登录态命中率一下就掉了不少。排查下来发现,B版本的登录态key里增加了一个版本号前缀,但Redis里旧key还在,新key没有直接建立,用户第一次请求B时找不到新key,就被当成未登录处理了。这类问题在AB交换里太典型了,数据结构和逻辑变了,兼容层没有做好,导致新环境无法读取老数据。
解决方案就是前面提到的双读双写:B版本读取时先尝试新key,如果没有再尝试旧key,一旦命中旧key就回源数据库并把数据同时写入新旧两个key。这个兼容逻辑至少保留一个业务周期,等所有旧key的TTL都过期以后再下线。这里有一个经验值:不要用一个恒定的TTL值来估算过期时间,因为生产环境里key的TTL是滑动续期的,总会有一些key被持续访问而永远不过期,所以判断下线的依据应该是“一段时间内旧key命中率已经降为零”,而不是“我觉得已经过了足够久”。
4.2 B版本数据库连接池被打满
切换到30%的时候,B版本的数据库连接数突然冲到连接池上限,再往上切就会有数据库连接超时的风险。当时第一反应以为是连接泄漏,后来查了一下,发现是B版本的一个查询缺少了索引,慢查询把连接占住不放,导致连接池被慢慢耗尽。这其实是个老问题:测试环境数据量小,没索引也能跑得挺快,生产环境数据量一上去,执行计划就完全不同了。
排查方式很直接:在数据库端开慢查询日志,找出B版本独有的慢SQL,用explain查看执行计划,对比A版本同类的SQL差异。我建议在切量之前就先把生产环境的慢查询阈值调低,比如从2秒调到0.5秒,先跑一天看有没有新增的慢SQL,而不是等到流量切上去之后被动发现。另外一个实用的建议:B版本的数据库账号和A版本用同一个账号是省事,但如果要做针对B的慢查询分析,最好给B单独一个账号,方便在数据库端做按账号维度的统计。
4.3 回滚到A后仍有部分流量打到B
这个问题非常隐蔽,当时差点造成二次故障。回滚操作执行后,入口nginx确实把流量全部切回了A环境,但监控上仍然看到B环境偶尔有个位数的QPS。排查下来发现,是B环境的服务实例还在注册中心里,而A环境的下游服务在做RPC调用时,通过注册中心随机选了一个实例,偶尔会选到B。也就是说,流量从“入口”回滚了,但从“内部调用链”没有回滚干净。
这类问题的处理思路有三层:第一层是B环境发布时尽量单独注册,不要和A环境混在同一个服务分组里;第二层是如果必须混用,就要在调用方配置强制路由规则,指定调用A分组;第三层是回滚操作完成后要检查注册中心里的服务实例列表,确认B环境已经摘除或标记为禁用。小梦这次之后,我把回滚检查清单里加了一条:回滚后5分钟内确认注册中心无B实例,同时确认nginx配置里的权重已恢复,双维度兜底。
4.4 配置漏改导致灰度期间行为不一致
还有一个容易被人忽略的坑是业务配置项。小梦的代码里有一些功能开关是放在配置中心的,B版本为了验证新逻辑,把某个开关打开了,但A环境还是关着的。切量到一半的时候,测试反馈说有一部分用户展示的数据和另一部分不一样,排查了半天才发现是有个开关造成了差异。不是代码的问题,是配置的差异。
这个问题的根源在于,AB交换期间,环境差异项应该是明确列出并经过评审的,而不是散落在各个配置中心里。我给团队定了一条纪律:每次AB交换前,从配置中心导出一份AB环境配置对比,所有差异项必须标注原因和预期影响,没有标注的差异一律视为异常。这个流程只需要花二十分钟,但能避免无数个“看起来代码一样但行为不同”的疑难杂症。
5. 一次AB交换的经验复盘和最后的小建议
小梦这次AB交换最终是平稳落地的,从开始切1%流量到全量切换完成,总共用了不到一天时间。整个过程中我个人收获最大的一点其实是克制:无论测试环境跑得多好,无论代码评审多顺利,都不要试图跳过小流量验证这个阶段。你在小流量阶段多花的一个小时,可能省下的是全量故障后的一个通宵。
有几个经验想特别提一下。第一,所有切换动作都要有脚本化和记录,不要手动执行,手动操作在深夜出错的概率会被无限放大,脚本哪怕再简陋,也比手工输入强。第二,切换的关键时刻不要把决定权交给某一个人拍脑袋,提前定好止损线和回滚条件,到时候大家只是在执行预案,而不是现场争论。第三,AB交换不是运维单方面的事,开发和测试必须全程在场,因为监控指标只能告诉你“出问题了”,但只有写代码的人能快速判断“为什么出问题”以及“这个错误要不要紧”。
最后再分享一个小技巧:切量期间我会额外搭建一个只对内部开放的可疑域名入口,指向B环境,让开发和测试可以随时绕过nginx分流规则直接访问B环境做验证。这个入口相当于一个后门,但它的存在让灰度期间的验证效率提高了不少。不用每次都去监控面板里找数据,直接拿真实请求测一遍就能感知到新版本的响应速度和页面表现。不过注意,这个入口要做IP白名单限制,不能裸奔在生产上。