本文为《Java工程师转Go实战》连载第 13 篇 / 共 20 篇
上一篇:Spring Boot vs Gin
下一篇:配置日志与中间件
类比开场
Java 访问数据库三件套:
- JPA/Hibernate—— ORM 全自动,
@Entity一标就行 - MyBatis—— 手写 SQL,灵活可控
- JdbcTemplate—— 最轻量,一行 SQL 一行映射
Go 没有 Spring Data JPA 那种「一个注解走天下」,但选项清晰且各有最佳场景。
一、选型对照总表
| Java 方案 | Go 对应 | 风格 | 适用场景 |
|---|---|---|---|
| JPA/Hibernate | GORM | 链式 API ORM | 快速开发、表多、CRUD 密集 |
| MyBatis | sqlx | 手写 SQL + struct 映射 | SQL 复杂、性能敏感 |
| MyBatis + 代码生成 | sqlc | SQL → 类型安全 Go 代码 | 最佳实践(新项目推荐) |
| JPA + QueryDSL | ent | Schema 驱动 + 类型安全查询 | 复杂领域模型 |
| JdbcTemplate | database/sql | 标准库 | 极简、无第三方依赖 |
团队选型建议
| 团队情况 | 推荐方案 | 原因 |
|---|---|---|
| Java 转 Go 新团队,快速出活 | GORM | 上手快,类 JPA 体验 |
| 性能要求高、SQL 复杂 | sqlx 或 sqlc | SQL 全透明可控 |
| 新项目从零开始 | sqlc(强推) | 编译期类型安全,零 runtime 反射 |
| 复杂领域模型 | ent | 图查询、代码生成 |
| 极简小服务 | database/sql | 零依赖 |
二、database/sql 标准库基础
所有 Go 数据库方案都建立在database/sql之上。即使用 GORM,底层也是它。
连接池配置
import("database/sql""time"_"github.com/go-sql-driver/mysql"// 驱动注册)funcinitDB()*sql.DB{dsn:="user:password@tcp(127.0.0.1:3306)/demo?parseTime=true&loc=Local&charset=utf8mb4"db,err:=sql.Open("mysql",dsn)iferr!=nil{panic(fmt.Sprintf("open db: %v",err))}// 连接池配置(对标 HikariCP)db.SetMaxOpenConns(50)// 最大连接数(HikariCP maximumPoolSize)db.SetMaxIdleConns(10)// 空闲连接数(HikariCP minimumIdle)db.SetConnMaxLifetime(time.Hour)// 连接最大存活时间(防止 MySQL 服务端断连)db.SetConnMaxIdleTime(10*time.Minute)// 空闲连接超时// 验证连接(sql.Open 不会真正连接)iferr:=db.Ping();err!=nil{panic(fmt.Sprintf("ping db: %v",err))}returndb}基础 CRUD
// 查询单行funcGetUserByID(ctx context.Context,db*sql.DB,idint64)(*User,error){varu User err:=db.QueryRowContext(ctx,"SELECT id, name, email, created_at FROM users WHERE id = ?",id,).Scan(&u.ID,&u.Name,&u.Email,&u.CreatedAt)iferrors.Is(err,sql.ErrNoRows){returnnil,ErrNotFound}iferr!=nil{returnnil,fmt.Errorf("scan user: %w",err)}return&u,nil}// 查询多行funcListUsers(ctx context.Context,db*sql.DB,limitint)([]*User,error){rows,err:=db.QueryContext(ctx,"SELECT id, name, email FROM users ORDER BY id DESC LIMIT ?",limit)iferr!=nil{returnnil,fmt.Errorf("query users: %w",err)}deferrows.Close()varusers[]*Userforrows.Next(){varu Useriferr:=rows.Scan(&u.ID,&u.Name,&u.Email);err!=nil{returnnil,fmt.Errorf("scan row: %w",err)}users=append(users,&u)}returnusers,rows.Err()// 别忘了检查迭代错误}// 插入funcCreateUser(ctx context.Context,db*sql.DB,u*User)error{result,err:=db.ExecContext(ctx,"INSERT INTO users (name, email) VALUES (?, ?)",u.Name,u.Email)iferr!=nil{returnfmt.Errorf("insert user: %w",err)}u.ID,_=result.LastInsertId()returnnil}黄金法则:永远传ctx——让超时和取消能传递到 DB 层。
三、GORM 实战(对标 JPA)
模型定义
import("time""gorm.io/gorm")typeUserstruct{IDint64`gorm:"primaryKey;autoIncrement" json:"id"`Namestring`gorm:"size:64;not null;index" json:"name"`Emailstring`gorm:"size:128;uniqueIndex" json:"email"`Ageint`gorm:"default:0" json:"age"`Statusint8`gorm:"default:1;comment:1-active 2-disabled" json:"status"`CreatedAt time.Time`gorm:"autoCreateTime" json:"createdAt"`UpdatedAt time.Time`gorm:"autoUpdateTime" json:"updatedAt"`DeletedAt gorm.DeletedAt`gorm:"index" json:"-"`// 软删除}// TableName 自定义表名(默认是 users)func(User)TableName()string{return"t_user"}初始化
import("gorm.io/driver/mysql""gorm.io/gorm""gorm.io/gorm/logger")funcinitGORM()*gorm.DB{dsn:="user:pass@tcp(127.0.0.1:3306)/demo?parseTime=true&charset=utf8mb4"db,err:=gorm.Open(mysql.Open(dsn),&gorm.Config{Logger:logger.Default.LogMode(logger.Info),// 开发时打印 SQL// 生产环境用:// Logger: logger.Default.LogMode(logger.Warn),})iferr!=nil{panic("connect db: "+err.Error())}// 获取底层 sql.DB 配置连接池sqlDB,_:=db.DB()sqlDB.SetMaxOpenConns(50)sqlDB.SetMaxIdleConns(10)sqlDB.SetConnMaxLifetime(time.Hour)// 自动建表(开发环境用,生产用 migration 工具)db.AutoMigrate(&User{})returndb}CRUD 对照 JPA
| JPA 操作 | GORM 操作 |
|---|---|
repository.save(entity) | db.Create(&user) |
repository.findById(id) | db.First(&user, id) |
repository.findAll() | db.Find(&users) |
repository.save(entity)(更新) | db.Save(&user)或db.Updates(...) |
repository.deleteById(id) | db.Delete(&User{}, id) |
JPQL@Query | db.Where(...).Find(...) |
完整 Repository 示例
typeUserRepositorystruct{db*gorm.DB}funcNewUserRepository(db*gorm.DB)*UserRepository{return&UserRepository{db:db}}// 创建func(r*UserRepository)Create(ctx context.Context,u*User)error{returnr.db.WithContext(ctx).Create(u).Error}// 按 ID 查找func(r*UserRepository)GetByID(ctx context.Context,idint64)(*User,error){varu User err:=r.db.WithContext(ctx).First(&u,id).Erroriferrors.Is(err,gorm.ErrRecordNotFound){returnnil,ErrNotFound}return&u,err}// 条件查询 + 分页func(r*UserRepository)Search(ctx context.Context,keywordstring,page,sizeint)([]*User,int64,error){varusers[]*Uservartotalint64query:=r.db.WithContext(ctx).Model(&User{})ifkeyword!=""{query=query.Where("name LIKE ?","%"+keyword+"%")}// 先统计总数iferr:=query.Count(&total).Error;err!=nil{returnnil,0,err}// 再分页查询err:=query.Order("id DESC").Offset((page-1)*size).Limit(size).Find(&users).Errorreturnusers,total,err}// 更新指定字段(避免零值问题)func(r*UserRepository)UpdateName(ctx context.Context,idint64,namestring)error{returnr.db.WithContext(ctx).Model(&User{}).Where("id = ?",id).Update("name",name).Error}// 批量更新func(r*UserRepository)BatchUpdateStatus(ctx context.Context,ids[]int64,statusint8)error{returnr.db.WithContext(ctx).Model(&User{}).Where("id IN ?",ids).Update("status",status).Error}GORM 零值陷阱(重要!)
// ❌ 危险:如果 user.Age = 0,GORM 会忽略它(认为是零值)db.Save(&user)// ✅ 明确更新哪些字段db.Model(&user).Updates(map[string]interface{}{"age":0,"name":"新名字",})// ✅ 或者用 Select 指定更新字段db.Model(&user).Select("Name","Age").Updates(user)四、事务处理
Java
@Transactional(rollbackFor=Exception.class)publicvoidtransfer(Longfrom,Longto,BigDecimalamount){accountRepo.debit(from,amount);accountRepo.credit(to,amount);transactionRepo.save(newTransaction(from,to,amount));}GORM 事务
func(s*AccountService)Transfer(ctx context.Context,from,toint64,amountint64)error{returns.db.WithContext(ctx).Transaction(func(tx*gorm.DB)error{// 扣款result:=tx.Model(&Account{}).Where("id = ? AND balance >= ?",from,amount).Update("balance",gorm.Expr("balance - ?",amount))ifresult.RowsAffected==0{returnErrInsufficientBalance}ifresult.Error!=nil{returnresult.Error}// 入账iferr:=tx.Model(&Account{}).Where("id = ?",to).Update("balance",gorm.Expr("balance + ?",amount)).Error;err!=nil{returnerr}// 记录流水returntx.Create(&Transaction{FromID:from,ToID:to,Amount:amount,}).Error})// 返回 nil → 自动 Commit// 返回 error → 自动 Rollback}标准库事务
func(r*Repo)TransferRaw(ctx context.Context,from,to,amountint64)error{tx,err:=r.db.BeginTx(ctx,nil)iferr!=nil{returnerr}defertx.Rollback()// 如果已 Commit,Rollback 是无操作_,err=tx.ExecContext(ctx,"UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",amount,from,amount)iferr!=nil{returnerr}_,err=tx.ExecContext(ctx,"UPDATE accounts SET balance = balance + ? WHERE id = ?",amount,to)iferr!=nil{returnerr}returntx.Commit()}五、sqlc:编译期类型安全(新项目强推)
工作流程
SQL 文件 → sqlc generate → 类型安全的 Go 代码定义 SQL
query/user.sql:
-- name: GetUser :oneSELECTid,name,email,created_atFROMusersWHEREid=?LIMIT1;-- name: ListUsers :manySELECTid,name,emailFROMusersORDERBYidDESCLIMIT?;-- name: CreateUser :execresultINSERTINTOusers(name,email)VALUES(?,?);-- name: UpdateUserName :execUPDATEusersSETname=?WHEREid=?;生成代码
sqlc generate生成的代码(类型完全安全):
// 自动生成,不需要手写func(q*Queries)GetUser(ctx context.Context,idint64)(User,error){...}func(q*Queries)ListUsers(ctx context.Context,limitint32)([]User,error){...}func(q*Queries)CreateUser(ctx context.Context,arg CreateUserParams)(sql.Result,error){...}sqlc 的优势
| 维度 | GORM | sqlc |
|---|---|---|
| SQL 可见性 | 链式 API 隐藏 SQL | SQL 直接写,完全透明 |
| 类型安全 | 运行时反射 | 编译期检查 |
| N+1 问题 | 容易中招 | 不可能(SQL 自己写) |
| 复杂查询 | 链式 API 表达力有限 | 原生 SQL 无限制 |
| 学习成本 | 学 GORM API | 学 SQL(你本来就会) |
| 性能 | 有反射开销 | 接近原生 database/sql |
六、关联查询与 N+1 问题
GORM 的 Preload(对标 JPA @EntityGraph)
typeOrderstruct{IDint64`gorm:"primaryKey"`UserIDint64User User`gorm:"foreignKey:UserID"`// 关联Items[]OrderItem}// ❌ N+1 问题:查 100 个订单会发 101 条 SQLvarorders[]Order db.Find(&orders)// 不会自动加载 Userfor_,o:=rangeorders{db.Model(&o).Association("User").Find(&o.User)// 每个循环一次查询}// ✅ 用 Preload 一次加载db.Preload("User").Preload("Items").Find(&orders)// 生成 3 条 SQL:查 orders + 查 users (IN) + 查 items (IN)// ✅ 条件 Preloaddb.Preload("Items","price > ?",100).Find(&orders)sqlx/sqlc 的做法:JOIN 手写
-- sqlc-- name: GetOrderWithUser :oneSELECTo.id,o.amount,u.nameasuser_nameFROMorders oJOINusers uONo.user_id=u.idWHEREo.id=?;没有 N+1 的可能——SQL 怎么写就怎么执行。
七、数据库迁移(对标 Flyway/Liquibase)
golang-migrate
# 安装goinstall-tags'mysql'github.com/golang-migrate/migrate/v4/cmd/migrate@latest# 创建迁移文件migrate create-extsql-dirmigrations-seqcreate_users_table生成两个文件:
-- 000001_create_users_table.up.sqlCREATETABLEusers(idBIGINTAUTO_INCREMENTPRIMARYKEY,nameVARCHAR(64)NOTNULL,emailVARCHAR(128)NOTNULLUNIQUE,created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,INDEXidx_name(name))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;-- 000001_create_users_table.down.sqlDROPTABLEIFEXISTSusers;# 执行迁移migrate-database"mysql://user:pass@tcp(127.0.0.1:3306)/demo"-pathmigrations up# 回滚migrate-database"..."-pathmigrations down1八、踩坑清单
| 坑 | 说明 | 解决方案 |
|---|---|---|
sql.Open不连库 | Open 只验证参数格式 | 启动时db.Ping() |
| GORM 默认日志太吵 | 开发方便但污染日志 | 生产配logger.Warn |
| N+1 查询 | GORM 默认不加载关联 | 用Preload或 JOIN |
| float 存金额 | 精度丢失 | 用分(int64)或shopspring/decimal |
| 时区问题 | DSN 没加parseTime=true&loc=Local | 加上 |
| 软删除影响唯一索引 | deleted_at 不为空时唯一索引失效 | 联合唯一索引包含 deleted_at |
| 批量插入太慢 | 逐条 Create | db.CreateInBatches(&users, 100) |
| 长事务 | 锁持有时间过长 | 控制事务粒度,不要在事务里做 RPC |
📦 面试追问
GORM 和 sqlx 怎么选?
开发效率 vs SQL 可控。GORM 适合快速 CRUD;sqlx/sqlc 适合性能敏感、SQL 复杂的场景(金融核心)。可以同项目混用:简单 CRUD 用 GORM,复杂报表用 sqlx。Go 连接池参数含义?
MaxOpenConns:数据库最大连接数;MaxIdleConns:保持空闲的连接数(减少重连);MaxLifetime:连接存活时间(防止 MySQL 的 wait_timeout 断连)。建议 MaxIdle = MaxOpen/5,Lifetime < MySQL wait_timeout。ORM 慢查询怎么查?
GORM 配置 SlowThreshold;生产用 APM(SkyWalking、OpenTelemetry);MySQL 开 slow_query_log。为什么推荐 sqlc?
SQL 即代码(Code as SQL),编译期发现错误(列名拼错、类型不匹配),无反射性能开销,IDE 有 SQL 高亮和补全。缺点是对动态查询(如可选条件筛选)支持不够好。GORM 的 Save 和 Updates 区别?
Save更新所有字段(包括零值!);Updates只更新非零值字段(或 map 指定字段)。生产中少用 Save,多用 Updates + Select。
💡 一句话总结
JPA 党用 GORM 快速出活,MyBatis 党用 sqlx/sqlc 精确控制——关键是把 ctx 传到底、连接池配对、事务包好。新项目如果没有历史包袱,强烈推荐试试 sqlc:SQL 直写 + 编译期类型安全,是 Go 数据库访问的最佳实践方向。
建议标签:GolangGORMMyBatisJPAJavaMySQL数据库sqlc