Skip to content
Go
数据库与缓存

数据库与缓存

go-zero 在数据访问层提供 sqlx(数据库连接封装)和 sqlc(带 Redis 缓存的数据库连接),通过 goctl model 一键生成 CRUD 代码和缓存管理逻辑。


架构层次

┌─────────────────────────────┐
│  Logic 层(业务逻辑)        │
│  调用 Repository 封装的方法  │
├─────────────────────────────┤
│  Model 层(goctl 生成)      │
│  单表 CRUD + 自动缓存管理    │
├─────────────────────────────┤
│  sqlc / sqlx                │
│  数据库连接 + Redis 缓存     │
├─────────────────────────────┤
│  MySQL / Redis              │
└─────────────────────────────┘

sqlx:数据库连接

初始化

import "github.com/zeromicro/go-zero/core/stores/sqlx"

// ServiceContext 中初始化
type ServiceContext struct {
    Config config.Config
    DB     sqlx.SqlConn  // 纯数据库连接,无缓存
}

func NewServiceContext(c config.Config) *ServiceContext {
    return &ServiceContext{
        Config: c,
        DB:     sqlx.NewMysql(c.DataSource),
    }
}

配置

DataSource: root:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=true

使用示例

// 查询单条
var user User
err := s.DB.QueryRowCtx(ctx, &user, "SELECT * FROM user WHERE id = ?", id)

// 查询多条
var users []User
err := s.DB.QueryRowsCtx(ctx, &users, "SELECT * FROM user WHERE status = ?", status)

// 执行写操作
result, err := s.DB.ExecCtx(ctx, "UPDATE user SET name = ? WHERE id = ?", name, id)

// 事务
err := s.DB.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
    if _, err := session.ExecCtx(ctx, "INSERT INTO ...", ...); err != nil {
        return err
    }
    if _, err := session.ExecCtx(ctx, "UPDATE ...", ...); err != nil {
        return err
    }
    return nil
})

sqlc:带缓存的数据库访问(核心)

初始化

type ServiceContext struct {
    Config    config.Config
    UserModel model.UserModel
}

func NewServiceContext(c config.Config) *ServiceContext {
    return &ServiceContext{
        Config:    c,
        UserModel: model.NewUserModel(
            sqlx.NewMysql(c.DataSource),
            c.Cache,
        ),
    }
}

配置

DataSource: root:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=true
Cache:
  - Host: 127.0.0.1:6379
    Pass: ""
    Type: node

生成 Model

# 从 DDL 文件生成
goctl model mysql ddl -c -src user.sql -dir .

# 从数据库直接生成
goctl model mysql datasource \
    -url "root:password@tcp(127.0.0.1:3306)/dbname" \
    -table "user" \
    -c \
    -dir ./model

生成的文件

model/
├── usermodel_gen.go    # 自动生成:基础 CRUD + 缓存
├── usermodel.go        # 自定义扩展:复杂查询、批量操作
└── vars.go             # 常量定义

💡 goctl 再次生成时只覆盖 usermodel_gen.go,你的 usermodel.go 不受影响。


sqlc 缓存机制(重要)

读操作:Cache Aside 模式

FindOne(ctx, id)
  → 查 Redis 缓存 (key: cache:user:id:123)
  → 命中 → 直接返回
  → 未命中 → 查 MySQL
        → 回填 Redis 缓存
        → 返回

写操作:删除缓存

Insert(ctx, data)
  → 写入 MySQL
  → 删除 Redis 缓存 (key: cache:user:id:123)

Update(ctx, data)
  → 更新 MySQL
  → 删除 Redis 缓存

Delete(ctx, id)
  → 删除 MySQL
  → 删除 Redis 缓存

🔬 为什么删缓存而不是更新缓存? 更新缓存存在并发写覆盖问题:A 更新 DB → B 更新 DB → B 更新缓存 → A 更新缓存(旧数据覆盖了新数据)。删除缓存是最安全的做法。

索引缓存

对于按唯一索引查询,sqlc 提供两层缓存:

FindOneByEmail(ctx, email)
  → 查 Redis: cache:user:email:alice@example.com → 拿到 id=123
  → 查 Redis: cache:user:id:123 → 拿到完整数据
  → 返回

🚨 sqlc 仅支持主键和唯一索引的单条记录缓存,不支持批量查询缓存。批量查询请用 sqlx.SqlConn 直接查 DB。


缓存配置选项

过期时间

// 自定义缓存过期时间(默认 7 天)
model.NewUserModel(conn, c, model.WithExpiry(time.Hour))

// 空值缓存过期时间(防缓存穿透,默认 1 分钟)
model.NewUserModel(conn, c, model.WithNotFoundExpiry(time.Second*5))

不读缓存/不删缓存

// 按主键查询——绕过缓存直接查库
var user User
err := db.QueryRowNoCacheCtx(ctx, &user, "SELECT * FROM user WHERE id = ?", id)

// 按索引查询——绕过缓存
err := db.QueryRowNoCacheCtx(ctx, &user, "SELECT * FROM user WHERE email = ?", email)

// 执行写入——不清除缓存(慎用)
result, err := db.ExecNoCacheCtx(ctx, "DELETE FROM user WHERE id = ?", id)

缓存一致性原理与取舍

读写并发问题

时间线:
T1: 线程A 删除缓存(准备更新DB)
T2: 线程B 读缓存(miss)→ 读DB(旧值)→ 回填缓存(旧值)
T3: 线程A 更新DB为新值
结果:缓存中是旧值,DB中是(可能)新值

go-zero 的选择:接受极低概率的不一致

  • DB 写操作(行锁)是慢操作
  • 读写并发的窗口期极短
  • 真正解决需要 2PC/Paxos,过于复杂
  • 在绝大多数业务场景下,短暂的不一致可以接受

💡 如果业务对强一致性有要求:先更新 DB → 再删除缓存,并设置合理过期时间作为兜底。

缓存穿透防护

sqlc 默认对不存在的数据也缓存(NotFoundExpiry 默认 1 分钟),防止频繁查询不存在的数据穿透到 DB。

缓存雪崩防护

  • 缓存过期时间设置为基础值 + 随机偏移
  • go-zero 使用 Redis 连接池,内部有重试机制

自定义 Model 扩展

usermodel_gen.go 提供了基本的 CRUD,复杂的查询在 usermodel.go 中扩展:

// usermodel.go
package model

import (
    "context"
    "fmt"
    "github.com/zeromicro/go-zero/core/stores/sqlx"
)

// 自定义接口扩展
type UserModel interface {
    userModel                  // 嵌入自动生成的接口
    FindByName(ctx context.Context, name string) (*User, error)
    FindByIds(ctx context.Context, ids []int64) ([]*User, error)
}

type defaultUserModel struct {
    *defaultUserModel          // 嵌入自动生成的实现
}

// 按名称模糊查询(不缓存)
func (m *defaultUserModel) FindByName(ctx context.Context, name string) (*User, error) {
    var user User
    query := fmt.Sprintf("SELECT %s FROM %s WHERE name LIKE ? LIMIT 1",
        userRows, m.table)
    err := m.CachedConn.QueryRowNoCacheCtx(ctx, &user, query, "%"+name+"%")
    return &user, err
}

// 批量按 ID 查询(不缓存)
func (m *defaultUserModel) FindByIds(ctx context.Context, ids []int64) ([]*User, error) {
    var users []*User
    query := fmt.Sprintf("SELECT %s FROM %s WHERE id IN (%s)",
        userRows, m.table, placeholders(len(ids)))
    err := m.CachedConn.QueryRowsNoCacheCtx(ctx, &users, query, ids...)
    return users, err
}

事务管理

基础事务

err := l.svcCtx.DB.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error {
    // 所有操作使用 session,不要用全局 DB
    _, err := session.ExecCtx(ctx, "UPDATE account SET balance = balance - ? WHERE id = ?", amount, fromId)
    if err != nil {
        return err
    }
    _, err = session.ExecCtx(ctx, "UPDATE account SET balance = balance + ? WHERE id = ?", amount, toId)
    return err
})

事务 + 缓存一致性

go-zero 中事务和缓存是分离的。如果 Model 层方法需要事务支持,需要在 Repository 层封装:

// Repository 层封装事务操作
func (r *UserRepository) Transfer(ctx context.Context, fromId, toId int64, amount float64) error {
    return r.db.TransactCtx(ctx, func(ctx context.Context, session sqlx.Session) error {
        // 事务内的操作不用缓存
        _, err := session.ExecCtx(ctx, "UPDATE account SET balance = balance - ? WHERE id = ?", amount, fromId)
        if err != nil {
            return err
        }
        _, err = session.ExecCtx(ctx, "UPDATE account SET balance = balance + ? WHERE id = ?", amount, toId)
        if err != nil {
            return err
        }
        // 事务提交后手动清除相关缓存
        r.cache.Del(fmt.Sprintf("cache:user:id:%d", fromId))
        r.cache.Del(fmt.Sprintf("cache:user:id:%d", toId))
        return nil
    })
}

go-zero + GORM 混合方案

如果你的团队更习惯 GORM:

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

type ServiceContext struct {
    Config config.Config
    DB     *gorm.DB
}

func NewServiceContext(c config.Config) *ServiceContext {
    db, err := gorm.Open(mysql.Open(c.DataSource), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    return &ServiceContext{Config: c, DB: db}
}

取舍对比

维度 go-zero 原生 (sqlx/sqlc) GORM
缓存管理 ✅ 内置 Redis 缓存,自动一致性 ❌ 需自行实现
代码生成 ✅ goctl 一键 CRUD+缓存 ✅ gorm/gen
复杂关联查询 手写 SQL 链式 + Preload
SQL 可控性 ⚡ 精确控制 黑盒生成
ORM 开销 几乎为零 有一定反射开销
社区生态 较小 极其庞大

💡 建议:对于标准 CRUD,用 goctl 生成的 sqlc Model;对于复杂关联查询,在 Repository 层手写 SQL 或用 GORM。


缓存最佳实践

实践 说明
只删不更新缓存 更新数据时删除缓存,读取时再回填
防缓存穿透 sqlc 默认缓存空值 1 分钟
过期时间设置 热数据设长、冷数据设短,默认 7 天
批量查询不走缓存 QueryRowNoCache 直接查库
业务层缓存 列表等复杂查询,自行用 Redis Sorted Set/Hash
缓存预热 启动时预加载热点数据到 Redis

常见陷阱

陷阱 说明
🚨 认为 go-zero 自带事务控制 默认单条 SQL 无事务,多条 SQL 必须手动 TransactCtx
🚨 事务内使用全局 DB 连接 事务内所有操作必须用 session,否则无法回滚
🚨 事务内做 RPC 调用 长事务占用锁导致并发阻塞,事务内只做 DB 操作
🚨 缓存永远不过期 异常情况下缓存可能永不过期,始终设置合理的过期时间
🚨 -table 参数大小写错误 MySQL 表名是小写,-table "User" 找不到表
🚨 手动修改 usermodel_gen.go 这个文件 goctl 会覆盖,自定义逻辑写在 usermodel.go
🚨 批量查询走缓存 sqlc 不支持批量查询缓存,走了也不会命中
🚨 需要 SHOW CREATE TABLE 权限 goctl model 从数据库生成时需要此权限