GORM 中如何使用原生 SQL?
当 GORM 的链式 API 无法满足复杂查询需求时,需要通过原生 SQL 直接操作数据库。GORM 提供了 Exec 和 Raw 两个核心方法,分别对应"不返回数据"和"返回数据"两种场景。
Exec 与 Raw 的本质区别
这是面试中最常被追问的知识点:Exec 用于执行不返回行的语句(INSERT/UPDATE/DELETE/DDL),Raw 用于执行需要返回结果集的查询(SELECT)。两者都支持参数化占位符,但返回值处理方式不同:
Exec返回的*gorm.DB可通过RowsAffected获取影响行数Raw必须配合Scan()或Rows()才能拿到数据
go// Exec:关心影响了多少行 result := db.Exec("DELETE FROM users WHERE id = ?", 1) fmt.Println(result.RowsAffected) // 影响的行数 // Raw:关心查到了什么数据 var user User db.Raw("SELECT * FROM users WHERE id = ?", 1).Scan(&user)
基础用法
Exec 执行写操作
godb.Exec("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))") db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", "John", "john@example.com") db.Exec("UPDATE users SET name = ? WHERE id = ?", "Jane", 1) db.Exec("DELETE FROM users WHERE id = ?", 1)
Raw 执行查询
go// 单条记录 var user User db.Raw("SELECT * FROM users WHERE id = ?", 1).Scan(&user) // 多条记录 var users []User db.Raw("SELECT * FROM users WHERE age > ?", 18).Scan(&users) // 查询特定字段,用匿名结构体接收 var results []struct { Name string Email string } db.Raw("SELECT name, email FROM users").Scan(&results)
Row 和 Rows 处理单行与多行
Row() 返回 *sql.Row,适合查单条单字段;Rows() 返回 *sql.Rows,适合逐行处理大数据集:
go// Row:查单值 var name string row := db.Raw("SELECT name FROM users WHERE id = ?", 1).Row() row.Scan(&name) // Rows:逐行处理,避免一次性加载到内存 rows, err := db.Raw("SELECT * FROM orders").Rows() if err != nil { panic(err) } defer rows.Close() for rows.Next() { var order Order db.ScanRows(rows, &order) // 逐条处理 }
原生 SQL 与 ORM 混合使用
GORM 允许在链式调用中穿插原生 SQL 片段,这是实际项目中最常见的用法:
go// 原生 SQL 作为子查询 var users []User db.Where("age > (?)", db.Raw("SELECT AVG(age) FROM users")).Find(&users) // 原生 SQL 作为条件 db.Where(db.Raw("DATE(created_at) = ?", "2024-01-01")).Find(&users) // Joins 中使用原生 SQL db.Joins("LEFT JOIN profiles ON users.id = profiles.user_id"). Where("profiles.status = ?", "active"). Find(&users)
混合使用的优势在于:复杂的条件或子查询交给原生 SQL,简单的 CRUD 仍用 ORM,既灵活又不失类型安全。
高级查询场景
复杂聚合
gotype Result struct { UserName string PostCount int } var results []Result db.Raw(` SELECT u.name AS user_name, COUNT(p.id) AS post_count FROM users u LEFT JOIN posts p ON u.id = p.user_id WHERE u.age > ? GROUP BY u.id HAVING COUNT(p.id) > ? ORDER BY post_count DESC LIMIT ? `, 18, 5, 10).Scan(&results)
CTE(公用表表达式)
govar results []struct { UserName string TotalAmount float64 } db.Raw(` WITH user_orders AS ( SELECT user_id, SUM(amount) AS total FROM orders WHERE created_at > ? GROUP BY user_id ) SELECT u.name AS user_name, o.total AS total_amount FROM users u JOIN user_orders o ON u.id = o.user_id `, time.Now().AddDate(0, -1, 0)).Scan(&results)
窗口函数
govar results []struct { UserName string Amount float64 Rank int } db.Raw(` SELECT u.name AS user_name, o.amount, RANK() OVER (PARTITION BY o.user_id ORDER BY o.amount DESC) AS rank FROM orders o JOIN users u ON o.user_id = u.id `).Scan(&results)
事务中使用原生 SQL
在事务回调中使用 tx 而非 db,保证所有操作在同一连接上执行:
goerr := db.Transaction(func(tx *gorm.DB) error { if err := tx.Exec("INSERT INTO users (name) VALUES (?)", "John").Error; err != nil { return err // 自动回滚 } if err := tx.Exec("UPDATE users SET email = ? WHERE name = ?", "john@example.com", "John").Error; err != nil { return err // 自动回滚 } return nil // 提交 })
命名参数
当参数较多时,命名参数比位置占位符更易读、更不容易出错:
go// map 形式 db.NamedExec("INSERT INTO users (name, email) VALUES (:name, :email)", map[string]interface{}{"name": "John", "email": "john@example.com"}) // 结构体形式,需加 db tag type UserParams struct { Name string `db:"name"` Email string `db:"email"` } db.NamedExec("INSERT INTO users (name, email) VALUES (:name, :email)", UserParams{Name: "John", Email: "john@example.com"})
ToSQL 调试技巧
ToSQL 可以在不执行的情况下生成最终 SQL,调试时非常有用:
gosql := db.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Raw("SELECT * FROM users WHERE id = ?", 1) }) fmt.Println(sql) // 输出完整 SQL 语句
安全与最佳实践
参数化查询防注入
go// 危险:字符串拼接,存在 SQL 注入风险 db.Raw(fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", userInput)) // 安全:参数化查询 db.Raw("SELECT * FROM users WHERE name = ?", userInput)
GORM 的 ? 占位符会自动转义参数值,但不会对表名和列名做转义。如果表名或列名来自用户输入,必须自行校验白名单。
Scan 映射结果
为复杂查询定义专用结构体接收结果,比用 map[string]interface{} 更安全、更易维护:
gotype UserSummary struct { Name string Count int } var summaries []UserSummary db.Raw("SELECT name, COUNT(*) AS count FROM users GROUP BY name").Scan(&summaries)
Rows 处理大数据集
查询结果可能很大时,用 Rows() 逐行读取,避免一次性把整张表加载到内存:
gorows, err := db.Raw("SELECT * FROM users").Rows() if err != nil { panic(err) } defer rows.Close() for rows.Next() { var user User if err := db.ScanRows(rows, &user); err != nil { panic(err) } // 处理每条记录 }
注意事项
- SQL 注入:始终使用参数化查询,不要拼接 SQL 字符串。表名和列名不能参数化,必须白名单校验
- 数据库兼容性:不同数据库 SQL 语法有差异(如 MySQL 用
?,PostgreSQL 用$1),GORM 会根据驱动自动处理占位符 - 错误处理:务必检查
db.Error,原生 SQL 不会触发 GORM 的回调(hook)机制 - Hook 丢失:原生 SQL 跳过了 GORM 的 BeforeCreate/AfterCreate 等回调,如果业务依赖这些钩子,不要用原生 SQL
- 可维护性:原生 SQL 越多,项目越难迁移数据库,复杂查询建议用 GORM 的 Clauses 构建
- 事务一致性:事务中的原生 SQL 操作和 ORM 操作共享同一个连接,一致性有保障
追问:什么时候该用原生 SQL?
ORM 无法覆盖的场景:复杂聚合(多表 JOIN + GROUP BY + HAVING)、窗口函数、CTE、数据库特有语法(如 MySQL 的 FORCE INDEX)、需要极致性能的批量操作。原则是"能用 ORM 就用 ORM,必须用原生 SQL 时才用",混合使用是常态。
追问:原生 SQL 会跳过哪些 GORM 特性?
Hook 回调(BeforeCreate/AfterCreate 等)、自动时间戳(CreatedAt/UpdatedAt)、软删除(DeletedAt)过滤。这意味着用 db.Exec("DELETE FROM users WHERE id = 1") 会真删,而不是软删除。如果需要软删除,必须手动加 WHERE deleted_at IS NULL 条件。