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 个服务,排障与值班无法闭环。这两个指标同时超标时,正确动作不是“继续优化每个服务”,而是合并:把同一变更节奏、同一数据所有者的服务合回去,用模块边界保留设计意图。

关联