27.1 单体架构的适用性

一句话

单体不是“落后的架构”,而是把复杂度集中在进程内:事务、调用、调试都便宜,代价是部署耦合与团队协作摩擦;判断该不该用单体,看“变更频率 × 团队规模”是否超过一个进程能承受的边界。

单体到底指什么

先把定义钉死,否则讨论会滑向“代码写了多少个文件”这种无关问题:

维度 单体 微服务
部署单元 1 个(一个进程/一个镜像) N 个,各自独立
数据库 通常 1 个(或少数几个) 每服务独立库
调用方式 进程内函数调用 跨网络 RPC
事务 本地 ACID 分布式事务 / 柔性事务
发布 整体发布(策略见 55.1 发布策略) 独立发布

“单体”不等于“大泥球”:一个分层清晰、模块边界明确的单体(模块化单体)仍然是单体,但它和“所有代码互相 import”的泥球是两回事(分层见 26.1 分层架构,包设计见 26.6 模块化与包设计)。

适用性判据:五个问题

问题 倾向单体 倾向拆分
团队多大? 1–2 个团队(≤ 15 人) 多个团队,各自有独立目标
发布多频繁? 每天几次、整体发布可接受 每天几十次,且各模块节奏不同
领域边界清楚吗? 边界还没想清(拆了会反复改) 边界稳定,能独立演进
事务要求强吗? 跨表强一致、资金类 可接受最终一致
需要独立伸缩吗? 各模块负载相近 某个模块流量是别的 10 倍

🔑 最容易被忽略的是第一条与第二条:单体的真正瓶颈不是代码量,而是“多条变更流挤一个发布通道”。下面的排队模型能把它算出来。

把“发布通道”算成一笔账

假设:每个团队每天发布 2 次,每次发布占用 20 分钟窗口(构建 + 测试 + 审批 + 观察),所有人共用一个发布通道。这是典型的 M/M/1 排队:

λ = 团队数 × 2 / 1440(次/分钟)
μ = 1/20(次/分钟)
ρ = λ/μ = 团队数 / 36
package main

import "fmt"

// 共享发布通道:M/M/1
// 每次发布占用 20 分钟;每团队每天发布 2 次
func main() {
	const deployPerTeam = 2.0
	const serviceMin = 20.0

	fmt.Println("=== 共享发布通道:M/M/1(服务时间 20 分钟/次)===")
	fmt.Printf("%-6s %-10s %-8s %-12s %-10s\n", "团队数", "发布/天", "利用率ρ", "平均排队(min)", "状态")
	for _, teams := range []float64{1, 3, 5, 8, 12, 24} {
		lambdaPerDay := teams * deployPerTeam
		lambda := lambdaPerDay / (24 * 60) // 每分钟到达率
		mu := 1 / serviceMin               // 每分钟服务率
		rho := lambda / mu

		if rho >= 1 {
			fmt.Printf("%-6.0f %-10.0f %-8.2f %-12s %-10s\n", teams, lambdaPerDay, rho, "∞", "队列发散")
			continue
		}
		w := rho / (mu - lambda) // M/M/1 平均排队时间
		state := "顺畅"
		if rho > 0.7 {
			state = "接近饱和"
		}
		fmt.Printf("%-6.0f %-10.0f %-8.2f %-12.1f %-10s\n", teams, lambdaPerDay, rho, w, state)
	}
	fmt.Println("\n(ρ 超过 0.7 后排队时间按 1/(1-ρ) 放大;到 1.0 时队列无界增长)")
}

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

=== 共享发布通道:M/M/1(服务时间 20 分钟/次)===
团队数    发布/天       利用率ρ     平均排队(min)    状态        
1      2          0.03     0.6          顺畅        
3      6          0.08     1.8          顺畅        
5      10         0.14     3.2          顺畅        
8      16         0.22     5.7          顺畅        
12     24         0.33     10.0         顺畅        
24     48         0.67     40.0         顺畅        

(ρ 超过 0.7 后排队时间按 1/(1-ρ) 放大;到 1.0 时队列无界增长)

这张表的读法:

团队数 平均排队 含义
12 10 分钟 还能忍,但已经开始“等窗口”
24 40 分钟 每次发布平均等 40 分钟,发布变成瓶颈
36 ρ = 1 队列无界:发布永远排不完,只能靠加班/插队
> 36 ρ > 1 数学上不可持续,实际表现是“发布冻结”“大 trains”

⚠️ 这个模型是简化(假设泊松到达与指数服务时间),真实的发布更规律、窗口也更短,但结论的量级是对的:一个共享发布通道理论上能支撑“每天 72 次”(ρ=1 的临界点)的发布,再多就必须拆通道(独立发布)或缩窗口(更快的流水线)。

单体的真实优势:不是情怀,是四笔成本

成本项 单体 微服务
事务 本地 ACID,一条 BEGIN/COMMIT 分布式事务/柔性事务(17.5 BASE 与柔性事务)
调用 函数调用(纳秒级) RPC(毫秒级,+序列化、超时、重试、熔断)
调试 一个进程、一个栈、一条日志 跨服务 trace,需要链路追踪才能拼出因果
部署 一个制品,版本天然一致 N 个制品 + 兼容矩阵 + 灰度编排

前三项是日常成本,每写一个功能都在付;第四项是发布成本,每次发布在付。拆分的收益(独立伸缩、独立发布、故障隔离)必须大于这四笔成本,才值得拆。

什么时候必须拆

信号 说明
某模块流量是其他模块的 10 倍 单体无法只扩这一个模块(37.1 垂直扩展与水平扩展)
发布频率差异巨大(一天 50 次 vs 一月 1 次) 共享发布通道成为瓶颈(上面的排队模型)
故障隔离要求(一个模块 OOM 不能拖垮全站) 进程级隔离
合规隔离(支付数据必须独立部署/独立审计) 物理边界要求
团队规模超过沟通上限 康威定律开始反向塑造架构(74.1 康威定律及其逆定律)
技术栈异构(某模块必须用 GPU/Python) 进程内无法混跑

反过来说:没有这些信号时拆微服务,是在为“想象中的规模”付真实的成本。技术选型的评估维度见 4.3 技术选型的评估维度。

三种形态的对比

维度 大泥球单体 模块化单体 微服务
部署 1 个 1 个 N 个
模块边界 无(互相 import) 强制(包/模块依赖规则) 网络边界
事务 本地 ACID 本地 ACID(跨模块也可) 分布式
发布耦合 高 中(仍是一个制品) 低
演进路径 先拆模块,再考虑服务 可按模块逐步拆出服务 难回退
适合 早期原型 大多数业务系统的默认解 规模/组织真的需要时

🔑 我的判断:模块化单体应该是大多数团队(< 50 人、单一产品线)的默认选择,微服务是“组织与规模逼出来的解”,不是“技术先进的标志”。模块化单体的做法(包结构、依赖规则、模块间接口)见 27.2 模块化单体与 27.3 模块边界与依赖规则。

反模式

反模式 后果
把“单体”当“不设计模块”的借口 长成大泥球,之后拆不动(27.4 大泥球的形成路径)
早期就拆微服务 分布式事务、链路追踪、N 倍运维成本,业务还没验证
单体但共享数据库给外部系统直连 数据库变成公共 API,无法演进
拆了服务却共享一个库 分布式单体:最差的组合(30.3 分布式单体反模式)
用“发布窗口”硬扛团队增长 排队时间按 1/(1-ρ) 爆炸,发布冻结
单体不做限流/隔离 一个慢接口拖垮整个进程

自检

Q1 一个 30 人的团队、单一产品线、每天发布 3 次,正在纠结要不要拆微服务。用本节的判据给出建议,并说明理由。

▸ 答案:建议先做模块化单体,不拆。判据:① 团队规模(30 人,约 2–3 个小组)在共享发布通道的可承受范围内(模型里 24 个团队、每天 48 次发布时平均排队 40 分钟,30 人每天 3 次远低于饱和点);② 单一产品线、领域边界可能还在变,拆早了会反复改;③ 拆分的四笔成本(事务、调用、调试、部署)每天都要付,而收益(独立伸缩/发布/隔离)当前并不存在。如果出现“某模块流量 10 倍”“合规隔离”“发布频率差异巨大”这类信号,再按模块边界逐步拆。

Q2 为什么说“单体的瓶颈不是代码量,而是发布通道”?用排队模型解释 36 这个数字。

▸ 答案:代码量只影响构建时间与认知负荷,而发布冲突随团队数非线性增长。设每团队每天 2 次发布、每次占 20 分钟窗口,则 ρ = 团队数 × 2 / 1440 × 20 = 团队数 / 36;ρ 到 1 时队列无界,也就是 36 个团队就把一条发布通道压满。在此之前排队时间按 1/(1-ρ) 放大:24 个团队(ρ=0.67)平均已要等 40 分钟。所以“加团队”不会线性增加产出,超过饱和点后反而互相阻塞。

Q3 单体在事务上的优势具体是什么?拆成微服务后,这笔成本转移到哪里?

▸ 答案:单体内多个表的修改在一个本地事务里完成,ACID 由数据库保证,失败自动回滚,不需要补偿、对账、幂等。拆成微服务后,跨服务的一致性要靠分布式事务或柔性事务:TCC/Saga/本地消息表,每一笔都要处理中间态、幂等、补偿失败与对账(17.5),成本从“数据库的几行配置”变成“业务代码 + 运维流程 + 人工兜底”。

Q4 什么情况下“拆”是必须的,而不是可选的?至少举三个不可回避的信号。

▸ 答案:① 独立伸缩:某模块流量是其他模块的 10 倍,单体只能整体扩容,成本按倍数浪费;② 故障隔离:一个模块的 OOM/死循环会拖垮整个进程,业务要求“一个模块挂了不影响其他”;③ 合规/物理隔离:支付、医疗数据要求独立部署、独立审计、独立网络边界;④ 发布节奏差异:一天 50 次与一月 1 次的模块共用发布通道,排队模型直接发散;⑤ 技术栈异构:必须用 GPU/Python 的模块无法与主进程混跑。这些都是“架构约束”,不是“技术偏好”。

关联