先拆查询还是先拆交易:我们两次微服务化的真实账单
我们团队在 2021 年和 2024 年分别做了两次微服务拆分,两次选择了完全相反的路径。第一次先拆查询,第二次先拆交易。回头看,两个决策都对了,但代价截然不同。关键变量只有一个:业务约束是否允许你犯错。
2021 年:先拆查询,因为旧系统不能碰
那是一个跑了 7 年的电商订单系统,Java 8 + Spring 4.3,单个 WAR 包 280MB,部署在 8 台物理机上。日均订单量 12 万,不算大,但问题在于查询场景已经崩了——运营后台查一个带时间范围的订单列表要 23 秒,客服那边点开订单详情经常超时。
当时的业务约束很明确:Q4 旺季马上到,核心下单链路绝对不能动。CTO 的原话是“你们可以加东西,别改已有的”。
所以我们决定先从查询侧下手。方案是用 Canal 1.1.5 订阅 MySQL 5.7 的 binlog,把订单主表、订单明细表、支付流水表同步到 Elasticsearch 7.10。然后新建一个 order-query-service,只暴露查询接口,内部直接查 ES。原来的单体应用里,我们把所有 /order/detail、/order/list 这类 GET 请求通过网关层重定向到新服务,POST 请求保持原路。
同步延迟我们实测下来,从 binlog 产生到 ES 可查,P99 是 380ms。业务方能接受,因为运营后台本身就有“订单状态可能有 1 分钟延迟”的心理预期。数据一致性靠定时对账任务补漏,每天凌晨跑一次,把 ES 和 MySQL 的数据做全量比对,差异条数通常在 200 条以内,自动修复。
这次拆分从决定到上线只用了 3 周,2 个人。上线后运营后台查询降到 600ms 以内,客服页面秒开。核心交易链路零改动,零故障。
但代价在后面。order-query-service 里的查询逻辑越堆越多,半年后膨胀到 47 个接口。有些接口需要 join 用户服务、商品服务的数据,我们开始往 ES 里灌更多表,最后 ES 索引从 3 个变成 11 个,同步任务从 1 个 Canal instance 拆成 4 个。到 2022 年中,查询服务本身已经是个小单体了,ES 集群从 3 节点扩到 9 节点,运维成本翻了 3 倍。
更隐蔽的问题在于领域逻辑的重复。订单状态机本来只在核心应用里,后来查询服务为了做状态筛选,自己实现了一套简化的状态流转判断。有一次核心应用改了“已发货”状态的触发条件,查询服务没同步更新,导致运营报表里“已发货”和“待收货”两个状态的订单数量对不上,财务对账才发现,数据偏差持续了 11 天。
所以先拆查询的本质是:用数据冗余换时间,用短期速度换长期维护成本。适合的场景就是旧系统不能动、或者动了风险太高的时候。
2024 年:先拆交易,因为旧系统拖不动了
第二次拆的是一个 SaaS 费控系统,单体 Spring Boot 2.7 应用,跑了 4 年。问题比上次严重得多——每次发布需要全量回归,回归用例 430 个,执行一遍要 6 小时。更致命的是,报销单提交接口的 P99 延迟已经到了 8 秒,因为里面串行了发票校验、预算扣减、审批流初始化、通知推送等 7 个步骤,任何一个慢都会拖垮整个请求。
这次没有“不能动核心链路”的约束,因为核心链路本身已经是瓶颈了。业务方给的窗口是春节前后 1 个月,流量最低,可以接受短暂不稳定。
我们决定直接拆交易链路。目标是把报销单提交流程从单体里剥离出来,变成一个独立的 expense-submit-service。这个服务只做一件事:接收报销单提交请求,编排发票校验、预算扣减、审批创建这一系列步骤,然后返回结果。
技术选型上,我们用了 DDD 的那套战术模式。先把报销聚合根、发票实体、预算值对象从老代码里抽出来,保持领域逻辑不变,只改调用方式。原来单体内部的方法调用,改成通过 tRPC(我们内部基于 gRPC 封装的框架,版本 0.13)远程调用。发票校验是一个独立服务,预算扣减是另一个,审批流又是另一个——这几个服务在拆分交易之前就已经独立了,只是原来由单体直接调,现在改由 expense-submit-service 调。
数据库层面,报销单相关的 6 张表从原来的 MySQL 实例里迁出来,单独建库。为了保证提交接口的事务一致性,我们没有上分布式事务,而是用了 Saga 模式,基于 RocketMQ 4.9 的事务消息做最终一致性。具体流程是:
expense-submit-service先写报销单主表,状态为SUBMITTING- 发半消息到 RocketMQ,然后执行本地事务——扣预算
- 预算扣减成功,提交消息,触发发票校验
- 发票校验成功,再发消息触发审批流创建
- 任何一步失败,通过回滚消息反向补偿
这里踩过一个坑。最开始我们把 Saga 的每一步回滚逻辑写在各服务的代码里,结果预算服务改了一版回滚逻辑后,交易服务的补偿流程断了,有 3 笔报销单卡在 SUBMITTING 状态,用户看不到也取消不了。后来我们改成由 expense-submit-service 统一编排回滚,其他服务只提供“撤销操作”的幂等接口,不自己判断何时回滚。
这次拆分从设计到上线花了 3 个月,4 个人。上线第一周出了 7 个故障,其中 2 个是 P1 级别。一次是 RocketMQ 消费者组配置冲突,导致消息重复消费,同一个报销单被扣了两次预算。另一次是 Saga 超时重试的间隔设太短,1 秒重试 3 次,预算服务被打挂。
但修复之后效果立竿见影。报销单提交的 P99 延迟从 8 秒降到 1.2 秒,因为异步化之后用户只需要等到预算扣减完成就能拿到结果,发票校验和审批创建都在后台跑。发布也不再需要全量回归,交易服务的回归用例只有 70 个,跑完 20 分钟。
更重要的收益是架构层面。交易服务拆出来后,我们对报销单状态机的控制变得非常精准。之前状态变更散落在单体各处,一个 status 字段被 14 个不同的方法修改。现在所有状态流转都收敛在 expense-submit-service 里,改状态机逻辑不需要惊动其他团队。
先拆交易的代价也很明显:对团队的成熟度要求高,对业务的容错空间要求高。如果没有春节窗口那 1 个月的低流量期,那 7 个故障里至少有 2 个会造成大规模用户投诉。而且 Saga 的调试难度远超同步调用,排查一个消息丢失问题,要在 4 个服务的日志里来回跳。
两条路径的选择框架
回头看两次经历,我总结了一个简单的判断框架:看旧系统是“不能死”还是“活不好”。
旧系统如果只是查询慢、报表卡,但核心交易链路稳定且业务不允许折腾,那就先拆查询。用数据同步把读流量引出去,不改写逻辑,风险最低。代价是数据冗余和领域逻辑重复,但这个代价可以延后支付——等业务窗口来了再重构。
旧系统如果本身已经成为瓶颈,发布周期长、性能差、改一行代码要测两周,那先拆交易反而是更务实的选择。因为查询侧的优化治不了根,核心链路的问题不解决,拆再多查询服务都没用。
还有一个隐性的判断标准是团队能力。先拆查询对团队要求低,两个人、熟悉 Canal 和 ES 就能干。先拆交易需要有分布式事务、消息队列、DDD 落地经验的人,而且要有处理生产故障的心理准备。2024 年那次拆分,我们团队有 2 个人之前做过类似的 Saga 落地,即使这样还是出了 7 个故障。如果是第一次接触最终一致性,故障数量大概率翻倍。
最后说说数据一致性这个老话题。两次拆分都绕不开它,但处理方式完全不同。拆查询的时候,我们接受短暂不一致,用对账兜底。拆交易的时候,我们不能接受预算多扣或者少扣,所以 Saga 的回滚必须严格测试,每个补偿接口都写了集成测试,覆盖 6 种失败场景。这不是技术偏好问题,是业务语义决定的——查询数据延迟 1 分钟没人在意,钱算错了会有人上门。
常见问题
为什么拆查询不用 Redis 而是直接上 ES?
因为查询场景需要多条件组合筛选和聚合统计。运营后台的订单列表要按时间范围、状态、金额区间、商品类目组合筛选,还要统计各状态的数量。Redis 的数据结构能支撑的查询维度有限,在 2021 年那个场景下,用 Redis 需要把每种筛选组合都预先算好存成 key,维护成本比 ES 高得多。ES 的倒排索引和聚合查询天然适合这种场景。
Saga 模式比分布式事务到底好在哪?
不是“好”,是“能落地”。分布式事务要求所有参与方都支持两阶段提交,我们当时的预算服务是外部供应商的系统,根本不可能配合改造。Saga 只需要各方提供正向操作和补偿操作的接口,耦合度低得多。代价是中间态可见——用户可能看到报销单在“提交中”卡几秒,这个需要产品层面做状态提示。
拆查询服务后,ES 数据膨胀怎么控制?
我们后来给 ES 索引加了 TTL,订单数据只保留 6 个月,历史数据归档到 ClickHouse 做离线查询。同时把查询服务拆成热查询和冷查询两个接口,热查询走 ES,冷查询走 ClickHouse,网关层根据请求参数里的时间范围自动路由。这个改造在 2022 年中做的,ES 集群节点从 9 个降回 5 个。
两次拆分后,单体最后还剩下什么?
2021 年那次,单体一直活着,只去掉了查询逻辑,核心交易还在里面。直到 2023 年才把交易也拆出去。2024 年这次,单体目前还剩一些管理后台的 CRUD 接口和定时任务,计划在 2025 年 Q2 完全下线。两次经历告诉我一个事实:微服务化几乎没有“完成”的时候,只有“当前拆到什么程度”的状态。