Appearance
第 10 讲|微服务演进
绝大多数系统在起步阶段都是单体应用——登录、用户、订单、报表、后台管理、定时任务全部塞在一个代码仓库、一个进程里,部署一个包,日志看一处,数据库事务在同一个库里搞定。
单体并不"落后"。内部系统、早期业务、小团队项目,单体往往更清晰、更高效。微服务是在以下条件成熟后才考虑的方案:
- 代码量增长到无法在一个仓库中协作
- 团队规模增长到不同小组需要并行迭代
- 流量增长到单一应用无法支撑
- 发布节奏要求服务级隔离
本讲讲清楚从单体到微服务的演进路径、每一步引入的能力与代价。微服务不是"更高级"的架构,是"撑不住时的选择"——拆得太早会带来不必要的复杂度。
一、单体应用
单体的典型形态
所有功能在同一进程里,各模块通过函数直接调用,所有表共享一个数据库。
单体的优势
| 优势 | 具体表现 |
|---|---|
| 开发简单 | 一个仓库、一个 IDE,模块间直接函数调用 |
| 部署简单 | 一个包、一次部署 |
| 排查简单 | 一份应用日志包含完整请求链路,链路短 |
| 事务简单 | 数据库事务能覆盖多张表 |
| 性能高 | 函数调用没有网络开销 |
| 资源利用率高 | 共享 JVM/进程,内存开销小 |
单体变重之后的症状
单体应用规模增长后,会逐步暴露以下问题:

| 现象 | 实际影响 |
|---|---|
| 启动越来越慢 | 本地开发、测试、发布都变慢,改一行代码要等数分钟 |
| 模块互相耦合 | 改订单代码,牵出用户、库存、报表一堆代码 |
| 发布范围大 | 一个小改动也要发整个应用,风险和工作量都增加 |
| 资源互相抢占 | 报表跑慢查询占满线程池,登录都登不上 |
| 测试范围扩大 | 改一处要回归很多无关功能 |
| 多团队协作冲突 | 多个团队改同一份代码,合并冲突频繁 |
| 技术栈难升级 | 整个应用要么一起升级,要么都不升 |
这些症状出现到一定程度,才是考虑拆服务的时机——而不是看到别人在用微服务就跟着拆。
单体也可以"模块化"
单体不等于"一团乱麻"。良好的单体应用应当有清晰的内部模块边界:
src/
├── user/ # 用户领域
│ ├── service.py
│ ├── model.py
│ └── api.py
├── order/ # 订单领域
│ ├── service.py
│ ├── model.py
│ └── api.py
└── report/ # 报表领域
└── ...模块之间通过接口/服务对象调用,而不是直接访问对方的内部数据结构。这种"模块化单体"(Modular Monolith)享受了单体的简单,又保留了未来拆分服务的可能。
代码本身写得乱,拆成微服务只会更乱——只是把"模块之间的混乱"换成了"服务之间的混乱"。先把单体写好,再考虑拆。
二、服务拆分

拆分后的形态
每个服务独立进程、独立代码库、独立数据库、独立部署、独立扩展。
微服务带来的能力
| 能力 | 具体价值 |
|---|---|
| 独立扩展 | 订单服务压力大,只扩订单服务,不浪费其他服务的资源 |
| 独立发布 | 用户服务改 bug 不影响支付服务,发布频率和节奏各自决定 |
| 故障隔离 | 报表服务挂了,不影响下单 |
| 团队自治 | 各团队负责自己的服务,技术栈、迭代节奏独立 |
| 技术栈多样 | 老模块用 Java,新服务用 Go,不强求统一 |
微服务的代价
微服务不会减少复杂度,只是把复杂度从"单体内部"转移到"服务之间"。
| 代价 | 含义 |
|---|---|
| 网络调用代替函数调用 | 增加延迟、失败可能、序列化开销 |
| 跨服务事务难处理 | 单数据库事务变成分布式事务问题 |
| 跨服务排查变难 | 一次请求跨多个服务,日志散落,需要 Trace ID 串起来 |
| 部署复杂度上升 | 几十上百个服务的部署、配置、依赖管理 |
| 服务版本兼容 | 上游服务接口变了,所有依赖它的服务都要跟着升 |
| 运维成本激增 | 监控、告警、日志、链路追踪都要从单服务升级到全链路 |
| 测试复杂度 | 端到端测试需要启动几十个服务 |
"是否值得拆"的核心判断标准是:拆服务带来的灵活性收益,是否超过引入的复杂度成本。小团队的小业务不值得拆,大团队的大业务才能扛起这套成本。
拆分原则:领域驱动
不是按"代码量大小"或"团队人数"机械拆分,而是按业务领域(Domain)拆。一个常见的判断标准:两段代码是否经常一起修改。
- 经常一起改 → 属于同一个领域,合在一起
- 各自独立演进 → 属于不同领域,可以拆开
- 拆开后跨服务的调用频率不应过高,否则说明拆错了
DDD(Domain-Driven Design)的"限界上下文"概念是拆分的常见参考。
三、服务发现

问题:调用方如何找到被调用方
服务拆开后,调用方需要知道被调用方的地址。最朴素的方法是写死 IP:
python
USER_SERVICE_URL = "http://10.0.1.23:8080"问题:用户服务扩容、重启、迁移、节点替换,IP 都会变化,所有调用方都得跟着改代码、重新发布。生产环境根本无法接受。
服务发现机制
服务发现解决的是服务名到实际实例地址的动态映射:
| 概念 | 作用 |
|---|---|
| 服务名 | 逻辑标识(如 user-service) |
| 实例 | 服务实际运行的 IP:Port 列表 |
| 注册中心 | 保存服务名到实例列表的映射 |
| 健康检查 | 定期判断实例是否可用,自动摘除异常实例 |
工作流程:
新实例上线、旧实例下线、节点故障,注册中心自动更新,调用方完全不需要改代码。
主流服务发现方案
| 方案 | 特点 |
|---|---|
| Nacos | 阿里开源,服务发现 + 配置中心一体 |
| Consul | HashiCorp,功能全面,运维成本中等 |
| Eureka | Netflix(已停止维护),Spring Cloud 生态 |
| etcd | 通用的分布式 KV,K8s 内部基础组件 |
| Kubernetes Service | K8s 内置的服务发现机制 |
Kubernetes 的服务发现
K8s 集群内的服务发现完全内置,不需要额外组件。每个 Service 自动有一个 DNS 名:
<service-name>.<namespace>.svc.cluster.localpython
# 调用方代码
import requests
resp = requests.get("http://user-service.default.svc.cluster.local/api/users/1")K8s 内部的 CoreDNS 负责把 Service 名解析到对应的 ClusterIP(虚拟 IP),kube-proxy 把流量负载均衡到实际 Pod。Pod IP 频繁变化对调用方完全透明。
这是云原生时代服务发现的标准模式——应用代码完全不感知有"服务发现"这件事,直接用 DNS 访问即可。
四、配置中心
问题:多服务多环境的配置散落
服务数量多了之后,配置散落是个棘手问题:
- 每个服务的配置文件在每台机器上各有一份
- 改一个数据库地址要登录 N 台机器、改 N 个文件
- 配置不一致很难发现(实例 1 用旧地址,实例 2 用新地址)
- 配置变更没有审计记录,出问题不知道是谁改的、什么时候改的
配置中心的核心能力
| 能力 | 价值 |
|---|---|
| 集中存储 | 所有服务的配置在一处管理 |
| 环境隔离 | 开发/测试/生产环境的配置分离 |
| 动态推送 | 改完配置无需重启服务,运行时生效 |
| 版本管理 | 每次变更都有记录,可以回滚 |
| 灰度发布 | 部分实例先拿到新配置,验证 OK 再全量 |
| 权限控制 | 不同人能改不同服务的配置 |
主流配置中心
| 方案 | 特点 |
|---|---|
| Nacos | 阿里开源,服务发现 + 配置一体 |
| Apollo | 携程开源,管理界面友好,审计完整 |
| Spring Cloud Config | Spring 生态,基于 Git 存储 |
| etcd / Consul KV | 通用 KV,K8s 生态原生 |
| K8s ConfigMap / Secret | Kubernetes 原生配置 |
配置变更的高敏感性
配置中心一旦接入生产,配置变更就和代码发布一样敏感,必须走同样的审批和审计流程。
举个典型的配置变更如何引发事故:
原配置:payment.timeout = 3s
新配置:payment.timeout = 30s
变更目的:解决某些慢请求超时
结果:
- 短期:接口不再报超时
- 中期:每个请求占用线程 30 秒
- 数小时后:线程池被占满,支付服务整体不可用
- 雪崩:上游服务等待支付返回,也陷入卡顿合理的配置变更流程:
- 提交变更:写明配置项、旧值、新值、变更原因
- 评审:相关人员审批
- 灰度:先在少量实例生效,观察指标
- 全量:确认无异常后,推送到所有实例
- 审计:变更人、时间、内容全部记录
发布记录必须包含变更人、时间、旧值、新值,出问题时第一时间能定位和回滚。
五、调用保护

问题:故障沿调用链扩散
微服务最讨厌的特性是故障会沿调用链扩散:
银行接口慢
↓
支付服务等银行接口返回,线程占用增加
↓
订单服务等支付服务返回,线程占用增加
↓
网关等订单服务返回,线程池被占满
↓
整条链路一起卡死这就是雪崩。一个底层服务的小故障,沿调用链向上扩散,最终影响整个系统。
调用保护的几种机制
| 机制 | 作用 |
|---|---|
| 超时(Timeout) | 不让一个慢请求长时间占用线程 |
| 重试(Retry) | 处理短暂网络抖动,但要控制次数 |
| 熔断(Circuit Breaker) | 下游持续失败时直接快速失败,不再调用 |
| 限流(Rate Limit) | 控制流量速率,保护下游不被打爆 |
| 降级(Fallback) | 下游不可用时返回兜底值 |
| 隔离(Bulkhead) | 不同下游用不同线程池,故障互不影响 |
超时:最基本的保护
任何远程调用都必须设超时。没有超时的调用可能永久占用线程,几个这样的调用就能把线程池耗光。
python
# 错误:没有超时
response = requests.get("http://payment-service/api/charge")
# 正确:显式超时
response = requests.get(
"http://payment-service/api/charge",
timeout=(3, 10) # 连接超时 3 秒,读超时 10 秒
)超时时间的设定原则:
- 比下游正常响应时间多 2-3 倍 — 给重试和正常波动留余地
- 小于上游对自己的超时 — 否则上游已超时,自己还在等
- 服务级、接口级分别设置 — 不同接口的合理超时不同
重试:必须控制
重试可以解决短暂的网络抖动,但滥用会导致重试风暴:
- 一个失败的请求重试 3 次
- 每次重试又触发下游服务的连锁失败
- 重试请求 = 实际流量的 N 倍
- 下游被加倍流量彻底打爆
合理的重试策略:
python
# 指数退避 + 抖动
for attempt in range(3):
try:
return request(...)
except RetriableError:
wait = (2 ** attempt) + random.random() # 1s, 2s, 4s + 抖动
time.sleep(wait)
continue
raise只对幂等接口重试——POST 创建订单的接口,重试可能造成重复订单。
幂等:从 API 设计层面避免重试副作用
幂等(Idempotent)指同一个请求执行多次,结果与执行一次相同。
- GET / PUT / DELETE 天然幂等(语义如此)
- POST 不幂等,需要应用层保证
实现 POST 幂等的标准方式:幂等键(Idempotency Key)
http
POST /api/payments
Idempotency-Key: 7f9c3b2e-4d1a-4e8b-9c2f-1a0b3c5d7e9f
Content-Type: application/json
{"amount": 100, "to_account": "..."}服务端用幂等键做去重:
- 第一次收到该 key:正常处理,记录结果
- 后续收到相同 key:直接返回上次的结果,不重复处理
支付、下单、发券这类接口必须设计幂等机制,否则一次网络抖动 → 客户端重试 → 重复扣款,是严重事故。
熔断:激进的保护
熔断比超时更激进:发现某个下游连续失败到达阈值,直接"断开",上游不再发请求,快速返回错误。
正常状态(Closed)
↓ 失败次数达阈值
熔断打开(Open)
↓ 持续 N 秒
半开(Half-Open):放少量请求试探
↓ 成功率恢复
关闭(Closed)熔断的双重价值:
- 保护下游:给它喘息恢复的时间
- 保护上游:不再被慢请求拖垮
用户看到的现象:"支付通道繁忙,请稍后再试"——这比"整个系统卡死"可控得多。
主流熔断库:Hystrix(Netflix,已停)、Resilience4j、Sentinel(阿里)。
限流:从源头控制
限流在入口控制请求速率,避免突发流量打爆下游:
| 算法 | 特点 |
|---|---|
| 计数器 | 简单粗暴,有临界问题 |
| 滑动窗口 | 平滑限流 |
| 令牌桶(Token Bucket) | 允许一定突发,主流选择 |
| 漏桶(Leaky Bucket) | 严格匀速,适合保护下游 |
Nginx 限流配置示例:
nginx
# 定义限流区:每 IP 每秒 10 个请求
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay; # 允许 20 个突发,超过即拒绝
proxy_pass http://backend;
}
}六、分布式事务
问题:跨服务的强一致性难保证
单体应用里,"扣库存 + 创建订单 + 写日志"可以在一个数据库事务中完成,要么全成功要么全回滚。
微服务拆分后,这些操作分散在不同服务、不同数据库:
1. 订单服务:创建订单(订单库)
2. 库存服务:扣库存(库存库)
3. 支付服务:生成支付单(支付库)
4. 消息服务:发送通知无法用一个数据库事务包起来——这就是分布式事务问题。
强一致方案:2PC、TCC、SAGA
| 方案 | 工作方式 | 适用场景 |
|---|---|---|
| 2PC(两阶段提交) | 协调者先 prepare,所有节点都同意才 commit | 强一致,但性能差,阻塞重 |
| TCC(Try-Confirm-Cancel) | 业务层定义 try/confirm/cancel 三个方法 | 强一致,业务侵入大 |
| SAGA | 一系列正向操作,失败时执行补偿操作 | 最终一致,适合大多数业务 |
绝大多数业务系统不需要强一致——SAGA 模式 + 最终一致性足够。
SAGA / 状态机 + 消息:业界主流方案
主流的处理方式是把流程拆成一系列状态变化,通过消息驱动整个流程往前走:
| 步骤 | 状态 |
|---|---|
| 创建订单 | created |
| 锁定库存 | stock_locked |
| 创建支付单 | paying |
| 支付成功 | paid |
| 发货 | shipped |
| 任一步失败 | cancelled(对应执行补偿) |
涉及的核心组件:
- 状态机:管理业务对象的状态流转
- 消息队列:Kafka、RocketMQ、RabbitMQ
- 补偿任务:定时扫描异常状态,执行补偿
- 幂等键:防止消息重复处理
- 对账系统:发现状态不一致并告警
运维侧的新挑战
分布式事务下,运维排查变得复杂:
| 单体场景 | 微服务 + 分布式事务场景 |
|---|---|
| 看事务是否提交 | 看业务对象当前停在哪个状态 |
| 看 SQL 是否失败 | 看消息队列是否有积压 |
| 看应用日志 | 看补偿任务是否正常执行 |
| 单点排查 | 看是否有跨服务的状态不一致 |
| / | 看对账系统的告警 |
这就是微服务的真实代价——灵活性上去了,一致性、可观测性、运维复杂度全下来了。
七、微服务的合理决策
什么时候考虑拆服务
| 信号 | 含义 |
|---|---|
| 单体应用启动需要数分钟 | 开发效率严重受影响 |
| 多团队改同一份代码冲突频繁 | 协作成本高于拆分成本 |
| 某些模块需要独立扩展 | 资源利用率压力 |
| 不同模块的发布节奏冲突 | 报表想随时发,支付一周一发 |
| 模块间的故障互相影响 | 报表崩了支付也挂 |
什么时候不应该拆
| 信号 | 含义 |
|---|---|
| 团队人数 < 10 人 | 拆服务的协作成本超过收益 |
| 业务模块之间耦合极深 | 拆开后跨服务调用频繁,性能反而差 |
| 没有完善的监控、日志、链路追踪 | 拆完了排查不了问题 |
| 没有 CI/CD 基础设施 | 几十个服务的部署运维直接崩溃 |
| 业务还在快速验证期 | 微服务限制了快速重构能力 |
微服务不是越多越好
服务粒度选择:
- 太粗(几个超大服务):还是单体的问题
- 太细(数百个小服务):管理成本爆炸,网络调用开销大,排查噩梦
单个服务的合理大小:一个 5-8 人小团队能完整维护。这是经验值,不是绝对标准。
替代方案:模块化单体
不到拆服务的程度,模块化单体是更好的选择:
- 内部按模块清晰组织(每个模块有独立接口、独立的数据访问)
- 模块间通过接口调用,不直接访问对方的数据
- 部署仍然是一个进程,运维简单
- 未来真要拆,模块边界清晰,拆分成本低
很多团队的"微服务焦虑"其实是模块化不够好的焦虑——把单体写好,微服务自然就成熟了。
八、微服务之外的核心配套
完整的微服务架构离不开一组配套基础设施。任何一项缺失,微服务都会沦为"分散的痛苦":
| 基础设施 | 解决什么 |
|---|---|
| 服务发现 | 实例地址动态变化(本讲第三节) |
| 配置中心 | 配置集中管理与动态推送(本讲第四节) |
| API 网关 | 统一入口、认证、限流、路由 |
| 消息队列 | 解耦、异步、削峰 |
| 分布式链路追踪 | 跨服务请求追踪(Jaeger、SkyWalking) |
| 集中日志 | 多服务日志聚合(ELK、Loki) |
| 监控告警 | 服务级与全链路监控(Prometheus + Grafana) |
| CI/CD | 自动化构建、测试、部署 |
| 容器与编排 | 标准化运行环境(Docker + K8s,见 第 11 讲) |
没有这套配套设施,微服务架构会变成运维噩梦。这就是为什么"微服务"和"云原生"两个概念经常一起出现——前者需要后者作为支撑。