距离上次在技术社区写长文已经过去一阵子了。最近刚把一个代号为“Apollo-11”的内部项目收尾趁着记忆还热把这套从拆解到落地的完整过程做个梳理。先说清楚这不是什么登月计划而是我们团队对一个运行多年的核心业务系统做的现代化重构。之所以起这个名字是因为这次重构的性质很像一次真实的发射任务——目标极其明确时间窗口有限而且不容许失败。系统不能停、业务不能断、数据不能丢所有压力都压在那个“点火”瞬间。这篇文章不是讲某个具体框架怎么写而是把从拿到代号、拆解需求到方案选型、落地拆服务、灰度切换、以及最后平稳收官的完整思路讲清楚。如果你正在面对一套历史包袱很重的老系统或者正准备启动一次重构这篇文章里的很多判断可以直接拿来参考。1. 代号背后的真实需求盘点1.1 遗留系统的典型病灶我先说说为什么要重构。这个系统在团队里运行了差不多八年功能上覆盖了公司核心的交易链路。在早年业务量不大的时候它算得上稳定可靠但随着业务迭代加速问题开始集中爆发。最典型的是代码库结构。整个系统是一个典型的单体应用几十个模块堆在同一个代码仓库里编译一次要二十分钟启动一次要五分钟。模块之间的边界早就模糊了订单模块直接读用户表的字段支付模块调用下单模块的内部类方法互相耦合得像一团毛线。更严重的是数据库层面核心库有上千个存储过程业务逻辑有一大半在数据库里代码里的逻辑反而只是“壳”。这导致一个很小的改动往往要同时改应用代码和存储过程联调成本极高。发布流程也很痛苦。那时候团队上线一个版本需要经过代码冻结、全量回归、半夜发布、次日观察这样的流程一次发版基本要提前两周准备。最夸张的一次因为某个底层表字段改动影响到了上游四个模块的数据读取回滚之后又连带产生了脏数据最后花了两天去补。这种“改不动、不敢改、改错了代价极大”的状态就是典型的遗留系统综合症。1.2 目标拆解不能只谈“重构”很多人一听重构第一反应是“把代码重写一遍换个新框架”。这个想法是大忌。重构的目标根本不是“代码变新了”而是“系统变得可维护、可演进”。所以我们在项目启动阶段没有急着去讨论技术栈而是先把目标拆成了三件事。第一件事是业务目标整个重构过程中对外功能不能变用户无感知。这不是选择题而是硬约束——系统承载的是真金白银的交易任何一次接口格式变动、返回字段缺失都可能造成线上故障。第二件事是技术目标让系统从“单体巨石”变成“模块化、可独立部署、可灰度”的形态。换句话说重构完成后任何一个业务模块都应该能单独开发、单独测试、单独发布而不是每次改动都牵连整个系统。第三件事是度量目标用数据来判断重构是否成功。我们定的几个核心指标包括发布频率从每月一次到每周多次、变更失败率明显下降、平均恢复时间从小时级降到分钟级。没有这些指标重构很容易变成一场自我感动。这三层目标定下来之后后面所有技术选型都围绕它们展开。遇到取舍时我们用一句话判断这个决定是否能让我们更快地发布、更安全地变更、更迅速地恢复。2. 方案选型为什么是绞杀者模式2.1 全量重写为什么不可行方案讨论阶段团队里最自然的想法是“干脆重写一套”。这种思路听着解气但实际操作中几乎不可行。旧系统承载了八年积累下来的业务规则很多规则写在代码里、存储过程里、甚至在某些人的脑子里——文档根本不全。如果推倒重来光是把这些隐性需求重新梳理一遍就要花费无法估量的时间而且重写过程中旧系统还得继续运行、继续迭代两边并行开发和维护人力直接翻倍。更致命的是风险不可控。全量重写的系统要做到“功能完全一致”基本是不可能的。业务系统不是算法题边界情况千奇百怪某个订单状态机有十几种流转分支某种优惠券叠加规则只在特定促销场景出现某类退款在银行渠道有特殊处理。重写团队很难把这些细节全部照顾到最终一定会出现“新系统上线后某些低频业务失败了结果没人知道为什么”的尴尬局面。所以我们最终没有选择“大爆炸式重写”而是采用了绞杀者模式。这个概念最早由几个人在探讨遗留系统改造时提出思路很朴素不推翻旧系统而是用一个新架构逐步包裹、替代旧系统的各个模块。像藤蔓绞杀宿主树一样旧系统还在那里但它的能力被一点点迁移走最终自然消亡。2.2 绞杀者模式的落地骨架绞杀者模式在落地时骨架大致是三层。最上面是一层统一的入口网关。所有外部流量先经过网关再被分发到后端。这一层很关键它是我们切换流量的“总开关”。改造初期网关把所有流量转发给旧系统随着每个模块被替换网关把对应路由切换到新服务。整个过程对调用方来说只是同一个地址返回仍然兼容。中间层是注册中心、配置中心和可观测性体系。新拆分出来的服务统一注册到注册中心由配置中心管理各种动态开关所有请求的日志、指标、链路追踪汇总到统一的监控平台上。没有这一层后面做灰度、做排查都会寸步难行。最底层才是被逐步替换的业务模块。我们把旧系统按照业务边界拆分成若干个候选服务每次只替换一个。替换过程中新旧模块并行运行通过双写保证数据一致灰度逐渐放量。等新模块稳定之后再把旧模块的代码从仓库里删除。整个骨架看上去不复杂但真正执行起来每一步都有很多细节。尤其是“并行期”系统里同一份业务既由旧逻辑处理、又由新逻辑处理如何保证两边数据一致、如何保证对外结果以一边为准是这个模式落地的最难点。这块我放在后面的实操环节专门讲。2.3 技术栈取舍与理由技术选型上我们没有追新。核心原则是“团队能驾驭、生态足够成熟、运维成本可控”。容器化和编排平台是基础这没什么好纠结的。新服务全部以容器方式部署统一由容器编排平台管理。带来的直接好处是环境一致性——开发、测试、生产的环境不再有差异以前“在我机器上是好的”这类问题基本绝迹。服务间通信用了最常规的HTTP与消息队列组合没有激进地引入一身复杂的服务网格体系。HTTP接口适合同步查询场景比如订单详情、用户信息简单直观排查问题也方便消息队列负责异步解耦场景比如下单后发通知、同步数据到搜索引擎。消息队列的价值在于削峰填谷和系统解耦比如大促期间瞬间涌入的订单先写队列再慢慢消费能避免把下游系统打垮。配置中心和注册中心是必备的基础设施。老系统最大的痛点是配置散落在各处改一个数据库地址要改配置文件然后重启。配置中心把这些集中管理起来支持动态刷新新模块上线时某个开关的调整不再需要发版。注册中心则让服务之间的地址发现变成动态的新服务扩容缩容对调用方完全透明。可观测性建设比很多人想象得更重要。老系统时代查问题靠登录服务器看日志、看数据库慢查询效率极低。我们提前搭好了日志采集、指标监控和链路追踪三大件。链路追踪尤其关键一个请求从网关进入经过多个服务节点最终落库谁慢、谁报错一眼就能定位。这次重构能顺利进行这套可观测性体系功不可没。3. 核心环节实操拆服务、双写、灰度3.1 三步拆出第一个微服务作为试点我们选了“用户权益”这个模块。为什么选它有两个原因一是它的业务边界相对清晰对外提供的是独立能力和主交易链路的耦合比较弱二是这个模块最近改动频繁老代码已经改得面目全非团队对它怨言最大。试点模块的选择直接决定了项目初期的士气成功概率高比什么都重要。拆出一个微服务我们严格走三步。第一步梳理业务边界和数据归属。把用户权益相关的表从核心库里识别出来列出这些表被哪些消费方访问、通过什么方式访问、大致流量是多少。这一步做完就得到了一个“数据字典”后面做数据迁移时靠它规划映射关系。第二步定义API契约。新服务对外暴露的接口格式必须完全兼容旧系统的内部调用方预期。我们逐条比对了旧代码里所有对这个模块的调用代码确认请求参数、响应字段、错误码格式一致后才动手写新接口。这一步要特别注意那些“隐式契约”——比如调用方依赖字段的默认值、空值处理方式、超时时间这些不通过代码走查很难发现。第三步数据拆分和同步。这是技术含量最高的一步。拆分前权益相关的表还在老系统的主库里拆分后数据要落到新服务的独立数据库里。迁移期间两边数据必须保持一致。我们采用的方式是双写旧系统的代码逻辑不变但每次对权益表做写操作时同时发送一条消息到消息队列新服务消费消息后同步更新自己的数据库。这样旧系统继续作为主读源新服务的库也逐步积累起完整数据。注意双写千万不要做成“应用代码里同时调用新旧两个库”。那样做维护成本极高一旦某个库写入失败事务无法保证数据容易不一致。通过消息队列异步同步是工程上更可控的方式——旧库为主新库最终一致。3.2 双写与幂等设计双写看着简单实际操作中有一个核心问题消息重复消费。生产者发送消息可能因网络超时而重发消费者处理成功后可能还没来得及提交消息就崩溃导致同一条消息被处理多次。如果同步逻辑对每次消息都执行一遍插入数据库就会出现重复数据。解决思路是幂等核心做法是给每条消息带上一个全局唯一的业务键在消费端建立唯一索引遇到重复键直接丢弃或覆盖。这段逻辑用伪代码表达大致是public void onMessage(Message msg) { String bizKey msg.getBizKey(); // 例如 userId 权益类型 操作时间戳 if (duplicateChecker.exists(bizKey)) { // 已经消费过直接返回 return; } try { syncToNewDb(msg); duplicateChecker.mark(bizKey); } catch (Exception e) { // 记录失败消息稍后重试 saveToRetryTable(msg); } }这个“已消费标记”可以用独立的去重表也可以利用新库目标表的唯一索引。只要业务键设计得足够细粒度就能保证同一条消息无论被推送多少次最终只生效一次。双写运行的验证也不能省。我们在两边库各加了一张对账表定时任务统计每日的业务量、金额留存比对差异。刚开始几次确实有出入排查发现是某个历史数据在旧系统里本身就不一致并不是双写逻辑的问题——这属于正常现象处理方式是确认旧系统数据本身就存在噪声之后以新系统的数据为准做一次基线对齐。3.3 灰度发布与路由规则数据双写稳定运行两周后我们开始灰度切流量。这一步是整个重构过程中最紧张的时刻相当于把乘客从旧桥慢慢引导到新桥。灰度做得不好可能一瞬间就把线上用户全部送到一条还没完全准备好的新链路上。灰度策略分两层。第一层是网关流量比例灰度。在网关上配置权益相关请求的路由权重比如先发5%的流量到新服务观察一段时间。这个比例从5%、20%、50%、80%逐步增长每一档停留至少24小时确认指标平稳后再继续加量。第二层是用户维度灰度。我们给用户ID做哈希分桶把一部分桶内的用户请求全部切到新服务。这样做的好处是可以做“同用户前后对比”——同一个用户昨天的行为走旧系统今天的走新系统如果业务结果在数据上能对得上说明新系统的逻辑处理没有语义漂移。这种灰度比单纯按流量比例更精细适合核心业务场景。灰度期间最需要盯的是错误率和耗时。我们在监控大屏上把新老两条链路的成功率、P99耗时、错误码分布并列展示一旦发现新服务错误率超过老服务0.1个百分点立即把该路由的权重回退到0。这一步不需要人工决策我们把“自动回退”脚本提前写好告警触发后自动执行同时也发通知给值班人。宁可过度保守也不能让一个异常请求漏过去。4. 切换、验证与兜底4.1 切换前置检查清单灰度比例到达100%之后并不意味着任务已经完成。真正的切换动作是修改调用方配置让所有流量正式由新服务承载旧系统进入只读或下线状态。这步我们设置了严格的“切换门禁”列了十几项检查必须全部通过才允许执行。检查项里比较核心的有几项新服务的依赖资源是否全部就绪比如数据库账号权限、缓存集群、消息队列topic监控告警是否能正常触发链路追踪是否能完整上报回滚脚本是否经过演练相关执行人是否明确了自己在回滚时的职责以及对外通知是否准备完毕比如如果涉及接口调用方变更是否需要提前和下游团队沟通好。切换时间我们选在工作日晚上十点之后。这个时间点业务流量最低即使出现问题影响面也最小而且工程师精神状态还能保持清醒。没有特殊情况不在周五切——经验之谈周五出事基本等于整个周末搭进去而且周一的业务高峰会把你打得很狼狈。4.2 回滚预案切换一定要有回滚预案而且这个预案不能只是纸面文档。我们团队里有个不成文的规定没写过回滚脚本、没演练过回滚流程的变更不允许执行实施动作。我们的回滚方案是这样设计的切换完成后新服务作为主链路工作但旧系统的服务实例不立即销毁只是把网关路由权重设置为0。如果切换后发现问题需要回滚时操作的只有两件事——把网关路由权重改回来同时通知新服务停掉进入旧库的写入动作。这里最核心的一个前提是双写期间旧库一直是主数据源新服务的数据是从旧库同步复制过去的。所以就算切到新服务后产生了一些新数据旧库里的数据也仍然完整。回滚后旧系统可以无缝继续工作不丢数据。这正是双写模式的额外好处——它天然给回滚留了后门。4.3 割接演练正式切换前我们在测试环境做了两次完整的割接演练。演练不是“走个流程”而是要模拟真实切换中可能遇到的各种情况。第一次演练是最理想的顺序按计划改路由、观察指标、验证业务、确认稳定。第二次演练是故意制造故障在切换后的某一时刻模拟新服务数据库连接池被占满观察监控能否及时告警、回滚脚本能否在五分钟内完成流量切回。这两次演练暴露出不少问题比如回滚脚本里有一个数据库连接池参数的配置在测试库和生产库上的值不一致导致回滚时实际能支撑的流量远小于预期。这个问题如果在真实切换时才暴露后果就是回滚链路本身不稳定系统卡在半切换的中间态。演练结束后我们把回滚脚本的参数全部改成与生产一致同时加了一个启动前的环境自检步骤——脚本运行前先读取当前配置并校验关键参数不匹配就拒绝执行。用一句话概括割接演练的真正目的不是“验证成功”,而是“让失败尽可能早地发生并确保失败可控”。5. 常见问题与避坑速查5.1 双写数据不一致双写运行期间我们几乎每天都会遇到对不上数的情况。最开始我们很紧张觉得是程序有bug后来经验增长发现大部分不一致来自三个原因一是旧系统本身有历史脏数据比如早期接入了某些第三方供应商的数据字段格式不统一但正常工作不受影响二是消息乱序比如同一用户的两次操作几乎是同时发生的后发消息反而先被消费导致新库的值和旧库最终值相反三是幂等键设计粒度不够把本该区分开的两条业务操作识别成了重复消息。排查时我们总结了一套固定流程先把对账时间范围缩小到分钟级定位第一批不一致的记录再分别从新旧库中导出该用户的操作流水比对操作时间戳判断是乱序还是缺失最后根据结论决定是调整幂等键粒度还是给消息队列加分区顺序保证。整个过程不要试图去“修数据”而是要先定位是哪个环节导致的不一致再对症下药。5.2 灰度路由被缓存击穿灰度期间遇到过一个很隐蔽的问题。网关上的路由规则是实时配置的但有部分老代码内部使用了本地缓存缓存了服务地址列表。灰度调整之后新流量已经转到新服务但旧代码里的本地缓存还在旧地址上导致部分请求依旧打到旧系统上灰度比例失真。排查过程花了不少时间因为从网关监控看切给新系统的流量比例是稳定的但新服务的实际日志量却没有按比例增长。后来我们抓包对比才发现是整个调用链路上还有一层缓存。解决方案是灰度前先对所有相关调用方做一次统一清理本地缓存的操作同时把缓存失效时间缩短到几十秒级别避免路由变更后长时间滞留。5.3 配置漂移另一个教训是配置漂移。拆分出来的新服务数量一多环境之间的配置差异开始累积测试环境一个线程池参数、预发环境一个超时时间、生产环境又一个值三套环境各自为政。某次我们准备发布新版本开发环境测得好好的升到预发后响应时间突然飙升排查了一整晚才发现是预发环境某个连接池初始化大小配置错了。从那以后我们把所有环境差异收敛到配置中心的同一个应用维度下不同环境用同一个模板只允许环境特有覆盖项存在同时加了一个配置比对脚本每次发版前自动检查发现与基线不一致直接阻断发布。这个脚本至今还在用已经帮我们拦住过一次配置事故。5.4 依赖梳理遗漏导致的割据最需要警惕的是“你以为拆干净了但某个角落里还有依赖”。拆服务时我们很仔细地梳理了接口和表依赖结果某个低频管理后台功能在切换后的第三周才被发现——它居然绕过网关直接走了旧系统的内部类调用读取了已经被拆分出去的表数据。这种问题很难通过静态分析完全避免更可靠的手段是分阶段审查加线上巡逻切换前让测试团队针对所有历史用例做回归切换后持续观察旧系统数据库的访问日志凡是有查询触达已迁移表的情况全部记录下来定位归属。一旦发现遗漏立即补上路由或修改调用方然后重新灰度。这个环节与其说靠工具不如说靠细心和耐心。5.5 关于节奏与心态的管理技术之外我想多说一句重构过程中团队的节奏管理和技术一样重要。刚开始做试点模块时大家热情很高恨不得一周拆两个服务。结果第一周就遇到双写数据不一致的问题连续加班排查士气掉得很快。后来我们把节奏调整为“两周拆一个服务、第一周开发、第二周双写观察与验证”这个速度看上去慢了但每一步都走得实。每个服务切换完成之后团队会复盘一次把踩到的坑、总结的经验沉淀成文档再开始下一个。事实证明从第一个模块到第五个模块单模块的耗时从最初的三周逐渐下降到了一周半后发优势非常明显。依赖关系方面还有一个心得拆分顺序很重要。我建议先拆“数据边界清晰、调用方少”的模块比如短信通知、操作日志、用户权益这类它们风险小、见效快能帮团队建立信心。最核心的交易链路放在中后期那时候团队对双写和灰度流程已经非常熟练处理起复杂模块来自然更稳。6. 写在最后的一点体会这次重构做完之后我有两个特别深的感慨。第一个是重构成功与否不在于用了多新潮的架构、多高级的框架而在于整个过程中有没有做到“风险可度量、回滚可执行、用户无感知”。技术方案里有大量取舍但衡量标准永远围绕这三句话展开。第二个是团队在执行这种大项目时真正的瓶颈往往不在编码能力而在对系统运行细节的理解深度。能把旧系统每一行代码背后的业务逻辑摸透比多会几个框架重要得多。最后分享一个小技巧如果手头也有类似的重构任务不妨先把“可观测性”体系建设放在所有模块拆分之前——哪怕只做日志集中采集和链路追踪这两件事也能显著提高效率。我刚启动时因为老系统没有链路追踪排查一个问题平均要一小时体系搭好之后同样的问题五分钟内就能定位到具体节点和SQL语句。这个投入是整个项目里性价比最高的一笔。
阅读完成 · 觉得有帮助?