单体拆成 12 个微服务后,一次下单要串 6 个 RPC:链路从 80ms 涨到 1.2s 的拆解
title: 单体拆成 12 个微服务后,一次下单要串 6 个 RPC:链路从 80ms 涨到 1.2s 的拆解
date: 2026-08-18
category: 架构演进
tags: [微服务, 架构演进, RPC, 链路耗时, 服务拆分]
2024 年我们做了一件事:把一个 42 万行的电商单体,按"业务域"拆成了 12 个微服务。上线那天架构师在群里发了个"恭喜拆服成功"的表情包,我也跟着乐。两个月后,大促前的全链路压测直接把乐子打没了——下单接口的 P99 从拆服前的 80ms 涨到了 1.2s,超时率从 0.1% 飙到 4.3%,监控里一条调用链清清楚楚挂着 6 个 RPC 节点。
这篇文章不是来唱衰微服务的。微服务该拆,但"拆"只是把问题从"代码耦合"换成了"网络耦合",链路耗时的坑一个没少。我把这次排查的全过程、源码层面的根因、以及我们最后怎么把 P99 压回 320ms 都写下来,供你避坑。
一、事故现场:一次下单为什么串了 6 个 RPC
拆服后的下单接口,内部调用关系是这样的(按代码里实际写的顺序):
Order-Service
├─> User-Service 查用户、风控等级
├─> Coupon-Service 查可用优惠券
├─> Inventory-Service 锁库存
├─> Price-Service 算最终价(含促销)
├─> Points-Service 算积分抵扣
└─> Inventory-Service 扣库存(二次确认)
这 6 步从头到尾是串行阻塞的。问题不在于"6 次调用",而在于这 6 次调用里,前 5 次的结果第 6 次才用得上,但代码里每一步都写成了"等返回再走下一步"。下面是当时下单服务的真实代码片段(脱敏后)。
// OrderCreateService.java —— 拆服初期的写法,6 步全串行
public OrderResult createOrder(OrderReq req) {
// 1. 查用户与风控等级,返回 userId + riskLevel
UserDTO user = userClient.query(req.getUserId()); // 同步阻塞 ~35ms
// 2. 查可用优惠券,依赖 user 的 riskLevel 过滤
CouponDTO coupon = couponClient.list(user.getRiskLevel()); // 同步阻塞 ~28ms
// 3. 锁库存,依赖 req 的 skuId
LockResult lock = inventoryClient.lock(req.getSkuId(), req.getQty()); // ~40ms
// 4. 算价,依赖 coupon + user
PriceDTO price = priceClient.calc(req.getSkuId(), coupon, user); // ~33ms
// 5. 算积分抵扣,依赖 price
PointsDTO points = pointsClient.calc(price.getFinalFee()); // ~22ms
// 6. 二次扣库存确认,依赖 lock 的 token
inventoryClient.confirm(lock.getLockToken()); // ~38ms
return OrderResult.ok(price, points);
}
逐行看这段代码的问题:
- 第 4 行
userClient.query返回后,第 6 行才用到user,但中间 2、3 步并不依赖user——可它们被写在了user之后,被迫等user返回。 - 第 7 行
priceClient.calc依赖coupon和user,但它排在inventoryClient.lock(第 9 行)之后,而lock其实和price没有任何依赖关系。 - 第 13 行二次确认
confirm才用到lock的 token,但lock在第 9 行就发出了,中间空等了 4 步。
把每一步的耗时加起来:35+28+40+33+22+38 = 196ms。这只是理想网络下的"毛估",真到大促线程池打满、下游排队时,每一步都从 30ms 涨到 200ms+,6 步一串行就是 1.2s。我们压测时 P99 1.2s 就是这么来的。
二、根因不是"调用多",是"依赖图被写成了直线"
我把这 6 步画成依赖图后发现,真正的数据依赖只有 3 条链:
链 A: user ──> coupon ──> price ──> points
链 B: inventory(lock) ──> inventory(confirm)
链 C: 无依赖,可独立发起(其实 price 和 inventory 没有交叉依赖)
也就是说,user/coupon/price/points 是一条链,inventory 的 lock 和 confirm 是另一条链,而 inventory.lock 和 price 之间没有依赖。原代码把它们全串行,等于把两条可以并行的链强行串成了一条,还多绕了几次。
这里有个很多人忽略的点:微服务把"方法调用"变成了"网络调用",但代码里的调用顺序没变。你在单体里写 a(); b(); c(); 时,JVM 方法调用是纳秒级,串不串行无所谓;换成 RPC 后,每一"步"都是一次 RT,串行顺序直接等于链路耗时。架构演进里最容易被低估的成本,就是这个"顺序没变但代价变了"。
三、第一次改造:用 CompletableFuture 把并行度榨出来
症结是"能并行的没并行"。最直接的解法是用 CompletableFuture 把无依赖的调用并发发起,用 thenCombine 在有依赖的地方汇合。改写后的核心逻辑:
// OrderCreateServiceV2.java —— 把依赖图还原成真正的并发结构
public OrderResult createOrderV2(OrderReq req) {
// 链 A 的第一跳:查用户(后续 coupon/price/points 都依赖它)
CompletableFuture<UserDTO> fUser = CompletableFuture
.supplyAsync(() -> userClient.query(req.getUserId()), bizExecutor);
// 链 B:锁库存,和链 A 完全独立,立刻并发发起
CompletableFuture<LockResult> fLock = CompletableFuture
.supplyAsync(() -> inventoryClient.lock(req.getSkuId(), req.getQty()), bizExecutor);
// coupon 依赖 user,等 user 好了再发;但和 fLock 并行
CompletableFuture<CouponDTO> fCoupon = fUser.thenApplyAsync(
u -> couponClient.list(u.getRiskLevel()), bizExecutor);
// price 依赖 user + coupon,等两者汇合
CompletableFuture<PriceDTO> fPrice = fUser.thenCombine(fCoupon,
(u, c) -> priceClient.calc(req.getSkuId(), c, u), bizExecutor);
// points 依赖 price
CompletableFuture<PointsDTO> fPoints = fPrice.thenApplyAsync(
p -> pointsClient.calc(p.getFinalFee()), bizExecutor);
// 等 price + lock 都好,再发起 confirm(依赖 lock 的 token)
CompletableFuture<Void> fConfirm = fPrice.thenCombine(fLock, (p, lock) -> {
inventoryClient.confirm(lock.getLockToken());
return null;
}, bizExecutor);
// 全部汇合
CompletableFuture.allOf(fUser, fLock, fCoupon, fPrice, fPoints, fConfirm).join();
return OrderResult.ok(fPrice.join(), fPoints.join());
}
逐行解释关键改动:
- 第 4-5 行用
supplyAsync(..., bizExecutor)把user查询丢进业务线程池,立刻返回Future,不阻塞当前线程。 - 第 9-10 行
fLock和fUser同时发出——这是把"串行直线"掰成"并行双链"的关键,inventory不再等user。 - 第 13 行
fUser.thenApplyAsync表示"user 好了才查 coupon",保留了真实依赖;但fCoupon和fLock在时间上是重叠的。 - 第 17-18 行
thenCombine是汇合点:price必须等user和coupon都返回,但price的等待期间fLock仍在跑。 - 第 24 行
thenCombine(fLock, ...)把confirm挂到price + lock双就绪之后,还原了"先锁后确认"的依赖。
改造后链路的关键路径变成:user(35) → coupon(28) → price(33) → points(22),加上 confirm 与 price 并行,关键路径约 35+28+33+22 = 118ms,比原来的 196ms 少了 40%。压测 P99 从 1.2s 降到 560ms。
四、第二次改造:给每条 RPC 加超时和熔断,别让一个慢节点拖死整条链
并行解决了"等待浪费",但没解决"下游挂了怎么办"。压测里我们还发现另一个问题:只要 Price-Service 一抖(GC 或 Full GC),整条下单链跟着超时,因为 fPrice.join() 会一直等。修复方式是给每个 supplyAsync 包一层超时与熔断。
// RpcGuard.java —— 统一 RPC 保护:超时 + 熔断降级
public class RpcGuard {
private final CircuitBreaker breaker; // 基于请求数/失败率的熔断器
public <T> CompletableFuture<T> call(String name,
Supplier<T> rpc,
T fallback) {
return CompletableFuture.supplyAsync(() -> {
// 1. 熔断开时直接走降级,不再打下游
if (breaker.isOpen(name)) {
metrics.counter("rpc.fallback", name);
return fallback;
}
try {
// 2. 硬超时 300ms,超时即抛,释放线程
T r = timeLimit(rpc, 300, TimeUnit.MILLISECONDS);
breaker.onSuccess(name);
return r;
} catch (TimeoutException e) {
breaker.onFailure(name); // 3. 超时计入失败,触发熔断
return fallback; // 4. 返回降级值,链路不中断
}
}, bizExecutor);
}
}
逐行解释:
- 第 8 行
breaker.isOpen(name):熔断器在"半开/打开"状态时,直接返回 fallback,不再往下游发请求,避免雪崩。 - 第 13 行
timeLimit(rpc, 300ms):每条 RPC 硬上限 300ms,即便下游卡死也不会无限等——这是把 P99 钉死在可控范围的核心手段。 - 第 16-17 行成功才
onSuccess、超时才onFailure:失败率超阈值(我们设 50% 且 20 次请求内)就打开熔断。 - 第 18 行返回
fallback:比如价格查不到就用"原价"兜底,订单仍能下,只是少个优惠——可用性优先于一致性。
加上这层保护后,即便 Price-Service 短暂故障,下单链路也只是"少算优惠",不会整体超时。P99 进一步从 560ms 压到 320ms,超时率回到 0.2% 以内。
五、两次改造的代价,和一张取舍表
别以为全是好事。两次改造也带来了真实的成本,我列一张表给你看清楚:
| 维度 | 单体(拆服前) | 串行 RPC(拆服初) | 并行+熔断(改造后) |
|---|---|---|---|
| 下单 P99 | 80ms | 1200ms | 320ms |
| 超时率 | 0.1% | 4.3% | 0.2% |
| 代码可读性 | 高(一个方法) | 中 | 低(Future 嵌套) |
| 排查难度 | 低(栈内) | 高(跨 6 服务) | 高(需 traceId 串联) |
| 单点故障影响 | 全站挂 | 一条链挂 | 降级兜底 |
| 团队耦合 | 高(改一处全量发) | 低(独立发版) | 低 |
表里最刺眼的是"代码可读性"和"排查难度"。CompletableFuture 嵌套写起来爽,新人接手时对着 thenCombine 一脸懵;跨服务排障必须上链路追踪(我们用的 SkyWalking),否则你连"慢在哪一步"都看不出。
六、复盘:那 4.3% 的超时率到底值不值得拆
我们复盘时算了一笔账:
- 拆服前,一次"改个优惠券规则"要全量回归 + 全量发版,平均 40 分钟,发版窗口还得挑凌晨。
- 拆服后,Coupon-Service 独立发版 3 分钟搞定,半年里我们发了 217 次,其中 190 次是热修,不用惊动其他 11 个服务。
- 链路耗时的坑,我们用"并行编排 + 超时熔断"补上了,代价是引入了 2 个必须会的工具(CompletableFuture、熔断器)和一套链路追踪。
所以我的判断很明确:微服务带来的"独立发版自由度",对多团队并行开发的价值,远大于链路耗时的那点代价。但如果你是小团队、一个服务都养不起专职人,单体真的够用,别为了"架构看起来高级"去拆——拆完你会发现,省下的发版时间,全花在填 RPC 的坑上了。
另外一个更个人的观点:很多人拆服时先画"服务边界图",却忘了画"调用时序图"。服务边界决定你能不能独立发版,调用时序决定你链路多快——这两张图一样重要,漏了时序图,上线必被 RT 教做人。
七、给你的落地清单
如果你正准备拆或已经拆了在填坑,这几条是我用 4.3% 超时率换来的:
- 拆服当天,就把每个接口的依赖图画出来,标清"哪些调用真有数据依赖、哪些只是代码顺序"。
- 所有跨服务调用,默认并行 + 默认带超时,别等出事再补。
- 熔断器一定要配,且 fallback 要"业务能接受"(价格用原价、库存用预占),不能一降级就报错。
- 链路追踪(traceId 透传)是拆服的入场券,没有它你连慢在哪都不知道。
- 服务粒度别细到"一个表一个服务",我们后来把 12 个合并回 9 个,P99 还降了 20ms——有些服务根本不值得独立部署。
思考题
你现在的系统里,有没有哪个接口其实只调了 2 个下游,却因为"等方法返回再继续"被写成了串行?把它改成并行后,预计能省多少 RT?欢迎在评论区贴出你的依赖图,我们一起算。
更多推荐




所有评论(0)