微服务:单体之外

Beyond the Monolith

3,108 words 18 min read
目录 33 节
  1. 微服务不只是“很多小服务”
  2. 围绕业务能力组织
  3. 可以独立部署
  4. 对自己的数据负责
  5. 服务在哪里?
  6. 服务发现之后还需要负载均衡
  7. 方法调用变成了网络协议
  8. 远程调用多了一种结果:不知道
  9. Deadline
  10. Retry
  11. Idempotency
  12. Circuit Breaker
  13. Bulkhead、限流与降级
  14. 为什么需要网关?
  15. API Gateway 与 Service Mesh 不是同一件事
  16. 数据不再属于一个事务
  17. 为什么不建议共享数据库?
  18. 同步调用还是异步消息?
  19. 同步调用
  20. 异步消息
  21. 配置为什么也变成了系统?
  22. 日志为什么不够用了?
  23. 服务如何发布和运行?
  24. Liveness
  25. Readiness
  26. Startup
  27. 服务之间如何建立信任?
  28. 接口如何演进?
  29. 服务边界也是团队边界
  30. 把所有东西放回一张图
  31. 最危险的结果:分布式单体
  32. 什么时候值得使用微服务?
  33. 微服务真正多出来的是什么?

假设我们正在写一个电商系统。

最开始,订单、库存、支付和优惠券都在同一个应用里:

Shop Application
    ├── Order Module
    ├── Inventory Module
    ├── Payment Module
    └── Coupon Module

创建订单时,代码可能只是几次普通的方法调用:

inventory.deduct(productId, quantity);
payment.charge(userId, amount);
coupon.markUsed(couponId);

它们发生在同一个进程。

调用目标已经由对象引用确定,参数可以直接存在内存里,异常沿着调用栈返回,几张表也可以放进同一个数据库事务。

@Transactional
public Order createOrder(Command command) {
    inventory.deduct(command.productId(), command.quantity());
    payment.charge(command.userId(), command.amount());
    coupon.markUsed(command.couponId());
    return orders.save(command.toOrder());
}

这个系统当然可能写得很乱。

但只要模块边界还算清楚,单体拥有许多天然优势:

  • 调用简单
  • 调试直接
  • 数据一致性容易处理
  • 本地开发成本低
  • 一次部署就能得到完整系统

后来业务增长了。

库存需要单独扩容,支付有更严格的安全要求,订单团队和商品团队的发布节奏也越来越不一样。

于是我们把库存拆成独立服务:

库存模块从单体进程中拆出以后,本地方法调用变成一次跨网络请求

代码表面上可能仍然接近:

inventoryClient.deduct(productId, quantity);

但这已经不是一次普通的方法调用了。

inventoryClient 背后需要经过序列化、服务发现、连接池、负载均衡和网络传输,最终才能到达另一台机器上的 Inventory Service。

更重要的是,原来由进程替我们保证的事情,现在都变成了问题:

  • Inventory Service 在哪里?
  • 它是否还活着?
  • 请求超时以后到底执行了没有?
  • 能不能重试?
  • 重试会不会重复扣库存?
  • 两个服务的数据库如何保持一致?
  • 一次请求经过五个服务以后,去哪查日志?
  • 两个团队如何升级接口而不互相阻塞?

接下来要讲的服务发现、重试、幂等、分布式事务和链路追踪,基本都在解决这些问题。

微服务不只是“很多小服务”

微服务没有一条被所有人共同接受的精确尺寸标准。

一个服务到底应该有五百行代码,还是五万行代码,并不是关键。

更重要的是它是否形成了真实边界:

Business Capability
        +
Independent Deployment
        +
Data Ownership
        +
Operational Ownership

围绕业务能力组织

Order Service 不是因为项目里有一个 order 包才存在。

它应该负责订单这个业务能力:

  • 创建订单
  • 维护订单状态
  • 执行订单规则
  • 对外提供订单接口
  • 对订单数据负责

边界来自业务语义,而不是 Controller、Service、Repository 这样的技术分层。

如果把原来的单体按技术层拆开:

Controller Service
Business Service
Repository Service

每次业务请求仍然要依次穿过所有服务。

系统只是把一次本地调用链变成了一次网络调用链,并没有得到真正的业务自治。

可以独立部署

如果修改 Inventory Service,必须同时发布 Order、Payment 和 Coupon,说明这些服务并没有真正独立。

独立部署意味着:

  • 有自己的构建产物
  • 可以单独发布和回滚
  • 可以独立扩容
  • 可以在其他服务不变的情况下演进

这并不等于服务之间完全没有依赖。

它意味着依赖通过稳定契约表达,而不是通过一次全系统协调发布维持。

对自己的数据负责

服务边界如果只停留在代码层,而所有服务都能直接修改同一组表,边界很容易被绕过:

Order Service ─────┐
Inventory Service ─┼──> Shared Database
Payment Service ───┘

今天 Order Service 为了赶需求直接更新库存表,明天 Inventory Service 又直接读取订单内部字段。

每一次捷径都会把服务重新绑在一起。

更完整的数据边界是:

Order Service     → Order Data
Inventory Service → Inventory Data
Payment Service   → Payment Data

其他服务想获取数据,需要经过接口或事件。

这也是为什么微服务经常和分布式一致性同时出现:一旦数据不再属于同一个本地事务,失败恢复就必须由系统显式设计。

服务在哪里?

在单体里,调用目标是一个对象引用。

inventory.deduct(...);

拆分以后,调用目标变成一个网络地址:

10.12.4.23:8080

这个地址并不稳定。

服务可能扩容出三个实例:

Inventory Service
    ├── 10.12.4.23
    ├── 10.12.7.18
    └── 10.12.9.41

发布时旧实例会被替换,机器故障后实例会迁移,容器重新创建也可能获得新地址。

调用方不能把某个 IP 永久写进配置文件。

于是出现了服务发现:

调用方通过稳定的服务名找到不断变化的健康实例

它解决的不是“记住一个地址”,而是维护一层稳定名字与动态实例之间的映射:

inventory-service

healthy endpoints

10.12.4.23
10.12.7.18
10.12.9.41

这层能力可以由注册中心提供,也可以由平台 DNS 和 Service 抽象提供。

Kubernetes 中的 Pod 是短暂资源,而 Service 提供稳定名称和访问入口;背后的 Pod 集合可以持续变化。

服务发现之后还需要负载均衡

知道所有实例以后,调用方还要选择一个:

request 1 → instance A
request 2 → instance B
request 3 → instance C

选择可以发生在客户端,也可以交给代理或平台数据面。

真正要回答的问题包括:

  • 哪些实例健康?
  • 新实例是否已经准备好接收流量?
  • 某个实例延迟突然升高时是否继续调用?
  • 同一个用户是否需要保持会话粘性?
  • 跨机房流量应该如何选择?

因此,服务发现、健康检查和负载均衡通常是一组相邻能力。

方法调用变成了网络协议

找到服务以后,两个进程还需要定义如何通信。

最常见的路线是:

HTTP + JSON
RPC + IDL
Message Queue + Event

HTTP API 容易被各种客户端理解,RPC 更接近类型化方法调用,消息则把生产者和消费者从时间上解耦。

它们不是互相淘汰的三代技术,而是不同的交互方式。

同一个系统里可能同时存在:

Client ──HTTP──> Gateway
Order  ──RPC───> Inventory
Order  ──Event─> Notification

关于 RPC 如何把网络重新包装成函数调用,可以继续看 《RPC:从本地函数到现代服务通信》

这里真正需要记住的是:

协议可以隐藏编码细节,却不能消除网络的不确定性。

远程调用多了一种结果:不知道

本地函数调用通常有两个明显结果:

return value
throw error

远程调用还会出现第三种:

unknown

假设 Order Service 调用 Inventory Service 扣减库存。

客户端等待两秒后超时:

同一次超时可能发生在请求到达前、业务执行中或响应返回途中

此时至少存在三种可能:

  1. 请求根本没有到达 Inventory Service
  2. 请求已经到达,但还没有执行完成
  3. 扣减已经成功,只是响应丢失了

调用方只看到了同一个 timeout,实际业务状态却完全不同。

如果立刻重试:

deduct(product-42, 1)
deduct(product-42, 1)

第三种情况下可能重复扣减。

因此,微服务里的可靠性设计不是简单地“失败就重试”。

它通常包含一组相互约束的机制。

Deadline

调用方需要明确自己愿意等待多久。

没有 Deadline,慢请求可能一直占据线程、连接和下游资源。

而一个完整请求经过多层服务时,时间预算还需要沿调用链向下传播:

Gateway          1000 ms
  Order           800 ms
    Inventory     300 ms
    Payment       400 ms

下游不能假装自己拥有无限时间。

Retry

重试适合处理短暂故障:

  • 瞬时网络错误
  • 某个实例刚好重启
  • 临时过载

但重试需要限制次数、使用退避和随机抖动,也要区分哪些错误可以重试。

如果所有调用方在服务恢复的一瞬间同时重试,重试本身会成为新的流量洪峰。

Idempotency

只要一个写操作可能被重试,就需要思考幂等。

例如为扣库存请求分配业务唯一键:

request_id = deduct-order-20260822-42

Inventory Service 保存处理结果:

request_id                     result
deduct-order-20260822-42       success

相同请求再次到达时,返回第一次结果,而不是再次扣减。

Circuit Breaker

如果 Inventory Service 已经持续失败,Order Service 不应该让每个请求都等待完整超时。

熔断器会在错误达到阈值后暂时停止调用:

CLOSED → OPEN → HALF_OPEN → CLOSED

它不是修复下游,而是阻止上游继续浪费资源,并为下游恢复留出空间。

Bulkhead、限流与降级

一个推荐服务变慢,不应该耗尽整个应用的线程池。

可以为不同下游设置独立连接池、并发上限和队列,这就是舱壁隔离的直觉。

限流保护系统不被超过容量的请求压垮。

降级则回答:

当完整结果暂时不可得时,用户还能得到什么?

商品推荐失败可以返回热门商品,头像服务失败可以使用默认头像。

支付结果不能随便猜测。

可靠性策略最终仍然由业务错误模型决定。

为什么需要网关?

如果客户端直接调用每个服务:

Web App ──> User Service
        ├─> Order Service
        ├─> Product Service
        └─> Coupon Service

客户端必须知道所有服务地址、认证方式和接口变化。

内部服务拓扑也直接暴露到了外部。

于是通常会在入口放置 Gateway:

Client


Gateway
   ├── User Service
   ├── Order Service
   └── Product Service

网关适合承载横切的入口能力:

  • 路由
  • 身份验证
  • 限流
  • TLS 终止
  • 请求追踪入口
  • 少量面向客户端的响应聚合

但网关不应该变成新的业务单体。

如果订单规则、库存规则和支付编排全部堆进网关,服务边界只是被搬到了另一个地方。

API Gateway 与 Service Mesh 不是同一件事

API Gateway 主要位于系统边界,处理外部流量进入内部服务。

Service Mesh 更关注服务之间的内部通信:

service-to-service

它可能把 mTLS、流量治理、遥测和重试等能力放到代理层。

Mesh 可以减少每种语言重复实现基础通信能力,但它不会替业务决定:

  • 哪个写操作可以重试
  • 哪种降级可以接受
  • 事务失败以后如何补偿

基础设施能执行策略,业务仍然必须定义策略。

数据不再属于一个事务

单体时期,我们可以写:

BEGIN;
INSERT INTO orders ...;
UPDATE inventory ...;
INSERT INTO payments ...;
COMMIT;

拆分以后,三份数据可能属于三个服务:

Order DB
Inventory DB
Payment DB

Order Service 不能直接打开另外两个服务的数据库事务。

这时需要重新思考一致性:

  • 哪一步必须立即成功?
  • 哪些状态允许短暂不一致?
  • 失败以后如何恢复?
  • 操作能否补偿?
  • 消息可能重复时消费者是否幂等?

常见方案包括:

  • Transactional Outbox
  • 可靠消息
  • Saga
  • TCC
  • 对账与补偿任务

它们不是微服务框架里的装饰组件,而是在填补本地事务消失后留下的空缺。

这部分可以继续看 《分布式一致性:从转账到失败恢复》

为什么不建议共享数据库?

共享数据库偶尔可以作为迁移阶段的现实选择。

但长期来看,它会让服务之间产生看不见的契约:

Inventory Service 修改字段

Order Service 的 SQL 突然失效

接口契约至少是显式的。

共享表结构形成的契约常常没有版本、没有所有者,也没有兼容策略。

如果必须暂时共享数据库,也应该明确:

  • 每张表由哪个服务写入
  • 其他服务只能通过什么方式读取
  • 什么时候完成数据迁移
  • 如何阻止新的跨边界 SQL

同步调用还是异步消息?

微服务之间不需要全部使用 RPC,也不需要全部事件驱动。

同步调用

同步调用适合调用方需要立即获得答案的场景:

Can this order be submitted?
What is the current price?
Is this account allowed to pay?

优点是流程直观,结果容易返回给用户。

代价是运行时耦合:

Order is available
but Payment is unavailable
therefore request fails

调用链越长,整体成功率和尾延迟越容易受到下游影响。

异步消息

异步消息适合不要求当前请求立即完成的后续动作:

OrderCreated
    ├── send notification
    ├── update analytics
    └── grant loyalty points

Order Service 发布事件以后,不需要等待所有消费者在线。

但异步不会让复杂性消失,只是改变复杂性的形状:

  • 消息可能重复
  • 消费顺序可能变化
  • 消费者可能长期失败
  • 事件格式需要兼容演进
  • 最终状态需要能够对账

同步与异步的选择,本质上是时间耦合和一致性要求的选择。

关于消息如何确认、重试、保持局部顺序,以及 RabbitMQ 与 Kafka 的模型差异,可以继续看 《消息队列:系统之间的缓冲地带》

配置为什么也变成了系统?

一个单体可能只有一个配置文件:

application.yml

几十个服务、多个环境和大量实例出现以后,配置需要回答更多问题:

  • 哪些配置属于开发、测试和生产?
  • 修改以后哪些实例生效?
  • 谁修改了生产限流阈值?
  • 错误配置如何快速回滚?
  • 数据库密码和普通配置是否应该一起保存?

因此通常会区分:

Configuration
Feature Flags
Secrets

普通配置描述运行参数。

Feature Flag 控制功能是否逐步开放。

Secret 管理数据库密码、证书和访问令牌。

动态配置很方便,也很危险。

一次没有审计和灰度能力的实时配置修改,本质上是一次绕过部署流程的生产变更。

日志为什么不够用了?

单体里,一次请求的日志通常位于同一个进程:

create order
deduct inventory
charge payment
order success

微服务里,它可能分散在多台机器:

Gateway
Order Service
Inventory Service
Payment Service
Message Consumer

如果没有统一上下文,我们甚至不知道五行日志是否属于同一个请求。

因此需要让 Trace Context 沿调用链传播:

trace_id = 7f31...

一次请求中的每个服务记录自己的 Span:

HTTP POST /orders
    ├── Order.create
    ├── Inventory.deduct
    └── Payment.charge

可观测性通常从三类信号理解:

Logs    发生了什么
Metrics 系统整体表现如何
Traces 这次请求经过了哪里

它们不是三套互不相关的工具。

真正有价值的是关联:

告警发现支付错误率升高

指标定位到 Payment Service

Trace 找到失败的下游调用

日志查看具体错误上下文

微服务把故障分散到了更多位置,可观测性负责重新建立因果关系。

服务如何发布和运行?

一个服务能够独立部署,只是起点。

真正运行时还需要:

  • 构建镜像或其他可部署制品
  • 注入配置和密钥
  • 启动多个实例
  • 判断实例是否健康
  • 把流量交给已经准备好的实例
  • 滚动替换旧版本
  • 失败时回滚
  • 根据负载扩缩容

这里经常出现三个容易混淆的健康概念。

Liveness

进程是否还能够继续工作?

如果发生死锁,重启实例可能恢复。

Readiness

实例是否已经准备好接收流量?

进程虽然启动了,但数据库连接池尚未初始化,此时不应该立刻进入负载均衡。

Startup

应用是否还处于正常启动过程?

启动较慢的服务不应该因为尚未完成初始化,就被 Liveness 反复重启。

Kubernetes 等平台把这些信号用于重启实例、移出流量和保护启动阶段。

容器与编排平台不是微服务的定义。

但服务数量增加以后,如果没有自动化部署、健康检查和环境标准化,运维成本会很快吞掉独立部署带来的收益。

服务之间如何建立信任?

在单体里,模块之间通常共享同一个进程身份。

拆分以后,每一次网络调用都需要重新考虑:

  • 谁在调用?
  • 它是否有权执行这个操作?
  • 凭证如何传递?
  • 传输过程是否加密?
  • 密钥如何轮换?

外部用户身份与内部服务身份也不是同一件事。

User Identity

Gateway

Service Identity

Internal Service

Gateway 验证用户 Token,不代表内部网络里的任意进程都可以冒充 Order Service。

系统可能使用短期凭证、mTLS、工作负载身份和细粒度授权来建立服务间信任。

但原则比具体方案更重要:

内网不是天然可信边界。

接口如何演进?

服务可以独立部署,意味着调用方和服务端不一定同时升级。

今天 Inventory Service 新增字段:

{
  "productId": "42",
  "available": 8,
  "warehouse": "shanghai"
}

旧客户端可能不认识 warehouse

通常新增可选字段比较容易兼容,删除字段、改变含义和收紧校验则危险得多。

微服务需要把接口当成长期契约:

  • 明确所有者
  • 保持向后兼容
  • 使用契约测试
  • 记录废弃周期
  • 控制 API 和事件 Schema 的演进

事件比同步 API 活得更久。

一条消息可能被存储、重放,也可能被很久没有升级的消费者读取。

因此事件格式同样需要版本与兼容策略。

服务边界也是团队边界

如果十个服务仍然由同一群人统一排期、统一发布、互相修改数据库,拆分只会增加仓库和部署数量。

比较健康的边界通常同时包含:

Code
Data
Deployment
Operations
Team Ownership

负责一个服务的团队不只是“写完代码交给运维”。

它还需要关心:

  • 生产指标
  • 容量
  • 告警
  • 故障恢复
  • 接口兼容
  • 下游使用体验

这也是 “you build it, you run it” 经常和微服务一起出现的原因。

微服务不仅重画了系统边界,也重画了责任边界。

把所有东西放回一张图

到这里,微服务体系看起来像是突然多出了很多名词。

其实它们可以放进几组问题:

微服务系统由业务服务、流量通信、可靠性、数据、可观测性、运行平台与治理共同组成

业务边界
    服务拆分、数据所有权、团队责任

流量与通信
    Gateway、服务发现、负载均衡、HTTP、RPC、消息

可靠性
    Deadline、Retry、Idempotency、Circuit Breaker、Rate Limit

数据一致性
    Outbox、Saga、补偿、对账

可观测性
    Logs、Metrics、Traces、Alerting

运行平台
    CI/CD、配置、Secret、健康检查、扩缩容

治理
    API 契约、Schema 演进、安全、所有权

这些并不是采用微服务第一天就必须部署的全家桶。

它们是一张问题地图。

系统拆到什么程度,就需要为相应问题付出成本。

最危险的结果:分布式单体

微服务最糟糕的状态,不是服务数量不够多。

而是既承担了分布式系统成本,又没有获得独立性。

典型表现包括:

  • 所有服务必须一起发布
  • 一个请求同步调用十几个服务
  • 多个服务直接读写同一数据库
  • 接口变化需要所有团队同时修改
  • 本地无法独立运行任何一个服务
  • 某个基础服务故障导致整个系统停止

这就是分布式单体。

它拥有网络延迟、部分失败、复杂部署和分布式事务,却仍然像一个耦合严重的单体那样演进。

问题通常不在服务数量,而在边界质量。

什么时候值得使用微服务?

微服务适合解决规模带来的组织和演进问题。

例如:

  • 不同业务能力有明显边界
  • 不同模块需要独立扩容
  • 多个团队需要并行发布
  • 某些能力有独立的安全或可用性要求
  • 单体发布已经成为持续交付瓶颈

但如果系统规模不大,团队只有几个人,业务边界仍然快速变化,微服务很可能提前引入大量固定成本。

这时模块化单体通常是更好的起点:

one deployable
clear modules
explicit boundaries
controlled dependencies

模块化单体不是微服务之前必须丢掉的临时方案。

它可以先帮助团队发现真实边界。

等到某个模块确实需要独立扩容、发布或治理时,再把它连同数据与责任一起抽出去。

系统从模块化单体开始,只在边界成熟并出现独立演进需求时逐步抽取服务

这条路线通常比一开始设计几十个服务更稳:

先建立模块边界

观察变化频率和团队协作

找到需要独立演进的业务能力

连同数据和所有权一起拆分

按实际问题补齐平台能力

微服务真正多出来的是什么?

表面上,微服务多出了:

  • 注册中心
  • 网关
  • RPC
  • 消息队列
  • 配置中心
  • 链路追踪
  • 容器平台
  • 熔断、限流和重试

但这些仍然只是工具。

真正多出来的是:

原本由一个进程、一次部署和一个数据库隐式保证的事情,现在都必须被显式设计。

对象引用变成服务发现。

方法调用变成网络协议。

异常变成部分失败和未知结果。

本地事务变成一致性与恢复流程。

单机日志变成跨服务因果链。

一次发布变成多版本长期共存。

代码所有权变成端到端的服务责任。

所以,微服务不是把代码切得更碎。

它是在用更高的分布式系统成本,换取更强的业务边界、独立部署和团队自治。

值不值得换,不由架构潮流决定。

它取决于当前系统的问题,是否已经大到足以支付这笔成本。

References

  1. James Lewis, Martin Fowler: Microservices
  2. Martin Fowler: Microservice Trade-Offs
  3. Zhamak Dehghani: How to Break a Monolith into Microservices
  4. gRPC: Deadlines
  5. gRPC: Retry
  6. Kubernetes: Service
  7. Kubernetes: Configure Liveness, Readiness and Startup Probes
  8. OpenTelemetry: Signals