Skip to content

第 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阿里开源,服务发现 + 配置中心一体
ConsulHashiCorp,功能全面,运维成本中等
EurekaNetflix(已停止维护),Spring Cloud 生态
etcd通用的分布式 KV,K8s 内部基础组件
Kubernetes ServiceK8s 内置的服务发现机制

Kubernetes 的服务发现

K8s 集群内的服务发现完全内置,不需要额外组件。每个 Service 自动有一个 DNS 名:

<service-name>.<namespace>.svc.cluster.local
python
# 调用方代码
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 ConfigSpring 生态,基于 Git 存储
etcd / Consul KV通用 KV,K8s 生态原生
K8s ConfigMap / SecretKubernetes 原生配置

配置变更的高敏感性

配置中心一旦接入生产,配置变更就和代码发布一样敏感,必须走同样的审批和审计流程

举个典型的配置变更如何引发事故:

原配置:payment.timeout = 3s
新配置:payment.timeout = 30s
变更目的:解决某些慢请求超时

结果:
- 短期:接口不再报超时
- 中期:每个请求占用线程 30 秒
- 数小时后:线程池被占满,支付服务整体不可用
- 雪崩:上游服务等待支付返回,也陷入卡顿

合理的配置变更流程:

  1. 提交变更:写明配置项、旧值、新值、变更原因
  2. 评审:相关人员审批
  3. 灰度:先在少量实例生效,观察指标
  4. 全量:确认无异常后,推送到所有实例
  5. 审计:变更人、时间、内容全部记录

发布记录必须包含变更人、时间、旧值、新值,出问题时第一时间能定位和回滚。

五、调用保护

超时重试熔断限流防止故障沿调用链扩散

问题:故障沿调用链扩散

微服务最讨厌的特性是故障会沿调用链扩散:

银行接口慢

支付服务等银行接口返回,线程占用增加

订单服务等支付服务返回,线程占用增加

网关等订单服务返回,线程池被占满

整条链路一起卡死

这就是雪崩。一个底层服务的小故障,沿调用链向上扩散,最终影响整个系统。

调用保护的几种机制

机制作用
超时(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 讲)

没有这套配套设施,微服务架构会变成运维噩梦。这就是为什么"微服务"和"云原生"两个概念经常一起出现——前者需要后者作为支撑。