把 12 个微服务合并回 4 个之后,P99 从 680ms 降到 470ms:过度拆分的 5 笔账
title: 把 12 个微服务合并回 4 个之后,P99 从 680ms 降到 470ms:过度拆分的 5 笔账
tags: [微服务, 单体架构, SOA, Serverless, 架构演进]
category: 后端
新来的同事第一周问了个问题:"查一个订单详情要调几个服务?"
我打开链路追踪给他看那张图:网关 → 订单服务 → 用户服务 → 会员等级服务 → 权益服务 → 商品服务 → 库存服务 → 价格服务 → 优惠券服务 → 物流服务 → 售后服务 → 评价服务。12 跳,其中 5 跳是串行的,7 跳能并行。P99 是 680ms,其中真正执行业务逻辑的时间加起来不到 90ms,剩下的全在网络往返、序列化、线程切换和等待上。
他说了句:"这不就是把方法调用改成了 HTTP 调用吗。"
这句话不客气,但准确。那之后我们花了七个月把这套东西合并回 4 个服务,P99 降到 470ms,故障率降了一半,团队的迭代速度反而快了。这篇把这个过程里算清的五笔账写下来。
先承认:当初拆成 12 个,理由在当时是成立的
2022 年那次拆分不是拍脑袋。当时的单体应用有 47 万行代码,一次全量构建 18 分钟,部署要停机 6 分钟,20 多个人共用一个代码仓库,每次发布前的合并冲突能开半天会。任何一个模块的内存泄漏都能把整个应用拖垮——我们真的遇到过报表导出功能的一个 List 没释放,导致下单接口全线 OOM。
拆分之后这些问题确实解决了:单服务构建 2 分钟,独立部署,故障隔离。
问题在于拆分的粒度。我们当时按"领域名词"拆,看到"会员等级"这个概念就拆一个服务,看到"权益"又拆一个。结果是会员等级服务只有 3 张表、8 个接口,代码量 4000 行,日常唯一的调用方就是权益服务,而权益服务的唯一调用方是订单服务。三个服务串成一条线,中间隔着两次 HTTP,却从来没有第二个调用方。
这就是典型的"分布式单体":物理上分开了,逻辑上还是一坨,改一个需求要同时发三个服务,还得排好顺序。
第一笔账:跨服务调用的固定成本
一次进程内方法调用大约 1-10 纳秒。一次同机房的 HTTP 调用,我们实测的分解是这样的:
| 环节 | 耗时(P50) | 说明 |
|---|---|---|
| 服务发现 + 负载均衡 | 0.02ms | 本地缓存命中时 |
| 请求序列化(Jackson) | 0.3ms | 平均 2KB 报文 |
| 网络往返(同可用区) | 0.4ms | 不含服务端处理 |
| 服务端反序列化 | 0.3ms | |
| 服务端业务逻辑 | 3-8ms | 真正干活的部分 |
| 响应序列化 + 反序列化 | 0.5ms | |
| 客户端线程切换 / 回调 | 0.1ms | 异步框架下 |
| 固定开销小计 | 约 1.6ms | 不含业务逻辑 |
1.6ms 看起来不多,但要乘以调用次数,而且这是 P50。P99 的分布完全不一样——任何一跳发生 GC、连接池等待、DNS 重解析,都能贡献几十毫秒。12 跳串下来,只要每跳有 1% 的概率慢 50ms,整条链路慢的概率就是 1-0.99^12 = 11.4%。
这就是为什么微服务的 P99 天然比单体差。我们那条链路上,5 跳串行的固定开销是 8ms,但 P99 贡献了 210ms。
第二笔账:数据一致性的复杂度
单体里下单扣库存是一个本地事务:
@Transactional(rollbackFor = Exception.class)
public Order createOrder(OrderRequest req) {
// 三张表在同一个数据库,同一个事务,要么全成要么全滚
inventoryMapper.deduct(req.getSkuId(), req.getQty());
Order order = orderMapper.insert(buildOrder(req));
couponMapper.markUsed(req.getCouponId(), order.getId());
return order;
}
拆成三个服务之后,这段代码变成了:
public Order createOrder(OrderRequest req) {
String txId = IdGen.next();
// 第一步:预扣库存(TCC 的 Try 阶段),冻结但不真扣
InventoryFreezeResult freeze = inventoryClient.freeze(
new FreezeRequest(txId, req.getSkuId(), req.getQty()));
if (!freeze.isSuccess()) {
throw new BizException("库存不足");
}
try {
// 第二步:锁定优惠券,同样是可撤销的中间态
couponClient.lock(new LockRequest(txId, req.getCouponId()));
} catch (Exception e) {
// 补偿:撤销库存冻结。注意这里的 catch 必须捕获所有异常,
// 包括超时——超时意味着"不知道成没成功",也必须补偿
safeCancel(() -> inventoryClient.unfreeze(txId));
throw e;
}
Order order;
try {
order = orderMapper.insert(buildOrder(req, txId));
} catch (Exception e) {
// 补偿两个:顺序无所谓,但每个都要保证幂等
safeCancel(() -> couponClient.unlock(txId));
safeCancel(() -> inventoryClient.unfreeze(txId));
throw e;
}
// 第三步:确认(Confirm 阶段)。这一步的失败最难处理——
// 订单已经落库了,但库存还是冻结态。只能靠异步补偿任务重试,
// 而重试期间用户看到的订单状态是"处理中"
asyncConfirm(txId, order.getId());
return order;
}
private void safeCancel(Runnable action) {
try {
action.run();
} catch (Exception e) {
// 补偿失败只能记账,交给对账任务兜底。
// 这张 compensation_failure 表我们线上一天能积几十条
compensationLogMapper.insert(CompensationLog.of(action, e));
log.error("compensation failed, recorded for retry", e);
}
}
从 5 行变成 40 行,还要额外维护:冻结记录表、补偿失败表、对账任务、超时清理任务、幂等去重表。我粗略统计过,这套分布式事务的配套代码有 2300 行,是原来那 5 行业务逻辑的 460 倍。
更要命的是它引入的新故障模式。我们上线后半年内,跟分布式事务相关的线上问题有 14 起:悬挂事务(Cancel 先于 Try 到达)3 起、空回滚 2 起、幂等失效导致重复扣减 4 起、补偿任务把已完成的订单又回滚了 1 起、对账脚本自身的 bug 4 起。
第三笔账:本地缓存失效
单体里我们大量使用 Caffeine 做本地缓存,商品基础信息的命中率能到 96%。拆分之后,商品服务变成独立进程,订单服务想缓存商品信息就得自己维护一份,而失效通知要通过 MQ 广播。
@Component
public class ProductLocalCache {
private final Cache<Long, ProductVO> cache = Caffeine.newBuilder()
.maximumSize(50_000)
// TTL 不能设太长:本地缓存 + MQ 失效的组合里,
// MQ 消息丢失是必然会发生的,TTL 是最后的兜底
.expireAfterWrite(Duration.ofMinutes(5))
// refreshAfterWrite 让过期时只有一个线程去回源,
// 其他线程先拿旧值,避免缓存击穿
.refreshAfterWrite(Duration.ofMinutes(2))
.recordStats()
.build(this::loadFromRemote);
private ProductVO loadFromRemote(Long productId) {
// 这里必须做超时和降级:商品服务挂了不能让订单服务跟着挂
try {
return productClient.getById(productId);
} catch (Exception e) {
// 返回 null 会让 Caffeine 不缓存,下次继续打远程,
// 高峰期这会变成雪崩。返回一个降级对象更安全
log.warn("load product {} failed, use degraded", productId, e);
return ProductVO.degraded(productId);
}
}
/**
* 商品服务发出变更消息后,各个消费方清本地缓存。
* 广播模式消费,每个实例都要收到——这一点在 RocketMQ 里
* 要显式设置 MessageModel.BROADCASTING,默认是集群模式只有一个实例收到,
* 我们上线第一周就栽在这个默认值上,导致 7 个实例里只有 1 个清了缓存
*/
@RocketMQMessageListener(topic = "product-change",
consumerGroup = "order-service-product-cache",
messageModel = MessageModel.BROADCASTING)
public void onProductChange(ProductChangeEvent event) {
cache.invalidate(event.getProductId());
}
}
这段代码本身不复杂,问题是它要在每个需要商品信息的服务里复制一份。我们有 6 个服务需要商品信息,就有 6 份几乎一样的缓存代码,6 个消费组,6 套监控指标。任何一处的失效逻辑写错,就是一次数据不一致事故。
而在单体里,这就是一个 Bean。
第四笔账:排查成本
单体时代排查一个"订单金额算错了"的问题,流程是:看日志,找到那次请求,从入口跟到出口,全在一个日志文件里,20 分钟能定位。
12 个服务之后,同样的问题要:拿 traceId 去链路追踪系统查调用链,找到可疑的那一跳,去那个服务的日志系统按 traceId 过滤(前提是它正确透传了 traceId),发现是它的下游返回了错误数据,再往下一跳……我们统计过,2023 年跨服务问题的平均定位时间是 2 小时 40 分钟,比单体时代长了 7 倍。
链路追踪能缓解但解决不了,因为追踪只告诉你"哪一跳慢/错了",不告诉你"为什么"。而"为什么"往往需要看那个服务的内部状态,那就得找到对应的负责人——如果那人在休假,等着吧。
第五笔账:组织成本
这一笔最容易被忽略,也最贵。
12 个服务,每个都需要:一套 CI/CD 流水线、一套监控告警、一份容量规划、一个值班责任人、一份接口文档、一套压测脚本。这些东西的边际成本不是零,加起来是实实在在的人力。
我们算过一笔账:维护一个微服务的固定运维成本大约是每月 0.3 人日(不含业务开发),12 个服务就是 3.6 人日/月,一年 43 人日。而当时团队只有 9 个人。
更隐蔽的是决策成本。一个需求要改 3 个服务,就要 3 个人对齐,排 3 次发布,做 3 次回归。康威定律说组织结构决定架构,反过来也成立:过细的架构会强行制造出不必要的协作边界。
我们怎么合并的:按变更频率而不是按领域名词
合并的原则是"看它们是不是总是一起改"。我们扒了两年的 Git 提交记录,统计每两个服务在同一个需求里同时被修改的频率:
| 服务对 | 共同变更次数 | 独立变更次数 | 耦合度 |
|---|---|---|---|
| 会员等级 ↔ 权益 | 47 | 3 / 5 | 0.85 |
| 订单 ↔ 售后 | 31 | 88 / 12 | 0.24 |
| 商品 ↔ 价格 | 52 | 6 / 4 | 0.84 |
| 库存 ↔ 订单 | 9 | 61 / 88 | 0.06 |
| 优惠券 ↔ 价格 | 38 | 14 / 6 | 0.65 |
耦合度 = 共同变更 / (共同变更 + 独立变更之和)。这个数字超过 0.6 的服务对,基本可以判定"不该分开"——它们没有独立演进的能力,只是被物理分开了。
最终的合并方案:会员等级 + 权益 → 会员域服务;商品 + 价格 + 优惠券 → 商品域服务;订单 + 售后 + 评价 → 交易域服务;库存、物流、用户各自保留(它们的耦合度都低于 0.2,而且有多个独立调用方)。12 个变成 4 个(加上保留的 3 个,实际是 7 个,但核心链路上只剩 4 跳)。
合并不是简单地把代码复制到一起。我们保留了模块边界:合并后的服务内部按原服务划分 Maven 模块,模块之间只能通过定义好的接口调用,用 ArchUnit 写规则在 CI 里强制检查。
@AnalyzeClasses(packages = "com.example.merchandise")
public class ModuleBoundaryTest {
/**
* 价格模块不能直接访问商品模块的 Mapper 层,只能走 Service 接口。
* 这条规则的意义在于:将来如果需要重新拆开,边界还在,
* 不至于合并两年后代码烂成一团再也拆不动
*/
@ArchTest
static final ArchRule price_module_should_not_touch_product_dao =
noClasses().that().resideInAPackage("..price..")
.should().accessClassesThat()
.resideInAPackage("..product.dao..")
.because("跨模块访问必须走 Service 接口,保留将来拆分的可能");
/**
* 各模块的领域对象不能互相引用,跨模块传递只能用 DTO。
* 这条比上面那条更容易被违反——写代码时随手 import 一个实体类太顺手了
*/
@ArchTest
static final ArchRule domain_objects_should_not_cross_module =
slices().matching("com.example.merchandise.(*)..")
.should().notDependOnEachOther()
.ignoreDependency(
resideInAPackage("..price.."),
resideInAPackage("..common.dto.."));
}
四种架构模式的适用边界
| 模式 | 团队规模 | 部署单元 | 数据一致性 | 主要成本 | 什么时候选它 |
|---|---|---|---|---|---|
| 单体 | 1-15 人 | 1 个 | 本地事务 | 构建慢、故障不隔离 | 业务未定型、快速验证阶段 |
| 模块化单体 | 10-40 人 | 1 个 | 本地事务 | 需要纪律维持模块边界 | 业务清晰但团队不大,我认为这是最被低估的选项 |
| SOA / 粗粒度服务 | 30-100 人 | 5-15 个 | 少量分布式事务 | 服务治理、接口版本管理 | 有明确的独立演进诉求 |
| 微服务 | 100 人以上 | 几十到上百 | 大量最终一致 | 运维、排查、组织协调 | 团队多到必须并行发布 |
| Serverless | 不限 | 函数级 | 强依赖外部状态 | 冷启动、可观测性弱、供应商绑定 | 流量波峰波谷极端、事件驱动型任务 |
我把"模块化单体"单独列出来,是因为它在国内讨论得太少。它保留了单体的部署简单和本地事务,同时用模块边界和构建期检查获得了大部分的"关注点分离"收益。代价是需要纪律——但需要纪律的架构,总比需要 43 人日/年运维成本的架构划算。
Serverless 那一行我要多说一句。我们在两个场景上用了函数计算:图片处理和数据导出。这两个场景的共同点是"事件触发、执行时间短、并发波动极大"。图片处理的日常 QPS 是 5,但运营做活动时能到 800,用常驻服务就得按 800 备容量。换成函数计算之后这块成本降了 74%。但我们没有把任何核心链路放上去——冷启动 300ms 到 2 秒的不确定性,在下单链路上是不可接受的。
我的判断
拆分的触发条件应该是组织问题,不是技术问题。 "这个模块太复杂了"不是拆分理由,那是重构理由。真正的拆分理由只有一个:两拨人需要独立发布,且合在一起会互相阻塞。
不要按名词拆,按变更频率拆。 领域驱动设计里的限界上下文是个好概念,但实践中很多人把它简化成了"按业务名词分类"。真正的边界应该用数据说话——把 Git 历史扒出来算耦合度,比开三天架构评审会有用。
合并回去不丢人。 这一点我想强调。技术圈有种氛围,好像架构只能往"更先进"的方向走,合并服务等于承认失败。但架构本来就该跟着业务和团队规模双向调整。我们那次合并,最大的阻力不是技术,是有人觉得"这是在开倒车"。
先把模块边界立住,再考虑要不要拆成进程。 边界清晰的单体随时可以拆;边界模糊的微服务永远合不回去,也拆不明白。
复盘:几个数字
- 合并前:核心链路 12 跳(5 串 7 并),P99 680ms,其中业务逻辑 90ms。
- 合并后:核心链路 4 跳,P99 470ms,降幅 31%。业务逻辑耗时没变,省下的全是网络和序列化。
- 分布式事务相关代码从 2300 行降到 610 行(库存和订单之间仍需要跨服务事务,但只剩一处)。
- 跨服务问题平均定位时间从 2 小时 40 分降到 55 分钟。
- 运维固定成本从 3.6 人日/月降到 1.5 人日/月。
- 服务器成本降了 22%:合并消除了大量为"每个服务至少 2 个实例保证高可用"而付出的冗余。
- 合并耗时 7 个月,分 4 个阶段灰度,期间发生 P2 事故 1 次(会员权益合并时漏迁了一张配置表,影响 40 分钟)。
留个问题
如果你现在接手一个 30 人团队、40 个微服务、核心链路 15 跳的系统,第一步会做什么?
我的答案是先花两周把调用拓扑和 Git 耦合度算出来,不动代码。但我见过另一种做法:直接冻结新服务创建,所有新需求只能加到现有服务里,靠"不再恶化"换时间。两种做法你更倾向哪一种,为什么?
更多推荐

所有评论(0)