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 单体架构的适用性 里的模块化单体正是这一步的载体。