
去年年初我们团队接了一个让所有人都头皮发麻的项目把一套跑了快八年的订单系统从单体老架构整体迁移到新的分布式架构上。项目代号被定为 Apollo-11。当时团队里的第一反应出奇一致这名字压力太大了。阿波罗11号是人类历史上第一次成功登月的任务属于那种只许成功不许失败的事。而我们手上这件事本质上也差不多——业务一天不能停订单一个不能丢新老系统要共存至少三个月真正的切换窗口只有一天。任何一个环节出了不可控的错误背锅的不只是技术团队整条业务线都会跟着遭殃。这篇东西算是对整个项目的一次完整复盘。我会尽量不写空话把项目从任务拆解、技术选型、实施落地到踩坑、上线、复盘的全过程摊开来讲。如果你想在自己团队里做一次类似的登月式改造这篇文章大概能帮你提前避开我们踩过的一半的坑。1. 为什么一个系统迁移项目要叫 Apollo-11项目背景与三个硬指标1.1 被促销活动逼到墙角的旧系统先说清楚我们迁移的到底是什么。这套订单系统是八年前搭建的单体 Java 应用订单、支付、库存、售后、优惠券的逻辑全部揉在一个工程里。数据库是单实例 MySQL没有读写分离也没有分库分表。前几年业务量不大扛得住。但从去年开始运营团队几乎每个月都要做一次大促活动系统的表现开始让人坐不住了。最典型的一次活动开场十分钟下单接口的 P99 延迟直接飙到 5.2 秒。用户的体验是点击提交订单之后页面转圈然后弹出一个系统繁忙请稍后再试。那十分钟里应用层的机器加到上限也没有用——瓶颈在数据库连接上单库的连接池早就被撑满了。更麻烦的是业务迭代速度。运营那边提一个小需求比如在订单列表里加一个筛选项代码改动只涉及两三行但开发和回归测试至少要排三天因为这个字段被几十个接口间接引用着谁都不敢拍胸脯说只影响一个地方。每次上线都像走钢丝回滚率越来越高业务方和我们互相看着对方叹气。这个状态持续了小半年后技术负责人终于拍板拆必须拆。于是系统改造项目正式立项代号 Apollo-11。取名时没有太多讨论大家默认了——这个项目跟登月一样没有退路只能稳稳地落在地上。1.2 只许成功的三个硬指标项目一开始我们做的第一件事不是选技术栈也不是画架构图而是先跟业务方一起把什么叫成功定义清楚。这个动作看起来简单实际是最关键的一步。如果没有硬性指标项目很容易变成边做边改、永无止境的泥潭。我们最终定了三个指标后来的所有决策都以它们为准绳第一个指标业务连续性。迁移期间订单数据零丢失整体可用性不低于 99.9%。这条要求对应的事实是我们没有任何停业几天专门迁移的奢侈选项业务每天都得照常跑用户下了单就必须能支付、能查到订单。第二个指标数据一致性。新老系统在共存期的数据差异率低于万分之一并且必须有定时的核对程序可查。注意不是尽力保持一致而是有程序在持续验证一致性。这一条后来帮我们抓出了不少真问题后面的踩坑记录里会详细讲。第三个指标切换停机时间不超过 15 分钟。从关闭旧系统写入口到新系统完全接管流量这个窗口最长只能有 15 分钟。那些年里积累的未完成订单、进行中的支付回调、定时关单任务都必须在这 15 分钟内平滑交接。这三个指标看起来朴素但它们把所有模棱两可的讨论都消解了。比如有人提议要不直接停机一晚上把数据全量导过去算了第一个指标就把它否决了。有人提议先切一部分功能看看效果第三个指标又限制了切换方式。指标就是项目的宪法后面所有技术方案都要拿这三个指标去走一遍过不了关就直接毙掉。2. 把登月拆成 326 个任务两个月的项目拆解工程2.1 一套适合实战的拆解方法拿到项目的第一个月我们基本没写代码全在拆任务。登月这种级别的项目最忌讳的就是上来就想最后一跃——你得先想清楚火箭要分几级、每级烧什么燃料、什么时候抛弃。我们用的是最朴素但最扎实的方法工作分解结构WBS。原则很简单拆到一个任务可由一个人在一周内完成为止。听起来轻松实际拆起来很痛苦。需要把系统里每一块业务逻辑、每一条数据流转路径、每一个外部依赖都捋一遍。最终拆出来的结果是6 个一级模块23 个二级模块326 个叶子任务。我列一下一级模块方便你感受项目全貌一级模块包含内容任务数基础设施环境搭建、网络打通、容器化部署、监控告警38订单域迁移下单、订单查询、状态机、超时关单74支付域迁移支付回调、对账、退款、账单52售后域迁移退款申请、售后工单、客服操作61数据同步与核对双写逻辑、消费程序、核对任务、差异修复48上线与回滚灰度策略、切换脚本、回滚预案、演练53拆完之后我们又做了一遍依赖关系梳理标出每个任务的前置依赖。这一步是必须的不然后面排期就是一团乱麻。比如核对程序开发依赖双写逻辑上线双写逻辑上线依赖消息队列环境准备好——这些关系全都画在依赖矩阵里挂在项目看板上。所有任务串起来形成一个有一条清晰关键路径的网络。2.2 里程碑设计与最小闭环先行任务拆完接下来的问题是326 个任务先做哪些、后做哪些我们定了四个里程碑全部采用倒排计划从最终切换日期往回推M1 破土基础设施环境就绪容器编排平台可以用监控告警接入。M2 双轨双写链路跑通新系统开始被动接收数据每天自动核对。M3 翻越第一批真实业务流量切到新系统从小流量到灰度。M4 登月核心链路全量切换旧系统进入只读状态15 分钟内交接完成。这里最值得展开说的是最小闭环先行的策略。当时团队里有人建议先啃订单域这块最硬的骨头理由是这个域最核心、最复杂早做早发现问题。但我们在 M2 阶段刻意反着来选了所有域里看起来最不起眼的售后域做第一个完整闭环。为什么这么干因为售后域有天然优势业务逻辑相对简单、数据量小、出问题的影响面可控。我们的真实目的是用这条小链路把整套迁移方法论完整跑一遍——从双写、核对、灰度、回滚到问题排查全部走通。那次演习异常顺利只花了两周半就完成了一共发现 17 个问题全是环境级和配置级的小问题。这些问题如果直接放到订单域迁移时才暴露处理难度至少翻三倍。所以我的建议很明确大型系统改造第一次执行一定要挑小的环节练手把方法论跑熟再碰核心链路。3. 三条关键决策线微服务边界、数据双写与灰度策略3.1 微服务拆分的边界从哪来技术选型的第一个大问题是拆成几个服务、边界画在哪里。很多人会直接想按业务域拆分但这个说法太虚了落到具体代码上部门里的老工程师们能吵三天三夜也吵不出结果。我们的做法分三步。第一步先把订单系统的核心领域模型完整画出来订单、支付单、售后单、商品库存、优惠券以及它们之间的关系。第二步对照旧代码的包结构逐层看找出哪些代码在频繁变动被一个需求改一次就牵一发而动全身的哪些代码已经稳定运行了很久。第三步以变更频率为主要维度切分——变更频繁的业务逻辑和变更稳定的逻辑必须拆开变更频繁彼此关联紧密的逻辑放在同一个服务里。最终拆成了 7 个服务订单服务、支付服务、库存服务、售后服务、用户服务、优惠券服务、消息网关。实话说这个拆分方案并不惊艳但它是团队所有人在现有代码约束下的一致选择。我们刻意没有引入事件溯源、CQRS 这类很酷但复杂的架构——不是因为它们不好而是团队现有人员的能力边界摆在那里复杂度越高项目失败的概率越大。Apollo-11 的历史经验也说明成功不是靠最先进的技术而是靠每一个环节都被充分验证过。3.2 数据双写方案为什么选它以及怎么设计的迁移项目里最大的技术难题永远是数据。老系统有数百万条历史订单新系统的数据结构又不完全一样怎么保证新旧两边数据最终一致我们在停机迁移双写全量加增量三个方案之间对比了很久方案优点缺点是否符合硬指标停机迁移简单直接一次性导入需要停机数小时业务中断不符合指标一全量加增量停机窗口短增量同步的实时性难保障切换瞬间可能丢数据有风险双写业务无感知可持续验证实现复杂要处理消息丢失、重复、乱序符合全部指标最后我们选了双写。设计上有一个关键点双写不是新系统同步调用旧系统两边同时写那样做会把新系统的故障直接传导给旧系统非常危险。我们的做法是旧系统的订单创建接口在原有逻辑之外额外发一条消息到消息队列新系统通过消费这条消息在新建库里落一份数据。旧系统的正常业务流程完全不依赖这条消息有没有发成功即使消息队列挂了旧系统该怎样还怎样。新系统的数据是异步追出来的不阻塞老链路。为了不丢消息旧系统里每个发消息的节点都有本地补偿任务做兜底——扫描最近 5 分钟内已创建订单但未成功推送的补发消息。新系统消费时则重点处理幂等每个消费处理函数都要求业务唯一键去重保证同一条消息无论被消费多少次最终落库效果都只算一次。这个细节直接避免了后面出现重复订单号的坑至于具体过程后面踩坑部分细说。冷数据那边我们做了一次全量历史订单迁移一次性导入然后从这个时点开始双写产出的新数据持续叠加。每天凌晨核对程序对前一日全部订单做差异比对并生成差异报告这个程序在双写跑通之后每次执行都是红点最多的那个系统但恰恰是它给了我们深夜睡得踏实的底气。3.3 灰度发布的三板斧灰度策略我们用了三套工具交错着用互相兜底。第一套是按用户 ID 哈希灰度。用户 ID 对某个固定值取模模落在多少以内的走新系统多少以外的走旧系统。好处是同一个用户的多次请求一定落在同一个系统上不会出现用户跳到新系统看了一眼刷新了一下又弹回旧系统这种割裂感。第二套是按比例灰度我们把核心接口的流量按百分比切给新服务5%、10%、30%、50%、100%每一档停留至少 48 小时观察监控数据。第三套是功能开关每个新服务的核心功能都挂在一个配置开关后面发现问题最坏情况下能按功能而不是按服务粒度地关掉。这三套策略不是三选一而是同时启用。用户维度保证体验一致流量比例保证压力可控功能开关保证最小粒度止损。这套组合拳在后面切换当天发挥了巨大作用尤其是第三套因为它的存在才让我们敢在有已知小问题的状态下继续推进灰度而不是所有问题清零才敢动。4. 三个真实踩坑记录从现象到根因的完整排查链路4.1 重复订单号核对程序抓出来的第一只老鼠双写上线第三天凌晨的核对任务报告了一条警报订单表按主键比对时发现某个订单号在旧系统中出现了一次在新系统中出现了两次。订单号完全相同但其中一条记录的数据和旧系统的对不上。排查过程是从核对程序开始的。我们先把差异记录拉出来发现重复的那两条记录创建时间相差 17 秒业务数据几乎一致只有状态字段不同。接着去翻消息队列的消费日志发现同一个消息 ID 被新系统的消费程序拉取并处理了两次。原因很快定位我们的消费逻辑用的消息队列客户端在消费超时后会自动触发重试而处理函数重试时没有做幂等判断直接往数据库插了一条主键冲突被框架吞掉后又因为数据访问层的存在即更新逻辑把状态字段给覆盖了。解决方案不算复杂在消费处理的最前面增加一个按业务唯一键查重的步骤如果数据库中已有同一订单号且处理状态为已完成直接跳过。这个修复在半天内完成并补上一个针对性的幂等单元测试。这件事给我们的最大启发是双写方案的每一环节都要默认不可靠。消息队列可能不丢消息但消费可能重复数据库可能不丢数据但逻辑可能重复执行。默认不可靠不是悲观是架构设计的起点。4.2 高峰期接口超时的完整排查链路灰度切到 30% 流量后的第一次大促模拟压测监控看板亮了一片红灯一个新服务的订单查询接口P99 从平时的 200 毫秒一路涨到 25 秒。那天的排查链路我记忆很深按顺序给大家捋一下。第一步看监控大盘初步判断是不是某个节点挂了。结果发现所有节点的 CPU 都不高网络带宽正常内存正常没有告警说明不是资源层面的问题。第二步看数据库指标马上看到问题这服务所连数据库的连接池使用率接近 100%活跃会话堆积严重。第三步登录数据库看慢查询日志发现上百条一模一样的 SQL都是同一张订单明细表上的单条等值查询执行耗时却高达 3 秒以上。第四步去翻应用的调用链日志结果发现了一个很眼熟的坏味道——这个查询接口内部有一个遍历订单明细列表的循环循环体里逐条执行了一次数据库查询。老代码里那种著名的循环查库反模式在新服务的迁移过程中被原封不动地搬了过来。平时数据量小根本看不出来灰度流量一上来并发一高每一条请求都放大成几十条 SQL连接池瞬间被打满。修复方法就是常见的循环外批量查询把拆散的单条查询合并成一次 IN 查询在内存里做匹配组装。优化后这个接口的 P99 降到 35 毫秒连接池使用率回落到 15% 左右。这件事教会我们一个教训代码重构的阻力比想象中更大迁移时不能只做物理搬迁必须对每一段核心代码做语义审查把过去几年积累的技术债真正还掉。4.3 灰度切换当天的那次意外回滚最惊险的一次发生在按用户粒度灰度到 50% 的那天下午。我们刚把灰度比例从 30% 提到 50%还没过一个小时客服那边就反馈说有一部分用户反馈售后工单里的图片加载不出来。技术侧的第一反应是新服务里对象存储部分出问题了。但奇怪的是监控看板一切正常后端接口没有错误对象存储访问也都成功。我们把测试账号翻出来一一对照找到了一个共同点出问题的用户全都走了新服务而且这些用户的网络出口需要通过一个特定网关我们在新服务里配置的对象存储访问地址用的却是内网域名。内网域名在大多数场景下是可通的但经过网关出公网的那部分用户请求内网域名无法解析。根因找到后修复本身只花了几分钟配置里换成公网域名。但前面浪费了不少时间因为灰度比例已经被切到 50%影响的用户数已经超过预期。我们当场做了一个决定先把灰度开关回滚到 10%等配置修复并验证 12 小时后再重新提升比例。这个回滚动作我们用了十分钟执行完毕业务影响被控制在一个很小的范围内。事后复盘时大家都庆幸当初准备了功能开关否则这种问题要改代码、重新发版至少得半个小时起步影响面会大得多。5. 上线当天的发射窗口管理checklist、指挥链路与回滚预案5.1 为什么选在凌晨两点动手正式的切换窗口定在一个周四的凌晨两点开始。选择这个时间点的原因很实在那个时段是全天的业务最低谷下单量只有白天高峰的百分之五不到即使出现极端情况需要回滚影响到的用户量也最小。同时那个时间点距离白天的核心业务高峰有六到七个小时足够我们完成切换、观察和微调。运营和客服团队也提前排了值班表切换当天晚上所有人都在线待命但要求不打扰技术操作只在出现用户反馈时做信息汇总。这里有一个值得单独说的决策我们拒绝了一切试试看的心态。发布窗口前一周我们就定了铁律——在切换当天任何未经过预发布环境验证的代码一律不允许动。切换过程只执行已经演练过的操作步骤任何新的想法即使是顺手修一个 bug的级别也绝不临时加上。这条纪律听起来有点不近人情但它是切换窗口不出大事故的前提。5.2 一张覆盖全链路的切换检查单我把切换当天的操作拆成了三个阶段每个阶段做成一张检查单每个步骤都由专人负责执行完打勾记录切换前约 30 分钟确认旧系统各核心接口的流量已经降到最低确认消息队列积压量低于 1000 条确认双写同步的核对程序最近一次执行为通过状态确认所有回滚脚本在备用机器上已验证可执行所有参与人员就位确认信息同步群在线。切换中约 15 分钟关闭旧系统写入口只保留读清空消息队列里未消费的积压消息并确认消费完成启动新系统的数据迁移补数任务切换流量入口确认新系统开始正常接单。切换后约 120 分钟每 10 分钟检查一次接口错误率核对新系统订单创建数量与旧系统切断前数据是否衔接检查支付回调是否正常运行一次全量核对任务观察数据库连接池、消息队列积压、服务 CPU 等核心指标。这三张检查单不是当天临时写的而是项目推进过程中一直在迭代的版本。我们有个习惯任何在演练中发现的遗漏项都会当天补进检查单里。到正式切换之前检查单已经迭代了 11 个版本。5.3 回滚不是认输是预案的一部分切换当天的回滚预案同样提前做了完整的演练。我们设定的回滚触发条件很明确切换后 15 分钟内新系统订单接口错误率超过 0.1%或者支付成功率明显下降或者核对任务的差异数超过设定阈值任何一项触发立即回滚不讨论、不犹豫。理由很简单切换窗口期内的每一分钟都是黄金时间早一分钟回滚就少一分钟的用户影响。而且因为我们选了低峰期操作即使回滚第二天业务照常跑我们还有下一次窗口机会。回滚步骤和切换步骤一样也被拆成了编号清单由专人执行。我们在演练中把切换后回滚这个流程完整跑过三次确保每个人都知道自己在那十分钟里该做什么。最终当天并没有用上回滚。整个切换过程大概 11 分钟完成新系统平稳接管第一个高峰周期一切正常。那个凌晨我们几个核心开发在群里发了一条消息我们落地了。配图是监控大屏上一条趋于平缓的线条。那种感觉跟看登月纪录片里宇航员踏上土地的一瞬间确实有点像。6. 项目结束后的三类沉淀文档、工具和团队手感6.1 复盘会只聊三个问题系统稳定运行三周后我们的核心力量做了一次两小时的项目复盘。复盘会没有那种总结成绩再展望未来的固定流程只讨论三个问题哪些决策做对了哪些地方浪费了时间哪些坑是下次应该主动避开的。决策做对的部分大家认可度最高的是拆解环节的扎实——先把 326 个任务拆出来之后每一步才没有迷失方向其次是里程碑里的最小闭环先行用最弱的域练手把方法论跑熟。浪费时间的部分集中在基础设施环境的准备上——中间因为网络隔离策略调整曾经整整三天的进度停滞当时如果早一点跟基础设施负责人拉齐需求边界可以省下一半时间。坑的规避清单就更直接了循环查库要重点审查、消费幂等必须要做、灰度开关必须预发布验证全是实战总结出来的硬经验。6.2 留下的自动化工具项目结束后我们顺手沉淀了一批小工具现在已经成为团队日常基础设施的一部分。核对程序从每天手动跑变成了持续运行的自动任务一旦出现差异会直接告警到值班群。监控看板添加了十几个针对核心接口的业务指标视图灰度切换有了可视化的支撑。回滚脚本从手动执行系列命令变成了一键执行并输出执行日志下次再需要回滚两分钟之内就能搞定。参数化压测脚本也保留下来每个版本发布前都能快速跑一轮关键链路的压测。这些工具都不复杂但它们解决的是人的记忆不可靠这件事。迁移项目最怕的就是经验只存在于几个核心工程师的脑子里关键人一请假其他人束手无策。把这些经验固化成脚本、看板和检查单等于给团队买了保险。6.3 我最想分享的一条体会项目做完之后我经常被问Apollo-11 这个项目里最重要的技术是什么。我每次的回答都是一样的技术上的难题像数据迁移、消息队列、微服务拆分都能在网上查到成熟的方案和教程真正的难点在于对不确定性的管理。这个项目的每个关键环节我们都在用不同的方式降低不确定性。拆解任务是为了让不确定性提前暴露出来双写加核对是为了让数据问题在无感知的背景下暴露出来灰度加功能开关是为了让流量问题在小范围暴露出来演练加检查单是为了让操作问题在正式切换前暴露出来。当一个项目把所有的不确定性都提前暴露并处理掉了最后一步落地本身就不会太惊险它只是水到渠成。如果你也要带一个大型改造项目我的建议就一条把消除不确定性当成第一优先级而不是把写代码当成第一优先级。代码总会写完的但如果你花了六个月写完代码才发现一开始的架构假设是错的那种代价是任何团队都付不起的。