缓存穿透防护:布隆过滤器与空值缓存
缓存穿透是指大量请求查询不存在的数据,请求绕过缓存直达数据库。攻击场景下这种流量可以让数据库瞬间过载。防护方案分两层:
第一层是布隆过滤器(Bloom Filter),在缓存之前设置一个概率过滤器,将不存在的Key拦截在外:
import (
"github.com/bits-and-blooms/bloom/v3"
"github.com/redis/go-redis/v9"
)
type CacheWithBloom struct {
rdb *redis.Client
filter *bloom.BloomFilter
}
func NewCacheWithBloom(rdb *redis.Client, expectedItems uint) *CacheWithBloom {
return &CacheWithBloom{
rdb: rdb,
filter: bloom.NewWithEstimates(expectedItems, 0.01), // 1%误判率
}
}
func (c *CacheWithBloom) Get(ctx context.Context, key string) (string, error) {
// 布隆过滤器前置检查
if !c.filter.Test([]byte(key)) {
return "", redis.Nil // Key不存在,直接返回
}
// 查询Redis
val, err := c.rdb.Get(ctx, key).Result()
if err == redis.Nil {
// 缓存未命中,查询数据库
// ...
}
return val, err
}
func (c *CacheWithBloom) Set(ctx context.Context, key string, val string) error {
c.filter.Add([]byte(key)) // 写入时同步更新布隆过滤器
return c.rdb.Set(ctx, key, val, 0).Err()
}
第二层是空值缓存,将数据库查询为空的Key也缓存起来,设置较短的TTL(60-120秒)。布隆过滤器有1%的误判率,空值缓存作为兜底可以拦截漏网的穿透请求。
缓存击穿防护:互斥锁与逻辑过期
缓存击穿发生在热点Key过期的瞬间,大量并发请求同时穿透到数据库。解决方案有两种:
方案一:互斥锁(Mutex Lock)——只允许一个请求构建缓存,其他请求等待:
func (c *CacheGuard) GetWithMutex(ctx context.Context, key string, ttl time.Duration, builder func() (string, error)) (string, error) {
val, err := c.rdb.Get(ctx, key).Result()
if err == nil {
return val, nil
}
lockKey := "lock:" + key
locked, _ := c.rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
if locked {
// 获得锁,查询数据库并写入缓存
defer c.rdb.Del(ctx, lockKey)
val, err = builder()
if err != nil {
return "", err
}
c.rdb.Set(ctx, key, val, ttl)
return val, nil
}
// 未获得锁,短暂等待后重试读取缓存
time.Sleep(50 * time.Millisecond)
return c.GetWithMutex(ctx, key, ttl, builder)
}
方案二:逻辑过期(Logical Expiration)——缓存永不过期,在值中存储逻辑过期时间,异步更新:
type CacheItem struct {
Data string `json:"data"`
ExpireAt time.Time `json:"expire_at"`
}
func (c *CacheGuard) GetWithLogicalExpire(ctx context.Context, key string, builder func() (string, error)) (string, error) {
raw, err := c.rdb.Get(ctx, key).Result()
if err == redis.Nil {
return "", redis.Nil
}
var item CacheItem
json.Unmarshal([]byte(raw), &item)
if time.Now().Before(item.ExpireAt) {
return item.Data, nil // 未过期,直接返回
}
// 逻辑过期,触发异步更新
go func() {
lockKey := "lock:" + key
locked, _ := c.rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
if locked {
defer c.rdb.Del(ctx, lockKey)
newVal, _ := builder()
newItem := CacheItem{
Data: newVal,
ExpireAt: time.Now().Add(30 * time.Minute),
}
data, _ := json.Marshal(newItem)
c.rdb.Set(context.Background(), key, string(data), 0)
}
}()
return item.Data, nil // 返回旧数据,不阻塞
}
互斥锁方案保证数据强一致但吞吐受限,逻辑过期方案吞吐高但容忍短暂数据不一致。电商商品详情页用逻辑过期,库存查询用互斥锁。
缓存雪崩防护:随机TTL与多级缓存
缓存雪崩是大量Key同时过期的场景。最直接的防护是给TTL添加随机偏移量:
import "math/rand"
func SetWithRandomTTL(rdb *redis.Client, ctx context.Context, key, val string, baseTTL time.Duration) {
jitter := time.Duration(rand.Intn(300)) * time.Second // 0-300秒随机偏移
rdb.Set(ctx, key, val, baseTTL+jitter)
}
多级缓存架构进一步降低雪崩风险:
type MultiLevelCache struct {
l1 *freecache.Cache // 进程内本地缓存,10ms级
l2 *redis.Client // Redis集群,1ms级
}
func (c *MultiLevelCache) Get(ctx context.Context, key string) (string, error) {
// L1: 本地缓存
if val, err := c.l1.Get([]byte(key)); err == nil {
return string(val), nil
}
// L2: Redis
val, err := c.l2.Get(ctx, key).Result()
if err == nil {
c.l1.Set([]byte(key), []byte(val), 60) // 本地缓存60秒
return val, nil
}
return "", redis.Nil
}
L1本地缓存的TTL远短于L2 Redis缓存(60秒 vs 30分钟),避免本地缓存与Redis大面积同时失效。
热Key探测与本地缓存治理
热Key(Hot Key)是访问量远超平均的Key,可能导致单个Redis分片过载。Redis 7.0+提供了redis-cli --hotkeys命令检测热Key,但仅限于单节点。生产环境推荐使用代理层统计:
# Redis Proxy统计Key访问频率
# 使用Redis的MONITOR命令采样(低峰期)
redis-cli MONITOR | \
awk '{print $4}' | \
sort | uniq -c | sort -rn | head -20
# 更安全的方式:使用INFO commandstats
redis-cli INFO commandstats | grep keyspace_hits
确认热Key后的处理策略:
1. 本地缓存+短TTL:在应用进程内缓存热Key数据,TTL设5-10秒,减少Redis读取频率
2. Key打散:将热Key拆分为多个子Key,读取时随机选择一个,写入时更新所有子Key
// 热Key打散写入
func SetShardedHotKey(ctx context.Context, rdb *redis.Client, key string, val string, shards int) {
pipe := rdb.Pipeline()
for i := 0; i < shards; i++ {
shardKey := fmt.Sprintf("%s:shard:%d", key, i)
pipe.Set(ctx, shardKey, val, 30*time.Minute)
}
pipe.Exec(ctx)
}
// 热Key打散读取
func GetShardedHotKey(ctx context.Context, rdb *redis.Client, key string, shards int) (string, error) {
shardIdx := rand.Intn(shards)
shardKey := fmt.Sprintf("%s:shard:%d", key, shardIdx)
return rdb.Get(ctx, shardKey).Result()
}
3. 只读从库分担读流量:热Key读请求路由到从库,写请求走主库。Redis Cluster的READONLY模式或Codis集群的从库读都可以实现。
缓存一致性:延迟双删与Canal订阅
缓存与数据库的一致性是缓存策略中争议最多的部分。工程上有两种可靠方案:
延迟双删:先删缓存,再更新数据库,延迟一段时间再删一次缓存。延迟时间需大于一次数据库读+缓存写的耗时:
func UpdateWithDoubleDelete(ctx context.Context, rdb *redis.Client, db *sql.DB, key string, updateSQL string) {
cacheKey := "cache:" + key
// 1. 删除缓存
rdb.Del(ctx, cacheKey)
// 2. 更新数据库
db.ExecContext(ctx, updateSQL)
// 3. 延迟再删(覆盖并发读带来的脏缓存)
go func() {
time.Sleep(500 * time.Millisecond)
rdb.Del(context.Background(), cacheKey)
}()
}
Canal订阅Binlog:通过Canal监听MySQL Binlog变更,异步删除或更新对应缓存。一致性最强,但引入了Canal组件的运维成本。方案选择标准:QPS<1万用延迟双删,QPS>1万或对一致性要求极高用Canal。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-shen-du-you-hua-cong-huan-cun-chuan/