GORM中执行原生SQL的两种主要方式详解

13 次阅读

GORM 中执行原生 SQL 是处理复杂查询、数据库特性以及性能敏感场景时非常实用的能力。虽然 GORM 提供了丰富的链式查询和模型操作接口,但面对复杂 JOIN、子查询、窗口函数、数据库特定语法等情况时,直接编写 SQL 往往更加直观。

GORM 执行原生 SQL 主要可以分为两种方式:使用 Raw 执行查询 SQL,以及使用 Exec 执行非查询 SQL。两者的核心区别在于是否需要返回并处理查询结果。

一、使用 Raw 执行原生查询 SQL

Raw 是 GORM 中执行原生 SQL 查询最常见的方式,适用于 SELECT 查询以及需要将查询结果映射到结构体的场景。

基本写法如下:

Go
db.Raw("SELECT * FROM users WHERE age > ?", 18).Scan(&users)

其中:

  • Raw 用于构造原生 SQL;

  • ? 是参数占位符;

  • 18 会作为参数传入;

  • Scan 负责将查询结果映射到 Go 结构体。

例如定义用户模型:

Go
type User struct {
    ID    uint
    Name  string
    Age   int
    Email string
}

查询年龄大于 18 岁的用户:

Go
var users []User

err := db.Raw(
    "SELECT id, name, age, email FROM users WHERE age > ?",
    18,
).Scan(&users).Error

if err != nil {
    log.Println(err)
}

这种方式特别适合 SQL 本身比较复杂的业务。

1. Raw 查询单条记录

如果只需要获取一条记录,可以将结果映射到单个结构体:

Go
var user User

err := db.Raw(
    "SELECT id, name, age, email FROM users WHERE id = ?",
    1001,
).Scan(&user).Error

if err != nil {
    log.Println(err)
}

也可以结合 GORM 的查询接口处理特定场景,但需要注意 Scan 与 First、Find 等方法的语义并不完全相同。

对于明确的原生 SQL 查询,通常直接使用:

Go
db.Raw(sql, args...).Scan(&result)

可读性更好。

2. Raw 查询指定字段

原生 SQL 不要求必须查询整张表。例如:

Go
type UserSummary struct {
    Name  string
    Email string
}

执行:

Go
var list []UserSummary

err := db.Raw(
    "SELECT name, email FROM users WHERE status = ?",
    "active",
).Scan(&list).Error

只要 SQL 查询出的列能够与目标结构体字段正确对应,GORM 就可以完成结果映射。

这种方式非常适合接口返回 DTO、统计结果等场景,可以避免查询无关字段。

3. Raw 执行复杂 JOIN

当查询涉及多个表时,Raw 的优势会更加明显。

例如查询订单及其所属用户:

Go
type OrderInfo struct {
    OrderID   uint
    UserName  string
    Amount    float64
    CreatedAt time.Time
}

可以直接编写:

Go
var orders []OrderInfo

err := db.Raw(`
    SELECT
        o.id AS order_id,
        u.name AS user_name,
        o.amount,
        o.created_at
    FROM orders o
    INNER JOIN users u ON u.id = o.user_id
    WHERE o.status = ?
    ORDER BY o.created_at DESC
`, "paid").Scan(&orders).Error

这里通过 SQL 的 AS 设置别名,使数据库返回的字段与 Go 结构体字段对应起来。

对于复杂报表、聚合统计、多表关联等业务,这种写法通常比大量 GORM 链式调用更加清晰。

二、使用 Exec 执行原生 SQL

Exec 主要用于执行不需要返回查询结果集的 SQL,例如:

  • INSERT

  • UPDATE

  • DELETE

  • 数据库维护语句

  • 部分 DDL 操作

最简单的例子是更新用户状态:

Go
result := db.Exec(
    "UPDATE users SET status = ? WHERE id = ?",
    "disabled",
    1001,
)

if result.Error != nil {
    log.Println(result.Error)
}

Exec 返回的 GORM *gorm.DB 对象中可以获取执行结果。

1. 获取影响行数

通过 RowsAffected 可以知道 SQL 实际影响了多少条记录:

Go
result := db.Exec(
    "UPDATE users SET status = ? WHERE age < ?",
    "inactive",
    18,
)

if result.Error != nil {
    log.Println(result.Error)
    return
}

fmt.Println("影响行数:", result.RowsAffected)

这在批量更新和数据清理场景中非常有用。

例如:

Go
result := db.Exec(
    "DELETE FROM logs WHERE created_at < ?",
    time.Now().AddDate(0, -6, 0),
)

if result.Error != nil {
    return result.Error
}

fmt.Printf("删除了 %d 条日志
", result.RowsAffected)

2. 使用 Exec 执行 INSERT

对于某些特殊数据写入场景,也可以直接使用:

Go
result := db.Exec(
    "INSERT INTO users (name, age, email) VALUES (?, ?, ?)",
    "Tom",
    25,
    "tom@example.com",
)

if result.Error != nil {
    return result.Error
}

如果需要获取自增主键,通常建议结合 GORM 模型创建能力,或者根据具体数据库使用相应的 RETURNING、LAST_INSERT_ID 等机制,而不是简单假设所有数据库的行为完全一致。

三、Raw 与 Exec 的核心区别

两种方式虽然都可以执行原生 SQL,但使用场景明显不同。

对比项目RawExec
主要用途查询执行非查询 SQL
常见 SQLSELECTINSERT、UPDATE、DELETE 等
是否处理结果集是通常不处理结果集
常与 Scan 配合是否
获取影响行数通常不是重点RowsAffected
复杂查询非常适合不适用
数据修改不作为主要方式非常适合

可以简单理解为:

需要读取数据 → Raw + Scan
需要修改数据 → Exec

例如:

Go
// 查询
var users []User
db.Raw("SELECT * FROM users WHERE age > ?", 20).Scan(&users)

// 更新
db.Exec("UPDATE users SET status = ? WHERE age < ?", "inactive", 18)

四、原生 SQL 中正确使用参数绑定

执行 GORM 原生 SQL 时,参数绑定是一个非常重要的问题。

推荐使用:

Go
db.Raw(
    "SELECT * FROM users WHERE name = ? AND age > ?",
    name,
    age,
).Scan(&users)

而不要通过字符串拼接生成 SQL:

Go
sql := "SELECT * FROM users WHERE name = '" + name + "'"
db.Raw(sql).Scan(&users)

字符串拼接不仅会降低代码可维护性,还可能造成 SQL 注入风险。

参数化查询能够让 SQL 结构与用户输入的数据分离,因此应该优先采用:

Go
db.Exec(
    "UPDATE users SET name = ? WHERE id = ?",
    name,
    id,
)

而不是:

Go
db.Exec("UPDATE users SET name = '" + name + "' WHERE id = " + strconv.Itoa(id))

需要动态生成 SQL 时怎么办?

参数绑定主要解决的是数据值,并不能直接把表名、字段名当成普通参数。

例如下面这种写法通常是不成立的:

Go
db.Raw("SELECT * FROM ? WHERE id = ?", tableName, id)

如果表名确实需要动态变化,应当使用白名单限制允许的表名:

Go
allowedTables := map[string]bool{
    "users":  true,
    "orders": true,
}

if !allowedTables[tableName] {
    return errors.New("invalid table name")
}

经过严格校验后,再将表名作为 SQL 结构的一部分处理。

五、Raw 与 Scan 的组合使用

实际项目中,最常见的原生查询模式就是 Raw + Scan。

例如统计不同状态下的订单数量:

Go
type OrderStats struct {
    Status string
    Count  int64
}

var stats []OrderStats

err := db.Raw(`
    SELECT status, COUNT(*) AS count
    FROM orders
    GROUP BY status
    ORDER BY count DESC
`).Scan(&stats).Error

if err != nil {
    return err
}

这种查询返回的数据并不一定对应某个具体数据库表,因此没必要强行定义一个与数据库表一一对应的 Model。

单独定义统计结果结构体通常更加合理:

Go
type OrderStats struct {
    Status string
    Count  int64
}

六、使用命名参数提高 SQL 可读性

当 SQL 参数较多时,可以使用命名参数,让 SQL 更容易理解。

例如:

Go
var users []User

err := db.Raw(`
    SELECT *
    FROM users
    WHERE age >= @minAge
      AND age <= @maxAge
      AND status = @status
`,
    sql.Named("minAge", 18),
    sql.Named("maxAge", 60),
    sql.Named("status", "active"),
).Scan(&users).Error

相比多个 ? 占位符,命名参数能够更加明确每个值对应的业务含义。

尤其是查询条件较多时,这种方式能够减少参数顺序错误。

七、在事务中执行原生 SQL

Raw 和 Exec 同样可以放在事务中执行。

例如需要完成多个数据修改:

Go
err := db.Transaction(func(tx *gorm.DB) error {
    result := tx.Exec(
        "UPDATE accounts SET balance = balance - ? WHERE id = ?",
        100,
        1,
    )

    if result.Error != nil {
        return result.Error
    }

    result = tx.Exec(
        "UPDATE accounts SET balance = balance + ? WHERE id = ?",
        100,
        2,
    )

    if result.Error != nil {
        return result.Error
    }

    return nil
})

如果事务函数返回错误,GORM 会回滚事务;正常返回 nil 时则提交事务。

这种方式适合转账、库存变更、订单状态流转等必须保证多个 SQL 操作一致性的业务。

需要特别注意,事务中的后续 SQL 必须使用 tx,而不是重新使用原来的 db:

Go
tx.Exec(...)

而不是:

Go
db.Exec(...)

否则可能导致操作脱离当前事务。

八、Raw 查询与传统 GORM 链式查询如何选择

原生 SQL 并不意味着应该完全取代 GORM 的 ORM 能力。

简单查询通常使用 GORM 链式 API 更合适:

Go
var users []User

err := db.
    Where("age > ?", 18).
    Where("status = ?", "active").
    Find(&users).Error

而复杂 SQL 可以直接使用:

Go
err := db.Raw(`
    SELECT u.*, COUNT(o.id) AS order_count
    FROM users u
    LEFT JOIN orders o ON o.user_id = u.id
    GROUP BY u.id
`).Scan(&users).Error

可以按照以下思路选择:

简单增删改查
    ↓
优先使用 GORM ORM API

复杂 JOIN / 聚合 / 子查询 / 窗口函数
    ↓
考虑 Raw

非查询型原生 SQL
    ↓
使用 Exec

需要事务保证多个 SQL 一致性
    ↓
使用 Transaction + tx.Raw / tx.Exec

九、执行原生 SQL 时的性能考虑

原生 SQL 并不意味着一定比 GORM 链式查询快。

如果最终生成的 SQL 完全相同,两者的数据库执行计划通常也不会因为调用方式不同而产生本质差异。性能差异更多来自 SQL 本身、索引、数据量、连接池以及数据库执行计划。

因此优化时不要简单地把“GORM”与“原生 SQL”进行二元对立。

例如:

SQL
SELECT *
FROM orders
WHERE user_id = ?
ORDER BY created_at DESC;

如果 user_id 和 created_at 的查询模式稳定,可以结合实际数据量和数据库执行计划考虑合适的联合索引。

同时,原生 SQL 也不应该为了“少写几行代码”而查询大量不需要的字段:

SQL
SELECT *

在接口只需要几个字段时,可以改成:

SQL
SELECT id, name, status
FROM users
WHERE ...

减少数据库、网络和应用层的数据传输。

十、常见错误与排查方法

1. Raw 执行后没有读取结果

下面的代码只是构造查询:

Go
db.Raw("SELECT * FROM users WHERE id = ?", id)

如果需要获取结果,还需要使用 Scan 等方式处理:

Go
db.Raw(
    "SELECT * FROM users WHERE id = ?",
    id,
).Scan(&user)

2. 忽略 Error

不要只调用:

Go
db.Exec(sql)

而忽略执行结果。

推荐:

Go
result := db.Exec(sql)

if result.Error != nil {
    return result.Error
}

对于 Raw:

Go
err := db.Raw(sql).Scan(&users).Error

if err != nil {
    return err
}

3. SQL 字段与结构体字段无法正确对应

例如 SQL 返回:

SQL
SELECT
    id AS user_id,
    name AS user_name
FROM users;

那么结构体可以设计成:

Go
type UserResult struct {
    UserID   uint
    UserName string
}

如果字段命名差异较大,可以通过 GORM 标签进一步明确映射关系:

Go
type UserResult struct {
    ID   uint   `gorm:"column:user_id"`
    Name string `gorm:"column:user_name"`
}

4. 把查询 SQL 和修改 SQL 混用

查询数据时优先考虑:

Go
db.Raw(sql, args...).Scan(&result)

数据修改时使用:

Go
db.Exec(sql, args...)

明确区分两种操作,有助于提升代码可读性。

十一、项目中的推荐封装方式

如果一个项目大量使用原生 SQL,可以将数据库访问逻辑封装在 Repository 层。

例如:

Go
type UserRepository struct {
    db *gorm.DB
}

func (r *UserRepository) FindActiveUsers() ([]User, error) {
    var users []User

    err := r.db.Raw(`
        SELECT id, name, age, email
        FROM users
        WHERE status = ?
        ORDER BY id DESC
    `, "active").Scan(&users).Error

    return users, err
}

业务层只需要调用:

Go
users, err := userRepo.FindActiveUsers()
if err != nil {
    return err
}

这样可以避免大量 SQL 散落在 Service、Controller 等业务代码中。

对于复杂项目,还可以进一步统一 SQL 常量、错误处理、事务管理和分页逻辑,让原生 SQL 更容易维护和测试。

十二、总结

GORM 执行原生 SQL 的两种主要方式分别是 Raw 和 Exec。

Raw 更适合数据查询,通常与 Scan 配合,将原生 SQL 的结果映射到结构体或结果集合:

Go
db.Raw(
    "SELECT * FROM users WHERE age > ?",
    18,
).Scan(&users)

Exec 更适合执行不需要返回结果集的 SQL,并且可以通过 RowsAffected 获取影响行数:

Go
result := db.Exec(
    "UPDATE users SET status = ? WHERE id = ?",
    "active",
    1001,
)

实际开发中,应根据 SQL 类型和业务复杂度选择调用方式。简单的增删改查可以继续使用 GORM ORM API,而复杂 JOIN、聚合查询、子查询以及数据库特有语法则可以通过 Raw 获得更直接的 SQL 控制能力。无论使用哪种方式,都应优先采用参数绑定、正确处理错误,并在涉及多步数据修改时结合事务,从而兼顾安全性、可维护性与数据库执行效率。