6 人团队维护 12 个微服务:拆分失败的代价和替代方案

格 格达软件 2026-11-05 4 次阅读

上周朋友问我一个问题:他们公司 6 个后端,业务是年营收千万级的 SaaS 后台,新来的技术总监规划了 12 个微服务。拆到第 8 个月,功能交付速度比拆分前慢了一半,两个人全职在维护 CI/CD 和 K8s,还出过一次单个服务重启把下单链路带崩的事故。

这不是微服务本身的问题,是服务数量和团队规模不匹配。下面只算账:10 人以下的团队拆微服务具体要付出什么,以及决定先不拆时,怎么用代码把单体守住,让将来真要拆的那天不至于推倒重来。

第一笔账:每个服务都有固定月供

我们团队 8 个后端,维护过 9 个服务,记录了半年的人日开销:

成本项 一次性 每月维护
CI 流水线(构建缓存、镜像推送) 0.5 人日 0.2 人日
K8s manifest / Helm chart 0.5 人日 0.1 人日
监控面板 + 告警规则 + 调参 1 人日 0.3 人日
日志采集、索引与保留策略 0.2 人日 0.1 人日
值班手册、故障演练 0.5 人日 0.2 人日
接口文档、版本兼容处理 0.3 人日 0.3 人日
合计 3 人日 1.2 人日

12 个服务的月度固定开销约 14 人日,折算成 0.7 个全职人力。这还只是“维持能跑”,不含排障。

真正的成本在联调。单体里改一个 DTO 字段,改完编译通过就完事;微服务里改一个字段要升 2 个服务的版本,协调发布顺序,处理灰度期间新旧版本的兼容。我们统计过一次跨服务改动的平均耗时:4.2 小时,同样改动在单体里是 0.5 小时。

注意这张表的前提:团队已经有成熟的 CI/CD 模板和监控体系。如果这些还在建设中,每个服务的成本要翻倍——因为你要先造一遍轮子,再造 12 遍。

第二笔账:进程内调用换成 RPC,延迟差被调用次数放大

这个可以自己测。写两个 endpoint,一个走进程内调用,一个走本地 HTTP:

@RestController
@RequiredArgsConstructor
public class CompareController {

    private final InventoryService inventoryService;   // 进程内实现
    private final RestClient restClient;

    @GetMapping("/local")
    public String local() {
        inventoryService.deduct("SKU-1", 1);
        return "ok";
    }

    @GetMapping("/remote")
    public String remote() {
        // 走一次本地 HTTP,模拟拆成独立服务后的调用开销
        restClient.get()
                .uri("http://localhost:8080/internal/inventory?sku=SKU-1")
                .retrieve()
                .body(String.class);
        return "ok";
    }
}
# 先各跑 10 秒预热,再压 30 秒
wrk -t4 -c64 -d30s --latency http://localhost:8080/local
wrk -t4 -c64 -d30s --latency http://localhost:8080/remote

4C8G 单机上,/local 的 p99 在 1ms 内,/remote 的 p99 在 4~6ms。单次差 4ms 看着无所谓,问题是这个差值是常数,会被调用次数放大。

我们踩过的写法:

// 订单列表页,一页 100 条
List<Order> orders = orderRepo.findByUserId(userId, page, 100);
for (Order o : orders) {
    // 每条订单查一次用户服务,100 次远程调用
    UserDTO user = userClient.getById(o.getUserId());
    vo.setUserName(user.getName());
}

100 次 × 4ms = 400ms,加上订单查询和序列化,一个列表页 600ms 起步。再叠加商品、库存,轻松破秒。上线后接口 p99 从 80ms 涨到 1.2s,日志里全是超时。

修法是批量接口:

List<Long> userIds = orders.stream().map(Order::getUserId).distinct().toList();
Map<Long, UserDTO> userMap = userClient.batchGet(userIds).stream()
        .collect(Collectors.toMap(UserDTO::getId, u -> u));
for (Order o : orders) {
    o.setUserName(userMap.getOrDefault(o.getUserId(), UserDTO.EMPTY).getName());
}

代价是每个跨服务读取都要设计批量接口,还要处理边界:批量接口的 id 数量上限(网关 URL 长度、服务端 IN 查询的性能拐点,MySQL 上一般控制在 500 以内),超了要分批。

单体里这就是一个 IN 查询。

第三笔账:分布式故障,排查难度不是线性增长

下单链路 A → B → C。C 因为数据库慢查询超时 3 秒,A 配了 3 次重试,B 也配了 3 次重试。

// A 服务
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 100))
public void createOrder(...) { orderClient.submit(...); }

// B 服务,同样的注解
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 100))
public void submit(...) { inventoryClient.deduct(...); }

C 收到的是 9 倍流量——本来快挂的数据库直接被重试打死。更糟的是库存被重复扣减,因为重试没有幂等保护。

两个动作必须做:

  1. 重试只在最外层做,内层传播失败。或者用重试预算(Resilience4j 的 retryBudget),让重试总量不超过正常请求的 10%。
  2. 所有写操作带幂等键,由发起方生成,一路透传到最下游。
// 幂等键在入口生成,不要在下游各自生成
String requestId = UUID.randomUUID().toString();
inventoryService.deduct(sku, qty, requestId);

// 下游用 requestId 做唯一索引,重复请求直接返回上次结果
// INSERT INTO t_deduct_log(request_id, ...) VALUES (?, ...)
// ON DUPLICATE KEY UPDATE ...

熔断配置:

resilience4j:
  circuitbreaker:
    instances:
      inventory:
        slidingWindowSize: 50
        failureRateThreshold: 50          # 失败率过半就打开
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 5
  retry:
    instances:
      inventory:
        maxAttempts: 2
        retryExceptions:
          - java.net.SocketTimeoutException

这套东西在单体里根本不存在——一个方法调用抛异常,catch 一下就完了。

先不拆的话,用代码把单体守住

模块化单体的核心只有一条:模块之间只能通过 api 包通信,impl 包对外不可见。这条规则靠 Maven 依赖 + ArchUnit 测试双重兜底。

Maven 多模块结构:

<!-- 父 pom -->
<modules>
    <module>order-api</module>
    <module>order-impl</module>
    <module>inventory-api</module>
    <module>inventory-impl</module>
    <module>app-boot</module>   <!-- 只负责打包和启动 -->
</modules>
<!-- order-impl/pom.xml:只能依赖别人的 api,不能依赖别人的 impl -->
<dependency>
    <groupId>com.geda</groupId>
    <artifactId>inventory-api</artifactId>
</dependency>
<!-- 严禁:<artifactId>inventory-impl</artifactId> -->

光靠 code review 拦不住,加 ArchUnit:

@AnalyzeClasses(packages = "com.geda", importOptions = ImportOption.DoNotIncludeTests.class)
class ModuleBoundaryTest {

    @ArchTest
    static final ArchRule order_impl_isolation =
            noClasses().that().resideOutsideOfPackage("..order.impl..")
                    .should().dependOnClassesThat().resideInAPackage("..order.impl..")
                    .because("order 的内部实现不允许被外部引用,要改就改 api");

    @ArchTest
    static final ArchRule inventory_impl_isolation =
            noClasses().that().resideOutsideOfPackage("..inventory.impl..")
                    .should().dependOnClassesThat().resideInAPackage("..inventory.impl..");
}

模块多了手动写规则很烦,SlicesRuleDefinition 配合 api/impl 的命名约定能自动生成,但规则写宽了会漏、写严了会误报,我倾向一个模块一条,出问题好定位。

数据库也要隔离。每个模块一个 schema,不允许跨 schema join:

-- 错误:跨模块 join,order 模块直接读 user 的表
SELECT o.*, u.name
FROM `order`.t_order o
JOIN `user`.t_user u ON o.user_id = u.id;

-- 正确:先查订单,再批量查用户,应用层拼装
SELECT * FROM `order`.t_order WHERE user_id = ?;
SELECT id, name FROM `user`.t_user WHERE id IN (...);

CI 里加个粗糙但有效的检查:

#!/usr/bin/env bash
# 拦住 SQL 迁移文件里的跨模块 join
set -euo pipefail
FAIL=0
for f in $(find . -path '*/db/migration/*.sql'); do
    if grep -Eiq 'join[[:space:]]+`?(user|inventory)`?\.' "$f"; then
        echo "发现跨模块 join: $f"
        FAIL=1
    fi
done
exit $FAIL

让拆分变成可逆操作:接口先于拆分

如果哪天真要拆,最怕的是业务代码里到处是 repository.getById()。提前把模块间调用收敛到接口上,接口的实现可以切本地或远程:

public interface InventoryService {
    // 按远程调用的标准设计:入参出参可序列化、粒度粗、带幂等键
    DeductResult deduct(String sku, int qty, String requestId);
}

// 默认本地实现
@Component
@ConditionalOnProperty(name = "app.inventory.mode", havingValue = "local", matchIfMissing = true)
public class LocalInventoryService implements InventoryService {
    private final InventoryRepository repo;
    private final DeductLogRepository logRepo;

    @Override
    @Transactional
    public DeductResult deduct(String sku, int qty, String requestId) {
        // 幂等:先查日志,已处理过直接返回上次结果
        // ... 本地事务扣减
    }
}

// 拆出去之后打开
@Component
@ConditionalOnProperty(name = "app.inventory.mode", havingValue = "remote")
public class RemoteInventoryService implements InventoryService {
    private final RestClient restClient;

    @Override
    public DeductResult deduct(String sku, int qty, String requestId) {
        return restClient.post()
                .uri("/internal/inventory/deduct")
                .header("X-Request-Id", requestId)
                .body(new DeductCmd(sku, qty))
                .retrieve()
                .body(DeductResult.class);
    }
}
app:
  inventory:
    mode: local    # 拆分当天改成 remote,业务代码一行不动

这里有几个坑,都是真实踩过的。

返回 JPA 实体。public Order getOrder(Long id) 在单体里没事,改成远程后 Jackson 序列化触发懒加载,抛 LazyInitializationException。接口签名一律用 DTO。

方法粒度太细。getSku()、setStock()、save() 这种 getter/setter 风格的方法,本地调用零成本,远程调用每个都是一次网络往返。接口要按用例设计,不是按数据模型。

把事务边界留在调用方。单体里 @Transactional 包住订单创建和库存扣减,拆开后这个事务跨进程了,回滚不了库存。要么用本地消息表(outbox)+ 最终一致,要么把扣库存改成“预占 + 确认/取消”两阶段。无论哪种,业务代码都要改——这就是为什么接口要提前定,让改动集中在实现类里。

什么时候真的该拆

判据按重要性排:

  1. 团队规模。至少 3 个 6~8 人的 squad,每个 squad 有独立业务域和独立发布节奏。后端少于 15 人,人均服务数会超过 2,维护成本吃掉拆分收益。
  2. 发布冲突。同一应用里,A 模块一天要发 5 次,B 模块一周发 1 次,且 B 的回归要 2 小时。这种情况先拆构建产物(同一仓库出不同镜像),不一定要拆服务。
  3. 伸缩曲线不同。报表导出、视频转码这类 CPU 密集模块和主应用抢资源,扩缩容策略完全不一样。
  4. 故障隔离。某个模块历史上 OOM 拖垮全站 3 次以上。
  5. 技术栈硬约束。ML 推理必须用 Python,其他是 Java。

反过来,这些理由站不住:

  • “代码太乱,拆开就清晰了”——拆完只是把乱代码分散到 12 个仓库,还多了网络调用。先做模块化。
  • “为了以后扩容”——单体也能水平扩容,加机器、加缓存、读写分离,QPS 到几千之前架构不是瓶颈。
  • “大厂都这么干”——大厂一条业务线的后端人数比你整个公司多。

折中方案:先切成 2~3 个应用,不是 12 个服务

按流量模型和故障影响面垂直切:C 端 API、运营后台、异步任务(定时任务 + 消息消费)。这三个的发布节奏、资源占用、故障影响面完全不同,拆开收益明确,运维成本几乎不增加。

# 同一个镜像,不同启动参数,共享一套 CI 和监控
services:
  api:
    image: geda/app:${TAG}
    command: ["java", "-jar", "app.jar", "--spring.profiles.active=api"]
  admin:
    image: geda/app:${TAG}
    command: ["java", "-jar", "app.jar", "--spring.profiles.active=admin"]
  worker:
    image: geda/app:${TAG}
    command: ["java", "-jar", "app.jar", "--spring.profiles.active=worker"]

约束:三个应用共享同一个数据库,所以不要在这三个应用之间搞跨库写事务,异步任务只消费消息、写自己的表。这条约束要写进架构文档,否则半年后一定有人图省事直接跨应用写。

先数人头,再数服务。人均服务数到 2 之前,把力气花在模块边界、批量接口、幂等设计上,收益比拆服务高得多——这些东西在真拆的那天会全部用上,而提前拆掉的成本收不回来。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。