前阵子电商大促活动,核心商品详情页突然打不开,报警短信轰炸式地发过来。进监控一看,数据库 CPU 直接拉满到 100%,连接池瞬间被撑爆。排查后发现,运营临时配置了一个爆款商品的秒杀活动,由于缓存预热没做好,活动一开始,几万个高并发突发请求同时打过来。当时缓存正好过期,这股海量流量直接穿透缓存击穿到 DB,导致数据库瞬间瘫痪。
这种高并发下的“热点数据击穿”和“缓存雪崩”绝对是每个后端开发都绕不开的大坑。如果不对查库操作做并发限制,一旦缓存失效,所有请求都会觉得“我有责任去更新缓存”,然后疯狂调 SQL。
要搞定这个痛点,最常用的招数就是基于 Redis 的 SETNX(或者新版 Redis 的 SET key value NX PX)去实现一个分布式互斥锁(Mutex)。当缓存失效时,只有一个线程能抢到互斥锁,获准去查库并重构缓存,其他抢不到锁的线程只能稍微眯一会儿(Sleep)然后重新尝试获取缓存。
下面是一个在 Go 语言中利用 go-redis 客户端实现的完整、防击穿的 Mutex 缓存读取逻辑:
package main
import (
"context"
"errors"
"time"
"github.com/redis/go-redis/v9"
)
type CacheManager struct {
redisClient *redis.Client
dbClient *MockDBClient
}
type MockDBClient struct{}
func (db *MockDBClient) FetchFromDB(ctx context.Context, key string) (string, error) {
// 模拟一次耗时 200ms 的复杂 DB 查询
time.Sleep(200 * time.Millisecond)
return "mocked-db-value-for-" + key, nil
}
func (cm *CacheManager) GetWithMutex(ctx context.Context, key string, expire time.Duration) (string, error) {
// 1. 尝试直接从 Redis 获取缓存
val, err := cm.redisClient.Get(ctx, key).Result()
if err == nil {
return val, nil
}
if !errors.Is(err, redis.Nil) {
return "", err
}
// 2. 缓存失效,尝试抢占互斥锁
lockKey := key + ":mutex"
// 设置 5 秒的锁超时时间,防止拿到锁的实例挂掉导致死锁
acquired, err := cm.redisClient.SetNX(ctx, lockKey, "1", 5*time.Second).Result()
if err != nil {
return "", err
}
if acquired {
// 抢锁成功,负责查 DB 并重写缓存
defer cm.redisClient.Del(ctx, lockKey)
dbVal, dbErr := cm.dbClient.FetchFromDB(ctx, key)
if dbErr != nil {
return "", dbErr
}
// 将新值写入缓存
err = cm.redisClient.Set(ctx, key, dbVal, expire).Err()
if err != nil {
return "", err
}
return dbVal, nil
} else {
// 抢锁失败,说明有其他线程正在重构缓存。等待 50 毫秒后重试
time.Sleep(50 * time.Millisecond)
return cm.GetWithMutex(ctx, key, expire)
}
}
使用这种机制后,我们在线上进行了压测,数据库的并发压力直线下降,原本上万的并发查询在缓存失效的一瞬间,最终只有 1 个请求真正打到了 DB 上,其余全部在短时间内被阻挡在外,等待重试直接命中新缓存。
不过需要注意,这种自旋重试的写法如果高并发请求过多,可能会导致大量协程堆积,甚至造成内存溢出。在更极端的生产环境里,可能需要结合 SingleFlight(Go 官方库提供的单路合并机制)来做本地内存级别的合并,或者直接做物理层面的热点数据永不过期策略。
你们在生产环境中碰到缓存击穿,是用 SingleFlight 还是用 Redis 互斥锁,或者是其他更骚的操作?