Appearance
模型缓存与缓存穿透防护
本篇深入讲解 go-zero 的缓存设计。我们将剖析 CachedConn 的工作原理,理解自动缓存的读写流程;然后系统介绍缓存三大经典问题(穿透、击穿、雪崩)以及 go-zero 的防护机制;最后通过一个高并发查询的实战对比,展示缓存方案的实际效果。go-zero 的缓存机制经过大量生产验证,理解其原理有助于在高并发场景下做出正确决策。
一、go-zero 缓存设计
go-zero 的缓存抽象集中在 core/stores/sqlc(DB 缓存)和 core/stores/cachex(通用缓存)两个包。核心接口是 CachedConn。
1. CachedConn 接口
CachedConn 是一个「带缓存的 DB 连接」抽象,定义在 core/stores/sqlc/cachedconn.go:
go
type (
// CachedConn 把 DB 操作和缓存操作组合在一起
CachedConn interface {
// 带缓存的查询(单行)
QueryRow(ctx context.Context, v any, key string, query QueryFn) error
// 带缓存的查询(多行,不缓存,仅统一接口)
QueryRows(ctx context.Context, v any, query QueryFn) error
// 带缓存的查询(无版本号)
QueryRowNoCache(ctx context.Context, v any, query QueryFn) error
// 执行写操作(自动删除缓存)
Exec(ctx context.Context, exec ExecFn, keys ...string) (sql.Result, error)
// 不带缓存的执行
ExecNoCache(ctx context.Context, exec ExecFn) (sql.Result, error)
// 主动删除缓存
DelCache(keys ...string) error
// 带 ctx 的版本
QueryRowCtx(ctx context.Context, v any, key string, query QueryCtxFn) error
QueryRowsCtx(ctx context.Context, v any, query QueryCtxFn) error
ExecCtx(ctx context.Context, exec ExecCtxFn, keys ...string) (sql.Result, error)
// ...
}
// 查询函数:传入实际查 DB 的逻辑
QueryCtxFn func(ctx context.Context, conn sqlx.SqlConn, v any) error
// 执行函数:传入实际写 DB 的逻辑
ExecCtxFn func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error)
)2. 创建 CachedConn
go
import (
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/core/stores/sqlc"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
// 缓存配置
cacheConf := cache.CacheConf{
cache.NodeConf{
RedisConf: redis.RedisConf{
Host: "127.0.0.1:6379",
Type: "node",
},
Weight: 100,
},
}
// 创建带缓存的连接
conn := sqlc.NewConn(sqlx.NewMysql(dsn), cacheConf)sqlc.NewConn 内部创建一个 cachedConn 实例,它持有:
sqlx.SqlConn:底层 DB 连接cache.Cache:缓存客户端(封装了 Redis)sync.WaitGroup:用于异步删缓存的等待
3. 自动缓存读写流程
查询流程(QueryRowCtx)
go
err := conn.QueryRowCtx(ctx, &user, "user:1001", func(ctx context.Context, conn sqlx.SqlConn, v any) error {
// 这里写实际查 DB 的逻辑
return conn.QueryRowCtx(ctx, v, "SELECT * FROM users WHERE id=?", 1001)
})执行流程:
1. 根据缓存 key "user:1001" 查 Redis
├─ 命中(非空值标记)→ 反序列化 → 写入 v → 返回 nil
├─ 命中(空值标记 notFound)→ 返回 sqlc.ErrNotFound
└─ 未命中 → 进入步骤 2
2. 用 singleflight 合并并发请求
├─ 同一 key 的并发查询只会有一组真正查 DB
└─ 其他 goroutine 等待结果复用
3. 在 singleflight 内调用 query 函数查 DB
├─ 查到数据 → 序列化 → 写入 Redis(带过期)→ 写入 v → 返回 nil
└─ 未查到 → 写入空值标记(短期过期)→ 返回 sqlc.ErrNotFound写流程(ExecCtx)
go
result, err := conn.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
return conn.ExecCtx(ctx, "UPDATE users SET name=? WHERE id=?", newName, 1001)
}, "user:1001") // 最后是要删除的缓存 key执行流程:
1. 执行 SQL(更新 DB)
2. 执行成功后,删除缓存 key "user:1001"
└─ 异步删除(不阻塞主流程)4. 缓存一致性:先写 DB 再删缓存
go-zero 采用「先写 DB,再删缓存」策略,这是工程上最常用的方案。为什么是「删」而不是「更新」?
- 删比更新简单:更新需要计算新值,删只需要让下次查询重新加载
- 避免并发更新冲突:多个写操作同时更新缓存可能产生脏数据
- 避免缓存和 DB 不一致:删缓存后,下次读会从 DB 加载最新值
为什么是「先写 DB,再删缓存」而不是「先删缓存,再写 DB」?
- 先删缓存再写 DB 的窗口期:删缓存后、写 DB 前,另一个读请求会把旧数据加载到缓存
- 先写 DB 再删缓存的窗口期:写 DB 后、删缓存前,读请求会返回旧缓存(短暂不一致)
后者窗口期更短,且可以通过「延迟双删」进一步消除。
5. 空值缓存机制
go-zero 在查询 DB 未命中时,会写入一个特殊的「空值标记」到 Redis,短期过期(默认 1 分钟)。下次同样查询会直接命中空值标记,返回 ErrNotFound,避免反复打 DB。
空值标记的实现:在 Redis 中存储一个特殊字符串(如 *),查询时识别这个标记就返回 not found。
二、缓存穿透防护
缓存穿透:查询一个根本不存在的数据,由于缓存不会命中,每次请求都会打到 DB。常见场景是恶意攻击(用大量不存在的 ID 查询)。
1. 空值缓存
go-zero 的 CachedConn 已经实现了空值缓存。我们看一个具体场景:
go
// 查询 id=99999 的用户(不存在)
user, err := l.svcCtx.UserModel.FindOne(ctx, 99999)
// 返回 ErrNotFound第一次查询:
- 查 Redis:未命中
- singleflight 合并
- 查 DB:未找到
- 写入空值标记到 Redis(key:
user:99999,value:*,过期 1 分钟) - 返回
ErrNotFound
后续 1 分钟内查询同样 id:
- 查 Redis:命中空值标记
- 直接返回
ErrNotFound - 不会打 DB
2. 布隆过滤器
对于 ID 空间巨大且大量不存在的场景(如订单号、UUID),空值缓存会占用过多 Redis 内存。这时可以用布隆过滤器:
go
import "github.com/zeromicro/go-zero/core/bloom"
// 在 ServiceContext 中初始化
filter := bloom.New(rds, "bloom:user_id", 64*1024*1024) // 64MB 容量
// 启动时加载所有存在的 ID
func (m *UserModel) WarmupBloomFilter(ctx context.Context, filter *bloom.Filter) error {
// 分批查 DB,把所有 ID 加入过滤器
var lastID int64
for {
ids, err := m.GetIDsAfter(ctx, lastID, 1000)
if err != nil || len(ids) == 0 {
break
}
for _, id := range ids {
filter.Add([]byte(strconv.FormatInt(id, 10)))
}
lastID = ids[len(ids)-1]
}
return nil
}
// 查询时先过布隆过滤器
func (l *GetUserLogic) GetUser(req *types.GetUserRequest) (*types.GetUserResponse, error) {
idStr := strconv.FormatInt(req.Id, 10)
exists, _ := l.svcCtx.BloomFilter.Exists([]byte(idStr))
if !exists {
// 布隆过滤器说不存在,肯定不存在
return nil, fmt.Errorf("user not found")
}
// 布隆过滤器说可能存在,继续走缓存和 DB
user, err := l.svcCtx.UserModel.FindOne(l.ctx, req.Id)
// ...
}布隆过滤器的特点:
- 可能有误判(说不存在一定不存在,说存在可能不存在)
- 占用内存小(1 亿 ID 约 100MB)
- 不能删除(要支持删除需用布谷鸟过滤器)
3. 限流
对查询接口加限流,防止恶意攻击打爆 DB:
go
limiter := limit.NewPeriodLimit(1, 100, rds, "user_query_rate")
code, _ := limiter.Take(clientIP)
if code == limit.OverQuota {
return nil, fmt.Errorf("rate limited")
}4. 参数校验
在 Logic 层做参数校验,过滤明显不合法的查询:
go
func (l *GetUserLogic) GetUser(req *types.GetUserRequest) (*types.GetUserResponse, error) {
if req.Id <= 0 || req.Id > 1e12 {
return nil, fmt.Errorf("invalid user id")
}
// ...
}三、缓存击穿防护
缓存击穿:一个热点 key 突然过期,瞬间大量请求同时打到 DB,可能导致 DB 压力骤增甚至崩溃。
1. singleflight
singleflight 是 Go 标准库提供的原语,用于合并并发请求:对同一个 key 的多个查询,只有一个 goroutine 真正执行,其他 goroutine 等待并复用结果。
go-zero 的 CachedConn 内部已经集成了 singleflight。我们看 core/stores/sqlc/cachedconn.go 的核心逻辑(简化版):
go
func (cc *cachedConn) QueryRowCtx(ctx context.Context, v any, key string, query QueryCtxFn) error {
// 1. 查缓存
err := cc.getCache(key, v)
if err == nil {
return nil // 缓存命中
}
if err == errPlaceholder {
return ErrNotFound // 空值标记
}
// 2. 缓存未命中,用 singleflight 合并
val, err := cc.doLoad(ctx, key, v, query)
return err
}
func (cc *cachedConn) doLoad(ctx context.Context, key string, v any, query QueryCtxFn) (any, error) {
// singleflight.Do:同一 key 的并发调用只会执行一次
return cc.do.Do(key, func() (any, error) {
// 真正查 DB
if err := query(ctx, cc.db, v); err != nil {
if err == sqlx.ErrNotFound {
// 写入空值标记
cc.setCacheWithExpire(key, placeholder, notFoundExpire)
return nil, ErrNotFound
}
return nil, err
}
// 写入缓存
cc.setCache(key, v)
return v, nil
})
}这样即使 1000 个请求同时查同一个 key,也只会有 1 个请求真正打 DB。
2. 手动使用 singleflight
如果不用 CachedConn,也可以手动使用 go-zero 的 Collection 包里的 singleflight:
go
import "github.com/zeromicro/go-zero/core/syncx"
sf := syncx.NewSingleFlight()
func (l *GetProductLogic) GetProduct(req *types.GetProductRequest) (*types.Product, error) {
key := fmt.Sprintf("product:%d", req.Id)
// 先查缓存
if val, _ := l.svcCtx.Redis.GetCtx(l.ctx, key); val != "" {
var p Product
json.Unmarshal([]byte(val), &p)
return &p, nil
}
// singleflight 合并
result, err := sf.Do(key, func() (any, error) {
// 只有第一个请求会执行这里
p, err := l.svcCtx.ProductModel.FindOne(l.ctx, req.Id)
if err != nil {
return nil, err
}
// 写入缓存
if data, _ := json.Marshal(p); data != nil {
l.svcCtx.Redis.SetexCtx(l.ctx, key, 3600, string(data))
}
return p, nil
})
if err != nil {
return nil, err
}
return result.(*Product), nil
}3. 互斥锁
替代 singleflight,可以用 redislock 实现分布式互斥锁:
go
import "github.com/zeromicro/go-zero/core/stores/redis"
lock := redis.NewRedisLock(rds, "lock:product:1001")
lock.SetExpire(5)
acquired, _ := lock.Acquire()
if !acquired {
// 等待一会儿重试,或者返回稍后再试
time.Sleep(100 * time.Millisecond)
// 重新查缓存
// ...
}
defer lock.Release()
// 查 DB,写缓存singleflight 和互斥锁的区别:
| 维度 | singleflight | 互斥锁 |
|---|---|---|
| 粒度 | 按 key 合并 | 按 key 互斥 |
| 等待行为 | 等待结果复用 | 等待锁释放后重试 |
| 适合场景 | 读多写少,结果可复用 | 严格互斥 |
| 复杂度 | 低(自动) | 中(需处理重试) |
推荐优先用 singleflight。
4. 热点 key 永不过期
对极热点的 key(如首页 banner),可以设为永不过期,通过后台任务定期更新:
go
// 启动时加载,并启动后台刷新
func (m *BannerModel) StartRefreshJob(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
go func() {
for {
select {
case <-ticker.C:
banners, _ := m.findAllFromDB(ctx)
data, _ := json.Marshal(banners)
m.rds.Set("banner:all", string(data)) // 永不过期
case <-ctx.Done():
return
}
}
}()
}四、缓存雪崩防护
缓存雪崩:大量 key 同时过期,导致请求全部打到 DB。
1. 随机过期时间
最简单的防护:在过期时间上加随机抖动。go-zero 的 CachedConn 默认就会加随机抖动(基于 cache.WithExpire 配置)。
手动管理缓存时,可以这样加抖动:
go
expire := 3600 + rand.Intn(600) // 1 小时 + 0~10 分钟随机
l.svcCtx.Redis.SetexCtx(ctx, key, expire, string(data))2. 多级缓存
本地缓存 + Redis 缓存的组合,减少 Redis 故障的影响:
go
import "github.com/zeromicro/go-zero/core/collection"
type CacheService struct {
localCache *collection.Cache // 本地 LRU 缓存
redis *redis.Redis
model ProductModel
}
func NewCacheService(rds *redis.Redis, model ProductModel) *CacheService {
// 本地缓存,5 分钟过期,最多 1 万条
localCache, _ := collection.NewCache(time.Minute*5, collection.WithLimit(10000))
return &CacheService{
localCache: localCache,
redis: rds,
model: model,
}
}
func (s *CacheService) GetProduct(ctx context.Context, id int64) (*Product, error) {
key := fmt.Sprintf("product:%d", id)
// L1: 本地缓存
if val, ok := s.localCache.Get(key); ok {
return val.(*Product), nil
}
// L2: Redis 缓存
if val, _ := s.redis.GetCtx(ctx, key); val != "" {
var p Product
if err := json.Unmarshal([]byte(val), &p); err == nil {
s.localCache.Set(key, &p)
return &p, nil
}
}
// L3: DB
p, err := s.model.FindOne(ctx, id)
if err != nil {
return nil, err
}
// 写回 L2 和 L1
if data, _ := json.Marshal(p); data != nil {
s.redis.SetexCtx(ctx, key, 3600+rand.Intn(600), string(data))
}
s.localCache.Set(key, p)
return p, nil
}多级缓存策略:
- L1(本地缓存):速度最快,但容量有限、不共享
- L2(Redis):共享、容量大,但有网络开销
- L3(DB):最慢,但数据最准确
读顺序:L1 → L2 → L3,任一层命中即返回。写顺序:L3 → 删 L2 → 删 L1。
3. 缓存预热
在低峰期或服务启动时,主动把热点数据加载到缓存:
go
func (s *CacheService) Warmup(ctx context.Context) error {
// 查出热点商品 ID
hotIDs, _ := s.model.FindHotProductIDs(ctx, 100)
for _, id := range hotIDs {
// 直接走 DB 查询并写缓存
s.GetProduct(ctx, id)
}
return nil
}4. 熔断降级
当 DB 压力过大时,返回降级数据(默认值、缓存旧数据),保护 DB:
go
import "github.com/zeromicro/go-zero/core/breaker"
func (s *CacheService) GetProduct(ctx context.Context, id int64) (*Product, error) {
// 用熔断器包裹 DB 查询
var product *Product
err := breaker.DoWithAcceptable(func() error {
p, err := s.model.FindOne(ctx, id)
if err != nil {
return err
}
product = p
return nil
})
if err != nil {
// 熔断后返回降级数据
return s.getFallbackProduct(id), nil
}
return product, nil
}五、缓存更新策略对比
| 策略 | 描述 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 先写 DB,再删缓存 | 中 | 低 | 通用 |
| Write-Through | 写 DB 同时写缓存 | 高 | 中 | 一致性要求高 |
| Write-Behind | 先写缓存,异步写 DB | 低 | 高 | 写多读少 |
| 双删 | 先删缓存→写 DB→延迟再删 | 高 | 中 | 强一致性 |
go-zero 默认采用 Cache-Aside,对大多数场景足够。强一致性需求可以用「延迟双删」:
go
func (m *UserModel) UpdateWithDoubleDelete(ctx context.Context, data *User) error {
key := fmt.Sprintf("user:%d", data.Id)
// 1. 先删缓存
m.DelCache(key)
// 2. 写 DB
_, err := m.ExecNoCacheCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
return conn.ExecCtx(ctx, "UPDATE users SET name=? WHERE id=?", data.Name, data.Id)
})
if err != nil {
return err
}
// 3. 延迟再删(异步)
go func() {
time.Sleep(500 * time.Millisecond)
m.DelCache(key)
}()
return nil
}六、实战:高并发查询的缓存方案
我们通过一个压测对比,展示缓存方案的实际效果。
1. 场景
- 商品详情接口:
GET /api/v1/product/:id - 数据量:1 万个商品
- 并发:1000 QPS
- 数据分布:80% 请求集中在 20% 的热点商品(典型长尾分布)
2. 无缓存方案
go
func (l *GetProductLogic) GetProduct(req *types.GetProductRequest) (*types.ProductResponse, error) {
product, err := l.svcCtx.ProductModel.FindOneFromDB(l.ctx, req.Id)
if err != nil {
return nil, err
}
// ...
}每次请求都查 DB。
3. go-zero 自动缓存方案
go
func (l *GetProductLogic) GetProduct(req *types.GetProductRequest) (*types.ProductResponse, error) {
// FindOne 自动走 CachedConn,含 singleflight 和空值缓存
product, err := l.svcCtx.ProductModel.FindOne(l.ctx, req.Id)
if err != nil {
return nil, err
}
// ...
}4. 多级缓存方案
go
type ProductCacheService struct {
localCache *collection.Cache
redis *redis.Redis
model ProductModel
sf *syncx.SingleFlight
}
func (s *ProductCacheService) GetProduct(ctx context.Context, id int64) (*Product, error) {
key := fmt.Sprintf("product:%d", id)
// L1: 本地缓存
if val, ok := s.localCache.Get(key); ok {
return val.(*Product), nil
}
// L2: Redis + singleflight
result, err := s.sf.Do(key, func() (any, error) {
// 在 singleflight 内查 Redis
if val, _ := s.redis.GetCtx(ctx, key); val != "" {
var p Product
if err := json.Unmarshal([]byte(val), &p); err == nil {
s.localCache.SetWithExpire(key, &p, time.Minute*5)
return &p, nil
}
}
// L3: DB
p, err := s.model.FindOne(ctx, id)
if err != nil {
return nil, err
}
// 写回 L2
if data, _ := json.Marshal(p); data != nil {
expire := 3600 + rand.Intn(600)
s.redis.SetexCtx(ctx, key, expire, string(data))
}
// 写回 L1
s.localCache.SetWithExpire(key, p, time.Minute*5)
return p, nil
})
if err != nil {
return nil, err
}
return result.(*Product), nil
}5. 性能对比(模拟数据)
| 方案 | 平均延迟 (ms) | P99 延迟 (ms) | DB QPS | Redis QPS |
|---|---|---|---|---|
| 无缓存 | 15 | 80 | 1000 | 0 |
| go-zero 自动缓存 | 2 | 10 | 50 | 1000 |
| 多级缓存 | 0.5 | 3 | 10 | 100 |
分析:
- 无缓存:DB QPS = 请求 QPS,DB 容易被打满
- go-zero 自动缓存:DB QPS 大幅下降(缓存命中率 95%+),延迟主要来自 Redis 网络
- 多级缓存:本地缓存命中后延迟极低,Redis QPS 也下降
6. 缓存命中率监控
在生产环境中,监控缓存命中率非常重要:
go
type Metrics struct {
cacheHit int64
cacheMiss int64
}
func (m *Metrics) RecordHit() { atomic.AddInt64(&m.cacheHit, 1) }
func (m *Metrics) RecordMiss() { atomic.AddInt64(&m.cacheMiss, 1) }
func (m *Metrics) HitRate() float64 {
hit := atomic.LoadInt64(&m.cacheHit)
miss := atomic.LoadInt64(&m.cacheMiss)
total := hit + miss
if total == 0 {
return 0
}
return float64(hit) / float64(total)
}更专业的做法是用 Prometheus:
go
import "github.com/prometheus/client_golang/prometheus"
var cacheCounter = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "cache_operations_total",
},
[]string{"name", "result"}, // result: hit / miss
)
func init() {
prometheus.MustRegister(cacheCounter)
}
// 在缓存查询点记录
cacheCounter.WithLabelValues("product", "hit").Inc()
cacheCounter.WithLabelValues("product", "miss").Inc()七、常见缓存问题排查
1. 缓存命中率低
可能原因:
- 过期时间太短
- key 设计不合理(每个请求都查不同 key)
- 数据访问不集中(长尾分布)
- 写操作频繁(频繁删缓存)
排查:监控命中率,分析 key 分布。
2. 缓存与 DB 不一致
可能原因:
- 先删缓存再写 DB(窗口期问题)
- 删缓存失败(Redis 故障)
- 主从延迟(Redis 主从同步延迟)
排查:对账工具定期对比缓存和 DB 数据。
3. 缓存占用内存过大
可能原因:
- key 没设过期
- 空值缓存积累
- 序列化数据冗余
排查:用 redis-cli --bigkeys 找大 key,定期清理。
八、小结
本篇深入讲解了 go-zero 的模型缓存设计与缓存防护机制。要点回顾:
CachedConn是 go-zero 的核心缓存抽象,把 DB 和 Redis 组合在一起- 查询流程:查 Redis → singleflight 合并 → 查 DB → 写缓存/空值标记
- 写流程:写 DB → 删缓存(Cache-Aside 策略)
- 缓存穿透防护:空值缓存(go-zero 内置)+ 布隆过滤器 + 限流 + 参数校验
- 缓存击穿防护:singleflight(go-zero 内置)+ 互斥锁 + 热点永不过期
- 缓存雪崩防护:随机过期时间(go-zero 内置抖动)+ 多级缓存 + 预热 + 熔断降级
- 强一致性场景可用「延迟双删」
- 多级缓存(本地 + Redis + DB)能显著降低延迟和 DB 压力
- 监控缓存命中率是运维缓存系统的关键指标
下一篇我们将进入服务治理,讲解 go-zero 内置的限流、熔断、负载均衡和服务发现。