0%

go-zero 源码分析 13: 数据访问与缓存一致性

go-zero 的数据访问层由 core/stores/ 下的六个子包构成——sqlxrediscachesqlcmonmonc。本文就沿着这条层次线索来阅读源码:先从底层的 sqlxredis 入手,理解连接管理和监控机制;然后深入到 cache 层,拆解 Cache-Aside 模式及其三种防护策略;最后看 sqlcmonc 如何把数据库和缓存编织在一起。

上述这些模块不是独立的功能模块,而是分层叠加的关系:

1
2
3
4
5
6
7
8
9
sqlc (Cached SQL Model)   monc (Cached Mongo Model)
↓ ↓
┌──────────┐ ┌──────────┐
│ cache │ │ cache │
├──────────┤ ├──────────┤
│ redis │ │ mon │
└──────────┘ └──────────┘
↓ ↓
sqlx mongo driver

底层提供最基础的数据库/Redis 连接、监控和容错能力,中间层 cache 实现缓存策略,最上层 sqlc/monc 将两者组合成带缓存的 Model。

底层连接管理:sqlx 的 SqlConn 与熔断包裹

问题:直接使用 database/sql 缺少什么

标准库 database/sql 提供了连接池、预编译语句和事务支持,但要在生产环境中可靠地使用它,还需要:

  • 连接复用:多个 handler 共享同一组连接,而不是各自调用 sql.Open
  • 熔断保护:数据库宕机时快速失败,而不是让每个请求都等待超时
  • 慢查询监控:自动记录耗时超过阈值的 SQL,在排查故障时能快速定位
  • 链路追踪:每次数据库调用生成 span,接入分布式追踪系统
  • 读写分离:写操作走主库,读操作可配置走从库
  • 自动映射:将 sql.Rows 扫描到带 db tag 的结构体,不用手写 Scan

sqlx 包正是为此而生。它的核心入口是 commonSqlConn——一个封装了 database/sql 连接的生产级数据库客户端。

commonSqlConn 的结构设计

1
2
3
4
5
6
7
8
9
10
11
// core/stores/sqlx/sqlconn.go
type commonSqlConn struct {
connProv connProvider // 连接获取器(支持读写分离)
onError func(context.Context, error) // 错误日志回调
beginTx beginnable // 开启事务的函数
brk breaker.Breaker // 熔断器
accept breaker.Acceptable // 自定义可接受错误判定
index uint32 // 轮询策略的计数器
}

type connProvider func(ctx context.Context) (*sql.DB, error)

这里有一个值得注意的设计:connProv 是一个函数,不是直接持有 *sql.DB。这个间接层的关键价值在于读写分离。当配置了从库(Replicas)时,connProv 会根据 context 中的标记决定返回主库还是从库连接:

1
2
3
4
5
6
7
8
9
10
11
// core/stores/sqlx/sqlconn.go
func getConnProvider(sc *commonSqlConn, driverName, datasource, policy string,
replicas []string) connProvider {
return func(ctx context.Context) (*sql.DB, error) {
replicaCount := len(replicas)
if replicaCount == 0 || usePrimary(ctx) {
return getSqlConn(driverName, datasource)
}
// ... 轮询或随机选择从库
}
}

默认情况下所有请求都走主库(usePrimary 在 context 中无标记时返回 true)。如果需要将读流量打到从库,可以通过 WithReadReplica(ctx) 在调用前标记 context。

连接复用:ResourceManager 的一层缓存

数据库连接不是说每次请求都创建新的,但也不能全局只创建一个——不同的数据源(DSN)需要不同的连接池。go-zero 用 ResourceManager(上一篇文章讨论过的 SingleFlight 应用)来管理连接的生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
// core/stores/sqlx/sqlmanager.go
var connManager = syncx.NewResourceManager()

func getCachedSqlConn(driverName, server string) (*sql.DB, error) {
val, err := connManager.GetResource(server, func() (io.Closer, error) {
conn, err := newDBConnection(driverName, server)
// ...
return conn, nil
})
// ...
return val.(*sql.DB), nil
}

同一个 DSN 的数据库连接只会创建一次,后续调用直接复用。ResourceManager 底层的 SingleFlight 保证了并发安全——多个 goroutine 同时请求同一 DSN 时,只有一个真正执行 newDBConnection

newDBConnection 中,go-zero 对连接池做了合理的默认设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// core/stores/sqlx/sqlmanager.go
const (
maxIdleConns = 64
maxOpenConns = 64
maxLifetime = time.Minute
)

func newDBConnection(driverName, datasource string) (*sql.DB, error) {
conn, err := sql.Open(driverName, datasource)
// ...
conn.SetMaxIdleConns(maxIdleConns)
conn.SetMaxOpenConns(maxOpenConns)
conn.SetConnMaxLifetime(maxLifetime)
// ...
}

这些数值不是随意的:maxOpenConns=64 避免了连接数无限膨胀,maxLifetime=1min 让连接定期轮换,避免因网络波动导致的长连接僵死。

每次调用都走熔断器

理解连接管理后,我们来看实际执行 SQL 的流程。以 ExecCtx 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// core/stores/sqlx/sqlconn.go
func (db *commonSqlConn) ExecCtx(ctx context.Context, q string, args ...any) (
result sql.Result, err error) {
ctx, span := startSpan(ctx, "Exec") // ① 开启 trace span
defer func() {
endSpan(span, err)
}()

err = db.brk.DoWithAcceptableCtx(ctx, func() error { // ② 熔断器包裹
var conn *sql.DB
conn, err = db.connProv(ctx) // ③ 获取连接(读写分离)
if err != nil {
db.onError(ctx, err)
return err
}
result, err = exec(ctx, conn, q, args...) // ④ 执行 + 慢查询日志
return err
}, db.acceptable) // ⑤ 可接受错误判定
// ...
return
}

整个执行流程经过了五层处理:

  1. Trace span:每个 SQL 操作都生成一个名为 sql 的 span,携带方法名(Exec/QueryRow 等)作为属性
  2. 熔断器:通过 brk.DoWithAcceptableCtx 包裹。如果数据库连续返回不可接受的错误,熔断器打开,后续请求快速失败(返回 ErrServiceUnavailable)而不是继续等待
  3. 连接获取connProv 根据 context 标记决定走主库还是从库
  4. 执行与慢查询日志:内部的 exec/query 函数使用 realSqlGuard 记录执行时间和 SQL 语句,超过慢阈值(默认 500ms)时输出 slow log
  5. 可接受错误判定sql.ErrNoRowssql.ErrTxDonecontext.Canceled 等属于"正常"错误,不计入熔断器的失败统计

查询操作(QueryRow/QueryRows)与 Exec 的结构几乎相同,区别在于 queryRows 内部额外处理了 “scan failed” 的场景——如果扫描失败,即使 SQL 执行成功,这次调用也不标记为可接受,而是更新熔断器状态。这背后的逻辑是:scan 失败通常意味着数据库返回了与预期结构不符的数据,这可能是一个值得熔断的信号。

结果映射:从 Rows 到 []Struct

一个经常被低估的痛点是从 sql.Rows 手动扫描字段。go-zero 提供了一套基于反射的自动映射机制,核心在 unmarshalRowunmarshalRows

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// core/stores/sqlx/orm.go
func unmarshalRow(v any, scanner rowsScanner, strict bool) error {
// ...
rv := reflect.ValueOf(v)
rve := rv.Elem()
switch rte.Kind() {
case reflect.Int, reflect.String, ...: // 基本类型:直接 Scan
return scanner.Scan(v)
case reflect.Struct:
columns, _ := scanner.Columns()
values, _ := mapStructFieldsIntoSlice(rve, columns, strict)
return scanner.Scan(values...)
}
}

对于基本类型(int、string 等),直接 Scan 即可。对于结构体,mapStructFieldsIntoSlice 通过 db tag 建立字段与列名的映射,再按 columns 的顺序排列成一个 []any 切片传给 Scan

“strict” 模式控制列数与字段数的匹配策略:strict 模式下,如果列数少于结构体字段数,返回错误;非 strict 模式下(即 Partial 后缀的方法),允许列数少于字段数,多余的字段保持零值。这在你只需要查询部分字段时非常有用。

事务与预编译语句

事务操作通过 TransactCtx 执行,它同样是熔断器包裹下的标准流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// core/stores/sqlx/tx.go
func transactOnConn(ctx context.Context, conn *sql.DB, b beginnable,
fn func(context.Context, Session) error) (err error) {
var tx trans
tx, err = b(conn)
// ...
defer func() {
if p := recover(); p != nil { // panic 恢复并回滚
// ...
} else if err != nil {
// 业务错误,回滚
} else {
err = tx.Commit() // 正常提交
}
}()
return fn(ctx, tx)
}

这个 defer 块是事务安全的核心:panic 一定回滚,错误一定回滚,只有正常返回才提交。没有什么特殊情况——这保证了事务的语义正确性。

预编译语句(Prepare)返回一个 statement 结构,它实现了 StmtSession 接口,行为上与 commonSqlConn 平行——同样带熔断器和 trace span。不同的是它的 accept 判定不包含内部默认的可接受错误判定(因为 sql.ErrNoRows 等已在 statement 层面处理)。

BulkInserter:周期执行器驱动批量插入

批量插入是一个独立的话题。如果每次都拼一条完整的 INSERT 语句,性能会很差。BulkInserter 的思路是用 PeriodicalExecutor 做批量聚合:

1
2
3
4
5
6
7
// core/stores/sqlx/bulkinserter.go
type BulkInserter struct {
executor *executors.PeriodicalExecutor // 每秒 flush 一次
inserter *dbInserter
stmt bulkStmt
lock sync.RWMutex
}

每次 Insert 调用不直接执行 SQL,而是将 value 格式化后通过 executor.Add(value) 追加到内部列表。PeriodicalExecutor 每 1 秒(或累积到 1000 行)自动触发 dbInserter.Execute,将所有 value 拼成一条 SQL 执行:

1
INSERT INTO users (name, email) VALUES (?, ?), (?, ?), (?, ?) -- 批量拼接

这意味着业务代码可以放心地在循环中逐行调用 Insert,框架自动完成批量拼装。如果你需要在批量插入前先做 update/delete 操作,UpdateOrDelete 方法会先 Flush 积压的插入再执行你的操作,保证执行顺序。

到这里,sqlx 层的全景就清晰了:

  • 连接管理ResourceManager + SingleFlight 保证同 DSN 只创建一次连接池
  • 读写分离connProv 通过 context 标记决定目标数据库
  • 熔断保护:每个 SQL 操作都经过 breaker.DoWithAcceptableCtx
  • 链路追踪:每个操作都生成 trace span
  • 慢查询:超过 500ms 自动告警
  • 结果映射:基于 db tag 的反射映射,支持 strict 和 partial 两种模式
  • 事务安全:defer 块保证 commit/rollback 语义
  • 批量插入:PeriodicalExecutor 自动聚合

Redis 客户端:与 sqlx 平行的设计哲学

理解了 sqlx 后,再看 redis 包的实现就能发现清晰的设计对称性——同样的熔断器模式,同样的 hook 链设计,同样的连接管理方式。

Redis 结构与连接管理

1
2
3
4
5
6
7
8
9
10
11
// core/stores/redis/redis.go
type Redis struct {
Addr string
Type string // "node" 或 "cluster"
User string
Pass string
protocol int
// ...
brk breaker.Breaker
hooks []red.Hook
}

sqlx 一样,每个 Redis 实例持有自己的熔断器。连接获取同样经过 ResourceManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// core/stores/redis/redisclientmanager.go
func getClient(r *Redis) (*red.Client, error) {
val, err := clientManager.GetResource(r.Addr, func() (io.Closer, error) {
store := red.NewClient(&red.Options{
Addr: r.Addr,
PoolSize: 10 * runtime.GOMAXPROCS(0), // 连接池大小与 CPU 核数挂钩
MaxRetries: 3,
// ...
})
hooks := append([]red.Hook{defaultDurationHook, breakerHook{brk: r.brk}}, r.hooks...)
for _, hook := range hooks {
store.AddHook(hook)
}
return store, nil
})
// ...
}

注意两个默认 hook:

  • defaultDurationHook:记录每次 Redis 操作的耗时,类似 sqlx 的慢查询日志
  • breakerHook:通过 ProcessHookProcessPipelineHook 将每次 Redis 命令调用用熔断器包裹起来

breakerHook 的实现很精炼——它在 go-redis 的 hook 体系中介入,对每个命令调用执行 brk.DoWithAcceptableCtx。值得注意的是 ignoreCmds——blpop 等阻塞命令会被跳过熔断检查,因为它们长时间持有连接是预期行为,不应被判定为故障:

1
2
3
4
5
6
7
8
9
10
11
// core/stores/redis/breakerhook.go
func (h breakerHook) ProcessHook(next red.ProcessHook) red.ProcessHook {
return func(ctx context.Context, cmd red.Cmder) error {
if _, ok := ignoreCmds[cmd.Name()]; ok {
return next(ctx, cmd)
}
return h.brk.DoWithAcceptableCtx(ctx, func() error {
return next(ctx, cmd)
}, acceptable)
}
}

阻塞操作与连接池隔离

Redis 的阻塞命令(BLPOPXREADGROUP 等)有一个特殊问题:它们会长时间占用一个连接,如果使用共享连接池,可能耗尽所有可用连接。go-zero 的解决方案是 CreateBlockingNode——为阻塞操作创建一个独立的、PoolSize=1 的专用客户端:

1
2
3
4
5
6
7
8
9
10
11
// core/stores/redis/redisblockingnode.go
func CreateBlockingNode(r *Redis) (ClosableNode, error) {
// ...
client := red.NewClient(&red.Options{
Addr: r.Addr,
PoolSize: 1, // 专用连接,不共享池
MinIdleConns: 1,
ReadTimeout: readWriteTimeout + blockingQueryTimeout,
})
return &clientBridge{client}, nil
}

所有阻塞操作的 API 都要求显式传入这个 RedisNode 参数(如 Blpop(node RedisNode, key string)),从类型系统层面保证了阻塞操作不会影响普通操作。

分布式锁:Lua 脚本保证原子性

在高并发场景下,有时需要跨进程的互斥。go-zero 的 RedisLock 使用两个 Lua 脚本来保证加锁和释放的原子性:

1
2
3
4
5
6
7
-- lockscript.lua: 可重入加锁
if redis.call("GET", KEYS[1]) == ARGV[1] then
redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
return "OK"
else
return redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])
end

这段脚本实现了可重入锁:如果持有锁的是自己,就刷新过期时间(续期);如果不是自己,则尝试用 NX 获取。对应地,释放锁也需要 Lua 保证"只释放自己的锁":

1
2
3
4
5
6
-- delscript.lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end

每个锁实例内部有一个随机生成的 id,只有 value 匹配时才执行删除,防止误删其他持有者的锁。锁的过期时间通过 SetExpire(seconds) 设定,使用 atomic 保证并发安全。

脚本缓存

频繁执行 EVAL 会有性能开销,因为每次都要传输完整的 Lua 脚本。更好的方式是先用 SCRIPT LOAD 将脚本缓存到 Redis,之后用 EVALSHA 通过 SHA 摘要调用。go-zero 的 ScriptCache 就是为此准备的——它是一个线程安全的 script → SHA 映射缓存。

缓存层:Cache 接口与三种防护策略

有了 sqlxredis 做地基,我们就可以构建缓存层了。go-zero 的缓存设计围绕一个核心接口展开:

1
2
3
4
5
6
7
8
9
10
// core/stores/cache/cache.go
type Cache interface {
Del(keys ...string) error
Get(key string, val any) error
Set(key string, val any) error
SetWithExpire(key string, val any, expire time.Duration) error
Take(val any, key string, query func(val any) error) error
TakeWithExpire(val any, key string, query func(val any, expire time.Duration) error) error
IsNotFound(err error) bool
}

其中 Take 是缓存策略的实现核心——它封装了Cache-Aside 模式:先查缓存,未命中再查数据库,结果回写缓存。它的两个变体 TakeCtxTakeWithExpire 分别增加了 context 支持和自定义过期时间。

cacheNode:单节点缓存的完整实现

cacheNodeCache 接口的核心实现:

1
2
3
4
5
6
7
8
9
10
11
12
// core/stores/cache/cachenode.go
type cacheNode struct {
rds *redis.Redis
expiry time.Duration // 正常缓存的过期时间
notFoundExpiry time.Duration // "不存在"标记的过期时间
barrier syncx.SingleFlight // 防击穿
r *rand.Rand
lock *sync.Mutex
unstableExpiry mathx.Unstable // 随机过期偏移
stat *Stat // 缓存命中率统计
errNotFound error
}

这里组合了我们之前介绍过的两个工具——SingleFlight 用于防击穿,Unstable 用于随机过期防雪崩。Stat 则记录缓存总请求、命中和未命中次数。

Take:一次缓存读取的完整流程

doTake 方法是整个缓存层的最高潮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// core/stores/cache/cachenode.go
func (c cacheNode) doTake(ctx context.Context, v any, key string,
query func(v any) error, cacheVal func(v any) error) error {
val, fresh, err := c.barrier.DoEx(key, func() (any, error) {
// 第一次查缓存
if err := c.doGetCache(ctx, key, v); err != nil {
if errors.Is(err, errPlaceholder) {
return nil, c.errNotFound // 占位值 → 返回"不存在"
} else if !errors.Is(err, c.errNotFound) {
return nil, err // 缓存系统错误 → 快速失败
}

// 缓存 miss → 回源查数据库
if err = query(v); errors.Is(err, c.errNotFound) {
// 数据库也没有 → 设置占位值防穿透
if err = c.setCacheWithNotFound(ctx, key); err != nil {
logger.Error(err)
}
return nil, c.errNotFound
} else if err != nil {
c.stat.IncrementDbFails()
return nil, err
}

// 数据库有 → 写入缓存
if err = cacheVal(v); err != nil {
logger.Error(err)
}
}
return jsonx.Marshal(v)
})
// ...
if fresh { return nil } // 我是第一个执行 query 的,不用再反序列化
// 复用了 barrier 的结果,需要 Unmarshal
return jsonx.Unmarshal(val.([]byte), v)
}

这个函数内部的决策树值得我们一步步解读:

第一步:SingleFlight 包裹。多个 goroutine 同时 Take 同一个 key 时,只有第一个真正进入查询逻辑,其余的等待它的结果。这直接解决了缓存击穿——热点数据过期瞬间,不会有一堆请求同时打到数据库。

第二步:查缓存doGetCache 返回三种可能:

  • 正常数据:直接反序列化返回(命中)
  • 占位符 *:说明之前回源过,数据库也没有,返回 errNotFound
  • 缓存 miss:errNotFound,进入回源逻辑
  • 系统错误:比如 Redis 挂了,这里直接返回错误。(注意注释中的关键决策:不向下游穿透——数据库可能已经压力很大了,不能再用缓存层的错误去增加它的负担)

第三步:回源。调用 query 函数(由上层 sqlc/monc 传入,实际执行数据库查询):

  • 数据库也查不到 → 设置占位值(防穿透
  • 数据库出错 → 记录 DbFails 指标,返回错误
  • 数据库有结果 → 写入缓存

第四步:共享结果处理。通过 DoExfresh 标志判断:

  • fresh=true:当前 goroutine 是实际执行者,数据已在 query 中填入 v,直接返回
  • fresh=false:复用了他人的结果,需要从 JSON 反序列化到 v

三种防护策略的实现

缓存三大经典问题——穿透、击穿、雪崩——在 doTake 实现中都有对应的防护策略:

  • 防穿透:当数据库也查询不到数据时,调用 setCacheWithNotFound 设置一个占位符(*),过期时间使用 notFoundExpiry(默认 1 分钟)。下次相同 key 的请求命中占位符,直接返回 not found,不再打到数据库。占位符使用 SetnxEx(SETNX + expire),过期时间同样经过随机抖动。

  • 防击穿barrier.DoEx 在 SingleFlight 层面保证同一 key 只有一个 goroutine 执行 query。这与 doGetCache 构成"双重检查":第一个进入 barrier 的 goroutine 再次检查缓存,避免在前一个 Take 调用刚写完缓存后,当前调用又去重复回源。

  • 防雪崩:每次设置缓存时,过期时间都会经过 unstableExpiry.AroundDuration(expire) 处理,给过期时间增加 ±5% 的随机抖动:

1
2
3
4
5
6
7
8
9
10
// core/stores/cache/cachenode.go
const expiryDeviation = 0.05

func (c cacheNode) aroundDuration(duration time.Duration) time.Duration {
return c.unstableExpiry.AroundDuration(duration)
}

func (c cacheNode) SetCtx(ctx context.Context, key string, val any) error {
return c.SetWithExpireCtx(ctx, key, val, c.aroundDuration(c.expiry))
}

这意味着 1000 个缓存条目不会在同一秒内集中过期——±5% 的抖动将它们分散在一段时间窗口内,避免回源请求的瞬时峰值。

删除失败的异步重试

缓存删除是一个容易被忽视的坑。更新数据库后,如果 Redis 删除命令因为网络抖动而失败,缓存中残留的就是旧数据。cacheNode.Del 的做法很务实:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// core/stores/cache/cachenode.go
func (c cacheNode) DelCtx(ctx context.Context, keys ...string) error {
if len(keys) > 1 && c.rds.Type == redis.ClusterType {
for _, key := range keys {
if _, err := c.rds.DelCtx(ctx, key); err != nil {
logger.Errorf("failed to clear cache with key: %q, error: %v", key, err)
c.asyncRetryDelCache(key) // 异步重试
}
}
} else if _, err := c.rds.DelCtx(ctx, keys...); err != nil {
c.asyncRetryDelCache(keys...)
}
return nil // 注意:Del 不向上返回错误
}

有两个设计细节:

  1. 集群模式下逐个删除:Redis Cluster 的 DEL 命令不支持跨 slot 的多 key 删除(会返回 CROSSSLOT 错误),所以集群模式下改为逐个删除
  2. 异步重试:通过 AddCleanTask 将失败的删除操作加入时间轮做重试。重试间隔采用指数回退——1 秒 → 5 秒 → 1 分钟 → 5 分钟 → 1 小时:
1
2
3
4
5
6
7
8
9
10
// core/stores/cache/cleaner.go
func nextDelay(delay time.Duration) (time.Duration, bool) {
switch delay {
case time.Second: return time.Second * 5, true
case time.Second * 5: return time.Minute, true
case time.Minute: return time.Minute * 5, true
case time.Minute * 5: return time.Hour, true
default: return 0, false
}
}

重试到达上限后放弃,输出错误日志并上报统计系统。

缓存集群:一致性哈希分发

当缓存数据量超过单机 Redis 容量时,需要水平扩展。cacheCluster 通过一致性哈希将 key 路由到不同的节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// core/stores/cache/cache.go
type cacheCluster struct {
dispatcher *hash.ConsistentHash // 一致性哈希环
errNotFound error
}

func New(c ClusterConf, barrier syncx.SingleFlight, st *Stat, errNotFound error,
opts ...Option) Cache {
if len(c) == 1 {
return NewNode(...) // 单节点直接用 cacheNode
}
dispatcher := hash.NewConsistentHash()
for _, node := range c {
cn := NewNode(...)
dispatcher.AddWithWeight(cn, node.Weight)
}
return cacheCluster{dispatcher: dispatcher, errNotFound: errNotFound}
}

配置了多节点时,内部为每个节点创建独立的 cacheNode,通过一致性哈希分发 key。每个节点有自己的 SingleFlight(通过参数共享同一个 barrier,避免跨节点的冗余回源)和 Stat

注意核心决策:所有 cacheNode 共享同一个 SingleFlight 实例——这意味着即使不同的 key 路由到不同的节点,只要 key 相同,SingleFlight 就能起作用。相比之下,如果每个节点各自创建自己的 SingleFlight,同一个 key 的两个请求落到不同节点时就会各自回源一次。

整合:CachedConn 与缓存模型的生成

理解了底层的数据库连接和缓存策略后,sqlc.CachedConn 的实现就变得透明了。它将 sqlx.SqlConncache.Cache 组合在一起,为上层提供带缓存的数据库操作:

1
2
3
4
5
6
7
8
// core/stores/sqlc/cachedsql.go
type CachedConn struct {
db sqlx.SqlConn
cache cache.Cache
}

var singleFlights = syncx.NewSingleFlight() // 整个进程中所有 CachedConn 共享
var stats = cache.NewStat("sqlc")

这里有两点耐人寻味的设计:

  • 共享 SingleFlight:所有 CachedConn 共享同一个 singleFlights 实例。这很重要——假设你有两个数据库连接,都缓存相同的 user 表,如果它们各用自己的 SingleFlight,高并发时同一个 key 还是会回源两次。共享的 singleFlights 解决了这个问题。
  • 共享统计stats 也是全局共享的,方便统一监控所有缓存命中率。

查询与缓存的协作模式

CachedConn.QueryRow 是最经典的使用方式——缓存优先,miss 则查询数据库:

1
2
3
4
5
6
func (cc CachedConn) QueryRowCtx(ctx context.Context, v any, key string,
query QueryCtxFn) error {
return cc.cache.TakeCtx(ctx, v, key, func(v any) error {
return query(ctx, cc.db, v) // 缓存 miss → 执行数据库查询
})
}

写操作的缓存处理是 Cache-Aside 的标准做法——先更新数据库,成功后删除缓存(即"写后删除"):

1
2
3
4
5
6
7
8
func (cc CachedConn) ExecCtx(ctx context.Context, exec ExecCtxFn, keys ...string) (
sql.Result, error) {
res, err := exec(ctx, cc.db) // 先执行写操作
if err != nil {
return nil, err
}
return res, cc.DelCacheCtx(ctx, keys...) // 成功后删除缓存
}

这里有一个微妙的顺序问题:为什么不先删缓存再写数据库?因为"先删缓存"会引入一个不一致窗口——在删缓存和写数据库之间,其他请求查到旧数据并写入缓存,导致后续请求读到旧数据。而"先写数据库再删缓存"的不一致窗口更小:在写数据库和删缓存之间,其他请求读到的是旧缓存——但一旦删除完成,下次请求就会从数据库中读到新数据。两者的不一致窗口是不可消除的,但"写后删除"的窗口通常更短。

此外,CachedConn 还提供了 *NoCache*PartialNoCache 系列方法,用于需要跳过缓存或只查询部分字段的场景——这些方法直接透传到 sqlx 层,不经过 cache.Take

索引缓存:QueryRowIndex 的两阶段回源

有时候查询条件不是主键而是唯一索引(比如通过邮箱查用户)。QueryRowIndex 处理这种场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// core/stores/sqlc/cachedsql.go
const cacheSafeGapBetweenIndexAndPrimary = time.Second * 5

func (cc CachedConn) QueryRowIndexCtx(ctx context.Context, v any, key string,
keyer func(primary any) string,
indexQuery IndexQueryCtxFn,
primaryQuery PrimaryQueryCtxFn) error {

var primaryKey any
var found bool

// 第一阶段:通过索引查询获取主键值
if err := cc.cache.TakeWithExpireCtx(ctx, &primaryKey, key,
func(val any, expire time.Duration) (err error) {
primaryKey, err = indexQuery(ctx, cc.db, v)
if err != nil { return }
found = true
// 将主键到实体的映射写入缓存(过期时间比索引缓存稍长)
return cc.cache.SetWithExpireCtx(ctx, keyer(primaryKey), v,
expire+cacheSafeGapBetweenIndexAndPrimary)
}); err != nil {
return err
}

if found { return nil }

// 第二阶段:通过主键缓存获取实体
return cc.cache.TakeCtx(ctx, v, keyer(primaryKey), func(v any) error {
return primaryQuery(ctx, cc.db, v, primaryKey)
})
}

这个方法做了两次 Take

  1. 先通过索引 key 查缓存,如果 miss 则用 indexQuery 从数据库查到主键值,同时将主键值缓存到索引 key 中,并将实体缓存到主键 key 中
  2. 然后通过主键 key 查缓存,如果 miss 则用 primaryQuery 从数据库查实体

注意 cacheSafeGapBetweenIndexAndPrimary = 5s:索引缓存的过期时间比主键缓存早 5 秒。这保证了当索引缓存过期时,主键缓存还没有过期——索引 key miss 后调用 indexQuery 可以拿到主键值,然后直接命中原有的主键缓存,避免了额外的数据查询。

事务中的缓存:为什么不要用缓存

CachedConn.Transact 直接透传到 sqlx 层,不涉及缓存:

1
2
3
4
func (cc CachedConn) TransactCtx(ctx context.Context,
fn func(context.Context, sqlx.Session) error) error {
return cc.db.TransactCtx(ctx, fn)
}

函数内通过 WithSession 可以获取一个绑定事务的 CachedConn,但注释明确警告:不建议在事务中使用缓存。原因是事务中可能读到未提交的数据——如果把未提交的数据写入缓存,当事务回滚时,缓存中的就是脏数据。

MongoDB:对称设计的装饰器模式

monmonc 包的设计与 sqlxsqlc 几乎是对称的。mon.Collection 接口定义了所有 MongoDB 操作方法,decoratedCollection 通过装饰器模式为每个方法加上熔断、trace span 和慢查询日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// core/stores/mon/collection.go
type decoratedCollection struct {
Collection monCollection // 原始的 mongo.Collection
name string
brk breaker.Breaker
}

func (c *decoratedCollection) FindOne(ctx context.Context, filter any,
opts ...options.Lister[options.FindOneOptions]) (res *mongo.SingleResult, err error) {
ctx, span := startSpan(ctx, findOne)
defer func() { endSpan(span, err) }()

err = c.brk.DoWithAcceptableCtx(ctx, func() error {
startTime := timex.Now()
defer func() { c.logDuration(ctx, findOne, startTime, err, filter) }()
res = c.Collection.FindOne(ctx, filter, opts...)
err = res.Err()
return err
}, acceptable)
return
}

这与 sqlx.commonSqlConn.ExecCtx 的模式一模一样——熔断、trace span、慢查询日志。唯一的区别是 acceptable 函数中多了 MongoDB 特有的错误类型(mongo.ErrNoDocumentsmongo.ErrNilValue、duplicate key error 11000 等),这些"正常"错误同样不会触发熔断器。

monc.Model 是 MongoDB 的带缓存版,组合了 mon.Modelcache.Cache

1
2
3
4
5
// core/stores/monc/cachedmodel.go
type Model struct {
*mon.Model
cache cache.Cache
}

它的 FindOneUpdateOneInsertOne 等方法的实现与 CachedConn 一致——查询走 cache.Take,写操作先执行数据库操作再删除缓存。

总结

go-zero 的数据访问层遵循一条清晰的层次关系:

  • sqlx/redis/mon:底层连接管理,统一的熔断 + trace + 慢日志包装模式
  • cache 层:Cache-Aside 策略,实现防穿透、防击穿、防雪崩三大防护,以及异步重试删除
  • sqlc/monc:组合下层能力,提供"带缓存的数据库操作"这一高层抽象

这种分层设计的好处是每层可以独立演进:sqlx 的连接池算法可以升级而不影响缓存层,缓存策略可以调整而不影响业务代码。各层之间通过接口耦合——Cache 接口使得缓存后端(单机 Redis、Redis Cluster)可以互换,Session 接口使得数据库后端(MySQL、PostgreSQL)可以替换。

防护策略 解决的问题 实现机制 位置
防穿透 大量请求查询不存在的数据 占位值 * + SetnxEx setCacheWithNotFound
防击穿 热点数据过期瞬间的多请求回源 SingleFlight + 双重检查 barrier.DoEx + doGetCache
防雪崩 大批缓存同时过期 过期时间 ±5% 随机抖动 unstableExpiry.AroundDuration
删除失败 缓存残留旧数据 时间轮异步重试(指数回退) AsyncRetryDelCache + cleaner