上周朋友问我一个问题:他们公司 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 倍流量——本来快挂的数据库直接被重试打死。更糟的是库存被重复扣减,因为重试没有幂等保护。
两个动作必须做:
- 重试只在最外层做,内层传播失败。或者用重试预算(Resilience4j 的
retryBudget),让重试总量不超过正常请求的 10%。 - 所有写操作带幂等键,由发起方生成,一路透传到最下游。
// 幂等键在入口生成,不要在下游各自生成
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)+ 最终一致,要么把扣库存改成“预占 + 确认/取消”两阶段。无论哪种,业务代码都要改——这就是为什么接口要提前定,让改动集中在实现类里。
什么时候真的该拆
判据按重要性排:
- 团队规模。至少 3 个 6~8 人的 squad,每个 squad 有独立业务域和独立发布节奏。后端少于 15 人,人均服务数会超过 2,维护成本吃掉拆分收益。
- 发布冲突。同一应用里,A 模块一天要发 5 次,B 模块一周发 1 次,且 B 的回归要 2 小时。这种情况先拆构建产物(同一仓库出不同镜像),不一定要拆服务。
- 伸缩曲线不同。报表导出、视频转码这类 CPU 密集模块和主应用抢资源,扩缩容策略完全不一样。
- 故障隔离。某个模块历史上 OOM 拖垮全站 3 次以上。
- 技术栈硬约束。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 之前,把力气花在模块边界、批量接口、幂等设计上,收益比拆服务高得多——这些东西在真拆的那天会全部用上,而提前拆掉的成本收不回来。