30.3 分布式单体反模式

一句话

分布式单体不是“拆得不好”,而是部署上分开了、变更上还绑在一起:判断标准不是有没有共享库,而是“一次业务变更需不需要同时发布两个以上的服务”——如果需要,你已经付了微服务的全部成本,却没拿到它的唯一收益。

定义:一句话拆开看

维度 单体 微服务 分布式单体
进程 1 个 N 个 N 个
数据 1 份 每服务 1 份 1 份(或互相直连)
发布 整体发布 各自发布 必须一起发
失败 一起挂 隔离 一起挂(同步调用链)
变更成本 低(一次改完) 低(各改各的) 高(协调 N 个团队)

分布式单体是这张表里唯一“每一项都最差”的形态:既有单体的耦合,又有微服务的运维成本。

五个症状,五个检测指标

# 症状 检测方法 阈值(经验值)
1 联合发布 发布日志里“同一次发布涉及的服务集合” 两服务的锁步率 ≥ 0.8
2 共享数据库 扫描各服务的连接串 / 表名,统计每张表的写者 任一表写者 ≥ 2
3 同步调用链过深 从调用边求最长路径 最长路径 > 3
4 共享 DTO / 公共库版本锁步 依赖清单里同一内部库被 ≥ 3 个服务以相同版本引用,且改动即全体升级 升级一次牵动 ≥ 3 个服务
5 跨服务事务 代码里出现 2PC / TCC / 跨服务的“同一事务”注释 出现即命中

🔑 五个指标里,只有第 1 个是定义性的:其余四个都是“导致必须联合发布”的原因。所以诊断顺序是“先测发布耦合,再找原因”。

代码:给一个服务群算分布式单体记分卡

package main

import (
	"fmt"
	"sort"
)

// 10 次发布,每次涉及的服务集合
var releases = [][]string{
	{"order", "pricing"},
	{"order", "inventory"},
	{"order", "pricing", "inventory"},
	{"payment", "order"},
	{"notify"},
	{"order", "pricing"},
	{"inventory", "order"},
	{"payment"},
	{"notify", "user"},
	{"order", "pricing", "inventory", "payment"},
}

// 同步调用边:caller -> callee
var edges = [][2]string{
	{"order", "inventory"},
	{"order", "pricing"},
	{"order", "payment"},
	{"payment", "user"},
	{"notify", "user"},
}

// 每张表被哪些服务写
var writers = map[string][]string{
	"orders": {"order", "notify"},
	"stock":  {"inventory", "order"},
	"users":  {"user", "order", "notify"},
	"prices": {"pricing"},
}

func main() {
	// ① 每个服务被发布的次数
	count := map[string]int{}
	for _, r := range releases {
		for _, s := range r {
			count[s]++
		}
	}

	// ② 两两锁步率 = 共同发布次数 / min(各自次数)
	// 样本太少的组合不可信:只发过 1 次的两个服务,锁步率必然是 1.00
	const minReleases = 3
	var svcs []string
	for s := range count {
		svcs = append(svcs, s)
	}
	sort.Strings(svcs)

	fmt.Println("=== ① 发布锁步率(同时发布 / min(各自发布))===")
	type pair struct {
		a, b string
		rate float64
		both int
	}
	var pairs []pair
	for i := 0; i < len(svcs); i++ {
		for j := i + 1; j < len(svcs); j++ {
			a, b := svcs[i], svcs[j]
			if count[a] < minReleases || count[b] < minReleases {
				continue // 样本不足,锁步率不可信
			}
			both := 0
			for _, r := range releases {
				hasA, hasB := false, false
				for _, s := range r {
					hasA = hasA || s == a
					hasB = hasB || s == b
				}
				if hasA && hasB {
					both++
				}
			}
			if both == 0 {
				continue
			}
			m := count[a]
			if count[b] < m {
				m = count[b]
			}
			pairs = append(pairs, pair{a, b, float64(both) / float64(m), both})
		}
	}
	fmt.Printf("(发布次数 < %d 的组合已跳过:样本太少,锁步率没有意义)\n", minReleases)
	sort.Slice(pairs, func(i, j int) bool { return pairs[i].rate > pairs[j].rate })
	locked := 0
	for _, p := range pairs {
		flag := ""
		if p.rate >= 0.8 {
			flag = "  ← 锁步:这两个服务其实是一个"
			locked++
		}
		fmt.Printf("%-10s × %-10s 共同发布 %d 次,锁步率 %.2f%s\n", p.a, p.b, p.both, p.rate, flag)
	}

	// ③ 同步调用最长路径
	graph := map[string][]string{}
	for _, e := range edges {
		graph[e[0]] = append(graph[e[0]], e[1])
	}
	memo := map[string]int{}
	var depth func(string) int
	depth = func(n string) int {
		if v, ok := memo[n]; ok {
			return v
		}
		best := 0
		for _, m := range graph[n] {
			if d := depth(m); d > best {
				best = d
			}
		}
		memo[n] = best + 1
		return memo[n]
	}
	longest, from := 0, ""
	for _, s := range svcs {
		if d := depth(s); d > longest {
			longest, from = d, s
		}
	}
	fmt.Printf("\n=== ② 同步调用最长路径 ===\n起点 %s,跳数 %d\n", from, longest)

	// ④ 共享表
	fmt.Println("\n=== ③ 写者 ≥ 2 的表 ===")
	var shared []string
	for t, ws := range writers {
		if len(ws) >= 2 {
			shared = append(shared, t)
		}
	}
	sort.Strings(shared)
	for _, t := range shared {
		ws := append([]string(nil), writers[t]...)
		sort.Strings(ws)
		fmt.Printf("%-8s 被 %d 个服务写: %v\n", t, len(ws), ws)
	}

	// ⑤ 记分卡
	fmt.Println("\n=== ④ 记分卡 ===")
	fmt.Printf("%-22s %-8s %-8s %s\n", "指标", "实测", "阈值", "判定")
	verdict := func(ok bool) string {
		if ok {
			return "OK"
		}
		return "严重"
	}
	fmt.Printf("%-22s %-8d %-8s %s\n", "发布锁步对(>=0.8)", locked, "0", verdict(locked == 0))
	fmt.Printf("%-22s %-8d %-8s %s\n", "最长同步路径", longest, "<=3", verdict(longest <= 3))
	fmt.Printf("%-22s %-8d %-8s %s\n", "共享表(写者>=2)", len(shared), "0", verdict(len(shared) == 0))
	if locked > 0 && len(shared) > 0 {
		fmt.Println("结论:分布式单体 —— 发布锁步 + 共享表同时命中,属于“每个维度都最差”的形态")
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== ① 发布锁步率(同时发布 / min(各自发布))===
(发布次数 < 3 的组合已跳过:样本太少,锁步率没有意义)
inventory  × order      共同发布 4 次,锁步率 1.00  ← 锁步:这两个服务其实是一个
order      × pricing    共同发布 4 次,锁步率 1.00  ← 锁步:这两个服务其实是一个
order      × payment    共同发布 2 次,锁步率 0.67
inventory  × pricing    共同发布 2 次,锁步率 0.50
inventory  × payment    共同发布 1 次,锁步率 0.33
payment    × pricing    共同发布 1 次,锁步率 0.33

=== ② 同步调用最长路径 ===
起点 order,跳数 3

=== ③ 写者 ≥ 2 的表 ===
orders   被 2 个服务写: [notify order]
stock    被 2 个服务写: [inventory order]
users    被 3 个服务写: [notify order user]

=== ④ 记分卡 ===
指标                     实测       阈值       判定
发布锁步对(>=0.8)           2        0        严重
最长同步路径                 3        <=3      OK
共享表(写者>=2)             3        0        严重
结论:分布式单体 —— 发布锁步 + 共享表同时命中,属于“每个维度都最差”的形态

读数:

读数 含义 动作
order × pricing 锁步率 1.00 每一次发 pricing 都同时发 order,两者事实上是一个可部署单元 要么合并,要么把共享的计价规则抽成库/服务并定契约
order × inventory 锁步率 1.00 库存变更与订单变更互相绑定 先断共享表,再用事件推进(29.4 领域事件)
最长同步路径 3 下单链路 order → payment → user 到达阈值,再深一跳就超标;把 user 信息缓存/冗余可降到 2
共享表 3 张 orders 被 order 与 notify 同时写,users 被 3 个服务写 这是最硬的证据:写者 ≥ 2 = 数据所有权不存在
记分卡里 2 项严重、1 项 OK 最长路径恰好 3,压在阈值上;锁步与共享表两项已经命中 先修数据(写者),再修发布;停在阈值上的那一项盯住不要变差
notify × user 被程序跳过 两者只各自发布过 1–2 次,锁步率必然算成 1.00——样本太少的指标不可信 这也是所有“耦合度”指标的通病:先问样本量,再看数值

⚠️ 这里的“锁步率”和 29.7 从模型到服务边界 的“共变率”不是一回事:共变率看代码是否一起改(用来验证边界画得对不对),锁步率看发布是否必须一起做(用来验证拆分是否真的成功)。代码不共变但发布锁步,通常是共享库或共享表在作祟。

典型成因:拆分顺序错了

分布式单体几乎都是同一个错误顺序造成的:

先拆进程 → 库还是共享的 → 只能靠同步调用对齐数据 → 发布必须一起 → 变成分布式单体

正确的顺序是反过来的:

步 做什么 验收标准
① 拆数据所有权 一张表只能有一个写者;其他服务改为读 API / 事件投影 扫表:每张表写者恰好 1 个
② 断共享库读 把“直连别人库读数据”改成 API 或订阅事件 连接串扫描:没有跨服务的库连接
③ 减少同步跳 非关键路径改异步(事件/消息),关键路径合并服务 最长同步路径 ≤ 3
④ 拆发布 每个服务独立流水线,不再“一起发” 锁步率 < 0.3

🔑 第 ④ 步是结果,不是手段。先拆发布、后拆数据,就会得到“独立发布了,但每次发布都要一起验证”的假独立。

反模式

反模式 后果
用“共享库”代替 API 契约 库一升级全体重发,锁步率 1.0
用“同步 RPC 链”代替事件 一条链上 5 个服务,可用性相乘,故障级联
拆了服务但保留跨库 join 数据库成为隐式 API,任何表变更都是跨团队变更(32.6 共享数据库反模式)
用 2PC/TCC 跨 3 个服务保证一致性 中间态、悬挂、空回滚全来了(24.4 TCC)
每个服务一个仓,但发版靠一张大表统一排期 “独立仓库”只改变了代码位置,没改变发布耦合
把“服务都跑在 K8s 上”当成拆分完成 部署形态变了,变更耦合没变

自检

Q1 一个系统有 12 个服务、12 个独立仓库、12 条流水线,但每次上线都要拉一个 2 小时的“联合发布窗口”。这是微服务还是分布式单体?用本节的判据回答。

▸ 答案:分布式单体。判据是锁步率而不是仓库数量:12 条流水线只是形式上的独立,联合发布窗口说明任意一次业务变更仍需多个服务同时上线。第 1 个指标(发布锁步)已经命中,再去看原因,多半能在第 2(共享表)或第 4(共享库)里找到。仓库/流水线是“能不能独立发布”的必要条件,不是充分条件。

Q2 为什么“共享表写者 ≥ 2”比“共享表读者 ≥ 2”严重得多?

▸ 答案:读者多只意味着耦合(一个服务的表结构变更会影响读取方),可以通过加字段、加视图、加 API 缓解;写者多意味着同一份不变量被两个服务各自维护,两个服务都能改同一行数据,冲突只能靠数据库锁或“约定”,而约定在跨团队时必然失效。写者多还意味着无法做数据所有权迁移:你不知道哪一方是权威来源。所以修复顺序里“一张表只能有一个写者”排在第一位。

Q3 最长同步路径是 3,卡在阈值上。团队想“加缓存把 user 服务从关键路径上拿掉”。这算降低 n 吗?

▸ 答案:算,但要区分两种情况。① 缓存命中时不调用 user 服务,n 从 3 降到 2,可用性提升;② 缓存未命中时仍要同步调用,n 回到 3,而且新增了“缓存与源数据不一致”的失败模式。所以正确做法是给这个依赖定降级策略:未命中时返回占位数据或走异步补齐(把“必须成功”改成“可以失败”),这样 n 才真正降到 2。只加缓存而不定降级,只是把失败概率降低,没改变 p^n 的结构。

Q4 一个团队说“我们先拆成微服务,再慢慢拆数据库”。这个计划的问题在哪?

▸ 答案:它把因果顺序搞反了。数据库共享时,服务之间只能靠“读同一份数据”或“同步调用对方接口”来保持一致,这两种方式都会把发布绑在一起——所以你会在“慢慢拆”的过程中一直处在分布式单体状态,而且新写的代码会继续依赖共享表(因为它是当下最方便的路径),债务越滚越大。正确顺序是先做数据所有权(哪怕还在一个进程里,用不同的 schema/包隔离),再拆进程;27.1 单体架构的适用性 里的模块化单体正是这一步的载体。

关联