Redis作为应用层缓存能大幅降低数据库压力,但缓存穿透、缓存击穿和缓存雪崩是三个经典问题。穿透指查询不存在的数据导致请求直达数据库;击穿指热点Key过期瞬间大量并发请求穿透到数据库;雪崩指大量Key同时过期导致数据库压力骤增。本文通过具体配置和代码实现三种问题的解决方案,并覆盖热点Key发现与缓存一致性保障。
缓存穿透问题与布隆过滤器方案
缓存穿透的典型场景:恶意请求查询不存在的ID(如负数ID或不存在的用户ID),缓存层无数据,每次请求都打到数据库。布隆过滤器(Bloom Filter)通过概率性数据结构,在缓存层之前拦截不存在的Key。
布隆过滤器原理:初始化一个长度为N的位数组(全为0)和K个哈希函数。写入元素时,用K个哈希函数计算K个位置,设为1。查询时,同样计算K个位置,如果全部为1则可能存在,任意一个为0则一定不存在。
Redis中通过RedisBloom模块或Lua脚本实现布隆过滤器:
# 安装RedisBloom模块
# 从 https://github.com/RedisBloom/RedisBloom 下载编译
# redis.conf 添加: loadmodule /path/to/redisbloom.so
# 创建布隆过滤器(容量100万,误判率0.1%)
redis-cli BF.RESERVE user_filter 0.001 1000000
# 添加元素
redis-cli BF.ADD user_filter "user:1001"
redis-cli BF.ADD user_filter "user:1002"
# 检查元素是否存在
redis-cli BF.EXISTS user_filter "user:1001" # 返回1
redis-cli BF.EXISTS user_filter "user:9999" # 返回0
Go代码实现查询拦截:
func GetUserByID(ctx context.Context, rdb *redis.Client, bf *BloomFilter, userID string) (*User, error) {
// 第一步:布隆过滤器检查
exists, err := bf.Exists(ctx, "user:"+userID)
if err != nil || !exists {
return nil, ErrUserNotFound // 一定不存在,直接返回
}
// 第二步:查询缓存
cached, err := rdb.Get(ctx, "user:"+userID).Bytes()
if err == nil {
var user User
json.Unmarshal(cached, &user)
return &user, nil
}
// 第三步:查询数据库
user, err := db.GetUserByID(ctx, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 数据库也不存在,设置空值标记(短过期时间)
rdb.Set(ctx, "user:"+userID, "", 5*time.Minute)
return nil, ErrUserNotFound
}
return nil, err
}
// 写入缓存
data, _ := json.Marshal(user)
rdb.Set(ctx, "user:"+userID, data, 30*time.Minute)
return user, nil
}
空值缓存方案是对布隆过滤器的补充:查询数据库不存在的Key也缓存空值(短过期时间5分钟),防止同一不存在的Key反复打数据库。两种方案可组合使用:布隆过滤器拦截绝大部分不存在的Key,空值缓存处理漏网的情况。
缓存击穿与互斥锁保护策略
缓存击穿发生在热点Key过期瞬间,大量并发请求同时穿过缓存查询数据库。解决方案是互斥锁(Mutex Lock):第一个穿透请求获取锁后查询数据库并重建缓存,其余请求等待锁释放后直接读缓存。
基于Redis SETNX实现互斥锁:
func GetHotProduct(ctx context.Context, rdb *redis.Client, productID string) (*Product, error) {
cacheKey := "product:" + productID
lockKey := "lock:" + cacheKey
// 查询缓存
if cached, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil {
var p Product
json.Unmarshal(cached, &p)
return &p, nil
}
// 缓存未命中,尝试获取互斥锁
lockOK, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
if err != nil {
return nil, err
}
if lockOK {
// 获取锁成功,查询数据库
defer rdb.Del(ctx, lockKey) // 释放锁
product, err := db.GetProductByID(ctx, productID)
if err != nil {
return nil, err
}
// 写入缓存(逻辑过期:不设置TTL,通过后台刷新)
data, _ := json.Marshal(product)
rdb.Set(ctx, cacheKey, data, 30*time.Minute)
return product, nil
}
// 获取锁失败,短暂等待后重试读缓存
time.Sleep(50 * time.Millisecond)
return GetHotProduct(ctx, rdb, productID) // 递归重试
}
逻辑过期方案(无锁设计):缓存不设TTL,但在value中存储逻辑过期时间。请求读到缓存后检查逻辑过期时间,若已过期则异步刷新缓存,当前请求返回旧数据。该方案不阻塞请求,但短时间返回旧数据。
type CacheData struct {
Data interface{} `json:"data"`
ExpireAt int64 `json:"expire_at"` // 逻辑过期时间戳
}
func GetWithLogicalExpire(ctx context.Context, rdb *redis.Client, key string) (interface{}, error) {
raw, err := rdb.Get(ctx, key).Bytes()
if err != nil {
// 缓存未命中,走正常查询流程
return loadFromDB(ctx, rdb, key)
}
var cd CacheData
json.Unmarshal(raw, &cd)
// 检查逻辑是否过期
if time.Now().Unix() > cd.ExpireAt {
// 异步刷新(使用互斥锁避免重复刷新)
lockKey := "lock:" + key
if ok, _ := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result(); ok {
go refreshCache(ctx, rdb, key) // 后台刷新
}
}
return cd.Data, nil // 返回旧数据,不阻塞
}
缓存雪崩预防:过期时间随机化与多级缓存
缓存雪崩的两个常见原因:大量Key同时过期,或Redis服务宕机。过期时间随机化解决前者,多级缓存和集群部署解决后者。
过期时间随机化:
func SetCache(ctx context.Context, rdb *redis.Client, key string, value interface{}, ttl time.Duration) error {
// 在基础TTL上增加随机偏移量,避免同时过期
jitter := time.Duration(rand.Intn(300)) * time.Second // 0-300秒随机
actualTTL := ttl + jitter
data, _ := json.Marshal(value)
return rdb.Set(ctx, key, data, actualTTL).Err()
}
// 批量设置时使用
func BatchSetCache(ctx context.Context, rdb *redis.Client, items map[string]interface{}) error {
pipe := rdb.Pipeline()
for key, val := range items {
ttl := 30*time.Minute + time.Duration(rand.Intn(600))*time.Second
data, _ := json.Marshal(val)
pipe.Set(ctx, key, data, ttl)
}
_, err := pipe.Exec(ctx)
return err
}
多级缓存架构:本地缓存(L1)+ Redis缓存(L2)+ 数据库(L3):
type MultiLevelCache struct {
localCache *lru.Cache // 本地缓存(进程内)
rdb *redis.Client // Redis分布式缓存
db *sql.DB // 数据库
}
func (c *MultiLevelCache) Get(ctx context.Context, key string) (interface{}, error) {
// L1: 本地缓存
if val, ok := c.localCache.Get(key); ok {
return val, nil
}
// L2: Redis缓存
if val, err := c.rdb.Get(ctx, key).Result(); err == nil {
c.localCache.Add(key, val) // 回填本地缓存
return val, nil
}
// L3: 数据库
val, err := c.db.QueryRow(ctx, key)
if err != nil {
return nil, err
}
// 回填L2和L1
c.rdb.Set(ctx, key, val, 30*time.Minute)
c.localCache.Add(key, val)
return val, nil
}
本地缓存使用LRU淘汰策略,容量根据JVM/Go进程内存设置。本地缓存命中率可通过Prometheus监控,命中率过低说明本地缓存容量不足或热点Key分布不均匀。
热点Key发现与本地缓存优化
热点Key指访问量远超平均水平的Key,可能导致单个Redis节点压力过大。发现热点Key的方案包括Redis的hotkeys功能和客户端统计。
# Redis 4.0+ 使用LFU淘汰策略时查看热点Key
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli OBJECT FREQ key_name # 查看Key访问频率
# 使用redis-cli --hotkeys
redis-cli --hotkeys
# 使用MONITOR命令(生产环境慎用,影响性能)
redis-cli MONITOR | grep -o "GET [^ ]*" | sort | uniq -c | sort -rn | head -20
Go客户端侧热点Key统计:
type HotKeyDetector struct {
counter map[string]*atomic.Int64
threshold int64
mu sync.RWMutex
}
func (d *HotKeyDetector) Record(key string) {
d.mu.RLock()
c, ok := d.counter[key]
d.mu.RUnlock()
if !ok {
d.mu.Lock()
if c, ok = d.counter[key]; !ok {
c = &atomic.Int64{}
d.counter[key] = c
}
d.mu.Unlock()
}
val := c.Add(1)
if val == d.threshold {
log.Printf("热点Key检测: %s 访问次数达 %d", key, val)
}
}
发现热点Key后,在应用层本地缓存该Key的数据,减少对Redis的访问。热点Key的数据变更通过发布订阅通知所有实例更新本地缓存。
Redis缓存一致性保障方案
缓存与数据库一致性是缓存系统的核心挑战。常用方案有Cache Aside、Write Through和Write Behind。
Cache Aside(旁路缓存)是最常用的方案:读时先查缓存,未命中查数据库并回填;写时先更新数据库再删除缓存。
// Cache Aside 读
func ReadData(ctx context.Context, key string) (string, error) {
val, err := rdb.Get(ctx, key).Result()
if err == nil {
return val, nil
}
val, err = db.QueryRow(ctx, key)
if err != nil {
return "", err
}
rdb.Set(ctx, key, val, 30*time.Minute)
return val, nil
}
// Cache Aside 写
func WriteData(ctx context.Context, key, value string) error {
// 先更新数据库
if err := db.Update(ctx, key, value); err != nil {
return err
}
// 再删除缓存(不是更新缓存,避免并发写入不一致)
if err := rdb.Del(ctx, key).Err(); err != nil {
log.Printf("删除缓存失败: %v", err)
// 发送到消息队列,异步重试删除
mq.Publish("cache_delete", key)
}
return nil
}
延迟双删策略解决主从复制延迟导致的不一致:写入后先删缓存,延迟500ms后再删一次。第二次删除清理在主从复制期间被旧数据回填的缓存。
func WriteWithDoubleDelete(ctx context.Context, key, value string) error {
rdb.Del(ctx, key) // 第一次删除
if err := db.Update(ctx, key, value); err != nil {
return err
}
time.AfterFunc(500*time.Millisecond, func() {
rdb.Del(ctx, key) // 延迟第二次删除
})
return nil
}
删除缓存失败时的保障机制:将删除操作写入消息队列,消费者不断重试直到成功。通过binlog监听(如Canal)自动触发缓存删除是更可靠的方案,保证数据库变更一定能同步到缓存层。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-shi-zhan-huan-cun-chuan-tou-ji-chuan/