MyBatis/JPA 对应 GORM 还是 sqlx?Go 数据库访问选型指南

📅 2026/7/23 12:43:40
MyBatis/JPA 对应 GORM 还是 sqlx?Go 数据库访问选型指南
本文为《Java工程师转Go实战》连载第 13 篇 / 共 20 篇上一篇Spring Boot vs Gin下一篇配置日志与中间件类比开场Java 访问数据库三件套JPA/Hibernate—— ORM 全自动Entity一标就行MyBatis—— 手写 SQL灵活可控JdbcTemplate—— 最轻量一行 SQL 一行映射Go 没有 Spring Data JPA 那种「一个注解走天下」但选项清晰且各有最佳场景。一、选型对照总表Java 方案Go 对应风格适用场景JPA/HibernateGORM链式 API ORM快速开发、表多、CRUD 密集MyBatissqlx手写 SQL struct 映射SQL 复杂、性能敏感MyBatis 代码生成sqlcSQL → 类型安全 Go 代码最佳实践新项目推荐JPA QueryDSLentSchema 驱动 类型安全查询复杂领域模型JdbcTemplatedatabase/sql标准库极简、无第三方依赖团队选型建议团队情况推荐方案原因Java 转 Go 新团队快速出活GORM上手快类 JPA 体验性能要求高、SQL 复杂sqlx 或 sqlcSQL 全透明可控新项目从零开始sqlc强推编译期类型安全零 runtime 反射复杂领域模型ent图查询、代码生成极简小服务database/sql零依赖二、database/sql 标准库基础所有 Go 数据库方案都建立在database/sql之上。即使用 GORM底层也是它。连接池配置import(database/sqltime_github.com/go-sql-driver/mysql// 驱动注册)funcinitDB()*sql.DB{dsn:user:passwordtcp(127.0.0.1:3306)/demo?parseTimetruelocLocalcharsetutf8mb4db,err:sql.Open(mysql,dsn)iferr!nil{panic(fmt.Sprintf(open db: %v,err))}// 连接池配置对标 HikariCPdb.SetMaxOpenConns(50)// 最大连接数HikariCP maximumPoolSizedb.SetMaxIdleConns(10)// 空闲连接数HikariCP minimumIdledb.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)}returnu,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)}usersappend(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(timegorm.io/gorm)typeUserstruct{IDint64gorm:primaryKey;autoIncrement json:idNamestringgorm:size:64;not null;index json:nameEmailstringgorm:size:128;uniqueIndex json:emailAgeintgorm:default:0 json:ageStatusint8gorm:default:1;comment:1-active 2-disabled json:statusCreatedAt time.Timegorm:autoCreateTime json:createdAtUpdatedAt time.Timegorm:autoUpdateTime json:updatedAtDeletedAt gorm.DeletedAtgorm:index json:-// 软删除}// TableName 自定义表名默认是 usersfunc(User)TableName()string{returnt_user}初始化import(gorm.io/driver/mysqlgorm.io/gormgorm.io/gorm/logger)funcinitGORM()*gorm.DB{dsn:user:passtcp(127.0.0.1:3306)/demo?parseTimetruecharsetutf8mb4db,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 对照 JPAJPA 操作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)JPQLQuerydb.Where(...).Find(...)完整 Repository 示例typeUserRepositorystruct{db*gorm.DB}funcNewUserRepository(db*gorm.DB)*UserRepository{returnUserRepository{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}returnu,err}// 条件查询 分页func(r*UserRepository)Search(ctx context.Context,keywordstring,page,sizeint)([]*User,int64,error){varusers[]*Uservartotalint64query:r.db.WithContext(ctx).Model(User{})ifkeyword!{queryquery.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 0GORM 会忽略它认为是零值db.Save(user)// ✅ 明确更新哪些字段db.Model(user).Updates(map[string]interface{}{age:0,name:新名字,})// ✅ 或者用 Select 指定更新字段db.Model(user).Select(Name,Age).Updates(user)四、事务处理JavaTransactional(rollbackForException.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.RowsAffected0{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()// 如果已 CommitRollback 是无操作_,errtx.ExecContext(ctx,UPDATE accounts SET balance balance - ? WHERE id ? AND balance ?,amount,from,amount)iferr!nil{returnerr}_,errtx.ExecContext(ctx,UPDATE accounts SET balance balance ? WHERE id ?,amount,to)iferr!nil{returnerr}returntx.Commit()}五、sqlc编译期类型安全新项目强推工作流程SQL 文件 → sqlc generate → 类型安全的 Go 代码定义 SQLquery/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 的优势维度GORMsqlcSQL 可见性链式 API 隐藏 SQLSQL 直接写完全透明类型安全运行时反射编译期检查N1 问题容易中招不可能SQL 自己写复杂查询链式 API 表达力有限原生 SQL 无限制学习成本学 GORM API学 SQL你本来就会性能有反射开销接近原生 database/sql六、关联查询与 N1 问题GORM 的 Preload对标 JPA EntityGraphtypeOrderstruct{IDint64gorm:primaryKeyUserIDint64User Usergorm:foreignKey:UserID// 关联Items[]OrderItem}// ❌ N1 问题查 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_idu.idWHEREo.id?;没有 N1 的可能——SQL 怎么写就怎么执行。七、数据库迁移对标 Flyway/Liquibasegolang-migrate# 安装goinstall-tagsmysqlgithub.com/golang-migrate/migrate/v4/cmd/migratelatest# 创建迁移文件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))ENGINEInnoDBDEFAULTCHARSETutf8mb4;-- 000001_create_users_table.down.sqlDROPTABLEIFEXISTSusers;# 执行迁移migrate-databasemysql://user:passtcp(127.0.0.1:3306)/demo-pathmigrations up# 回滚migrate-database...-pathmigrations down1八、踩坑清单坑说明解决方案sql.Open不连库Open 只验证参数格式启动时db.Ping()GORM 默认日志太吵开发方便但污染日志生产配logger.WarnN1 查询GORM 默认不加载关联用Preload或 JOINfloat 存金额精度丢失用分(int64)或shopspring/decimal时区问题DSN 没加parseTimetruelocLocal加上软删除影响唯一索引deleted_at 不为空时唯一索引失效联合唯一索引包含 deleted_at批量插入太慢逐条 Createdb.CreateInBatches(users, 100)长事务锁持有时间过长控制事务粒度不要在事务里做 RPC 面试追问GORM 和 sqlx 怎么选开发效率 vs SQL 可控。GORM 适合快速 CRUDsqlx/sqlc 适合性能敏感、SQL 复杂的场景金融核心。可以同项目混用简单 CRUD 用 GORM复杂报表用 sqlx。Go 连接池参数含义MaxOpenConns数据库最大连接数MaxIdleConns保持空闲的连接数减少重连MaxLifetime连接存活时间防止 MySQL 的 wait_timeout 断连。建议 MaxIdle MaxOpen/5Lifetime MySQL wait_timeout。ORM 慢查询怎么查GORM 配置 SlowThreshold生产用 APMSkyWalking、OpenTelemetryMySQL 开 slow_query_log。为什么推荐 sqlcSQL 即代码Code as SQL编译期发现错误列名拼错、类型不匹配无反射性能开销IDE 有 SQL 高亮和补全。缺点是对动态查询如可选条件筛选支持不够好。GORM 的 Save 和 Updates 区别Save更新所有字段包括零值Updates只更新非零值字段或 map 指定字段。生产中少用 Save多用 Updates Select。 一句话总结JPA 党用 GORM 快速出活MyBatis 党用 sqlx/sqlc 精确控制——关键是把 ctx 传到底、连接池配对、事务包好。新项目如果没有历史包袱强烈推荐试试 sqlcSQL 直写 编译期类型安全是 Go 数据库访问的最佳实践方向。建议标签GolangGORMMyBatisJPAJavaMySQL数据库sqlc