旧系统是 6 个微服务,靠 HTTP 与 URL 配置互相调用。听起来很标准,但真实成本集中在两处:一是服务间调用的网络开销与失败处理,二是「改一个功能要动三个仓库、部署三个服务」。
把调用变回函数调用
模块化单体的核心不是「不拆」,而是「在进程内拆」。8 个业务域进程内聚合,域与域之间的调用是普通函数调用——没有网络、没有序列化、没有服务发现。
unile-go — 两层依赖图
01// 每个域拆成两个 module:02// cms/api → 契约,只依赖 base03// cms/internal → 实现,外部不可 import0405// edge 层做跨域编排06func (h *Handler) SubmitAttempt(c *gin.Context) {07 node := cms.MustGetNode(id) // 域内调用08 result := quiz.Grade(node, answers) // 跨域:只经 api09 cms.Records.Save(node, result) // 再调回 cms10 c.JSON(200, result)11}边界由编译器守,不是靠 lint
Go 的 internal 机制让跨域 import 直接编译失败。这条约束比任何 lint 规则或架构文档都硬——文档会过期,编译器不会。
- 域间只能 import 别人的 api(契约),不能碰 internal(实现)
- api 只依赖 base,base 零外部依赖
- 需要编排多个域的逻辑统一写在 edge,依赖图是一棵两层树
- 新增域只需补 api 与 internal,边界规则不需要重新讨论
我们失去了什么
代价是真实的,不掩饰:
- 独立扩缩容能力没了。哪个域压力大,只能整体扩容
- 故障隔离变弱。一个域 panic 可能影响整个进程(靠 recover 与健康检查兜)
- 发布粒度变粗。改一个域要重新发布整个二进制
- 技术栈锁定。不能一个域用 Go、另一个用 Java
为什么这笔交易划算
因为这个系统的真实瓶颈不是单域吞吐,而是迭代速度与运维复杂度。6 个微服务意味着十余个容器、服务发现、配置中心、分布式追踪——这些基础设施的成本,远高于我们在扩缩容上损失的那点弹性。
交付形态对比
01之前:6 个服务 · 十余个容器 · 服务发现 · 配置中心02现在:1 个二进制 · 1 个容器 · 启动时自动迁移0304$ make docker && docker run -p 8080:808005[migrate] cms v12 → v14 ✓06[migrate] quiz v07 → v09 ✓07[server] listening on :8080架构决策的本质是比较成本,不是追求某个「更先进」的形态。如果哪天单域吞吐真的成了瓶颈,把那个域拆出去也只是复制一次 api 契约。