1. 立项背景:从"能用"到"扛不住",Apollo-11 的真实起点
1.1 一次大促前夜的容量压测
在立项之前,我们先做了一次完整的容量压测。当时距离年中大促还有五周,运营团队已经把预期流量报告发过来了:高峰期下单通道的瞬时请求量是平日的十倍。按照我们当时系统的表现,几乎是必崩。
压测结果我记得很清楚。用 JMeter 模拟逐步增长的流量,监控端给出的数据如下:
| 压测 QPS | 数据库 CPU | 连接池使用率 | P99 响应时间 | 系统表现 |
|---|---|---|---|---|
| 500 | 30% | 42% | 600ms | 稳定 |
| 800 | 52% | 75% | 1.2s | 偶发超时 |
| 1000 | 78% | 98% | 3.5s | 出现错误响应 |
| 1200 | 满 | 满 | >10s | 大量连接超时,系统接近不可用 |
看到这个结果的时候,没有人觉得意外,但气氛很沉重。问题出在三条线上:第一,核心库只有一台主库,所有订单写入都落在同一个点上,无法横向扩展;第二,订单表数据量已经超过一亿行,很多查询扫描了太多的索引页,慢查询日志里塞满了单次执行超过 3 秒的 SQL;第三,应用层本身就是单体的服务,没有连接池的概念,每个请求都要短连接连一次数据库,一到大流量连接就立刻耗尽。说白了,我们再买几台数据库也救不了,瓶颈在应用架构和数据模型上。
有一个细节很值得说:压测时我们发现,当数据库 CPU 到达 78% 的时候,真正拖垮系统的并不是 CPU 本身,而是行锁和连接池资源的争用。各个请求都在等同一批行记录上的锁,等待越久,连接池里的连接就被占得越久,新的请求只能排队,最终形成连锁雪崩。所以后来我们做性能评估时,不再只看 CPU 和内存,而是会重点关注"资源等待链路",这个习惯在 Apollo-11 后续的故障排查里帮了大忙。
1.2 为什么选择重构,而不是继续加机器
越是遇到这种问题,团队里越容易冒出"买机器"的声音。多采购几台高性能服务器,把老系统分几套部署,再用负载均衡分摊流量,似乎也能顶过去。但算过一笔账后,大家就放弃了:横向复制单体应用会遇到数据一致性问题,如果多个应用节点直接连同一个库,数据库依然只有一主一从,单点容量并没有变;如果把订单数据按不同商品拆到不同库里,又得在应用层做路由,这等于重写应用层。
另外,研发团队的感受也很直接:单体的代码库越来越大,不同需求的代码相互纠缠,上线窗口被拉得很长。过去三个月里,团队花了大量时间在处理"上线之前临时改一个文件、结果影响到了另一个完全无关的服务"的冲突。继续打补丁只会让这种情况更严重。综上,我们决定启动重构。需要说明的是,这种决策并不是说所有系统都该重构。当初我们评估过三个前提,都满足才敢动手:一是业务逻辑相对稳定,订单中心的核心流程在过去两年里没有大的结构性调整;二是已经有足够接口级别的自动化测试覆盖;三是团队可以接受灰度切换而不是一次性切换。如果你手里的系统还处于业务模式每天都在变的阶段,那还是先别动架构,用临时手段顶住,等模型稳定了再考虑。
很多人会把"重构"和"重写"混为一谈。我们这次做的事情更接近"渐进式重构":老系统继续用着,新系统在旁边长起来,两边并行,通过灰度逐步替换。好处是风险可控,但坏处也很明显——你得在很长一段时间里同时维护两套系统,任何一次数据同步或者逻辑转换的疏忽,都会变成线上的幽灵 Bug。关于双写和同步的坑,后面我会专门展开。
1.3 代号 Apollo-11 的由来与项目目标
这个项目代号是某次技术例会上一位同事提的。他说这次做的事有点像把一堆旧零件重新组装成一艘新飞船,干脆叫 Apollo-11。当时的氛围没那么严肃,大家都笑了,但名字就这么留了下来。Apollo 是希腊神话里的太阳神,我们内部对它的解释是:希望新系统像太阳运行轨迹一样稳定、可预测。
笑归笑,项目目标必须量化。我们用了大约一周时间拉通运营、产品和研发三方,最终确定了下面这几条非功能性目标:
- 峰值下单链路 P99 响应时间从当时的 4.2 秒降到 800 毫秒以内,目标 500 毫秒。
- 高峰期系统可用性不低于 99.99%,不再出现数据库连接池耗尽导致的全局故障。
- 订单数据支持水平扩展,单表不再无限增长。
- 上线过程中支持按流量百分比灰度,并且能在 5 分钟内全量回滚到老系统。
- 迁移过程不丢失订单数据,支持新旧系统数据对账。
后面所有架构设计、拆库方案、灰度计划,都是围着这五个目标展开的。目标定得足够清晰的好处是,后续每一个技术争论都可以用目标来裁决,而不是靠职位或者嗓门。比如有人提议要不要引入某个新组件时,我们只需要问一句:"它和这五个目标里的哪一个直接相关?" 答不上来,就再议。这一招在缩减技术讨论范围上非常有效。
2. 架构设计与技术选型:在"引入新东西"和"控制风险"之间找平衡
2.1 重新划分业务域,先拆逻辑再拆物理
重构的第一步并不是选技术栈,而是重新理解现有代码。我们用几周时间梳理了订单中心的所有功能点,把散落在各个文件里的逻辑按业务能力重新分组。最后画出来的边界是:订单创建域、订单查询域、库存预占域、支付回调域、超时取消域。这一步很像给一个乱糟糟的抽屉重新做隔断,不做这件事,后面拆库的时候你会发现服务和数据之间到处是蜘蛛网。
代码模块的拆分要遵守单向依赖原则,订单查询域不能反向调用订单创建域的内部方法。我们当时的做法是先把这些域拆成独立的代码模块,用接口隔离,数据访问层必须在模块内部自包含。这个阶段不引入服务化,只改代码组织方式,测试还能继续跑。等你发现某个域的改动频率和另一个域是完全独立的,再去把它拆成独立服务也不迟。很多人一上来就会微服务拆分,最后发现拆完的分布式事务问题比原来还严重,正是因为忽略了逻辑边界优先于物理边界这一原则。
这里有一个非常实用的技巧:怎么判断哪些代码应该归到同一个域?看一眼它的"修改理由"。如果一个文件被频繁修改是因为库存规则调整,另一个文件是因为促销活动调整,那它们就是两个域。通过分析最近半年的 Git 提交记录,你能很准确地画出真实的耦合边界,这比几个架构师坐在会议室里凭空画出来的图靠谱得多。我们正是靠着这个思路,把原本纠缠在一起的十几个模块重新归拢成了六个边界清晰的域。
2.2 技术栈调整:为什么从旧语言换成 Java
老系统的技术栈是 PHP + MySQL,新系统我们用了 Java + Spring Boot。这个决定在当时引发过争论。有人说短期内用 PHP 重写也能解决问题,为什么非得换语言?
我的理由有两条。第一,我们要解决的核心问题是长链路下的稳定性和大流量下的异步处理,Java 生态在连接池、线程池、消息队列等基础设施上的成熟度明显更高。PHP 擅长快速渲染网页,但我们的订单中心本质已经是内部服务,每天要处理大量的 JSON 请求和异步任务。第二,未来要做分库分表、分布式事务、服务治理,Java 的工具链更完善,例如 ShardingSphere、Sentinel 这类组件都有成熟实现,PHP 这边要么没有官方维护,要么落地成本很高。
这不代表 PHP 不行,在快速迭代和页面渲染场景下它依然有优势。但架构选型要看未来五年的走向,而不是只看眼前能不能跑通。我们在表格里对比过新旧栈的核心组件:
| 层次 | 老系统 | Apollo-11 新系统 |
|---|---|---|
| 应用框架 | PHP 单体应用 | Spring Boot 微服务模块 |
| 数据库访问 | 短连接直连 MySQL | HikariCP 连接池 + MyBatis-Plus |
| 缓存 | 简单文件缓存 | Redis Cluster |
| 异步消息 | 本地任务表轮询 | RabbitMQ 延迟队列 |
| 数据分片 | 无 | ShardingSphere + 分库分表 |
| 部署方式 | 虚拟机手工部署 | Docker + Kubernetes |
| 可观测性 | 日志文件 | Prometheus + Grafana + 日志链路 |
列完之后,我们团队内部又做了一轮工作量评估,结论是:Java 方案在目标容量下表现最优,虽然开发速度一开始会慢一点,但中后期收益很明显。不过也要提醒一句,技术栈迁移的成本一定要算进去。团队有没有人熟悉这套东西?运维能不能接住容器化?如果答案是否定的,不要盲目跟风。技术栈没有绝对的好坏,只有和你的团队现状、业务阶段匹配不匹配。
2.3 容量估算和目标分解
技术选型定了之后,就是算清楚系统需要多大容量。这一步决定了你要分多少个库、准备多少台机器、设置多大的消息队列。我们按大促峰值来算:当时的日常下单 QPS 大约是 1500,运营给出的峰值倍率是 8 倍,也就是峰值 12000。考虑到未来半年业务增长和促销活动的不确定性,我们把设计目标定在了 20000 QPS,压测目标为 25000 QPS,留了足够的余量。
存储容量方面,我们统计过一条订单记录(包括订单主表、明细表、支付表、日志表和扩展字段)大约 1.2KB。按日均 800 万单计算,一年新增数据约 3.5TB。单库单表肯定撑不住。我们最终规划了 12 个数据库,每个库 16 张表,合计 192 张订单表。分片键选择的是用户 ID 的哈希值取模,这样同一用户的订单会落在同一个分片上,后续按用户维度查询订单列表时不需要跨库跨表。这是一个典型的"读写折中":订单明细查询通常按用户来查,而不是按订单号来查;跨订单号的运营后台查询走搜索引擎和汇总表,不依赖分库分表。
容量估算不能只算应用层,还要算依赖层。比如订单创建后要发送消息给库存服务,那 RabbitMQ 的消息积压能力、消费者的处理速度、失败重试的次数都要提前设计。我们在设计文档里把这些依赖全部列成了一张"容量依赖图",每个节点都标了预估 QPS 和故障时的降级策略。这张图在后续灰度阶段起码帮我们排查了三次疑难问题,建议所有做大型重构的人都画这么一张。
具体计算逻辑可以这样理解:每台应用服务器的处理能力大约是 2000 QPS,2 万个 QPS 就需要至少 10 台应用节点,加上弹性伸缩和故障冗余,我们准备了 14 台。每个应用节点默认配置了 200 个线程的线程池,数据库连接池配置为 60,避免连接数被某个慢请求拖垮。这些参数不是拍脑袋定的,都是一轮一轮压测压出来的。比如连接池从 40 调到 60 的时候,P99 明显下降,但再往上调到 80,反而因为线程切换和锁竞争上升了。这种曲线只能用压测去摸,没有捷径。
3. 实施过程中的四个最棘手的坑,以及我们是怎么爬出来的
3.1 数据迁移:双写方案为什么会丢数据
重构离不开数据迁移。我们最初采用的是应用层双写:新老系统并行运行,同一笔订单同时写入老系统和新系统。这个方案听起来简单,操作起来却很折磨人。双写的代码既要考虑顺序,又要考虑事务边界,最常见的坑是:如果新系统写入失败,老系统已经成功了,你重试新系统的时候,业务状态可能已经发生了变化;反过来,如果老系统先写成功、新系统后写失败,数据补回老系统会对账带来巨大的麻烦。
我们在联调环境里模拟了异常注入,发现双写至少存在三种丢失情况:主键冲突、异步回调乱序、部分字段在不一致的状态下被覆盖。最后我们放弃了应用层双写,改成基于数据库 Binlog 的增量同步 + 每日全量对账。具体做法是:先把历史数据通过离线工具导入到新系统,并记录一个基准时间点;然后订阅老库的 Binlog,把从基准时间点开始的所有增量变更同步到新系统;同时每天跑一个对账脚本,按订单号比较两边的状态字段、金额字段和时间字段。发现不一致就进入"修复队列",由补偿任务按变更日志逐步纠正。
这中间还有一个昂贵的教训:刚开始同步的时候我们没有给同步任务加幂等保护,某个后端节点重启导致同一批 Binlog 消息被重复消费,订单数据被覆盖成旧值,光排查就花了两天。后来我们在每个同步任务里加了消息去重表,以 Binlog 事件位点作为唯一键,才彻底解决重复消费的问题。如果你也在做类似的迁移,请一定先把幂等机制设计好,再谈数据同步。从最终结果看,双写方案确实最简单,但也是最难做对的一种方案。生产环境里任何一个角落的网络抖动或者偶发超时,都会让双写的一致性瞬间崩塌,而 Binlog 同步是基于数据库本身的日志机制,至少不会因为应用层状态不同步而出现逻辑丢失。
3.2 缓存层的三个经典故障:穿透、击穿、雪崩
新系统上线后,订单详情接口是核心链路,我们理所当然地引入了缓存。结果就是,该踩的缓存坑一个没少。第一个是缓存穿透:某个用户使用脚本疯狂请求不存在的订单 ID,这些请求每次都绕过缓存直达数据库,导致数据库瞬时压力增大。我们用了一个事前布隆过滤器把合法订单 ID 放进去,不在集合里的请求直接返回空,防住了穿透。
第二个是缓存击穿:某个热门订单突然过期,一瞬间所有对该订单的请求全部落到数据库。我们在缓存重建逻辑里加了互斥锁,但后来发现互斥锁有个副作用:一旦持锁线程宕机,后面所有线程都会阻塞等待,直到锁超时。这个场景最终我们改成了"逻辑过期"的方案:缓存里不设置物理过期时间,而是附加一个逻辑过期字段;后台有一个定时任务去刷新热点数据,如果发现逻辑过期,就触发异步重建,同时直接返回旧值。这个方案牺牲了极端情况下的数据新鲜度,但换来了接口稳定性。
三个坑里最危险的还是缓存雪崩。大部分 key 的过期时间集中在某个时间窗口,导致大量请求同时打到后端。给过期时间加上 1 到 5 分钟的随机值就能很好地缓解,这个大家应该都知道。但我们的教训是:不止业务缓存的 TTL 需要随机,限流器的滑动窗口、任务调度的触发时间、定时任务的固定间隔,凡是会形成"共振"的地方都要加抖动。不然一场小波动就可能被自放大成全站故障。
这里还要补充一个容易被忽视的问题:缓存更新和数据库更新之间的顺序。我们最初是先更新数据库,再删除缓存。结果在极端场景下,一个请求读到了旧缓存,另一个请求更新了数据库,两者之间出现了一段时间的脏数据。后来我们改成先删除缓存,再更新数据库,并配合短暂的缓存空窗期,才把不一致的概率降到可接受的范围。所有缓存一致性方案都逃不过"延迟时间"和"最终一致"这两个维度,关键是在业务接受范围内做选择。
3.3 从单库到分库分表:查询和事务的连锁反应
分库分表是 Apollo-11 里工程量最大、也最不直观的部分。一开始我们天真地以为,只要把 ShardingSphere 的配置写好,剩下就自动路由了。结果第一轮联调就出了岔子:写入数据后按订单号查询能查到,但按用户 ID 查询订单列表时,发现大量订单"凭空消失"。排查后发现,这是因为分片键只配置了订单 ID,而查询条件用的是用户 ID,路由器不知道去哪个分片查,只能把请求发到所有分片,再在全部分片里用 JOIN 查询,最后又因为 JOIN 跨库导致部分数据被过滤掉了。
正确的做法是,对"用户 ID 查订单列表"这个场景单独建一张索引表,或者把它也纳入分片键规则里。我们最终用的是双分片策略:主分片键是用户 ID,订单 ID 作为辅助路由字段。所有新订单在写入时,分别在两张表里维护映射关系,查询时先用用户 ID 定位分片,再用订单 ID 做表内查询。这中间我们并没有依赖 ShardingSphere 的自动改写,而是把路由逻辑显式写在 DAO 层,代码虽然多一点,但是可排查性更好。
分库分表还带来了分布式事务问题。订单创建不仅仅是插入订单表,还要扣减库存、生成支付流水、发送消息。我们最初尝试了 Seata 的 AT 模式,但发现数据库连接被事务组持有过久,在大流量下反而成了瓶颈。后来折中成基于流程状态机的"本地事务 + 可靠消息"模式:先写业务流程状态表,通过事务消息触发后续环节;每个环节执行完更新状态;后台有补偿任务扫描超时未完成的状态。这套方案比分布式事务简单,对业务侵入也小,但逻辑分支多了不少,需要业务侧配合。
另外,分表之后的自增主键也成了一个麻烦。不同分片如果都从 1 开始自增,合并时必然冲突。我们引入了基于时间戳 + 机器编码 + 序号的三段式 ID 生成器,保证全局唯一。这里的教训是:全局唯一 ID 看似简单,但一旦上了生产,想要在数据已经分布到几十张表之后再回头改 ID 方案,成本会高到让你崩溃。所以这类涉及全局约束的设计,必须放在最前面解决。
3.4 灰度发布时的流量调度陷阱
新系统开发完成,进入灰度阶段。我们计划按流量比例灰度:先 5%,再 10%,逐步加到 100%。第一轮 5% 灰度很顺利,新老系统数据对账接口也一致,于是我们放心地加到了 20%。结果不到半小时,新系统的订单状态和库存系统出现了不一致,大量单子卡在"支付成功但库存未扣减"的状态。
排查了半天,问题出在异步消息链路上。灰度开关只控制了下单入口的流量,但订单系统发出的"支付成功"消息是全局的。新老系统都会发出消息,消息队列里的生产端因为绑定的交换机配置还是老一套,导致消息被重复投递到下游库存服务。库存服务的幂等性没有覆盖到这两个版本的重复消息,于是出现了同一个订单被扣减两次库存的情况。
这个坑告诉我们,灰度发布真正要控制的不只是入口流量,还包括所有对外依赖和消息出口。我们后来做了一个"灰度链路标记":在消息体里写入灰度版本标识,下游消费者根据标识决定是否忽略;同时在灰度阶段把双写开关、消息路由开关和任务调度开关全部做成独立的配置项,任何一类流量都不能"自己偷偷走老路径"。调整以后,后续从 20% 到 50% 再到 100% 的过程中,再没有出现过类似问题。
第四点关于回滚。灰度阶段我们演练过两次回滚,第一次老系统因为日志文件权限问题,启动时卡住了,回滚用了七分钟。后来我们给回滚脚本加了一个前置检查,确保老系统的依赖进程方式、日志路径、临时目录都处于预期状态。回滚不能只在 Postmortem 文档里写"如果出现问题就回滚",而是要在上线之前实际跑一遍。很多团队等到出问题时才发现,回滚按钮是个摆设,那时就晚了。
4. 灰度上线后的验证:性能指标的确提升了,但也暴露了新的问题
4.1 完整的灰度流程和参数
灰度成功与否,不能只靠感觉。我们设置了五个阶段:5%、10%、30%、50%、100%。每个阶段至少观察 30 分钟,并参照以下指标:
| 阶段 | 观察时间 | 错误率阈值 | P99 阈值 | 数据对账差异率 | 退出条件 |
|---|---|---|---|---|---|
| 5% | 30分钟 | <0.1% | <800ms | <0.01% | 指标连续稳定 |
| 10% | 30分钟 | <0.1% | <600ms | <0.01% | 指标连续稳定 |
| 30% | 1小时 | <0.1% | <500ms | <0.01% | 无新增慢 SQL |
| 50% | 2小时 | <0.1% | <500ms | <0.01% | 回滚预案演练通过 |
| 100% | 持续观察 | <0.1% | <500ms | <0.01% | 老系统下线 |
这里有一个容易忽略的细节:每次放大流量后,都要先做一次完整的对账,而不是只看监控曲线。因为监控只能告诉你系统有没有报错,对账才能告诉你数据有没有错。我们有一次就是在对账环节发现了一个老系统特有的乱码问题,这个如果放到后面再发现,回滚成本就高多了。灰度其实更像一个"实验状态机",每个阶段都对应一组独立的验证动作,而不是简单改一下负载均衡的权重。
4.2 线上观测工具和告警阈值
观测层面我们用的是 Prometheus + Grafana,应用的指标通过 Micrometer 暴露,数据库、中间件的指标通过对应的 Exporter 接入。告警规则设置了几条主要的:错误率超过 0.1% 持续五分钟;P99 响应时间超过 800ms 持续五分钟;Redis 命中率低于 90%;消息队列积压超过十万条。
告警系统在灰度阶段狠狠给我们上了一课。有一段时间告警非常频繁,但点进去看每一项指标又都正常。后来发现是因为告警规则里没有聚合维度,同一个服务的多个实例各自告警,导致一个轻微的抖动瞬间变成几十条告警。我们后来给告警加了按服务、按接口、按机房的聚合,并且引入了告警静默窗口。真正有效的告警不是越多越好,而是要能在一堆噪音里找出真正需要人介入的那条。
还有一点:我们要求每个告警都必须附带一个"可执行动作",要么是短期内能定位到原因的入口,要么是明确的降级操作。如果一个告警发出来之后,值班同事还要翻半天文档才能想起来"这是什么意思",那这个告警本质上就是无效的。因此,我们在告警信息里直接加上相关的 Dashboard 链接、日志查询语句和红头文档入口,这一步最开始做的时候很繁琐,但到了第二次值班周期就能明显感觉到效率的提升。
4.3 最终效果与业务侧的感知
全量切换到新系统后的两周,我们做了一次完整的性能复测。大促当天的数据如下:下单链路 P99 响应时间稳定在 380ms 左右,相比重构前的 4.2 秒下降了约十倍;数据库主库 CPU 使用率从 90% 降到 45% 左右,因为流量被分散到了 12 个分库里;订单系统的可用性达到了 99.99%,没有出现一次全局级别的故障。业务侧最直观的感受是,以前大促期间客服群里到处是"提交失败"的截图,这次几乎没有收到相关客诉。
不过新系统也不是没有问题。让我印象很深的是,上量后我们发现新系统的平均 CPU 消耗比老系统高了 30%,原因是一大堆线程池和异步任务都是常驻的,而老系统是短请求模型,执行完就释放。后来针对 GC 参数做了一轮优化,把堆内存从 4G 调整到 8G,并调整了年轻代比例,CPU 消耗才压了下来。性能优化是个长期工程,不是换完架构就万事大吉。
另外一个在验证期才发现的问题是慢查询依然存在。虽然我们分了 12 个库,但运营后台的某些特殊筛选条件会把所有分片扫一遍,最终耗时超过 10 秒。这类查询根本不是靠分库分表能解决的,它的正确解法是把运营后台的数据需求迁到独立的宽表和搜索库,避免在交易链路上跑分析型 SQL。因为这个问题暴露得比较晚,我们不得不在灰度期的最后阶段又追加了一个"有限查询只允许联合索引覆盖"的限制,虽然不是最优解,但总算保证了大促前不再出现查询超时。
5. 复盘:如果让我再做一次 Apollo-11,我会调整什么
5.1 决策层面的遗憾:过度设计还是刚刚好?
Apollo-11 最终达到了目标,但复盘时我心里的第一反应是:有些地方我们做重了。比如,当时为了完成项目汇报的 PPT,我们把订单查询域也改成了独立微服务,其实它的访问量虽然大,但逻辑非常简单,完全没有必要单独部署。给它额外引入的服务发现、配置中心、独立 HPA,反而带来了更高的运维成本。直到三个月后,我们才把它合并回订单服务内部。
这个教训可以用一句话概括:先模块化,后服务化。很多团队一听到"重构"就立刻想到微服务、容器、服务网格,恨不得把所有新潮技术全用上。但微服务是一种组织结构的映射,不是为了技术而技术的装饰。如果团队本身只有十几个开发,把业务拆成七八个服务,带来的沟通成本可能比重构前还高。所以如果你也在做类似的迁移,我建议你先问一个问题:这个模块未来的迭代频率和团队规模,是否真的需要独立部署?
还有一个遗憾是容器化和 Kubernetes 的引入时机。我们在项目初始阶段就把所有服务都容器化并纳入了 K8s,从技术上看很先进,但初期调试网络和存储时消耗了大量本可以用在业务代码上的时间。如果让我重来,我会先任性地用虚拟机部署,等核心业务稳定之后,再在低峰期平移上 K8s。架构演进不需要一步到位,关键是要在每一个节点上都有"能不能退回去"的余地。
5.2 组织协作:跨团队沟通比技术更难
技术上再麻烦的问题,最后总能找到解法。但 Apollo-11 过程中最磨人的,其实是与其他团队之间的依赖协调。比如我们依赖库存服务的库存扣减接口,但对方的改动排期比我们晚了三周;又比如中间件的申请流程走了一个多月,实际上系统早就具备权限了,只是没人去盯流程。
这让我意识到,大型项目的关键路径往往不是写代码,而是外部依赖的确认和排队。我们后来做了一张"依赖地图",把所有需要外部团队支持的事项列出来,标注负责人和截止时间,每周开一次进度对齐会。这门功课如果在项目启动前就做,至少能省出两周时间。项目经理和技术负责人应该把至少一半的精力放在这里,而不是埋头抠技术方案。
关于跨团队的进度对齐,我还有一个体会:不要只开"进度汇报会",而是要把会议变成"依赖同步会 + 问题裁决会"。比如某个接口联调迟迟没通过,就要让双方开发当场拿出错误日志,一起看看到底卡在哪一层。如果只是泛泛地汇报一句"联调还没完成",那下次开会还是一样的状态。用具体的技术日志和数据说话,比任何责任制表格都更有推动力。
5.3 一份可直接复用的项目启动检查清单
最后分享一份我们后来整理出来的检查清单。如果你要启动类似的系统重构或性能攻坚项目,建议在动手前逐项确认:
| 检查项 | 说明 | 完成标志 |
|---|---|---|
| 量化目标 | 性能、可用性、容量、迁移时间 | 可度量、可验证 |
| 基线数据 | 当前峰值QPS、P99、资源使用率 | 能画成图表 |
| 回滚方案 | 什么情况下回滚、需要多少分钟 | 演练过至少一次 |
| 数据对账脚本 | 新老系统数据如何对比、差异如何修复 | 跑通一轮 |
| 灰度开关 | 入口流量、消息、任务调度是否可分环境控制 | 联调验证 |
| 监控告警 | 核心链路指标、阈值、告警联系人 | 上线前确认 |
| 外部依赖地图 | 各团队负责人、交付时间、风险点 | 每周跟踪 |
这份清单不是从教科书上抄来的,而是我们踩过坑之后的总结。它不一定适合所有团队,但如果你认真逐项完成,至少不会在项目后半段发现一个应该早就想到的问题。比如"回滚方案"这一项,如果我们没有在实际环境里演练,就不会发现日志目录权限不一致这个隐蔽问题。等到紧急故障时再来处理,几分钟的延迟都可能是致命的。
在我个人看来,Apollo-11 之所以能落地,除了技术上的硬功夫,更重要的是团队始终把"稳定"和"可控"放在所有炫技念头的前面。项目代号是什么并不重要,重要的是在每一次决策时都问自己:这个改动如果炸了,我们要花多久才能恢复?只要这个答案永远是可承受的,项目就还有得可做。
复盘结束那天,我在项目文档最后一页写了一句话:Apollo-11 不是目标,而是一套习惯的开始。以后的每次架构调整,我都会默认开启灰度开关、对账脚本和回滚预案这三件套。最后再分享一个小技巧:所有关键开关类的配置,在代码里一定要有默认值,即使配置中心取不到值,系统也能用安全配置启动。我们有一次在灰度到 50% 时临时调整消息路由开关,由于配置中心短暂抖动,部分实例加载了空配置,差点把灰度整体退回去。从那以后,所有开关都有"默认关闭"或"默认全量走老路径"的兜底逻辑。这个小习惯,值得写进任何团队的规范里。