单体还是微服务?我把一年的运维成本算出来之后,砍掉了 7 个服务
去年 Q3 我做了一件在组里挺不讨喜的事:把 12 个微服务合成了 5 个。合完之后运维同学的第一反应是“监控大盘少了 7 个,看着空落落的”,而我自己最大的感受是——终于不用每天早上第一件事就翻 8 个 Kibana 索引找一条日志了。合完的第一个月,线上 P2 问题的平均定位时间从 3.5 小时掉回 40 分钟。
这不是一篇“微服务已死”的文章。恰恰相反,我们剩下那 5 个服务里,有 2 个我认为即使成本再高也必须拆。我想说的是另一件事:架构不是信仰问题,是一道算术题。大部分团队(包括去年的我们)拆微服务的时候,只算了收益栏,没算成本栏。
先看两个真实的翻车现场
第一个是 Amazon Prime Video。2023 年 3 月他们发了一篇 AWS 官方博客,标题我记到现在:Scaling up the Prime Video audio/video monitoring service and reducing costs by 90%。这个服务原来被拆成多个 Serverless 组件,中间用 S3 做数据传递。问题出在哪?视频监控要每秒轮询成千上万路流的状态,这个访问频率下 S3 的请求费用和状态管理开销直接失控。他们改回一个相对单体的架构,成本降了 90%。注意是 90%,不是 9%。
第二个是 Segment,2018 年那篇 Goodbye Microservices。他们从一百多个服务退回单体,理由特别朴实:一个简单的功能改动要同时改十几个 repo,每个 repo 有自己的 CI、自己的版本号、自己的依赖树。作者 Alexandra Noonan 说合并回去之后,工程师终于能把注意力放在业务上,而不是放在依赖升级上。
我把这两篇打印出来贴在工位上,然后开始算我们自己的账。
那笔账到底怎么算
先给个我自己的公式,粗糙但好用:
一个微服务的年净收益 = 独立部署单次省下的时间 × 年变更次数 − 该服务的年度固定成本
固定成本这一项,我们组当时是这么量的(数字是我们自己的,不具普适性,你按自己的来):
- CI/CD 流水线维护:一个服务平均一年 1.5 人周。12 个服务就是 18 人周。
- 监控 + 告警 + SLA 面板:每个服务首次配置 3 天,之后每年维护 0.5 人周。
- on-call 成本:12 个服务,值班表要覆盖到人,线上问题定位从单体的中位数 25 分钟涨到 3.5 小时,因为要串多个服务的日志。
- 跨服务接口变更成本:一次改动平均涉及 2.3 个 repo,联调要拉三个人的日历。
第二项 on-call 那个数字是我最在意的。我们那年线上 P2 以上问题一共 47 个,其中 31 个需要跨服务排查。单体时代同样量级的问题,定位时间中位数 25 分钟——这个我专门翻了 2020 到 2021 年的旧工单确认过。微服务化之后中位数到了 210 分钟。31 个问题 ×(210−25)分钟 ≈ 95 小时。一个中高级工程师月薪按 3 万算,时薪大概 170 块,95 小时就是 1.6 万。这一项单拎出来就够呛。
再看收益侧。我们那个“用户标签服务”,一年变更 4 次。独立部署省下的时间是什么?单体时代每次发版要跑全量回归 90 分钟,独立部署后只需要跑这个模块的用例 12 分钟。一次省 78 分钟,一年 4 次,5.2 小时。5.2 小时对比上面那些固定成本,结论不用我多说。那还留着它干嘛。
但有三个判据,我一步都不敢动
合了 7 个,剩下 5 个。其中 2 个我认为即使成本倒挂也必须保留:
- PDF 生成服务。它吃 CPU 吃得离谱,峰值单机 CPU 打满。跟主应用放一起,一次批量出报表会把 API 的 p99 从 80ms 拉到 2s。这个是物理隔离需求,跟架构风格无关。
- 对外的开放 API 网关层。它要独立的安全审计、独立的限流策略、独立的发布节奏,而且有外部客户直接依赖它的版本号,不能随手就合。
所以判据其实就三条:
- 变更频率:一年改不到 6 次的模块,拆出去大概率亏。用 git log 数一下就知道,别靠感觉。
- 资源画像差异:CPU 密集 vs IO 密集 vs 内存密集,混在一起会互相拉扯。这条是硬需求,跟团队规模无关。
- 团队边界:这条最容易被忽略。康威定律不是玩笑——如果一个团队是 8 个人,所有人每天都在一起开站会,那拆成 3 个服务只会让你多开 3 次同步会。
具体怎么合,给一套能跑的步骤
如果你算完账,觉得自己的服务也有几个该合,我这套流程踩过坑,直接给你:
第一步,先画依赖,别凭记忆。我当时凭记忆画的关系图错了 3 处。用 jdeps 扫一遍最省事:
jdeps -verbose:class -filter:none target/classes | grep com.yourcompany
第二步,用 git 的共同变更频率找真正的耦合。这个比看 import 准得多。两个模块如果每次都是一起改,说明它们本来就是一件事:
git log --since='12 months ago' --name-only --pretty=format:=== \
-- src/main/java/com/yourcompany > /tmp/changes.txt
# 再用脚本统计任意两个文件出现在同一个 commit 里的次数
我当时跑出来的 top 5 组合里,有 3 组是我完全没预料到的。有几个服务在代码里看着八竿子打不着,但在 git 历史里几乎每次都是手拉手出现。
第三步,如果决定继续保留单体,用 Spring Modulith 或者 ArchUnit 把边界钉死。这是模块化单体的关键——你可以不拆服务,但你得让代码里的边界变成真的边界,而不是注释里写着“别的模块不要调我”。Spring Modulith 1.0 是 2023 年 11 月 GA 的,它会扫描包结构,如果 A 模块直接调了 B 模块的 internal 类,测试直接挂。ArchUnit 也能做同样的事,而且不需要改框架:
@ArchTest
static final ArchRule order_should_not_touch_inventory_internals =
noClasses().that().resideInAPackage("..order..")
.should().dependOnClassesThat()
.resideInAPackage("..inventory.internal..");
第四步,合并时别急着删旧代码。我们当时是先把两个服务指向同一个数据库连接,双写了一周多观察,确认没有隐藏的时序依赖,再删旧服务。这个观察期是我拍脑袋定的,但确实抓到过一次问题——有个定时任务隐式依赖了另一个服务的执行先后顺序,平时看不出来,压力一上来就炸。
一个我自己也不太确定的观点
写完上面这些,我知道这篇东西肯定会被骂。因为如果团队够大(比如 100 个工程师以上)、业务边界足够清晰、基础设施(K8s、服务网格、可观测性)已经打磨得很成熟,那微服务的固定成本是会被摊薄的——摊薄的分母就是服务数量和使用人数。这种情况下拆是对的,而且必须拆。
但现实是,我见过的绝大多数团队,规模在 10 到 30 人之间,基础设施是自己拿 K8s 硬堆的,可观测性就是 ELK 加几个 Grafana 面板,链路追踪还没上。在这个区间里,微服务的固定成本是压死人的,而它承诺的收益几乎体现不出来——因为你没有那么多业务变化需要独立发布。
所以我现在的判断标准特别粗暴:先做模块化单体,把代码边界用架构测试写死,等到某个模块的变更频率高到每周都要独立发版,或者资源画像实在合不到一起,再把它拆出去。顺序很重要。先拆再补边界,代价比先补边界再拆大得多——这话我在书上看过一百遍,没感觉,重组了 12 个服务之后才真正理解。
大概就这样。如果你也在纠结,建议先算账,别先讨论架构风格。风格是给别人看的,账单是自己的。