第一个 CRUD 跑起来以后,Go 后端开发会出现一道明显的断层。路由能注册,JSON 能返回,数据库也能读写,但并发增加、网络抖动或需求开始变化时,很多问题已经超出框架教程的范围。
生产代码需要回答更多问题:连接什么时候应该超时,数据库竞争如何处理,业务逻辑放在哪一层,配置错误怎样在启动阶段暴露,跨服务操作失败后又该如何恢复。框架可以减少样板代码,无法替项目做这些决定。
框架能省代码,不能替代理解网络
net/http 已经处理了 HTTP 请求解析、连接复用、路径匹配和响应写入。大多数项目没有理由重新实现 HTTP server,但了解它下面的 TCP 字节流,会直接影响故障判断。
一个最小 TCP server 的入口只有 Listen 和 Accept:
listener, err := net.Listen("tcp", ":8080")
if err != nil {
return fmt.Errorf("listen: %w", err)
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
return fmt.Errorf("accept connection: %w", err)
}
go serveConnection(conn)
}这段代码只展示了连接入口,离生产可用还很远。TCP 提供有序字节流,不保留应用消息边界。一次 Read 不保证拿到完整请求,客户端也可能连接后迟迟不发送数据。上面的循环还没有并发上限、deadline、连接追踪和 graceful shutdown,错误退出时也没有区分临时故障与 listener 关闭。
手动处理一次这些问题,再回到 net/http,许多配置就不再是模板里的神秘参数:
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
}这些值不能机械复制。大文件上传可能需要更长的读取时间,流式响应也会改变 WriteTimeout 的设置方式。配置的意义是限制慢连接占用资源的时间,具体数值要由请求类型、上游网关和延迟数据决定。
数据库连接池也有同样的问题。sql.DB 代表连接池,不是单条连接。接口变慢时,问题可能出在 SQL,也可能是 SetMaxOpenConns 太小导致排队,或者太大导致数据库被连接压满。理解连接如何建立、复用和释放,才能判断延迟出现在应用逻辑、连接池还是网络上。
架构选择要记录代价
后端设计很少存在脱离上下文的标准答案。一个方案是否合适,要看它承诺了什么、失败后会损失什么,以及团队能否观察和恢复。
| 决策 | 更适合的条件 | 需要承担的代价 |
| Session | 浏览器应用、需要集中撤销、服务可以访问共享会话状态 | 会话存储、跨实例访问和失效策略 |
| JWT | 多个服务需要本地验证、自包含身份声明有明确收益 | 密钥轮换、撤销、过期和 claim 演进 |
| Optimistic Concurrency Control | 冲突较少,操作可以检测版本并安全重试 | 冲突后的重试与用户反馈 |
| 悲观锁或分布式锁 | 同一资源竞争频繁,必须限制并行修改 | 等待、超时、锁失效和故障恢复 |
| Modular Monolith | 团队和业务边界仍在变化,独立部署收益不明确 | 需要用 package 和 CI 约束模块边界 |
| Microservices | 已有独立扩缩容、故障隔离或团队所有权需求 | 网络失败、契约演进、可观测性和数据一致性 |
JWT 与 Session 可以组合使用。系统可以使用短期 access token 配合服务端 refresh session,乐观并发也可以只用在部分低冲突写操作。一个模式只覆盖系统的一部分时,它的代价会随边界变化。
做选择时,先写清楚一致性、延迟、安全和恢复要求,再估算正常路径与失败路径的成本。没有观测数据时,应优先选择删除和替换成本较低的方案。等冲突率、流量、团队边界或部署需求真的变化,再根据证据增加复杂度。
Repository 隔离的是变化与失败语义
业务代码直接调用 SQL 或 ORM 后,分页对象、驱动错误、事务句柄和表字段很快会进入 Handler 与业务判断。单元测试也会依赖数据库才能触发相关分支。
Repository 在应用逻辑和持久化实现之间定义一组小接口:
type UserRepository interface {
FindByID(ctx context.Context, id int64) (User, error)
Save(ctx context.Context, user User) error
}
type UserService struct {
users UserRepository
}
func NewUserService(users UserRepository) *UserService {
return &UserService{users: users}
}接口应放在使用它的 package,方法只覆盖当前业务需要的操作。应用层依赖 UserRepository,PostgreSQL adapter 负责实现它,并把 sql.ErrNoRows 等驱动错误转换成稳定的应用错误。业务代码因此不需要知道表名、SQL 方言或连接类型。
业务规则可以用 fake 或 mock 验证,存储实现可以独立做集成测试,缓存、批量查询和事务实现也可以等需求明确后再定。生产系统未必真的更换数据库,接口仍然把存储变化限制在 adapter 内。
Repository 的方法应围绕业务操作组织,并返回领域对象或稳定错误。为每张表机械生成 CRUD,或者暴露 *sql.Rows、ORM query builder 和数据库 model,都会把存储实现泄漏回应用层。Mock 只能证明应用逻辑如何调用接口,无法证明 SQL、索引、事务隔离和 schema migration 正确。涉及持久化的项目仍然需要连接真实数据库的集成测试。
依赖倒置让分层保持轻量
运行时调用方向通常是 Handler -> Service -> Repository。源码依赖则指向应用层:应用层定义需要的接口,外层 adapter 实现接口,main 或 composition root 负责把它们装配起来。
Go 项目可以直接手写依赖注入:
users := postgres.NewUserRepository(db)
userService := application.NewUserService(users)
userHandler := transport.NewUserHandler(userService)Handler 只处理 HTTP 输入、调用应用能力并写出响应。Service 负责业务规则、事务边界和多个依赖之间的协调。Repository 处理持久化,PostgreSQL 和路由框架都停留在外层。
简单接口可以不设 service 目录。Handler 只做参数转换和单次存取时,可以直接依赖小型 Repository interface。等操作需要协调多个 Repository、发送消息、执行权限策略或复用到 Worker 时,再引入应用服务。业务逻辑是否需要稳定归属,决定了是否增加 Service 层;目录层数不提供判断依据。
依赖图能在一个 composition root 里读完时,手写构造通常更容易排查。模块增长到装配代码重复且难以维护,再评估代码生成或容器化 DI。
把生产要求放进日常工具链
生产服务需要让配置、数据库变更、日志、接口契约和进程生命周期都可以验证。
| 位置 | 实施方式 | 失败时应该发生什么 |
| Config | 配置与代码分离,环境变量或 secret manager 注入 | 缺少必需配置时拒绝启动 |
| Migration | 用 golang-migrate 等工具保存版本化 SQL,作为独立发布动作 | migration 失败时停止继续发布 |
| Logs | 使用 log/slog 等结构化日志写入标准输出 | 保留 request ID、错误类型和关键业务标识 |
| API Contract | 维护 OpenAPI source of truth,生成文档或 server stub | CI 检测生成结果与契约漂移 |
| Lifecycle | 提供 liveness、readiness 和 graceful shutdown | 实例先退出流量,再等待在途请求完成 |
12-Factor 要求配置与构建产物分离,并把日志当作事件流。配置可以来自环境变量或其他注入渠道,但不应进入代码和构建产物。结构化日志需要让一次请求跨 Handler、数据库和下游调用时仍能被检索,不能只是在日志外面套一层 JSON。
Swagger UI 只负责展示,不会自动保证文档与实现一致。项目可以选择 OpenAPI-first,由规范生成 server interface;也可以从代码生成规范。无论哪种方向,CI 都要重新生成并检查差异,避免文档和实际响应各自演进。
分布式事务同样不能见到外部调用就套 Saga。发送邮件失败通常适合 Outbox、幂等消费和重试。Saga 更适合多个服务各自提交本地事务、整体业务又需要补偿的过程,例如订单创建后依次预留库存与扣款。每个成功步骤都要定义可执行的补偿动作,而补偿本身也可能失败,仍然需要状态记录、重试和人工处理入口。
开发阶段运行单元测试和集成测试,CI 检查 migration、OpenAPI 生成结果与静态分析,发布时构建同一个 Go binary,再用启动探针和运行指标确认它可以接收流量。这样,配置、migration、契约或启动问题会在对应阶段失败,不必等到流量进入后才暴露。
排查问题时沿依赖逐层定位
接口延迟上升时,可以沿着连接、HTTP timeout、连接池、SQL、锁竞争和下游依赖逐层排查。需求变化时,需要判断修改应该停在 Handler、Service 还是 Repository;发布失败时,则要区分配置、migration、契约和进程生命周期问题。
TCP 知识用于解释 HTTP server 的网络行为,Repository interface 用于限制存储细节的传播。每一层抽象都应该对应一种真实变化或失败方式,并且有测试、日志或运行指标证明它仍然有效。