30.2 服务粒度
一句话
粒度不是“服务有多大”,而是“一次业务变更要动几个服务、一条关键路径要串几个服务”——前者决定协作成本,后者决定可用性
p^n与延迟s × t;把服务拆细只能优化其中之一,另一个通常会变差。
两个数,两件事
设关键路径上必须成功的服务数为 n,串行跳数为 s,单服务可用性为 p,单跳延迟为 t:
可用性 = p^n ← 只跟“必须成功的服务数”有关,串行并行都一样
延迟 = s × t ← 只跟“串行跳数”有关
这个区分是本节的骨架:并行化降低 s,但不降低 n;合并服务同时降低 n 和 s;拆细服务同时抬高 n 和 s。并行化到底能榨出多少收益,上限由 37.5 加速比与阿姆达尔定律 给出。
package main
import (
"fmt"
"math"
)
// 一次下单的关键路径,四种粒度方案的对比
// n = 路径上必须成功的服务数;s = 串行跳数(扇出的部分只算一跳)
var scenarios = []struct {
name string
n, s int
}{
{"A 单体(1 个进程)", 1, 0},
{"B 3 服务串行", 3, 3},
{"C 8 服务串行", 8, 8},
{"D 8 服务但并行扇出", 8, 3},
{"E 5 服务、2 跳(聚合层)", 5, 2},
}
func main() {
const (
p = 0.999 // 单服务可用性
tPerHopMs = 1.0 // 单跳 p50 延迟(毫秒,量级假设)
monthMin = 30 * 24 * 60
)
fmt.Println("=== 粒度对可用性与延迟的影响(p=0.999,单跳 1ms)===")
fmt.Printf("%-24s %-4s %-4s %-10s %-12s %-8s\n",
"方案", "n", "s", "可用性", "月停机(min)", "p50(ms)")
for _, sc := range scenarios {
avail := math.Pow(p, float64(sc.n))
downtime := (1 - avail) * monthMin
fmt.Printf("%-24s %-4d %-4d %-10.4f %-12.1f %-8.1f\n",
sc.name, sc.n, sc.s, avail, downtime, float64(sc.s)*tPerHopMs)
}
fmt.Println("\n=== 关键结论的量化 ===")
c := scenarios[2]
d := scenarios[3]
availC := math.Pow(p, float64(c.n))
fmt.Printf("C(串行 8 跳)月停机 %.1f 分钟\n", (1-availC)*monthMin)
fmt.Printf("D(并行后仍 8 个服务)月停机 %.1f 分钟 —— 与 C 完全相同\n", (1-availC)*monthMin)
fmt.Printf("D 省下的只是延迟:%.0fms -> %.0fms\n",
float64(c.s)*tPerHopMs, float64(d.s)*tPerHopMs)
}
本机实测(macOS,Apple M1,Go 1.27):
=== 粒度对可用性与延迟的影响(p=0.999,单跳 1ms)===
方案 n s 可用性 月停机(min) p50(ms)
A 单体(1 个进程) 1 0 0.9990 43.2 0.0
B 3 服务串行 3 3 0.9970 129.5 3.0
C 8 服务串行 8 8 0.9920 344.4 8.0
D 8 服务但并行扇出 8 3 0.9920 344.4 3.0
E 5 服务、2 跳(聚合层) 5 2 0.9950 215.6 2.0
=== 关键结论的量化 ===
C(串行 8 跳)月停机 344.4 分钟
D(并行后仍 8 个服务)月停机 344.4 分钟 —— 与 C 完全相同
D 省下的只是延迟:8ms -> 3ms
四条读数:
| 读数 | 含义 | 动作 |
|---|---|---|
| B(n=3)月停机 129.5 分钟,C(n=8)344.4 分钟 | 可用性按 p^n 指数衰减,不是线性;月停机从 2.2 小时涨到 5.7 小时 | 关键路径上每多一个服务,都要问“它必须同步、必须成功吗” |
| C 与 D 的月停机完全相同 | 并行扇出把 8 跳压成 3 跳,但 8 个服务仍必须全部成功 | 想提高可用性只能减少 n(合并、降级为可选、异步化),不能靠并行 |
| D 的延迟比 C 低 5ms | s 从 8 降到 3 | 这是并行化唯一确定买得到的东西 |
| A(单体)的可用性就是 0.999 | 单体只有一个失败点,但它是唯一的失败点 | 单体的可用性靠“进程内冗余 + 快速重启”,微服务靠“减少关键路径依赖”,两条路不同 |
🔑 为性能而并行扇出,是拿可用性换延迟:n 不降(甚至因为多拆了几个服务而上升),s 降了。如果团队把“拆细 + 并行”当成性能优化的默认手段,最后会得到一个延迟还行、但月停机翻倍的系统。
粒度指标:五个数一起看
| 指标 | 健康区间(经验值) | 超标说明什么 |
|---|---|---|
| 每团队服务数 | 1–3 | > 5:认知负荷爆炸,值班、发布、排障都不能闭环 |
关键路径服务数 n | ≤ 3 | > 5:可用性掉到 99.5% 以下(p=0.999 时) |
串行跳数 s | ≤ 3 | > 5:p99 延迟被网络抖动放大 |
| 每服务接口数 | 5–20 | > 50:这是一个“服务里的单体”,该按上下文再切 |
| 每服务数据表 | 1–20 | 跨服务 join 需求 > 3:粒度与数据模型错位(30.7) |
⚠️ 这张表是经验区间,不是定律。用法是“五个数里超了两个,就停下来重新看边界”,而不是逐条打分。
拆分的三种合法理由
| 理由 | 判据 | 例子 |
|---|---|---|
| 独立变更节奏 | 两块的发布频率差 ≥ 10 倍,且互不依赖 | 商品目录一天 20 次,结算一月 1 次 |
| 独立伸缩 | 两块的峰值 QPS 差 ≥ 10 倍,或资源类型不同(CPU / GPU / 内存) | 图片处理需要 GPU,其余是 IO 密集 |
| 故障隔离 / 合规 | 一块挂掉不能拖垮另一块;或数据必须物理隔离 | 支付回调独立部署;医疗数据独立审计 |
这三种理由都能落到“独立部署带来什么”上。反过来说,下面三种不是理由:
| 不是理由 | 为什么 | 替代做法 |
|---|---|---|
| 代码太长了 | 行数是服务内复杂度,不是服务间边界 | 拆模块/包(26.6 模块化与包设计) |
| 团队想独立 | 独立的前提是端到端闭环,不是“不想被别人阻塞” | 先看组织(74.1) |
| 想用不同语言/框架 | 技术异构可以是理由,但成本极高(多一套构建、监控、招聘) | 只在“必须用 GPU / 必须复用某个生态”时才成立 |
| 只是“同一产品的功能变体” | 变体不是部署边界,硬拆成服务会多出一倍运维 | 用同进程插件化,见 37.8 微内核与插件化 |
粒度与团队:一个团队管几个服务
| 服务数 / 团队 | 结果 |
|---|---|
| 1 个团队 : 1–3 个服务 | 健康:能记住全部接口、能轮值、能独立发布 |
| 1 个团队 : 5 个服务 | 边缘:排障要翻 5 套日志,值班表开始出现“不熟的服务” |
| 1 个团队 : 10 个服务 | 已经在还技术债:没人能说清全链路,发布靠“谁改谁发” |
另一个反向算法:先按人数定服务数区间,再倒推边界——10 人 ≈ 4 个服务,100 人 ≈ 40 个,等价于 “一个服务 3 人”(三个火枪手):刚好能通读全系统又能分工、互为备份、能讨论又能收敛(2 人易僵持、1 人陷盲区、4 人以上有人只出工不出力)。它成立的前提是 “按业务逻辑拆” 本身站不住:同一个电商,拆 3 个(商品/交易/用户)和拆 6 个(订单/支付/发货…)都能自圆其说,所以先定数量、再切边界反而更可操作。
反过来,服务数少于团队数也是问题:两个团队守一个服务,发布要排队,等于把单体的发布瓶颈搬到了微服务里(27.1 的排队模型同样适用)。
反模式
| 反模式 | 后果 |
|---|---|
| 按“一个聚合一个服务”切 | n 暴涨,跨服务事务遍地(29.7) |
| 把“能并行”当成拆服务的理由 | n 上升、可用性下降,只换来延迟 |
| 服务拆到只做 CRUD 转发 | 每个服务 2k 行,全部逻辑堆在网关/BFF,团队失去所有权 |
| 用代码行数定粒度 | 行数与耦合无关,拆完耦合还在 |
| 一个服务被两个团队改 | 发布排队,微服务退化成“多个小单体” |
| 粒度一次定终身 | 业务变化后边界会失效,需要定期用共变率复核(29.7) |
自检
Q1 一个下单接口调用 6 个服务,全部串行。团队为了降低延迟,把它改成“3 个并行 + 3 个串行”,延迟从 60ms 降到 35ms。可用性会变好吗?
▸ 答案:不会。可用性公式是 p^n,n 是“必须成功的服务数”,并行之后仍是 6 个服务都必须成功,所以可用性一点没变(若并行分支里有一个允许失败并降级,n 才会降到 5)。并行化只把串行跳数 s 从 6 降到 4,买到的是延迟。要提升可用性必须减少 n:合并服务、把非关键依赖改成异步/可选、或给每个依赖加降级兜底。
Q2 为什么“每团队 1–3 个服务”比“每服务多少行代码”更适合当粒度指标?
▸ 答案:因为微服务的主要成本是协作与排障成本,而不是代码阅读成本。行数只影响“一个人能不能读懂这个服务”,而服务数/团队影响“一个人能不能记住所有接口、能不能独立值班、发布要不要排队”。一个 5 万行但由一个团队端到端拥有的服务,通常比 8 个 3000 行、分属 4 个团队的服务更容易维护——后者的排障要跨 4 个值班表。
Q3 关键路径上有 8 个服务(p=0.999),月停机大约多少?如果业务要求 99.95%,该怎么办?
▸ 答案:0.999^8 ≈ 99.204%,月停机 0.00796 × 43200 ≈ 344 分钟(约 5.7 小时)。要到 99.95%(月停机 ≈ 21.6 分钟),单靠提高单服务可用性不现实:需要 p = 0.9995^(1/8) ≈ 0.99994,也就是每个服务都做到 99.994%,且 8 个都做到。可行路径只有减少 n:① 合并强耦合的服务;② 把非关键步骤异步化(n 减到 2–3);③ 对每个同步依赖做降级兜底(依赖失败时返回缓存/默认值,把“必须成功”改成“可失败”)。这也是 35.6 熔断与降级 里“降级先定返回什么”的原因。
Q4 一个电商系统把“下单”拆成 12 个服务,理由是“每个服务职责单一”。用本节的五个指标诊断,最可能踩中哪两个?
▸ 答案:① 关键路径服务数 n:下单主链路很容易串到 8–12 个服务,可用性掉到 99% 附近,一个月停几小时;② 每团队服务数:12 个服务通常由 2–3 个团队维护,平均每人要熟悉 4–6 个服务,排障与值班无法闭环。这两个指标同时超标时,正确动作不是“继续优化每个服务”,而是合并:把同一变更节奏、同一数据所有者的服务合回去,用模块边界保留设计意图。