微服务:单体之外
Beyond the Monolith
目录
- 微服务不只是“很多小服务”
- 围绕业务能力组织
- 可以独立部署
- 对自己的数据负责
- 服务在哪里?
- 服务发现之后还需要负载均衡
- 方法调用变成了网络协议
- 远程调用多了一种结果:不知道
- Deadline
- Retry
- Idempotency
- Circuit Breaker
- Bulkhead、限流与降级
- 为什么需要网关?
- API Gateway 与 Service Mesh 不是同一件事
- 数据不再属于一个事务
- 为什么不建议共享数据库?
- 同步调用还是异步消息?
- 同步调用
- 异步消息
- 配置为什么也变成了系统?
- 日志为什么不够用了?
- 服务如何发布和运行?
- Liveness
- Readiness
- Startup
- 服务之间如何建立信任?
- 接口如何演进?
- 服务边界也是团队边界
- 把所有东西放回一张图
- 最危险的结果:分布式单体
- 什么时候值得使用微服务?
- 微服务真正多出来的是什么?
假设我们正在写一个电商系统。
最开始,订单、库存、支付和优惠券都在同一个应用里:
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 扣减库存。
客户端等待两秒后超时:
此时至少存在三种可能:
- 请求根本没有到达 Inventory Service
- 请求已经到达,但还没有执行完成
- 扣减已经成功,只是响应丢失了
调用方只看到了同一个 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
- 消息队列
- 配置中心
- 链路追踪
- 容器平台
- 熔断、限流和重试
但这些仍然只是工具。
真正多出来的是:
原本由一个进程、一次部署和一个数据库隐式保证的事情,现在都必须被显式设计。
对象引用变成服务发现。
方法调用变成网络协议。
异常变成部分失败和未知结果。
本地事务变成一致性与恢复流程。
单机日志变成跨服务因果链。
一次发布变成多版本长期共存。
代码所有权变成端到端的服务责任。
所以,微服务不是把代码切得更碎。
它是在用更高的分布式系统成本,换取更强的业务边界、独立部署和团队自治。
值不值得换,不由架构潮流决定。
它取决于当前系统的问题,是否已经大到足以支付这笔成本。