Skip to content

微服务

微服务不是"把项目拆成几个 repo",而是用网络调用换团队自治——它换来独立发布与按域伸缩,代价是分布式系统全部的复杂度。本文先讲清楚什么时候不该拆,再沿着"拆分 → 发现 → 网关 → 通信 → 韧性 → 可观测 → 契约"这条主线展开,每一步都给出可直接照搬的判断表与代码骨架。

一、先问:要不要拆

该拆的信号不该拆的信号
多团队在同一代码库互相阻塞,发布要排队三五人的小团队,一个仓库就能说清楚
不同模块伸缩特性差异巨大(如转码 vs 管理后台)所有模块一起扩缩,流量特征一致
某模块需要独立的技术栈或合规隔离业务逻辑高度耦合,拆完要一起改一起发
故障需要隔离(支付挂了不能拖垮浏览)没有可观测性与自动化部署的基础设施

判断原则:单体优先(Monolith First)。如果连模块边界在单体里都划不清,拆成微服务只会得到"分布式单体"——部署是分开的,改动仍然牵一发动全身,还额外附赠网络超时、数据一致性和排障难度。先在一个进程内把边界跑通,再拆。

拆分的真实代价(拆之前必须买单):

  • 网络不可靠:调用可能超时、重试、乱序,必须有超时/重试/幂等;
  • 数据不再共享事务:跨服务一致性需要 Saga/消息事务(见 系统设计);
  • 排障变难:一次请求横跨 N 个服务,没有链路追踪几乎无法定位;
  • 运维成本:服务数 ×(部署、监控、告警、扩容、证书、日志)。

二、怎么拆:按业务能力,而不是按技术分层

拆法例子评价
按业务能力/限界上下文订单、库存、支付、履约正确:边界内高内聚,边界间低耦合
按技术分层controller 服务、service 服务、dao 服务反模式:改一个字段要发三个服务
按数据量大表单独拆只对分库分表有效,不等于服务拆分
按团队现状有几个人就拆几块组织决定架构(康威定律),但要以业务域为准

三个反模式信号:

  1. 共享数据库:两个服务读写同一张表 = 边界失效,任何一方改表结构都要同步另一方;
  2. 分布式单体:任何需求都要改 3 个服务并同时发布,比单体还慢;
  3. 纳米服务:拆得过细,一次业务操作跨十几个调用,延迟与运维成本压垮收益。

数据所有权是拆分的核心:每个服务拥有自己的数据,只能通过 API 被访问。这条守不住,其他都是空谈。

三、服务注册与发现:别把 IP 写死

text
服务启动 → 向注册中心注册(名称、IP、端口、元数据、健康检查地址)
         → 注册中心定期健康检查,失败则摘除
调用方   → 按服务名查询可用实例列表 → 负载均衡挑一个 → 发起调用
发现模式机制代表特点
客户端发现调用方从注册中心拉列表,自行负载均衡Eureka + Ribbon、Nacos、Consul少一跳,但负载均衡逻辑要每种语言实现一遍
服务端发现调用方只访问固定入口,由负载均衡器转发K8s Service、阿里云 SLB、Nginx对语言透明,容器环境的默认选择
注册中心一致性健康检查适用
NacosAP/CP 可切换心跳 + 主动探测国内 Java 生态常用,兼作配置中心
ConsulCP(Raft)多种 check多语言、多机房
EurekaAP客户端心跳Spring Cloud 生态(已停止重大更新)
K8s DNS/Service依赖 etcdreadiness/liveness 探针容器环境下直接用,不必再引一个注册中心

健康检查三类,别只做一种:

  • 存活探针(liveness):进程是否卡死,失败则重启;
  • 就绪探针(readiness):能否接流量,失败则从负载均衡摘除(启动中、依赖未就绪);
  • 业务健康:能否真正完成工作(依赖的 DB/下游是否可用),用于更精细的摘除与告警。

四、API 网关与 BFF

网关是所有流量的统一入口,但它是"横切关注点"的归宿,不是业务逻辑的垃圾桶。

该放网关不该放网关
路由、限流、鉴权(见 鉴权认证业务编排(把三个服务聚合成一个业务接口)
TLS 终止、跨域、请求/响应改写数据转换与业务规则
日志、TraceID 注入、审计数据库访问
熔断降级与灰度分流任何需要频繁变动的逻辑(会让网关成为发布瓶颈)

需要按端定制的聚合,交给 BFF(Backend For Frontend):Web / App / 开放平台各自一个 BFF,由前端团队维护,专门为各自的界面裁剪数据,避免"一个通用 API 服务所有端"导致的过度请求与字段膨胀。

五、服务间通信

方式特点适用
HTTP/REST + JSON通用、易调试、可读外部 API、跨语言一般调用
gRPC(HTTP/2 + Protobuf)强类型契约、二进制、多路复用、流式内部高频调用首选
消息队列异步、削峰、解耦、可重放事件通知、耗时任务、最终一致(见 消息队列
GraphQL客户端按需取字段多端聚合场景,注意复杂度与缓存难度

同步调用的三条铁律:

  1. 必须设超时,且下游超时必须逐级递减(调用链总预算固定,避免层层放大);
  2. 重试要克制:只对幂等操作重试,且必须带退避与抖动,否则会引发重试风暴把下游打垮;
  3. 幂等是前提:重试、消息重投、用户重复提交都会导致重复执行,用幂等键(业务唯一号)在服务端去重。
text
超时预算示例(总预算 800ms):
网关 800ms → 订单服务 600ms → 库存服务 300ms → 数据库 100ms
每一层都比上一层小,避免上游已超时、下游还在白干

六、韧性:超时、重试、熔断、限流、隔离、降级

6.1 熔断:快速失败胜过雪崩

js
// 熔断状态机核心(CLOSED → OPEN → HALF_OPEN → CLOSED)
class CircuitBreaker {
  constructor({ failThreshold = 3, cooldownMs = 1000, halfOpenMax = 2 } = {}) {
    this.failThreshold = failThreshold
    this.cooldownMs = cooldownMs
    this.halfOpenMax = halfOpenMax
    this.state = 'CLOSED'
    this.fails = 0
    this.halfOpenPass = 0
    this.openedAt = 0
  }
  async call(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.openedAt < this.cooldownMs) return 'rejected(fast-fail)'  // 快速失败,不压下游
      this.state = 'HALF_OPEN'; this.halfOpenPass = 0                                 // 冷却结束,试探
    }
    try {
      const r = await fn()
      if (this.state === 'HALF_OPEN' && ++this.halfOpenPass >= this.halfOpenMax) {
        this.state = 'CLOSED'; this.fails = 0                                          // 试探成功,恢复
      } else if (this.state === 'CLOSED') this.fails = 0
      return r
    } catch {
      if (this.state === 'HALF_OPEN') { this.state = 'OPEN'; this.openedAt = Date.now(); return 'rejected(reopen)' }
      if (++this.fails >= this.failThreshold) { this.state = 'OPEN'; this.openedAt = Date.now() }
      return 'error'
    }
  }
}

Node v22.22.1 实测(连续失败阈值 3,冷却 1s):

text
第 1 次调用: error               state=CLOSED
第 2 次调用: error               state=CLOSED
第 3 次调用: error               state=OPEN      ← 达到阈值,熔断打开
第 4 次调用: rejected(fast-fail) state=OPEN      ← 不再打下游
第 5 次调用: rejected(fast-fail) state=OPEN
冷却结束 → HALF_OPEN 试探: ok    state=HALF_OPEN
再成功一次: ok                   state=CLOSED    ← 探测通过,恢复正常

三个关键参数:失败阈值(多少次要熔断)、冷却时间(熔断保持多久)、半开试探数(几个成功才恢复)。熔断必须配降级(返回兜底数据/默认值/排队页),否则快速失败只是把错误更快地抛给用户。

6.2 限流:算法决定行为

算法行为边界表现适用
固定窗口计数每窗口重置计数窗口交界处可放行 2 倍流量简单统计,不推荐做严格限流
滑动窗口计数统计最近 N 毫秒平滑,无边界突刺精确 QPS 限制(网关常见)
令牌桶匀速生成令牌,桶可积攒允许突发(≤ 桶容量),平均速率受限绝大多数业务限流(Guava/Redis 实现)
漏桶恒定速率出水削峰填谷,输出恒定需要严格匀速(如下游写入保护)

虚拟时钟实测(阈值 100 次/秒,窗口 1000ms):

text
t=990ms 打 100 次  → 固定窗口通过 100,滑动窗口通过 100
t=1001ms 再打 100 次 → 固定窗口通过 100,滑动窗口通过 0
11ms 内总通过:固定窗口 200(2 倍阈值),滑动窗口 100(不超阈值)

令牌桶实测(桶容量 100,速率 50/s):

text
瞬时 200 次    → 通过 100 次(= 桶容量,允许一次性突发)
1 秒后打 50 次 → 通过 50 次(1 秒补充 50 个令牌)
紧接着 50 次   → 通过 0 次(令牌耗尽,平均速率被限制在 50/s)

选型直觉:要"允许合理突发"用令牌桶;要"严格匀速"用漏桶;要"精确 QPS 阈值"用滑动窗口。分布式限流用 Redis(Lua 脚本保证原子)或网关层统一做,单机限流只能保护自己那一份。

6.3 隔离与降级

  • 舱壁隔离(Bulkhead):不同下游用独立线程池/连接池/信号量,一个慢下游不会耗尽全部资源;
  • 降级:关闭非核心功能(推荐、评论、积分),保住主链路(浏览、下单、支付);
  • 超时 + 重试 + 熔断 三者必须成套配置,只配一个反而更危险(无超时的重试 = 放大故障)。

七、可观测性:三支柱

支柱回答什么工具
日志 Logging发生了什么(离散事件)ELK / Loki,结构化 JSON + TraceID
指标 Metrics整体如何(可聚合数值)Prometheus + Grafana
链路 Tracing一次请求在哪慢Jaeger / Zipkin / SkyWalking(OpenTelemetry)
text
TraceID 透传链路(每个服务都必须往下传,不能断):
网关生成 TraceID/SpanID → 放入 HTTP Header(traceparent)
  → 服务 A 记录并生成子 Span → 调用 B 时带上
    → 服务 B 记录 → 调 DB/MQ 时继续带
所有日志打印 TraceID → 用 TraceID 串起一次请求的全部日志与耗时

关注黄金四指标:延迟(Latency)、流量(Traffic)、错误(Errors)、饱和度(Saturation)。告警要基于症状(用户可见的错误率/延迟),而不是基于原因(CPU 高了)——原因型告警会产生大量无效告警。

八、配置与契约

事项做法
配置外置配置中心(Nacos/Apollo)或 ConfigMap + Secret,禁止打进镜像
密钥管理Secret 独立存储、加密、可轮换,不进 Git(见 容器与部署
契约管理gRPC/OpenAPI 定义先行,生成代码;禁止口头约定字段
契约测试消费者驱动契约(Pact/Spring Cloud Contract),在 CI 里验证兼容性
版本兼容向后兼容优先(加字段不改语义),破坏性变更走新版本路径 + 灰度
灰度发布按流量比例/用户维度灰度,配合指标对比自动回滚(见 容器与部署

九、检查清单

  1. 每个服务有自己的数据存储,不共享数据库表;
  2. 服务间调用全部有超时,且沿调用链递减;
  3. 重试只对幂等操作,带退避 + 抖动 + 重试预算;
  4. 关键下游有熔断 + 降级,熔断参数(阈值/冷却/试探数)有明确配置;
  5. 有接入层限流(网关/Redis 分布式),明确限流维度(用户/IP/接口/租户);
  6. 有分布式链路追踪,TraceID 贯穿日志、MQ 消息头、线程池切换处;
  7. 健康检查区分存活与就绪,就绪探针真实反映依赖状态;
  8. 配置外置、密钥集中管理且可轮换;
  9. 接口有契约(OpenAPI/Proto)与契约测试,破坏性变更有灰度路径;
  10. 每个服务有独立的 owner、告警与值班响应机制(拆了没人管 = 失控)。

十、常见坑速查

  1. 没划清边界就拆:得到分布式单体,改动成本比单体更高;
  2. 共享数据库:边界失效,改表结构要跨团队同步——数据所有权必须排他;
  3. 没设超时:线程池被慢调用耗尽,整条链路雪崩;
  4. 无限制重试 + 无退避:一次抖动引发重试风暴,把刚恢复的下游再次打死;
  5. 重试非幂等接口:重复扣款、重复下单——先做幂等键,再谈重试;
  6. 熔断没有降级:只是把错误更快抛给用户,体验更差;
  7. 限流用固定窗口:边界处放行 2 倍流量(实测见 §6.2),压测能过、线上被打穿;
  8. TraceID 在异步/线程池处断掉:日志无法串联,排障回到"人肉 grep";
  9. 把业务逻辑塞进网关:网关变成发布瓶颈,改一次业务要全网灰度;
  10. 日志无结构、无统一字段:无法聚合分析,只能靠关键字碰运气——统一 JSON 结构 + TraceID 是底线;
  11. 配置打进镜像 / 密钥进 Git:改配置要重新发版,密钥泄露无法快速轮换。

状态与参考

下一步

  • [ ] 用 opossum / Resilience4j 跑通熔断 + 降级组合,对比自研实现的参数差异
  • [ ] 搭一套 Prometheus + Grafana + OpenTelemetry 的最小可观测链路并截图归档
  • [ ] 写一篇"从单体拆出第一个微服务"的实操复盘(含数据迁移与双写方案)

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀