拆微服务之前,先想清楚一件事
架构是什么?不是框架,不是技术栈,不是画图。架构是你在写第一行业务代码之前做的那些选择——怎么拆服务、怎么通信、数据怎么流、错误怎么传。这些选择一旦定下来,后续所有开发都绕着它们走。
拆微服务是架构里最容易踩坑的决策之一。拆少了怕耦合,拆多了怕运维爆炸。本文从五个常见陷阱出发,附带可操作的判断标准。
一个典型的微服务反面案例
某一 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.01ms | Protobuf marshal |
| 网络往返 | ~0.3-0.5ms | 同机房 TCP + 内核协议栈 |
| 反序列化 | ~0.01ms | Protobuf unmarshal |
| 对端调度 | ~0.1-0.2ms | Go scheduler + gRPC handler |
| Context 传递 | ~0.05ms | trace/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" # 超过被 throttlerequests 和 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 个服务的运维体系跑成熟。
判断清单
一个模块要不要拆成独立服务,问三个问题:
- 扩缩容节奏不同吗? — 它需要独立的 HPA 配置,不能跟着别人一起扩缩?
- 发布节奏不同吗? — 它改了需要独立上线,不想跟别人的变更绑在一起发版?
- 故障隔离有价值吗? — 它挂了,业务可以用降级逻辑继续跑,而不是全站瘫痪?
三个答案都是否 → 一个包,不是一个服务。 有一个是 → 值得认真考虑拆。 两个以上是 → 拆。
拆分决策矩阵
| 模块 | 独立扩缩 | 独立发版 | 故障隔离 | 结论 |
|---|---|---|---|---|
| 认证 | 否 | 是 | 是 | 拆 |
| 对话 | 是 | 是 | 是 | 拆 |
| LLM 代理 | 否 | 否 | 否 | 合并回对话 |
| 导出 | 否 | 否 | 是 | 合并回对话 |
| 短链接 | 否 | 否 | 否 | 合并 |
| Webhook | 否 | 否 | 否 | 合并 |
| ID 生成 | 否 | 否 | 否 | 改成包 |
拆微服务的数量不是目标,独立部署的能力才是。
← All posts