单体还是微服务:如何正确选择
1. 概述
关于“该选择单体还是微服务”的问题在几乎每场系统设计讨论中都会出现。本文不提供万能答案,而是通过具体的标准来分析:团队规模、发布速度、运维复杂度以及服务间的通信成本。读完本文后,你将拥有一份检查清单,能够自己评估系统应该处于“单体——模块化单体——微服务”这个轴的哪个位置。
2. 单体:简单但容易被低估
单体意味着所有业务(订单、客户、支付、库存……)在一个进程、一个代码库、一次部署中运行。
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request);
return ResponseEntity.ok(OrderResponse.from(order));
}
}
@Service
public class OrderService {
private final InventoryService inventoryService;
private final PaymentService paymentService;
public OrderService(InventoryService inventoryService, PaymentService paymentService) {
this.inventoryService = inventoryService;
this.paymentService = paymentService;
}
public Order create(OrderRequest request) {
inventoryService.reserve(request.productId(), request.quantity());
paymentService.charge(request.customerId(), request.amount());
return new Order(request); // 假设构造函数负责保存到数据库
}
}
OrderService 直接通过同一 JVM 内的方法调用 InventoryService 和 PaymentService。没有网络,没有序列化,事务可以包装在单个 @Transactional 中。这是单体最大的优势:容易保证一致性、容易调试、一次部署就完成。
缺点出现在团队扩大时:每个人都在修改同一个代码库,构建时间增加,部署一个小模块的风险也会导致整个应用重新构建。
注意: 单体不等同于“代码混乱”。一个写得好的单体,具有清晰的模块(按领域分包,没有交叉依赖),仍然可以很好地为百万级用户系统运行。
3. 微服务:分离以独立扩展,但代价是运维复杂性
微服务将订单、库存、支付分离为独立的服务,通过网络(REST、gRPC、消息队列)进行通信。
@Service
public class OrderService {
private final RestClient restClient;
public OrderService(RestClient restClient) {
this.restClient = restClient;
}
public Order create(OrderRequest request) {
// 通过HTTP调用Inventory Service
restClient.post()
.uri("http://inventory-service/reservations")
.body(new ReservationRequest(request.productId(), request.quantity()))
.retrieve()
.toBodilessEntity();
// 通过HTTP调用Payment Service
restClient.post()
.uri("http://payment-service/charges")
.body(new ChargeRequest(request.customerId(), request.amount()))
.retrieve()
.toBodilessEntity();
return new Order(request);
}
}
与第 2 章的单体版本相比,区别在于 restClient.post(...):进程内方法调用变成了网络上的 HTTP 调用。后果:
- 每次调用都可能因网络超时、服务宕机、DNS 错误而失败——这些错误在单体中不存在。
- 不再有跨服务的 ACID 事务。您必须使用 saga 模式或最终一致性来处理。
- 需要额外的服务发现、负载均衡、分布式追踪来在出错时进行调试。
3.1. 隐藏成本:分布式事务
@Service
public class OrderSagaOrchestrator {
public void createOrder(OrderRequest request) {
try {
inventoryClient.reserve(request.productId(), request.quantity());
paymentClient.charge(request.customerId(), request.amount());
} catch (PaymentFailedException e) {
// 必须自己编写补偿逻辑来回滚
inventoryClient.release(request.productId(), request.quantity());
throw e;
}
}
}
在单体中,回滚由 @Transactional 自动处理。在微服务中,您必须自己编写补偿逻辑(reserve 失败后 release)。这是单体版本中不存在但在微服务中必须存在的代码,以避免数据不一致。
4. 决定标准:不仅仅是“大公司在做什么”
4.1. 团队规模和康威定律
康威定律:系统结构反映了组织结构。如果你有 5 个开发人员,分成 5 个服务意味着每个人负责一个服务——没有人互相审查代码,你失去了“多人看一个 bug”的优势。
实际参考阈值:
1–15 个开发者,1 个团队 -> 单体(模块化)
15–50 个开发者,2–5 个团队 -> 模块化单体或分离2-3个大服务
50+ 个开发者,多个团队 -> 按bounded context的微服务
4.2. 发布速度真的是瓶颈吗?
如果你的团队每两周发布一次单体且没有严重的合并冲突,分离服务来“独立发布”不会带来价值——它只会增加运维成本(独立的 CI/CD、独立的监控、API 版本管理)。
微服务值得投资的时机是:团队 A 需要为支付功能每天部署 10 次,而团队 B 的库存功能只需每周部署 1 次。分离可以让 A 不用等待 B。
4.3. 基础设施的故障容忍能力
# docker-compose.yml - 演示微服务最小基础设施复杂度
services:
order-service:
image: order-service:1.0
depends_on:
- inventory-service
- payment-service
- service-registry
inventory-service:
image: inventory-service:1.0
payment-service:
image: payment-service:1.0
service-registry:
image: eureka-server:1.0
api-gateway:
image: spring-cloud-gateway:1.0
仅仅有 3 个业务服务,你就需要额外的服务注册表和 API 网关。乘以 20 个实际服务,活动部件数量按指数增长——需要足够强大的 DevOps 团队来运维 Kubernetes、分布式日志(ELK)、追踪(Jaeger/Zipkin)。
注意: 如果公司还没有人精通容器编排和可观测性栈,微服务会把每个事件变成一场“大海捞针”式的多个服务日志查询。
5. 模块化单体:被忽视的中间选择
模块化单体是单体,但代码被组织成独立的模块,通过明确的接口进行通信,没有跨模块的数据库依赖。
// Package: com.nonladev.shop.order
public interface InventoryPort {
void reserve(String productId, int quantity);
}
// Package: com.nonladev.shop.inventory
@Service
public class InventoryService implements InventoryPort {
@Override
public void reserve(String productId, int quantity) {
// 业务逻辑,访问inventory模块的专属数据库
}
}
OrderService 只依赖于 InventoryPort 接口,不知道 inventory 模块的实现细节。因此,如果以后需要将 inventory 分离为独立服务,你只需将 InventoryPort 的实现替换为 HTTP 客户端——OrderService 中的调用代码不需要改变。
这正是许多大型系统(如早期的 Shopify、GitHub)成功采用的“先从单体开始,逐步分离”策略。
6. 测试:衡量从进程内调用到网络调用的成本
下面的测试示例展示了延迟的差异——这是一个在决定分离服务前需要实际测量的因素。
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
class OrderServiceLatencyTest {
@Test
void inProcessCallShouldBeFasterThanRemoteCall() {
InventoryPort inProcess = (productId, qty) -> { /* no-op */ };
long startInProcess = System.nanoTime();
inProcess.reserve("P-1", 1);
long inProcessDuration = System.nanoTime() - startInProcess;
InventoryPort remoteSimulated = (productId, qty) -> {
try {
Thread.sleep(20); // 模拟网络往返延迟~20ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
long startRemote = System.nanoTime();
remoteSimulated.reserve("P-1", 1);
long remoteDuration = System.nanoTime() - startRemote;
assertThat(remoteDuration).isGreaterThan(inProcessDuration);
}
}
这个测试不测量你的基础设施的真实性能,但说明了本质:每次 Order 通过网络调用 Inventory,你都增加了几到几十毫秒。如果一个请求需要调用 5 个服务的链,累积延迟可能会超过 SLA——这是在纸上绘制架构图时经常被忽视的成本。
7. 何时不应该选择微服务
- 少于 10 人的团队: 多个服务的运维成本(CI/CD、监控、轮流待命)超过了分离的收益。
- 域边界未定: 如果业务不断变化(初创公司寻找产品-市场契合度的阶段),服务边界会经常出错,分离后又要合并——工作量翻倍。
- 缺乏可观测性: 没有分布式追踪、集中式日志,跨服务调试几乎不可能。
- 需要强一致性的事务(如核心银行系统):保持在单个事务边界(单体)内比实现复杂的 saga 更安全。
相反,微服务值得投资的情况是:多个独立团队、需要分别扩展不同部分(如搜索服务需要比订单服务更多 CPU)、或每个域需要不同技术(用 Node.js 处理实时聊天,用 Java 处理账单)。
8. 结论
没有“绝对正确”的架构——只有适合你当前团队规模、发布速度和运维能力的架构。如果不确定,可以从模块化单体开始:保持域之间的接口清晰,避免数据库交叉依赖,这样当真正需要分离时(由于扩展或团队增长),你只需要改变实现,而不是重写整个系统。微服务是解决组织和扩展问题的工具,而不是架构目标本身。