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 依赖 couponuser,但它排在 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.lockprice 之间没有依赖。原代码把它们全串行,等于把两条可以并行的链强行串成了一条,还多绕了几次。

这里有个很多人忽略的点:微服务把"方法调用"变成了"网络调用",但代码里的调用顺序没变。你在单体里写 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 行 fLockfUser 同时发出——这是把"串行直线"掰成"并行双链"的关键,inventory 不再等 user
  • 第 13 行 fUser.thenApplyAsync 表示"user 好了才查 coupon",保留了真实依赖;但 fCouponfLock 在时间上是重叠的。
  • 第 17-18 行 thenCombine汇合点price 必须等 usercoupon 都返回,但 price 的等待期间 fLock 仍在跑。
  • 第 24 行 thenCombine(fLock, ...)confirm 挂到 price + lock 双就绪之后,还原了"先锁后确认"的依赖。

改造后链路的关键路径变成:user(35) → coupon(28) → price(33) → points(22),加上 confirmprice 并行,关键路径约 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% 超时率换来的:

  1. 拆服当天,就把每个接口的依赖图画出来,标清"哪些调用真有数据依赖、哪些只是代码顺序"。
  2. 所有跨服务调用,默认并行 + 默认带超时,别等出事再补。
  3. 熔断器一定要配,且 fallback 要"业务能接受"(价格用原价、库存用预占),不能一降级就报错。
  4. 链路追踪(traceId 透传)是拆服的入场券,没有它你连慢在哪都不知道。
  5. 服务粒度别细到"一个表一个服务",我们后来把 12 个合并回 9 个,P99 还降了 20ms——有些服务根本不值得独立部署。

思考题

你现在的系统里,有没有哪个接口其实只调了 2 个下游,却因为"等方法返回再继续"被写成了串行?把它改成并行后,预计能省多少 RT?欢迎在评论区贴出你的依赖图,我们一起算。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐