
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块里先定义两个upstreamupstream xiaomeng_a { server 10.0.1.10:8080 weight100; server 10.0.1.11:8080 weight100; keepalive 32; } upstream xiaomeng_b { server 10.0.1.20:8080 weight0; server 10.0.1.21:8080 weight0; 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的缓存结构原来缓存的是一个完整的用户对象JSONB版本改成了按字段拆分的多个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白名单限制不能裸奔在生产上。