← Back

拆微服务之前,先想清楚一件事

16 min read
#architecture#microservices#go#k8s

架构是什么?不是框架,不是技术栈,不是画图。架构是你在写第一行业务代码之前做的那些选择——怎么拆服务、怎么通信、数据怎么流、错误怎么传。这些选择一旦定下来,后续所有开发都绕着它们走。

拆微服务是架构里最容易踩坑的决策之一。拆少了怕耦合,拆多了怕运维爆炸。本文从五个常见陷阱出发,附带可操作的判断标准。


一个典型的微服务反面案例

某一 AI 平台后端,Go 语言,K8s 部署,服务间走 gRPC。团队按照"一个功能一个服务"的原则,拆出了十多个微服务:

HTTP 网关    ~10000 行   十几个下游调用
认证服务       ~5000 行   OIDC/OAuth2/SAML
用户服务       ~4000 行   CRUD + 关联查询
对话服务       ~3500 行   聊天流式返回
AI Agent      ~3500 行   智能体配置
营销服务       ~2000 行   活动 + 短链
LLM 代理      ~2000 行   多厂商生成代理
数据导出       ~1300 行   异步导出
后端服务A       ~1000 行   基础 CRUD
后端服务B       ~1000 行   基础 CRUD
Webhook       ~800 行    外部回调
辅助网关        ~500 行    另一个 HTTP 入口
ID 生成器       ~400 行    分布式 ID

问题不在服务数量本身,而在于拆分的依据错了。下面逐个拆解。


陷阱一:按功能拆,而不是按生命周期拆

企业级项目最常见的拆分逻辑:"每个功能一个服务"。听起来合理,但会造出大量"伪微服务"——部署上独立,逻辑上绑定。

判断标准不是功能不同,是生命周期不同。

两个模块如果总是同时改、同时发版、同时扩容,它们就是一个服务。硬拆只会多出部署成本和网络调用开销。

什么是生命周期不同

维度相同生命周期不同生命周期
发布节奏业务迭代一起上一个有紧急修复,一个稳定不动
扩缩容节奏流量同涨同跌一个有高峰低峰,一个平稳
故障影响挂了同时不可用一个挂了另一个能降级
数据耦合共享表、级联查询数据独立,通过 API 交换
团队归属同一个团队维护不同团队维护

ID 生成器是最典型的反面教材。400 行代码,被几乎所有服务依赖。它的生命周期和任何调用方都相同——所有服务都需要它,它挂了全系统炸。它不该是服务,应该是一个进程内库。

企业级做法:Bounded Context

DDD 里的 Bounded Context 不是"一个实体一个服务"。一个 Context 可以包含多个聚合、多个实体。划分依据是业务能力边界,不是数据表。

错误划分(按表):          正确划分(按能力):
  用户服务                  身份与访问管理
  权限服务    ——合并——▶      (用户 + 认证 + 权限)
  认证服务

  对话服务                    对话平台
  消息服务    ——合并——▶      (对话 + 消息 + AI 生成 + 导出)
  AI 服务
  导出服务

一个 Bounded Context 内,功能之间用包隔离,用函数调用。Context 之间,走 API 或消息队列。

// 一个 Context 内部:用包隔离
platform/
  internal/
    chat/        // 对话逻辑
    message/     // 消息存储和分表
    llm/         // LLM 调用和流控
    export/      // 导出逻辑
    // 互相之间直接函数调用,不需要 gRPC

陷阱二:以为服务多 = 并发高

很多团队拆服务的理由是"服务多了,各自独立扩容,整体吞吐量就上去了"。

这个推导是错的。

每一次远程调用都要交税

gRPC 调用的实际开销:

环节耗时备注
序列化~0.01msProtobuf marshal
网络往返~0.3-0.5ms同机房 TCP + 内核协议栈
反序列化~0.01msProtobuf unmarshal
对端调度~0.1-0.2msGo scheduler + gRPC handler
Context 传递~0.05mstrace/meta propagation
合计~0.5-0.8ms还没算业务逻辑

单机函数调用:纳秒级。跨服务调用:毫秒级。差了三个数量级。

调用链越深,延迟越不可控

假设一个请求经过 4 个服务,每跳 0.6ms:

用户请求 → 网关 → 对话服务 → AI 代理 → 上游 LLM
             0.6ms    0.6ms     0.6ms

3 跳 = 1.8ms 纯网络开销

如果某个服务 GC 停顿 50ms,整条链超时。
如果某个服务 Pod 重启,请求失败需要重试。

企业级做法:在单服务内做并发,不靠拆分做并发

单服务内部并发模型:

  请求1 ── goroutine1 ── LLM调用1 (等待 I/O)
  请求2 ── goroutine2 ── LLM调用2 (等待 I/O)
  请求3 ── goroutine3 ── LLM调用3 (等待 I/O)
  ...
  请求10000 ── goroutine10000 ── LLM调用10000

  一个 Go 进程,几万个 goroutine
  CPU 在等待 I/O 的间隙处理其他请求

Go 的 goroutine 设计就是为这种场景做的。每 goroutine ~2KB 栈内存,一个 1GB 内存的 Pod 理论上能跑几十万个。实际瓶颈在 GC 和上游限流,不在并发模型。

什么时候该拆?拆了能消除热点。

场景: 对话服务(I/O密集)和 报表服务(CPU密集)混在一起
问题: 报表查询吃掉 CPU,阻塞对话请求的调度
方案: 拆开,给报表独立 Pod,限制 CPU,不影响对话

不是因为"报表是个功能"所以拆,是因为"报表和对话的资源画像不同,混部互相干扰"所以拆。


陷阱三:算不清单 Pod 的 QPS 上限

扩容之前,你得先知道一个 Pod 到底能扛多少。算 QPS 不是拍脑袋。

通用公式

单 Pod 最大 QPS = 单 Pod 最大并发连接数 / 平均请求处理时间(秒)
                  ↑                                  ↑
            受资源限制                             受业务限制

而最大并发连接数取这四个值的最小值:

min(
  CPU瓶颈: 核数 × (1/单请求CPU时间秒),
  内存瓶颈: (可用内存 - runtime占用) / 单请求内存占用,
  连接池瓶颈: DB连接数 / 单请求占用连接时间秒,
  上游瓶颈: API key数量 × 单key QPS限制
)

逐层算一遍

以对话服务为例,8 核 3 Pod,等 LLM 响应平均 60 秒:

CPU:不是瓶颈

等 LLM 时 goroutine 挂起在 I/O 等待上,不占 CPU。8 核可以同时调度几万个 goroutine。

内存:第一个瓶颈

每请求 = goroutine栈(8KB) + gRPC stream(20KB) + SSE buffer(100KB) + 业务状态(70KB)
      ≈ 200KB

Pod 1GB, Runtime 150MB, 可用 874MB
单 Pod 最大并发 = 874MB / 200KB ≈ 4300

实际操作中,Go GC 在堆超过 ~800MB 后开始频繁触发,每 GC 周期 STW 约 0.5-2ms。并发越高,GC 越频繁,实际吞吐会打折。

上游 LLM:往往是真正卡死的地方

如果 AI 平台单 key 限流 60 RPM,共 5 个 key:

最大吞吐 = 5 × 60 / 60 = 5 QPS

服务能扛几千并发,但上游每分钟只放 5 个请求进来。99% 的并发槽位在空等。

算出来的结果

8 核 3 Pod, 等 LLM 60s:
  理论内存上限:  4300 并发 ≈ 215 QPS
  GC 打折后:     2000 并发 ≈ 100 QPS
  上游限流卡死:   300 RPM ≈ 5 QPS ← 真正瓶颈
  
结论: 不是 Pod 扛不住,是上游不让扛。
      扩 Pod 没用,加 API key 有用。

企业级做法:压测 + 逐步加压

公式是估算,真实数字必须压测。标准的压测阶梯:

阶段1: 基准测试    — 单 Pod, 关掉上游依赖, 测纯服务吞吐
阶段2: 集成压测    — 单 Pod, 上游用 mock, 但模拟延迟
阶段3: 全链路压测   — 多 Pod, 全真环境, 逐步加压找拐点
阶段4: 混沌测试    — 随机杀 Pod/断网/注入延迟, 看降级行为

工具选型:

wrk2      — 固定 QPS 压测, 测延迟分布 (P50/P99)
vegeta    — 指定 attack rate, 适合 CI 集成
k6        — JS 脚本编排, 适合多步骤场景

陷阱四:以为加服务能解决扩容问题

在 K8s 生态里,有个常见推理:

Pod 有连接数上限 → 一个服务能力有限 → 拆成多个服务突破限制

两层的误解。

Pod 没有你想象中那么"有限"

gRPC 基于 HTTP/2,天然支持多路复用。一条 TCP 连接上可以并发几百个 stream。一个 Pod 对下游只需要维护一个连接池(通常每目标 2-5 条长连接),就能跑满所有并发请求。

HTTP/1.1 模型:  1 请求占用 1 TCP 连接, 连接用完就得等
HTTP/2 模型:    N 请求复用 1 TCP 连接, 理论上无上限

实际限制:  Go gRPC 默认 MaxConcurrentStreams = 未限制
           Linux somaxconn = 4096 (listen backlog)
           Kubernetes Service 的 iptables 规则没有连接数限制

"Pod 连接数上限"在 gRPC + Go 的语境下几乎不存在。

K8s 扩的是 Pod,不是服务

水平扩展的正确姿势:

  1 个服务 → 3 个 Pod → HPA 自动扩 → 20 个 Pod
  ↑                    ↑
  服务是部署单元        Pod 是扩容单元

把服务拆成 10 个,每个 1 个 Pod,总并发和 1 个服务 10 个 Pod 是一样的,但多了 9 次网络跳转。

企业级做法:HPA + 自定义指标

先配好资源,否则 scheduler 无法做正确的调度决策:

resources:
  requests:
    memory: "512Mi"    # scheduler 用这个值找节点
    cpu: "2"
  limits:
    memory: "1Gi"      # 超过被 OOMKilled
    cpu: "4"           # 超过被 throttle

requests 和 limits 之间留 buffer。I/O 密集型服务 CPU limit 可以设高但不会真用到。内存 limit 要覆盖峰值并发。

HPA 的标准指标是 CPU/Memory,但对于 I/O 密集型服务,用 goroutine 数或请求队列长度更准:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  minReplicas: 3
  maxReplicas: 20
  scaleDown:
    stabilizationWindowSeconds: 300  # 缩容前等 5 分钟,避免抖动
  metrics:
    - type: Pods
      pods:
        metric:
          name: go_goroutines
        target:
          type: AverageValue
          averageValue: "2000"

这些自定义指标需要通过 Prometheus + KEDA 或自定义 metrics adapter 暴露给 K8s API。


陷阱五:Go 轻量,所以多几个服务没事

一个 Go 二进制 ~15-20MB,启动 ~15ms。一个 Java 服务 ~512MB,启动 ~10s。所以 Go 团队经常觉得"多拆几个无所谓"。

资源上确实无所谓。但微服务的主要成本从来不是资源。

运维复杂度才是真正的成本

每多一个服务,就多一套:

□ Dockerfile + .dockerignore
□ CI 构建流水线
□ K8s Deployment + Service + Ingress
□ HPA / PDB / NetworkPolicy
□ 健康检查端点 (/health, /ready)
□ 优雅关闭逻辑 (SIGTERM → drain → exit)
□ 日志采集和结构化字段
□ 分布式追踪的 span 配置
□ 告警规则 (错误率、延迟、重启次数)
□ On-call Runbook ("X 服务挂了怎么止血")

这些跟语言无关。Go 的轻量让启动快、占用少,但不会帮你写 Runbook。

企业级做法:运维自动化 + 服务模板

如果确实需要大量服务,必须用工具降运维成本,不能手写 14 套 yaml:

1. Helm Chart 模板
   所有服务共用同一个 chart,通过 values.yaml 差异化

2. 项目模板生成
   一个命令生成完整的服务骨架(cmd/router/internal/grpc/k8s)
   → 类似 kubebuilder 的 scaffold 方式

3. GitOps + 声明式
   所有 K8s 资源在 Git 里,ArgoCD/Flux 自动同步
   人力不碰 kubectl apply

4. 统一 sidecar
   日志、trace、metrics 注入统一用 DaemonSet 或 sidecar
   不每个服务自己配

如果这些基础设施没准备好,先别拆那么多服务。先把 3-5 个服务的运维体系跑成熟。


判断清单

一个模块要不要拆成独立服务,问三个问题:

  1. 扩缩容节奏不同吗? — 它需要独立的 HPA 配置,不能跟着别人一起扩缩?
  2. 发布节奏不同吗? — 它改了需要独立上线,不想跟别人的变更绑在一起发版?
  3. 故障隔离有价值吗? — 它挂了,业务可以用降级逻辑继续跑,而不是全站瘫痪?

三个答案都是否 → 一个包,不是一个服务。 有一个是 → 值得认真考虑拆。 两个以上是 → 拆。


拆分决策矩阵

模块独立扩缩独立发版故障隔离结论
认证
对话
LLM 代理合并回对话
导出合并回对话
短链接合并
Webhook合并
ID 生成改成包

拆微服务的数量不是目标,独立部署的能力才是。


← All posts