Go 后端开发实战(5):数据库操作与 ORM

发布时间:2026/9/2 20:52:16
Go 后端开发实战(5):数据库操作与 ORM 上一篇把 Handler、service、repository 分开本篇把 repository 从抽象接口推进到可落地的数据访问层。重点不是背诵某个 ORM 的方法名而是建立一套换数据库、换驱动甚至暂时换回内存实现时仍成立的边界业务层只认识领域错误和事务用例适配器负责 SQL、连接与驱动错误。我们先理解database/sql的资源模型再确定事务归属最后说明 GORM 应该隐藏哪些重复劳动、又有哪些行为绝不能隐藏。一、先掌握 database/sql 的资源模型sql.DB不是单条连接而是并发安全的连接池句柄应在进程启动时创建并长期复用。每个请求新建 DB 会失去池化并耗尽数据库连接。sql.Open通常只校验参数不保证已连通启动检查可用带超时 context 的PingContextreadiness 则要避免高频探测放大故障。启动阶段还要区分“配置错误”和“依赖暂时不可用”。DSN 缺少数据库名、TLS 模式非法应立即退出让部署系统暴露错误数据库正在切主或短暂重启则可以由平台按退避策略重新拉起。Handler 传入的r.Context()要一路传到QueryContext这样客户端断开或网关超时后后端不会继续占着连接做无意义查询。不要在 repository 内改成context.Background()那等于切断了请求的取消链。通过SetMaxOpenConns限制总连接数值要与数据库上限、实例数量和管理连接预算共同计算。例如数据库允许 100 条业务连接、部署 4 个副本不能每个副本都配 100。SetMaxIdleConns减少反复建连SetConnMaxLifetime让连接在代理或服务端生命周期之前轮换。观察DB.Stats()的 WaitCount、WaitDuration而不是凭感觉加池。一个可迁移的估算方法是先预留迁移工具、监控和人工排障连接再把剩余额度除以应用最大副本数而不是当前副本数。压测时同时记录请求延迟和池等待若数据库 CPU 很低而WaitDuration上升池可能偏小若数据库已饱和继续加连接只会制造更多并发排队。连接池配置因此属于容量模型的一部分应跟随部署配置进入评审而不是散落在代码常量里。查询必须使用占位符绑定参数不能用字符串拼接用户输入。PostgreSQL 常用$1MySQL 常用?差异属于适配器。QueryContext返回的 Rows 要关闭并在迭代后检查rows.Err()单行查询用QueryRowContext其错误在 Scan 时出现。NULL 需要sql.NullString等显式表达不能无意识映射为空字符串。扫描结果时建议显式列出字段避免SELECT *在迁移增加列后破坏 Scan 顺序。数据行不存在应翻译为稳定的ErrNotFound唯一键冲突翻译为ErrConflict其他错误保留原因并增加操作上下文例如“find user by email”。service 据此决定 404、409 或 500不能读取某个 PostgreSQL 驱动的错误字符串。这个翻译层正是 repository 让业务代码跨存储迁移的价值。下面以 repository 契约和内存实现展示唯一约束与防御性返回。代码独立运行下一段再把事务原理落到可执行状态机。packagemainimport(contexterrorsfmtstrings)varErrConflicterrors.New(email conflict)typeUserstruct{IDint;Emailstring}typeUserRepositoryinterface{Create(context.Context,User)(User,error)FindByEmail(context.Context,string)(User,bool)}typeMemoryUsersstruct{nextint;byEmailmap[string]User}func(m*MemoryUsers)Create(_context.Context,user User)(User,error){key:strings.ToLower(user.Email)if_,exists:m.byEmail[key];exists{returnUser{},ErrConflict}m.nextuser.IDm.next m.byEmail[key]userreturnuser,nil}func(m*MemoryUsers)FindByEmail(_context.Context,emailstring)(User,bool){user,ok:m.byEmail[strings.ToLower(email)]returnuser,ok}funcmain(){repo:MemoryUsers{byEmail:make(map[string]User)}first,_:repo.Create(context.Background(),User{Email:aexample.com})_,err:repo.Create(context.Background(),User{Email:Aexample.com})found,ok:repo.FindByEmail(context.Background(),aexample.com)fmt.Printf(created%d found%t same%t conflict%t\n,first.ID,ok,found.IDfirst.ID,errors.Is(err,ErrConflict))}运行输出created1 foundtrue sametrue conflicttrue二、事务边界由业务用例决定事务不是 repository 每个方法各自开启而是覆盖必须原子完成的业务用例。例如扣库存与创建订单必须同成同败service 应通过事务执行器让两个操作使用同一个*sql.Tx。事务内所有查询都必须走 Tx误用全局 DB 会逃出事务。提交失败也要返回错误不能因为业务语句成功就忽略 Commit。推荐模式是 BeginTx 后 defer Rollback再执行逻辑并 Commit。已经提交后 Rollback 返回sql.ErrTxDone可忽略。context 超时会取消查询和事务但数据库驱动能否立即中断取决于实现。隔离级别按不变量选择默认级别常足够涉及并发抢占可用条件 UPDATE、行锁或可重试的序列化事务。事务执行器可以暴露WithinTransaction(ctx, func(Repositories) error) error回调拿到绑定同一个 Tx 的仓储集合。这样 service 能表达“创建订单、扣减库存、写入审计事件”是一个原子用例却不需要知道*sql.Tx。若团队规模较小也可以让 service 直接依赖一个很窄的DBTX接口关键是同一事务中的所有适配器共享执行句柄并且测试能故意制造第二步失败来验证第一步没有落库。不要用“先查再插”保证唯一性两请求可能同时查不到。数据库唯一索引才是最终裁判适配器将驱动错误翻译为ErrConflict。同理库存扣减可写成UPDATE ... SET stockstock-1 WHERE id$1 AND stock0根据受影响行数判断而非在应用内读后写。对死锁和序列化失败可以做有限重试但只能重试幂等的完整事务并加入随机退避。不能只重放事务中最后一条 SQL也不能在事务内部调用不可撤销的外部接口。发送邮件、发布消息这类副作用可使用 outbox业务数据与待发送事件在同一事务写入独立 worker 在提交后投递并记录去重键。这个模式把“数据库原子性”与“外部系统最终一致”连接起来也为以后拆分服务保留了迁移路径。以下小程序用快照模拟转账事务失败时不提交。它不是数据库替代品而是可执行地展示“修改副本、验证不变量、原子发布”的事务语义。packagemainimport(errorsfmt)varErrInsufficienterrors.New(insufficient balance)typeLedgermap[string]intfunctransfer(current Ledger,from,tostring,amountint)(Ledger,error){next:make(Ledger,len(current))foraccount,balance:rangecurrent{next[account]balance}ifamount0||next[from]amount{returncurrent,ErrInsufficient}next[from]-amount next[to]amountreturnnext,nil}funcmain(){ledger:Ledger{alice:100,bob:20}committed,err:transfer(ledger,alice,bob,30)iferrnil{ledgercommitted}fmt.Printf(after_success alice%d bob%d\n,ledger[alice],ledger[bob])committed,errtransfer(ledger,alice,bob,1000)iferrnil{ledgercommitted}fmt.Printf(after_failure alice%d bob%d rollback%t\n,ledger[alice],ledger[bob],errors.Is(err,ErrInsufficient))}运行输出after_success alice70 bob50 after_failure alice70 bob50 rollbacktrue三、让 ORM 服务于查询而不是隐藏查询GORM 提供模型映射、关联、钩子和迁移能提高常规 CRUD 速度但生成 SQL 仍需审查。开启开发环境参数化日志使用 DryRun 或数据库慢查询日志检查 SQL。列表中循环加载关联会形成 N1一条列表查询外加 N 条关联查询用 Preload、Join 或批量WHERE id IN (...)解决并对查询次数写集成测试。领域对象与 GORM 模型不必是同一个结构体。数据库模型可以包含软删除时间、可空字段和版本号repository 显式转换为领域对象这样标签变化、关联加载策略和 ORM 钩子不会渗入 service。对简单项目合并模型能少写代码但仍应禁止 Handler 直接持有*gorm.DB否则校验、错误翻译和事务边界会在各端点重复出现后续替换 ORM 将成为全局改造。ORM 的 AutoMigrate 适合原型不足以承担严格生产迁移。生产迁移应使用有版本、可审查的 SQL 工具如 golang-migrate 或 Atlas先增加兼容列和索引再部署兼容新旧结构的代码最后清理旧列。大表加非空列、建索引和改类型可能锁表需按照目标数据库能力设计在线变更。分页优先使用稳定排序。offset 简单但翻到深页会扫描并且在并发插入时漂移高流量列表用游标把(created_at,id)编码为不透明 token并以相同复合索引查询。任何动态排序字段都应走白名单参数占位符不能安全绑定列名。索引设计要从实际查询反推。WHERE tenant_id? AND status? ORDER BY created_at DESC,id DESC通常需要以租户和状态开头、排序字段结尾的复合索引只有单列索引未必能避免排序。上线前用目标数据库的EXPLAIN检查计划上线后结合慢查询样本校正而不是为每个字段机械建索引。索引会占空间并增加写放大所以每个索引都应能对应一个明确的读取路径或约束。数据库层测试至少覆盖真实方言、唯一约束、事务回滚和迁移。纯内存 SQLite 与 PostgreSQL 在 JSON、锁、大小写和 SQL 语法上不同不能作为全部证据。可在 CI 用容器启动目标数据库并为每个测试创建事务或独立 schema。落地时可以按四步迁移先冻结 repository 接口和领域错误再为目标数据库编写适配器与版本化迁移随后用契约测试同时跑内存实现和数据库实现最后在预发布环境用真实数据量检查池等待、慢查询和回滚。这样得到的不是“会写 CRUD”而是一条能从单机原型迁到 PostgreSQL、从手写 SQL 迁到 ORM 或反向迁出的稳定数据边界。下一篇会在 HTTP 与数据库之间加入认证、日志、限流和跨域中间件并设计版本化路由其中限流状态需要明确选择单机内存还是跨实例存储。参考来源Go 官方文档database/sqlGo 数据库访问教程GORM 官方文档PostgreSQL事务隔离 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《Go 后端开发实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。