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,不足以支撑架构级决策;我们贴墙的那几张表连续记录了八周,看见趋势固化在数字里之后才真正下决心。

延伸阅读