Go 项目从0到生产——一个真实 SaaS 项目的架构演进过程
Go 项目从0到生产——一个真实 SaaS 项目的架构演进过程
适读人群:有 Java 背景、正在或计划用 Go 做 SaaS 项目的工程师 | 阅读时长:约18分钟 | 核心价值:避开 Go SaaS 项目从原型到生产的常见架构坑
那个让我崩溃的周五下午
那是去年11月一个普通的周五下午3点47分,我盯着 Grafana 上的监控面板,看着 API 网关的 P99 延迟从正常的 23ms 一路飙到了 4837ms,然后整个服务开始返回 503。
我们的 SaaS 产品刚上线43天,用户数从0涨到了2100个,那天下午恰好有个小 V 在朋友圈推荐了我们,流量突然涌进来。
然后我发现了一个让我想骂自己的问题:我把 Go 写成了 Java。
不是说语法,而是架构思路。我在 Go 里用了一套跟 Spring 高度相似的分层结构,Controller -> Service -> Repository,每个请求进来都要 new 一堆对象,还有一个全局的"Bean 容器"用 map 存着各种单例——没错,我手写了一个迷你 IoC 容器,大概 400 行代码。当初写的时候还觉得自己挺厉害的,现在看起来……很蠢。
这篇文章就是复盘这个项目从0到生产的完整架构演进过程,包括我踩的坑和最终稳定下来的方案。
第一阶段:原型期(0-500用户)
项目背景
这是一个面向中小企业的 API 管理 SaaS,核心功能是:API 文档托管、Mock 服务、API 测试和团队协作。技术选型上我们选了 Go + PostgreSQL + Redis + 前端 React。
我从 Java 转 Go 大概一年半了,原型期选择 Go 完全是因为想验证一件事:用 Go 能不能比 Spring Boot 更快速地搭出一个可用的 MVP。
结论是:可以,但有前提条件。
原型期架构
原型期的结构很简单:
project/
├── main.go
├── handler/ # HTTP 处理器
├── service/ # 业务逻辑
├── repository/ # 数据访问
├── model/ # 数据模型
└── config/ # 配置用的是 gin 框架,数据库用 GORM,没有消息队列,没有缓存(Redis 只用来存 session),部署在一台 2核4G 的云服务器上。
这个阶段完全OK,跑了差不多3个月,用户涨到了500个。这期间代码改了又改,功能迭代很快,Go 的编译速度在这里确实给了我很大的好处——改完代码,go build 几秒钟,重启,完事。对比之前用 Spring Boot 要等20多秒的 Maven 构建,心情是真的好。
第一个坑:我手写了一个 IoC 容器
当用户涨到200个左右,我开始感觉代码组织有点乱,各个 service 之间的依赖关系不清晰。Java 背景让我立刻想到了依赖注入,然后我做了一个现在觉得很蠢的决定:手写了一个迷你 IoC 容器。
现象: 启动时偶尔出现初始化顺序问题,某些 service 在依赖的 service 初始化之前就被调用,导致 nil pointer dereference 崩溃。
原因: 我用 map[string]interface{} 存储所有组件,用字符串 key 获取,初始化顺序完全依赖调用顺序,没有依赖图分析。
解法: 直接删掉了这400行代码,改用显式依赖注入——就是在 main.go 里手动按顺序初始化,把依赖当参数传进去。Go 的最佳实践从来都不是框架帮你管理依赖,而是你自己明确写出来。现在 main.go 大概长这样:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/laozhang/apimanager/config"
"github.com/laozhang/apimanager/handler"
"github.com/laozhang/apimanager/repository"
"github.com/laozhang/apimanager/service"
"github.com/laozhang/apimanager/infra/db"
"github.com/laozhang/apimanager/infra/redis"
)
func main() {
// 加载配置
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// 初始化基础设施
pgDB, err := db.New(cfg.Database)
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
defer pgDB.Close()
rdb, err := redis.New(cfg.Redis)
if err != nil {
log.Fatalf("failed to connect redis: %v", err)
}
defer rdb.Close()
// 初始化 Repository 层
userRepo := repository.NewUserRepository(pgDB)
apiRepo := repository.NewAPIRepository(pgDB)
teamRepo := repository.NewTeamRepository(pgDB)
// 初始化 Service 层(显式传入依赖)
userSvc := service.NewUserService(userRepo, rdb, cfg.JWT)
teamSvc := service.NewTeamService(teamRepo, userRepo)
apiSvc := service.NewAPIService(apiRepo, teamSvc)
// 初始化 Handler 层
userHandler := handler.NewUserHandler(userSvc)
apiHandler := handler.NewAPIHandler(apiSvc)
teamHandler := handler.NewTeamHandler(teamSvc)
// 设置路由
router := gin.New()
router.Use(gin.Recovery())
setupRoutes(router, userHandler, apiHandler, teamHandler)
// 优雅启动
srv := &http.Server{
Addr: cfg.Server.Addr,
Handler: router,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}()
// 等待信号优雅退出
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown error: %v", err)
}
}这段代码在 Java 工程师眼里可能很"原始",没有注解,没有自动扫描,一切都是手动的。但这恰恰是 Go 的哲学——显式优于隐式,你能一眼看出整个应用的依赖关系。
第二阶段:成长期(500-2000用户)
用户涨到500之后,产品进入了一个相对稳定的增长期。但问题也开始变多了。
数据库连接池踩坑
现象: 在并发稍微高一点的时候(大概同时50个请求),API 响应时间从正常的 31ms 跳到了 2600ms,还有部分请求返回 "too many connections" 错误。
原因: 我没有正确配置 GORM 的连接池。GORM 底层是 database/sql,默认配置下 MaxOpenConns 是无限制的,意味着每个 goroutine 都可能尝试建立新连接,直到 PostgreSQL 的 max_connections(默认100)被打满。
解法: 配置连接池参数,这一步在 Java 里我是靠 HikariCP 的默认配置"自动完成"的,Go 里需要自己设:
func New(cfg DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("get sql.DB: %w", err)
}
// 连接池配置——这几行很关键
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) // 最大连接数,建议设为 CPU核数*2 到 CPU核数*4
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) // 最大空闲连接数
sqlDB.SetConnMaxLifetime(cfg.ConnMaxLifetime) // 连接最大存活时间,避免被数据库服务端踢掉
sqlDB.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) // 空闲连接最大存活时间
return db, nil
}我们最终的配置是 MaxOpenConns=20, MaxIdleConns=10, ConnMaxLifetime=1h, ConnMaxIdleTime=10m。配置完之后高并发场景的响应时间降回了 28ms。
架构分层开始力不从心
随着功能增多,原来的扁平结构开始出现问题。最明显的是 service 层越来越胖,APIService 一个文件超过了1800行,里面混杂了业务逻辑、权限校验、缓存处理、事件发布……完全不是人看的。
这时候我做了第一次真正意义上的架构重构,引入了领域概念(不是严格的 DDD,但有类似的分层):
project/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── domain/ # 领域对象和接口定义
│ │ ├── api/
│ │ ├── team/
│ │ └── user/
│ ├── application/ # 应用层:用例编排
│ │ ├── api_service.go
│ │ └── team_service.go
│ ├── infrastructure/ # 基础设施实现
│ │ ├── persistence/ # 数据库实现
│ │ ├── cache/ # 缓存实现
│ │ └── messaging/ # 消息队列
│ └── interfaces/ # 对外接口
│ ├── http/ # HTTP 处理器
│ └── grpc/ # gRPC 处理器(后来加的)
├── pkg/ # 可复用工具包
│ ├── errors/
│ ├── logger/
│ └── pagination/
└── config/这次重构花了我整整11天,期间产品照常上线新功能——这也是 Go 的好处,每个包的边界清晰,可以逐步迁移。
第三阶段:那个周五崩溃之后(2000+用户)
回到开头说的那个下午。服务崩了之后,我花了大概47分钟排查出根本原因。
坑三:goroutine 泄漏导致内存暴涨
现象: 流量上来之后,进程内存从正常的 180MB 涨到了 1.4GB,然后 OOM 被 K8s 杀掉。
原因: 我在处理 WebSocket 连接时,为每个连接起了两个 goroutine(一个读,一个写),但是在连接异常断开时,写 goroutine 没有正确退出——它在等一个没有人关闭的 channel。
这个 bug 在 Java 里我不会犯,因为我会用线程池,线程泄漏的问题相对容易发现。Go 的 goroutine 太轻量了,轻量到你不经意间就会开很多,然后忘了关。
解法: 用 context 统一管理生命周期:
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// 创建带取消的 context,连接关闭时取消所有关联 goroutine
ctx, cancel := context.WithCancel(r.Context())
defer cancel() // 函数退出时,取消 ctx,通知所有子 goroutine 退出
client := &Client{
conn: conn,
send: make(chan []byte, 256),
ctx: ctx,
cancel: cancel,
}
s.hub.register <- client
defer func() {
s.hub.unregister <- client
conn.Close()
}()
// 启动写 goroutine
go client.writePump()
// 读循环在当前 goroutine 运行
client.readPump()
}
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done(): // context 取消时退出
return
case message, ok := <-c.send:
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}修完这个 bug 之后,我加了 goroutine 数量监控,告警阈值设在了 10000 个——正常运行的时候我们大概 800-1200 个 goroutine。
引入 Wire 做依赖注入代码生成
第二阶段末期,main.go 里的手动依赖注入代码已经 200 多行了,而且每次加新组件都要去改 main.go,有点烦。
这时候我引入了 Google 的 Wire,它是一个编译时依赖注入工具。跟运行时反射不同,Wire 在 go generate 阶段生成真实的 Go 代码,没有任何运行时开销,也没有反射。
Java 工程师第一次看 Wire 会觉得很奇怪,因为你需要写 Provider 函数,然后运行 wire gen 生成 wire_gen.go。但它生成的代码跟你手写的一模一样,只是省去了手写的麻烦。
微服务拆分的时机
2000用户之后,团队规模也扩大了,开始有第二个后端工程师加入。这时候我开始认真考虑要不要拆微服务。
我的结论是:没到时候,不要拆。
在 Java 生态里,Spring Cloud 的存在让微服务拆分变得很"自然",注册中心、配置中心、网关……一套下来感觉很完整。Go 里没有这种天然的引力,所以反而能让你更冷静地思考:当前的单体架构是否真的撑不住了?
我们最终的策略是:继续保持单体,但做好内部模块边界,通过接口而不是直接调用来解耦。等到某个模块的部署频率、扩缩容需求明显与整体不匹配时,再独立出去。
截至目前(用户规模在6800左右),我们只拆出了一个独立服务:负责 API Mock 响应的 mock-engine,因为它需要单独扩容,也需要支持用户自定义代码执行(沙箱环境),跟主服务的安全隔离是必要的。
Go vs Java:这个视角必须说
在架构层面,从 Java 转 Go 最大的心态转变是:
Go 没有框架帮你做架构决策,所有架构都是你自己的决策。
Spring Boot 的 @SpringBootApplication 背后是一整套自动配置,你不需要知道 DispatcherServlet 怎么初始化,连接池怎么配置,Bean 的生命周期怎么管理。这降低了入门门槛,但也让很多 Java 工程师对"架构"的感知变得模糊——你以为你在做架构决策,其实你只是在选 Starter。
Go 逼着你从第一行代码就思考:这个东西该放在哪里?这个依赖该怎么传递?这个资源该在哪里释放?
这种"麻烦"在一开始确实让我不适应,但它培养了一种对代码结构的直觉,这种直觉比任何框架文档都有价值。
最后
从 MVP 到支撑6800个用户,这个项目走了将近14个月。架构从一开始的"Go 版 Spring MVC"演进到现在这个样子,踩了很多坑,也补了很多认知盲区。
如果让我总结一句话给准备用 Go 做 SaaS 的 Java 工程师:不要把 Go 当成更快的 Java,它是一门要求你对显式性和简单性有信仰的语言。
下篇我会写 K8s 场景下的 Go 优雅停机——那也是这个项目里踩过的坑,而且比你想象的细节多很多。
