GORM 使用教程:从入门到实战

在 Go 项目中,如果你需要操作 MySQL、PostgreSQL、SQLite、SQL Server 等关系型数据库,除了直接使用标准库 database/sql,还可以使用 ORM 框架来提高开发效率。

GORM 是 Go 生态中非常流行的 ORM 库,它提供了:

  • 模型映射
  • 自动迁移
  • CRUD 操作
  • 关联关系
  • 事务
  • 钩子函数
  • 软删除
  • 预加载
  • 链式查询
  • Context 支持

本文以 GORM v2 为基础,从安装、连接数据库、定义模型、CRUD、关联查询到事务和最佳实践,带你完整入门 GORM。


一、GORM 是什么?

ORM 的全称是 Object Relational Mapping,也就是对象关系映射。

简单来说,ORM 可以让我们用 Go 结构体来表示数据库表,用结构体对象来表示数据库记录。

例如一张 users 表:

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    email VARCHAR(100),
    age INT
);

在 GORM 中可以定义为:

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

之后就可以通过 Go 代码完成查询、插入、更新和删除,而不需要手写大量 SQL。


二、安装 GORM

创建 Go 项目:

mkdir gorm-demo
cd gorm-demo
go mod init gorm-demo

安装 GORM 核心库:

go get -u gorm.io/gorm

根据你使用的数据库安装对应驱动。

SQLite

SQLite 适合本地学习和小型项目:

go get -u gorm.io/driver/sqlite

MySQL

go get -u gorm.io/driver/mysql

PostgreSQL

go get -u gorm.io/driver/postgres

三、连接数据库

1. 连接 SQLite

SQLite 不需要单独启动数据库服务,最适合入门演示。

package main

import (
    "log"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

func main() {
    db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    if err != nil {
        log.Fatal("连接数据库失败:", err)
    }

    _ = db
}

运行后,当前目录会生成一个 test.db 文件。

2. 连接 MySQL

package main

import (
    "log"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

func main() {
    dsn := "root:123456@tcp(127.0.0.1:3306)/gorm_demo?charset=utf8mb4&parseTime=True&loc=Local"

    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("连接 MySQL 失败:", err)
    }

    _ = db
}

MySQL DSN 常见参数说明:

参数说明
root:123456用户名和密码
tcp(127.0.0.1:3306)数据库地址和端口
gorm_demo数据库名称
charset=utf8mb4支持完整 Unicode,包括 emoji
parseTime=True自动解析时间类型
loc=Local使用本地时区

3. 连接 PostgreSQL

package main

import (
    "log"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func main() {
    dsn := "host=localhost user=postgres password=123456 dbname=gorm_demo port=5432 sslmode=disable TimeZone=Asia/Shanghai"

    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("连接 PostgreSQL 失败:", err)
    }

    _ = db
}

四、定义模型

GORM 使用 Go 结构体表示数据库表。

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

默认情况下:

  • 结构体 User 会映射到表 users
  • 字段 Name 会映射到列 name
  • 字段 CreatedAtUpdatedAt 会自动维护时间
  • 字段 ID 默认会作为主键

使用 gorm.Model

GORM 提供了一个内置结构体 gorm.Model

type Model struct {
    ID        uint           `gorm:"primaryKey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

你可以直接嵌入它:

import "gorm.io/gorm"

type User struct {
    gorm.Model
    Name  string
    Email string
    Age   int
}

这样 User 表中会自动拥有:

  • id
  • created_at
  • updated_at
  • deleted_at

其中 deleted_at 用于软删除。


五、常用字段标签

GORM 通过结构体标签控制字段映射。

type User struct {
    ID       uint   `gorm:"primaryKey"`
    Name     string `gorm:"size:100;not null"`
    Email    string `gorm:"size:255;uniqueIndex"`
    Age      int    `gorm:"default:18"`
    Password string `gorm:"column:password_hash"`
}

常见标签:

标签作用
primaryKey主键
column:name指定列名
type:varchar(100)指定数据库类型
size:100指定字段长度
not null非空
default:18默认值
uniqueIndex唯一索引
index普通索引
autoIncrement自增
-忽略该字段

例如忽略字段:

type User struct {
    ID       uint
    Name     string
    TempData string `gorm:"-"`
}

TempData 不会映射到数据库。


六、自动迁移 AutoMigrate

AutoMigrate 可以根据模型自动创建或更新表结构。

err := db.AutoMigrate(&User{})
if err != nil {
    log.Fatal(err)
}

完整示例:

package main

import (
    "log"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Name  string `gorm:"size:100;not null"`
    Email string `gorm:"size:255;uniqueIndex"`
    Age   int
}

func main() {
    db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    if err != nil {
        log.Fatal(err)
    }

    if err := db.AutoMigrate(&User{}); err != nil {
        log.Fatal(err)
    }
}

需要注意:

  • AutoMigrate 会创建不存在的表、字段、索引和约束
  • AutoMigrate 会尽量安全地更新字段结构
  • 为了保护数据,AutoMigrate 不会自动删除数据库中已经存在但模型里删除了的字段
  • 生产环境中建议使用专门的数据库迁移工具管理复杂变更

七、新增数据 Create

1. 插入一条记录

user := User{
    Name:  "张三",
    Email: "zhangsan@example.com",
    Age:   20,
}

result := db.Create(&user)
if result.Error != nil {
    log.Fatal(result.Error)
}

fmt.Println("新用户 ID:", user.ID)
fmt.Println("影响行数:", result.RowsAffected)

GORM 插入成功后,会把数据库生成的主键回填到 user.ID

2. 批量插入

users := []User{
    {Name: "张三", Email: "zhangsan@example.com", Age: 20},
    {Name: "李四", Email: "lisi@example.com", Age: 22},
    {Name: "王五", Email: "wangwu@example.com", Age: 25},
}

result := db.Create(&users)
if result.Error != nil {
    log.Fatal(result.Error)
}

也可以指定批次大小:

db.CreateInBatches(users, 100)

八、查询数据 Query

1. 根据主键查询

var user User

result := db.First(&user, 1)
if result.Error != nil {
    log.Fatal(result.Error)
}

fmt.Println(user.Name)

First 会按照主键升序取第一条记录。

常用查询方法:

方法说明
First查询第一条记录,按照主键升序
Last查询最后一条记录,按照主键降序
Take查询一条记录,不指定排序
Find查询多条记录

2. 处理记录不存在

var user User

err := db.First(&user, "email = ?", "notfound@example.com").Error
if errors.Is(err, gorm.ErrRecordNotFound) {
    fmt.Println("用户不存在")
} else if err != nil {
    log.Fatal(err)
}

需要导入:

import (
    "errors"
    "gorm.io/gorm"
)

3. 条件查询

var users []User

db.Where("age >= ?", 18).Find(&users)

多个条件:

db.Where("age >= ? AND name LIKE ?", 18, "%张%").Find(&users)

结构体条件:

db.Where(&User{Name: "张三"}).Find(&users)

Map 条件:

db.Where(map[string]any{
    "name": "张三",
    "age":  20,
}).Find(&users)

4. 排序和分页

var users []User

db.Where("age >= ?", 18).
    Order("created_at DESC").
    Limit(10).
    Offset(20).
    Find(&users)

这通常用于分页:

func ListUsers(db *gorm.DB, page, pageSize int) ([]User, error) {
    if page < 1 {
        page = 1
    }
    if pageSize <= 0 {
        pageSize = 10
    }

    var users []User
    offset := (page - 1) * pageSize

    err := db.Order("id DESC").
        Limit(pageSize).
        Offset(offset).
        Find(&users).Error

    return users, err
}

5. 只查询部分字段

var users []User

db.Select("id", "name", "email").Find(&users)

九、更新数据 Update

1. 更新单个字段

var user User

db.First(&user, 1)

result := db.Model(&user).Update("age", 21)
if result.Error != nil {
    log.Fatal(result.Error)
}

2. 更新多个字段

使用结构体:

db.Model(&user).Updates(User{
    Name: "张三三",
    Age:  22,
})

使用 map

db.Model(&user).Updates(map[string]any{
    "name": "张三三",
    "age":  22,
})

3. 结构体更新的零值问题

使用结构体更新时,GORM 默认只更新非零值字段。

db.Model(&user).Updates(User{
    Name: "",
    Age:  0,
})

上面代码中的 NameAge 都是零值,默认不会被更新。

如果确实要更新零值,可以使用 map

db.Model(&user).Updates(map[string]any{
    "name": "",
    "age":  0,
})

也可以使用 Select 指定字段:

db.Model(&user).
    Select("name", "age").
    Updates(User{Name: "", Age: 0})

十、删除数据 Delete

1. 根据主键删除

db.Delete(&User{}, 1)

也可以先查询再删除:

var user User

db.First(&user, 1)
db.Delete(&user)

2. 条件删除

db.Where("age < ?", 18).Delete(&User{})

3. 软删除

如果模型中包含 gorm.DeletedAt 字段,或者嵌入了 gorm.Model,GORM 默认会执行软删除。

type User struct {
    gorm.Model
    Name string
}

执行:

db.Delete(&user)

实际并不会立刻删除数据库记录,而是更新 deleted_at 字段。

普通查询会自动忽略软删除数据:

db.Find(&users)

如果要查询包含软删除的数据:

db.Unscoped().Find(&users)

如果要物理删除:

db.Unscoped().Delete(&user)

4. 防止全表删除

GORM 默认会阻止没有条件的批量删除:

db.Delete(&User{})

这类操作通常会返回错误,避免误删全表。

如果你确实要删除全部数据,应该显式写出条件:

db.Where("1 = 1").Delete(&User{})

十一、关联关系

实际项目中,表与表之间经常存在关联关系。

GORM 支持:

  • Belongs To
  • Has One
  • Has Many
  • Many To Many

1. Belongs To:用户属于公司

type Company struct {
    ID   uint
    Name string
}

type User struct {
    ID        uint
    Name      string
    CompanyID uint
    Company   Company
}

这里 User 中的 CompanyID 是外键,表示用户属于某家公司。

可以添加约束:

type User struct {
    ID        uint
    Name      string
    CompanyID uint
    Company   Company `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
}

2. Has One:用户有一个资料页

type User struct {
    ID      uint
    Name    string
    Profile Profile
}

type Profile struct {
    ID     uint
    UserID uint
    Bio    string
}

3. Has Many:用户有多篇文章

type User struct {
    ID       uint
    Name     string
    Articles []Article
}

type Article struct {
    ID     uint
    UserID uint
    Title  string
    Body   string
}

4. Many To Many:用户拥有多个角色

type User struct {
    ID    uint
    Name  string
    Roles []Role `gorm:"many2many:user_roles;"`
}

type Role struct {
    ID   uint
    Name string
}

many2many:user_roles; 表示中间表名为 user_roles

迁移时:

db.AutoMigrate(&User{}, &Role{})

GORM 会自动创建用户表、角色表以及中间表。


十二、预加载 Preload

默认情况下,GORM 查询主表时不会自动查询关联数据。

例如:

var user User

db.First(&user, 1)

此时 user.Articles 通常是空的。

如果要同时查询关联数据,可以使用 Preload

1. 预加载一层关联

var user User

err := db.Preload("Articles").First(&user, 1).Error
if err != nil {
    log.Fatal(err)
}

2. 预加载多个关联

var users []User

db.Preload("Articles").
    Preload("Profile").
    Preload("Roles").
    Find(&users)

3. 预加载时添加条件

var users []User

db.Preload("Articles", "status = ?", "published").Find(&users)

4. 嵌套预加载

var users []User

db.Preload("Articles.Comments").Find(&users)

这表示查询用户时,同时加载文章和文章下的评论。


十三、事务 Transaction

涉及多步写入时,应该使用事务。

例如:创建订单时,需要同时创建订单和扣减库存。如果其中一步失败,所有操作都应该回滚。

1. 使用 db.Transaction

err := db.Transaction(func(tx *gorm.DB) error {
    order := Order{
        UserID:  1,
        Amount: 19900,
    }

    if err := tx.Create(&order).Error; err != nil {
        return err
    }

    if err := tx.Model(&Product{}).
        Where("id = ? AND stock > 0", 1).
        Update("stock", gorm.Expr("stock - ?", 1)).Error; err != nil {
        return err
    }

    return nil
})

if err != nil {
    log.Fatal("事务执行失败:", err)
}

Transaction 回调中:

  • 返回 nil:提交事务
  • 返回 error:回滚事务

2. 手动事务

tx := db.Begin()
if tx.Error != nil {
    log.Fatal(tx.Error)
}

if err := tx.Create(&user).Error; err != nil {
    tx.Rollback()
    log.Fatal(err)
}

if err := tx.Create(&profile).Error; err != nil {
    tx.Rollback()
    log.Fatal(err)
}

if err := tx.Commit().Error; err != nil {
    log.Fatal(err)
}

3. 保存点 SavePoint

tx := db.Begin()

tx.Create(&user1)
tx.SavePoint("sp1")
tx.Create(&user2)
tx.RollbackTo("sp1")
tx.Commit()

RollbackTo("sp1") 会回滚到保存点,但不会回滚整个事务。


十四、Context 支持

在 Web 服务中,数据库操作通常需要支持超时和取消。

GORM 支持通过 WithContext 传入 context.Context

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

var users []User
err := db.WithContext(ctx).
    Where("age >= ?", 18).
    Find(&users).Error

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

在 HTTP 服务中,通常使用请求自带的 context:

func GetUsers(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    var users []User
    if err := db.WithContext(ctx).Find(&users).Error; err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // 返回 JSON ...
}

十五、钩子 Hooks

GORM 支持在创建、查询、更新、删除前后执行钩子函数。

常见钩子:

钩子执行时机
BeforeCreate创建前
AfterCreate创建后
BeforeUpdate更新前
AfterUpdate更新后
BeforeDelete删除前
AfterDelete删除后
AfterFind查询后

例如创建用户前自动设置 UUID:

func (u *User) BeforeCreate(tx *gorm.DB) error {
    if u.UUID == "" {
        u.UUID = uuid.NewString()
    }
    return nil
}

钩子中也可以读取 context:

func (u *User) BeforeCreate(tx *gorm.DB) error {
    ctx := tx.Statement.Context
    _ = ctx

    return nil
}

注意:钩子适合处理和模型强相关的逻辑,不建议在钩子中写复杂业务流程。


十六、Scopes:封装可复用查询条件

Scopes 可以把常用查询条件封装成函数。

func ActiveUsers(db *gorm.DB) *gorm.DB {
    return db.Where("status = ?", "active")
}

func MinAge(age int) func(*gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("age >= ?", age)
    }
}

使用:

var users []User

err := db.Scopes(ActiveUsers, MinAge(18)).Find(&users).Error
if err != nil {
    log.Fatal(err)
}

Scopes 很适合封装:

  • 分页
  • 多租户条件
  • 状态过滤
  • 权限过滤
  • 公共排序规则

分页也可以封装为 Scope:

func Paginate(page, pageSize int) func(*gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        if page < 1 {
            page = 1
        }
        if pageSize <= 0 {
            pageSize = 10
        }

        offset := (page - 1) * pageSize
        return db.Offset(offset).Limit(pageSize)
    }
}

使用:

db.Scopes(Paginate(2, 10)).Find(&users)

十七、日志和配置

1. 开启 SQL 日志

调试时可以开启 GORM 日志:

import (
    "log"
    "os"
    "time"

    "gorm.io/gorm/logger"
)

newLogger := logger.New(
    log.New(os.Stdout, "\r\n", log.LstdFlags),
    logger.Config{
        SlowThreshold:             time.Second,
        LogLevel:                  logger.Info,
        IgnoreRecordNotFoundError: true,
        Colorful:                  true,
    },
)

db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{
    Logger: newLogger,
})

2. 跳过默认事务

GORM 默认会在写操作时使用事务,以保证数据一致性。

如果你非常确定业务场景不需要默认事务,可以开启:

db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{
    SkipDefaultTransaction: true,
})

这样可能提升写入性能,但会降低默认安全性,生产环境需要谨慎开启。

3. 开启预编译语句缓存

db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{
    PrepareStmt: true,
})

也可以按 Session 开启:

tx := db.Session(&gorm.Session{PrepareStmt: true})
tx.Find(&users)

十八、GORM 泛型 API 简介

较新的 GORM 版本提供了泛型 API,例如:

ctx := context.Background()

err := gorm.G[User](db).Create(ctx, &User{Name: "Alice"})
if err != nil {
    log.Fatal(err)
}

user, err := gorm.G[User](db).
    Where("name = ?", "Alice").
    First(ctx)
if err != nil {
    log.Fatal(err)
}

err = gorm.G[User](db).
    Where("id = ?", user.ID).
    Update(ctx, "age", 18)
if err != nil {
    log.Fatal(err)
}

泛型 API 的特点是:

  • 类型更明确
  • 方法通常显式接收 context.Context
  • 返回值更贴近普通 Go 函数风格

不过目前很多项目仍然大量使用传统链式 API。入门阶段建议先掌握传统 API,再根据团队习惯选择是否使用泛型 API。


十九、完整示例:用户管理

下面是一个可以直接运行的 SQLite 示例。

package main

import (
    "errors"
    "fmt"
    "log"

    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Name  string `gorm:"size:100;not null"`
    Email string `gorm:"size:255;uniqueIndex"`
    Age   int
}

func main() {
    db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    if err != nil {
        log.Fatal("连接数据库失败:", err)
    }

    if err := db.AutoMigrate(&User{}); err != nil {
        log.Fatal("迁移失败:", err)
    }

    user := User{
        Name:  "张三",
        Email: "zhangsan@example.com",
        Age:   20,
    }

    if err := db.Create(&user).Error; err != nil {
        log.Fatal("创建用户失败:", err)
    }
    fmt.Println("创建用户成功,ID:", user.ID)

    var found User
    err = db.First(&found, "email = ?", "zhangsan@example.com").Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        fmt.Println("用户不存在")
        return
    }
    if err != nil {
        log.Fatal("查询失败:", err)
    }
    fmt.Println("查询用户:", found.Name, found.Age)

    if err := db.Model(&found).Update("age", 21).Error; err != nil {
        log.Fatal("更新失败:", err)
    }

    var users []User
    if err := db.Where("age >= ?", 18).Order("id DESC").Find(&users).Error; err != nil {
        log.Fatal("列表查询失败:", err)
    }
    fmt.Println("用户数量:", len(users))

    if err := db.Delete(&found).Error; err != nil {
        log.Fatal("删除失败:", err)
    }
    fmt.Println("删除用户成功")
}

运行:

go run main.go

二十、项目中推荐的分层方式

在真实项目中,不建议到处直接使用全局 db 操作数据库。更常见的方式是封装 Repository。

目录示例:

project/
├── main.go
├── internal/
│   ├── model/
│   │   └── user.go
│   ├── repository/
│   │   └── user_repository.go
│   └── service/
│       └── user_service.go

model/user.go

package model

import "gorm.io/gorm"

type User struct {
    gorm.Model
    Name  string
    Email string `gorm:"uniqueIndex"`
    Age   int
}

repository/user_repository.go

package repository

import (
    "context"

    "gorm.io/gorm"

    "project/internal/model"
)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, user *model.User) error {
    return r.db.WithContext(ctx).Create(user).Error
}

func (r *UserRepository) FindByID(ctx context.Context, id uint) (*model.User, error) {
    var user model.User
    if err := r.db.WithContext(ctx).First(&user, id).Error; err != nil {
        return nil, err
    }
    return &user, nil
}

func (r *UserRepository) List(ctx context.Context, page, pageSize int) ([]model.User, error) {
    var users []model.User

    if page < 1 {
        page = 1
    }
    if pageSize <= 0 {
        pageSize = 10
    }

    offset := (page - 1) * pageSize

    err := r.db.WithContext(ctx).
        Order("id DESC").
        Offset(offset).
        Limit(pageSize).
        Find(&users).Error

    return users, err
}

这种写法的好处是:

  • 数据库访问集中管理
  • 业务层不直接依赖复杂 SQL 细节
  • 方便单元测试
  • 方便后续替换数据库实现

二十一、常见坑和最佳实践

1. 永远检查 Error

不要只写:

db.Create(&user)

推荐:

if err := db.Create(&user).Error; err != nil {
    return err
}

如果需要影响行数:

result := db.Create(&user)
if result.Error != nil {
    return result.Error
}
fmt.Println(result.RowsAffected)

2. 更新零值时注意 Updates 行为

结构体更新默认忽略零值。

db.Model(&user).Updates(User{Age: 0})

如果要把年龄更新为 0,用 mapSelect

db.Model(&user).Updates(map[string]any{"age": 0})

3. 谨慎使用 Save

Save 会保存所有字段,容易在不注意时覆盖数据。部分更新更推荐使用:

db.Model(&user).Updates(map[string]any{
    "name": user.Name,
    "age":  user.Age,
})

4. 不要忽略 Context

在 Web 项目中,数据库操作应该使用请求的 context

db.WithContext(r.Context()).Find(&users)

这样请求取消或超时时,数据库操作也有机会被取消。

5. 生产环境谨慎使用 AutoMigrate

AutoMigrate 很适合开发阶段快速迭代,但生产环境中,复杂表结构变更最好使用迁移工具,并经过审核和回滚设计。

6. 避免 N+1 查询

如果你在循环里查询关联数据:

for _, user := range users {
    db.Where("user_id = ?", user.ID).Find(&user.Articles)
}

就可能产生 N+1 查询问题。

推荐使用 Preload

db.Preload("Articles").Find(&users)

7. 明确选择字段

接口列表通常不需要查询所有字段,可以使用 Select 降低数据传输量。

db.Select("id", "name", "email").Find(&users)

8. 不要拼接用户输入到 SQL 字符串

错误示例:

db.Where("name = " + name).Find(&users)

推荐使用参数绑定:

db.Where("name = ?", name).Find(&users)

这样可以减少 SQL 注入风险。


二十二、总结

本文介绍了 GORM 的核心用法:

  • 安装 GORM 和数据库驱动
  • 连接 SQLite、MySQL、PostgreSQL
  • 定义模型和字段标签
  • 使用 AutoMigrate 自动迁移
  • 使用 CreateFirstFindUpdatesDelete 完成 CRUD
  • 使用关联关系和 Preload 查询关联数据
  • 使用事务保证多步写入一致性
  • 使用 WithContext 支持超时和取消
  • 使用 Hooks 和 Scopes 封装通用逻辑
  • 了解日志、性能配置和常见坑

如果你是 Go 初学者,建议先掌握传统链式 API;如果你已经熟悉 Go 泛型,也可以进一步尝试 GORM 的泛型 API。

GORM 的优势是开发效率高、功能完整、生态成熟;但在复杂查询、高性能写入和严格数据库变更场景下,仍然需要理解 SQL 本身,不能完全依赖 ORM。


参考资料

曼波