在 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, data, 30*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) (string, error) {
// 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) (string, error) {
// 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”获取源码
友情链接:加班费计算器(vx小程序搜索“加班计”)
如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!