1.1 单体程序什么时候该拆:三个征兆
先交代一下我们团队的底子:五个人,一个 Go 写的单体电商,跑了两年多,日活卡在三万出头的临界点。订单、商品、用户三个模块在同一个 main.go 里初始化,共享一个 MySQL 实例和一个 Redis,部署在一台 8 核 16G 的腾讯云 CVM 上。
平时它活得好好的。开发快、部署简单、出了问题一条堆栈看到底,单体在早期简直是创业团队的续命药。真正让我们难受的,是每月一次的会员日大促,以及大促前后越来越频繁的发布冲突。
拆还是不拆,我们纠结了两个月。拆早了,五个人分成三个服务,每个服务都半死不活;拆晚了,每次大促都在重症监护室里度过。后来我们把讨论沉淀成三个可量化的征兆,三个里命中两个,就正式启动拆分评估。这一节把三个征兆和我们当时的真实数据摆出来。
征兆一:发布开始互相阻塞
最早让我们难受的不是性能,是发布。那时候一周大约三到四次上线,商品组要上促销价格逻辑,订单组在修支付回调的 bug,两份改动都合进同一个 master 分支,打包进同一个二进制。
出事是在一个周二晚上:商品组新写的促销计算里有个没判空的指针,直接把进程带崩了。重启之后我们发现,订单组刚修好的支付回调也跟着同一个镜像被重新部署了一遍——两个完全不相干的功能,被迫同生共死。回滚更难受,只能整体回滚,把订单组的修复一起回掉,对方还得再走一遍完整的构建、灰度、观察流程。
我们把合并冲突和发布阻塞的频次统计下来,贴在墙上:
| 观察指标 | 半年前 | 拆分前 | 判断 |
|---|---|---|---|
| 周均发布次数 | 2.1 | 4.6 | 发布节奏明显变密 |
| 发布因冲突推迟的比例 | 6% | 31% | 超三成互相阻塞 |
| 回滚波及无关模块 | 0 次 | 4 次/季度 | 开始误伤同事 |
| 紧急修复平均排队等待 | 0.5 小时 | 3.2 小时 | 修复也得排队 |
这张表比任何架构图都直观:当一个单体里有两组人在抢同一个部署通道,且超过三成的发布因为不相干改动被推迟时,它在工程协同上已经不是一个系统,是五个人的互相拖累。
数据不是拍脑袋编的。发布统计我们写了个小脚本,每天凌晨从 git log 和 CI 记录里聚合:
# 按周统计合并 PR 数与因冲突重新排期的次数
git log --since="1 weeks ago" --merges --pretty=format:"%h %ad" \
--date=short | awk "{print \$2}" | sort | uniq -c
延迟能从 CI 的构建记录里对出来:一个 PR 从最后一次 approval 到实际部署的间隔超过 24 小时,就记一次阻塞。这种土办法胜在零成本,不需要引入任何平台,周一晨会看一眼数字就知道这周有没有退化。
征兆二:大促抖动集中在少数接口
第二个征兆是性能,但不是那种慢吞吞的慢,而是突发性的雪崩。会员日晚上八点,流量冲到日常的四倍,订单创建接口先开始抖。商品详情页本来好好的,因为订单接口把数据库连接池打满,整个 MySQL 被拖住,用户连首页都刷不出来。
抓现场靠的是大促前就埋好的 pprof 调试端口:
import _ "net/http/pprof"
// 在独立调试端口暴露,不走业务流量,不占用主端口
go func() {
_ = http.ListenAndServe("0.0.0.0:6060", nil)
}()
大促时另找一台机器 curl 这个端口,把 goroutine 和堆抓下来,能看到大量 goroutine 卡在 database/sql 的连接等待上。火焰图里那一根又粗又平的柱子就是证据:不是订单逻辑本身多复杂,是连接池成了共享资源里的单点。
| 大促指标 | 平日 | 会员日峰值 | 原因归类 |
|---|---|---|---|
| 订单创建接口 P99 | 180ms | 4200ms | 数据库连接池打满 |
| 商品详情接口 P99 | 90ms | 2600ms | 被数据库连带拖垮 |
| 首页可用率 | 100% | 91% | 无辜模块受牵连 |
| CVM CPU 利用率 | 35% | 62% | 远没到机器瓶颈 |
关键信息在最后一行:CPU 才六成,内存也不紧张,说明机器不是瓶颈,共享数据库和共享进程才是。如果你也遇到“资源远没用满但系统已经抖”的现象,基本可以确认瓶颈在模块耦合而不是容量,这时候加机器是治标不治本,1.4 节讲选型时还会再印证这一点。
征兆三:改一个功能牵动三个模块
第三个征兆最隐蔽,我们叫它改动扩散。判断方法很简单,在仓库里跑一条 git 命令,看最近一个月里越界修改的频率:
# 统计最近一个月订单相关提交里,有多少同时碰了商品模块的文件
git log --since="1 month ago" --name-only --pretty=format: \
| grep "^order/" | wc -l
我们跑出来的数字是:订单模块的改动里,四成左右的提交同时修改了 product 或 user 目录下的文件。最典型的是算订单总价时要查商品促销价、再查用户会员等级,一段逻辑横跨三个模块。改促销规则那天,三个人坐在一起改同一坨代码,互相 review 到晚上十一点。
还有一个更主观但很准的信号:你说出一个功能名,看团队里谁能独立把它改完。如果大多数功能都需要两个人以上跨模块配合,说明模块边界在代码层面已经名存实亡,只差一层 nobody 敢动的依赖。
补充一个判断维度:看代码库里的 TODO 注释指向谁。我们发现单体后期的 TODO 里,有一半写着“等商品那边改完再处理”“需用户域确认”这类跨模块措辞。这些注释是最诚实的边界信号——它们标记了每一个本该是接口、却变成口头约定的位置。
拆分决策:三中得二即可启动
三个征兆整理成一张决策表,后来成了我们团队评估任何改造的标准动作:
| 征兆 | 观察方式 | 命中阈值 | 我们当时的数据 |
|---|---|---|---|
| 发布互相阻塞 | 推迟率、回滚误伤 | 推迟率超 20% | 命中,31% |
| 大促集中抖动 | 接口 P99 与资源利用率剪刀差 | 资源不满但接口雪崩 | 命中 |
| 改动跨模块扩散 | git 越界提交比例 | 超三成提交越界 | 命中,四成 |
三个全中,没什么好犹豫的,我们启动了拆分。但如果只命中一个,建议先别拆——单点征兆往往有更轻的解法,比如把热点接口拆成独立二进制做进程级拆分,或者给数据库加从库做读写分离,改造成本都远低于微服务化。关于这条原则的展开,可以看腾讯云架构实践里同类规模案例的处理方式。
踩过的坑:差点为了“先进”而拆
启动前还有一段插曲。有段时间团队里有人刷到各种微服务文章,言必称服务网格、云原生,觉得赶紧上也整一套。当时的负责人很冷静地问了一句:我们现在大促抖,到底是因为架构落后,还是因为容量规划没做?这句话把大家拉回来了。
如果只是为了技术先进感去拆,几乎必然失败:拆分带来的运维、联调、一致性成本是刚性的,而收益是间接的、滞后的。我们还见过另一个极端:隔壁一个做工具产品的团队,日活才三千,因为看了几篇公众号文章就热血上头拆了微服务,结果两个人维护六个服务,每次发版像渡劫,半年后默默合回单体。反例比道理更有说服力,他们后来成了我们小组内部会议的固定段子。
我们最终说服自己的不是微服务多好,是前面那三张表里的数字。顺带一提,拆分后新服务的部署机器是新购的 CVM,我们赶在腾讯云 CVM 新用户特惠期间下的单,五人团队第一批三台机器没花多少预算,部署细节在第二篇细说。
小结
判断单体该不该拆,别凭感觉,看三个可量化的征兆:发布是否互相阻塞、大促是否在资源没满的情况下集中雪崩、改动是否大面积跨模块。三中得二,启动评估;三中得一,先上更轻的手段。下一节聊划定服务边界的具体方法——这是拆分里最容易返工的一步,我们前后改了两版才定下来。
最后提醒一句:三个征兆的观测周期建议拉长到两个月以上。单周的波动可能来自一次活动或者一个 bug,不足以支撑架构级决策;我们贴墙的那几张表连续记录了八周,看见趋势固化在数字里之后才真正下决心。
延伸阅读
- 1.2 拆分边界:按业务而不是按技术切 —— 服务边界怎么划才不返工
- 1.3 团队与发布节奏:微服务的隐性成本 —— 拆分的人和流程代价
- 腾讯云架构实践 —— 同类规模团队的架构演进参考
