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 提交记录,统计每两个服务在同一个需求里同时被修改的频率:

服务对共同变更次数独立变更次数耦合度
会员等级 ↔ 权益473 / 50.85
订单 ↔ 售后3188 / 120.24
商品 ↔ 价格526 / 40.84
库存 ↔ 订单961 / 880.06
优惠券 ↔ 价格3814 / 60.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 耦合度算出来,不动代码。但我见过另一种做法:直接冻结新服务创建,所有新需求只能加到现有服务里,靠"不再恶化"换时间。两种做法你更倾向哪一种,为什么?

更多推荐