每日一Go-56、什么是缓存穿透、击穿和雪崩?如何保障数据的一致性?

0 阅读8分钟

在 Go 语言开发高性能后端系统时,缓存(如 Redis)是必不可少的。但如果不处理好击穿、穿透、雪崩以及一致性问题,缓存反而可能成为系统的“定时炸弹”。

以下是针对这些核心概念的深度解析及 Go 语言的应对方案:

一、缓存穿透

  • 现象:查询一个根本不存在的数据(数据库里也没有)。由于缓存不命中,请求每次都会打到数据库,导致数据库压力过大。场景:恶意攻击或业务逻辑Bug。

  • 解决方案:

  • 缓存空对象:即使数据库返回空,也在Redis中存一个过期时间很短的特定值(如 nil 或 “empty”)。

  • 布隆过滤器:在访问缓存前,先通过布隆过滤器判断Key是否可能存在。Go可以使用github.com/bits-and-blooms/bloom库。

func GetDataWithAntiPenetration(ctx context.Context, key string) (string, error) {
    // 1. 尝试从 Redis 获取
    val, err := rdb.Get(ctx, key).Result()
    if err == redis.Nil {
        // 2. 缓存未命中,查数据库
        data, err := db.Query(key)
        if err == sql.ErrNoRows {
            // 【核心】:数据库没有,也存一个过期时间短的空值到缓存
            rdb.Set(ctx, key, "EMPTY_PLACEHOLDER"5*time.Minute)
            return "", errors.New("data not found")
        }
        // 3. 正常回填
        rdb.Set(ctx, key, data30*time.Minute)
        return data, nil
    }

    // 如果拿到的是占位符,说明数据库确实没值
    if val == "EMPTY_PLACEHOLDER" {
        return "", errors.New("data not found")
    }
    return val, nil
}

二、缓存击穿

  • 现象:一个热点Key(例如秒杀商品)在过期的瞬间,大量并发请求涌入。因为缓存失效,这些请求会同时去查询数据库并尝试回填缓存,到hi数据库瞬间挂掉。

  • 解决方案:

  • 设置逻辑过期:缓存永远存储,但在Value中记录一个过期时间。由后台Goroutine定期更新。

  • 互斥锁:使用 sync.Mutex 或 Redis 分布式锁SETNX,保证只有一个请求去查询数据库。

  • SingleFlight:Go官网扩展包 golang.org/x/sync/singleflight 可以将针对同一个Key的多个并发请求合并为一个,只执行一次DB查询。

import "golang.org/x/sync/singleflight"

var g singleflight.Group

func GetHotKeyWithSingleFlight(ctx context.Context, key string) (stringerror) {
    // 1. 尝试读缓存
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        return val, nil
    }

    // 2. 缓存失效,使用 singleflight 保证只有一个请求去查 DB
    // 第一个返回值是函数返回的结果,第二个是 error,第三个是是否发生了共享(合并)
    v, err, shared := g.Do(key, func() (interface{}, error) {
        // 模拟查 DB
        data, dbErr := db.QueryFromSQL(key) 
        if dbErr != nil {
            return nil, dbErr
        }
        // 回填缓存
        rdb.Set(ctx, key, data, 30*time.Minute)
        return data, nil
    })

    if err != nil {
        return "", err
    }
    return v.(string), nil
}

三、缓存雪崩

  • 现象:大量缓存在同一时间集中过期,或者Redis服务宕机。所有请求瞬间堆积到数据库,造成系统级崩溃。

  • 解决方案:

  • 过期时间随机化:为Key的过期时间加上一个随机抖动,防止集体失效。

  • 多级缓存:结合本地缓存(freecache/bigcache)和分布式缓存。

  • 熔断降级:当数据库压力过大时,直接返回默认值或错误,保护核心链路。

func SetDataWithRandomTTL(ctx context.Context, key string, value string) {
    // 设置基础过期时间 30 分钟
    baseTTL := 30 * time.Minute

    // 【核心】:生成 0-5 分钟的随机偏移量
    rand.Seed(time.Now().UnixNano())
    randomOffset := time.Duration(rand.Intn(300)) * time.Second

    actualTTL := baseTTL + randomOffset

    // 这样大量 Key 的过期时间就会均匀分布
    rdb.Set(ctx, key, value, actualTTL)
}

四、数据一致性

保证缓存与数据库数据同步是分布式系统的难点。常见的模式有:

1. Cache Aside(旁路缓存) - 最通用

  • 读:先读缓存,不中则读DB并写入缓存。

  • 写:先更新DB,再删除缓存。

    为什么删除而不是更新?防止并发写导致的数据覆盖。

    为什么先DB后删除?配合“延迟双删”策略可以极大降低数据不一致概率。

2. Read/Write Through(读写穿透)

把缓存当作主要数据,由缓存服务负责同步DB。代码层只与缓存交互。

3. Write Behind(异步回写)

更新数据时只更新缓存,然后异步批量更新DB。性能最高,但如果缓存宕机会丢数据(适用于点赞量、点击量)。

func GetSmartCache(ctx context.Context, key string) (stringerror) {
    // 1. 先查缓存
    val, err := rdb.Get(ctx, key).Result()
    if err == nil {
        if val == "EMPTY" { return "", errors.New("not found") }
        return val, nil
    }

    // 2. 击穿保护:同一时刻只有一个请求去读 DB
    v, err, _ := g.Do(key, func() (interface{}, error) {
        data, dbErr := db.GetFromDB(key)

        // 3. 穿透保护:处理 DB 为空的情况
        if dbErr == sql.ErrNoRows {
            rdb.Set(ctx, key, "EMPTY"5*time.Minute)
            return "", errors.New("not found")
        }

        // 4. 雪崩保护:设置带随机扰动的过期时间
        ttl := 30*time.Minute + time.Duration(rand.Intn(600))*time.Second
        rdb.Set(ctx, key, data, ttl)

        return data, nil
    })

    return v.(string), err
}

五、Go里的最佳写法

package repos
import (
    "codee_jun/internal/components"
    "codee_jun/internal/interfaces"
    "codee_jun/internal/models"
    ...
)
// Goods 商品仓储实例,提供商品数据的CRUD操作
var (
    Goods            *GoodsRepo
    GoodsBloomFilter *components.RedisBloomFilter
)
// GoodsRepo 商品仓储结构体,实现了Repo接口
// 封装了商品数据的数据库操作、缓存操作和布隆过滤器
type GoodsRepo struct {
    interfaces.Repo[*models.Goods]
}
// NewGoodsRepo 创建商品仓储实例
// 初始化Goods实例,并初始化布隆过滤器
func NewGoodsRepo() {
    Goods = &GoodsRepo{
        Repo: *interfaces.NewRepo[*models.Goods](components.DB),
    }
    initGoodsBloomFilter()
}
// init 包初始化函数,将商品仓储注册到仓储注册表中
func init() {
    RegisterRepos(NewGoodsRepo)
}
// initGoodsBloomFilter 初始化商品布隆过滤器
// 使用Redis实现布隆过滤器,用于快速判断商品ID是否存在
func initGoodsBloomFilter() {
    GoodsBloomFilter = components.NewRedisBloomFilter(components.Redis, "goods_bloom_filter")
    Goods.InitAllIds()
}
// InitAllIds 初始化所有商品ID到布隆过滤器
// 从数据库中查询所有商品ID,并添加到布隆过滤器中
// 用于防止缓存穿透,快速判断商品ID是否存在
func (r *GoodsRepo) InitAllIds() {
    var ids []uint
    r.Repo.DB.Model(&models.Goods{}).Pluck("id", &ids)
    for _, id := range ids {
        GoodsBloomFilter.Add(context.Background(), id)
    }
}
// AddOne 添加一个新商品
// 在添加商品后,将新商品的ID添加到布隆过滤器中
// 参数:
//   - c: Gin上下文
//   - model: 商品模型
//
// 返回:
//   - newId: 新插入的商品ID
//   - err: 错误信息
func (r *GoodsRepo) AddOne(c *gin.Context, model *models.Goods) (newId uint, err error) {
    newId, err = r.Add(c, model)
    GoodsBloomFilter.Add(context.Background(), newId)
    return
}
// GetById 根据ID获取商品信息
// 实现了多级缓存策略:
// 1. 布隆过滤器快速拦截不存在的ID(防止缓存穿透)
// 2. Redis缓存读取
// 3. SingleFlight击穿保护(防止大量并发请求打到数据库)
// 4. 数据库查询
// 5. 缓存回填(带随机过期时间防止缓存雪崩)
// 参数:
//   - c: Gin上下文
//   - id: 商品ID
//
// 返回:
//   - res: 商品模型
//   - err: 错误信息
func (r *GoodsRepo) GetById(c *gin.Context, id uint) (res *models.Goods, err error) {
    // --- 0. 第一步:Redis 布隆过滤器拦截 ---
    // 只有返回 false 时才确定不存在,返回 true 可能是误报,需进一步查缓存/DB
    // 布隆过滤器的作用:快速判断商品ID是否可能存在,避免无效的缓存和数据库查询
    exists, err := GoodsBloomFilter.Exists(c.Request.Context(), id)
    if err == nil && !exists {
        return nil, gorm.ErrRecordNotFound
    }
    // 1. 尝试读缓存
    // 构建缓存key,格式为 "goods:{id}"
    key := "goods:" + cast.ToString(id)
    val, err := components.Redis.Get(c.Request.Context(), key).Result()
    if err == nil {
        // 命中缓存穿透占位符,说明商品不存在
        if val == cacheNilValue {
            return nil, gorm.ErrRecordNotFound
        }
        // 反序列化缓存数据
        err = json.Unmarshal([]byte(val), &res)
        if err != nil {
            return
        }
        return
    }
    // 2. 击穿保护:使用singleflight合并请求
    // 当缓存失效时,大量并发请求会同时打到数据库,造成击穿
    // 使用singleflight确保同一时间只有一个请求去查数据库,其他请求等待结果
    rawRes, err, _ := sfGroup.Do(key, func() (interface{}, error) {
        // 双重检查:防止在等待锁的时候,缓存已被其他Goroutine填好
        val, err := components.Redis.Get(c.Request.Context(), key).Result()
        if err == nil {
            if val == cacheNilValue {
                return nil, gorm.ErrRecordNotFound
            }
            err = json.Unmarshal([]byte(val), &res)
            return res, err
        }
        // 3. 查DB并处理穿透
        var goods *models.Goods
        err = r.Repo.DB.WithContext(c.Request.Context()).Where(`id=?`, id).First(&goods).Error
        if err != nil {
            // 如果商品不存在,使用缓存穿透防护策略
            // 缓存空对象(占位符),设置较短过期时间(5分钟)
            // 这样后续请求会直接从缓存中获取到占位符,避免频繁查询数据库
            if errors.Is(err, gorm.ErrRecordNotFound) {
                components.Redis.Set(c.Request.Context(), key, cacheNilValue, 5*time.Minute)
                return nil, err
            }
            return nil, err
        }
        // 4. 缓存数据回填+雪崩保护(随机过期时间)
        // 将查询到的商品数据序列化后存入Redis
        data, _ := json.Marshal(goods)
        // 随机过期时间,防止缓存雪崩
        // 所有缓存同时过期会导致大量请求同时打到数据库
        // 设置300-600秒之间的随机过期时间,分散缓存失效时间
        ttl := time.Duration(rand.Intn(300)+300) * time.Second
        components.Redis.Set(c.Request.Context(), key, data, ttl)
        return goods, nil
    })
    if err != nil {
        return
    }
    res = rawRes.(*models.Goods)
    return
}
// UpdateById 根据ID更新商品信息
// 实现了数据库事务更新和缓存删除策略:
// 1. 开启事务更新数据库
// 2. 更新成功后删除缓存
// 3. 使用延迟双删策略,防止并发场景下的脏数据
// 参数:
//   - c: Gin上下文
//   - id: 商品ID
//   - data: 需要更新的字段和值
//
// 返回:
//   - err: 错误信息
func (r *GoodsRepo) UpdateById(c *gin.Context, id uint, data map[string]interface{}) (err error) {
    key := "goods:" + cast.ToString(id)
    // 1. 开启事务更新DB
    err = r.Repo.DB.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
        // 2. 更新DB
        // Omit(`created_at`) 排除创建时间字段,不更新
        var goods *models.Goods
        err = tx.Model(&goods).Omit(`created_at`).Where(`id=?`, id).Updates(data).Error
        if err != nil {
            return err
        }
        // 3. 更新完DB后直接删除缓存
        // 采用Cache Aside模式:先更新数据库,再删除缓存
        // 删除缓存而不是更新缓存,因为删除操作更简单,且避免并发更新的问题
        components.Redis.Del(c.Request.Context(), key)
        return nil
    })
    // 延迟双删
    // 在删除缓存后,延迟500ms再次删除缓存
    // 目的:防止在更新数据库和删除缓存之间,有其他请求读取了旧数据并写入了缓存
    // 延迟时间需要大于主从复制的延迟时间(如果有主从复制)
    time.AfterFunc(500*time.Millisecond, func() {
        components.Redis.Del(c.Request.Context(), key)
    })
    return
}

*源码地址*

1、公众号“Codee君”回复“每日一Go”获取源码

2、pan.baidu.com/s/1B6pgLWfS… 

友情链接:加班费计算器(vx小程序搜索“加班计”)


如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!