Redis缓存穿透与雪崩防护实战:布隆过滤器、多级缓存与持久化选型

Redis缓存架构设计原则

Redis作为内存数据库,读写性能在10万QPS以上,是缓解数据库压力的核心组件。缓存架构设计的核心是在命中率、一致性和可用性之间取得平衡。命中率低则缓存形同虚设,一致性差则业务数据出错,可用性差则缓存一旦宕机数据库立刻被打垮。以下从缓存穿透、缓存雪崩、持久化方案三个维度展开实战方案。

缓存穿透防护实战

缓存穿透指大量请求查询一个数据库中也不存在的数据,每次请求都穿透缓存直接打到数据库。典型场景:爬虫遍历ID、恶意攻击、业务删除数据后未清理缓存。

方案一:布隆过滤器

在Redis前面加一层布隆过滤器,所有可能存在的数据哈希映射到一个足够大的bitmap中。请求进来先查布隆过滤器,判断不存在的直接拒绝,判断存在的再查Redis和数据库。布隆过滤器有误判率(不存在可能判为存在),但不会漏判(存在一定判为存在):

import mmh3
import math

class BloomFilter:
    def __init__(self, capacity: int, error_rate: float = 0.001):
        """capacity: 预计元素数量, error_rate: 期望误判率"""
        self.size = int(-capacity * math.log(error_rate) / (math.log(2) ** 2))
        self.hash_count = int(self.size / capacity * math.log(2))
        self.bit_array = bytearray(math.ceil(self.size / 8))
    
    def _get_offsets(self, key: str) -> list[int]:
        offsets = []
        for i in range(self.hash_count):
            h = mmh3.hash(str(key), i) % self.size
            offsets.append(h)
        return offsets
    
    def add(self, key: str):
        for offset in self._get_offsets(key):
            byte_idx = offset // 8
            bit_idx = offset % 8
            self.bit_array[byte_idx] |= (1 << bit_idx)
    
    def exists(self, key: str) -> bool:
        for offset in self._get_offsets(key):
            byte_idx = offset // 8
            bit_idx = offset % 8
            if not (self.bit_array[byte_idx] & (1 << bit_idx)):
                return False
        return True

# 使用:加载所有合法ID到布隆过滤器
bf = BloomFilter(capacity=10_000_000, error_rate=0.001)
for item_id in db_query_all_ids():
    bf.add(str(item_id))

def get_item(item_id: str):
    if not bf.exists(item_id):
        return None  # 布隆过滤器判定不存在,直接返回
    return cache_or_db_get(item_id)

方案二:缓存空值

当数据库查询结果为空时,将空结果缓存起来,设置较短的过期时间(60-300秒)。这样重复请求不会穿透到数据库:

import redis
import json

r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

def get_user(user_id: str) -> dict | None:
    cache_key = f"user:{user_id}"
    
    # 查缓存
    cached = r.get(cache_key)
    if cached is not None:
        if cached == "NULL":
            return None  # 缓存的空值
        return json.loads(cached)
    
    # 查数据库
    user = db_query_user(user_id)
    if user is None:
        # 缓存空值,TTL设短(120秒),避免占用过多内存
        r.setex(cache_key, 120, "NULL")
        return None
    
    # 缓存真实数据,TTL较长
    r.setex(cache_key, 3600, json.dumps(user))
    return user

缓存雪崩预防方案

缓存雪崩指大量缓存key在同一时刻过期,或Redis节点宕机,瞬间大量请求涌入数据库。与穿透不同,雪崩的请求查询的是合法存在的数据,只是缓存恰好失效了。

方案一:过期时间加随机抖动

设置缓存TTL时加上随机偏移量,避免大量key在同一秒过期:

import random
import redis

r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

def set_cache_with_jitter(key: str, value: str, base_ttl: int, jitter_range: int = 300):
    """base_ttl: 基础过期时间(秒), jitter_range: 随机偏移范围(秒)"""
    ttl = base_ttl + random.randint(0, jitter_range)
    r.setex(key, ttl, value)

# 示例:基础TTL 1小时,加0-5分钟随机抖动
set_cache_with_jitter("user:1001", user_data, 3600, 300)
set_cache_with_jitter("user:1002", user_data, 3600, 300)
# 两个key的过期时间分别为3723秒和3891秒,不会同时失效

方案二:缓存永不过期 + 异步更新

缓存不设TTL,通过后台任务定期刷新。即使刷新任务失败,旧数据仍在缓存中可用,不会击穿到数据库:

import threading
import time
import redis

r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

def async_refresh(key: str, ttl_key: str, refresh_func, refresh_interval: int = 1800):
    """异步刷新缓存:检查逻辑过期时间,过期则触发后台刷新"""
    # 检查逻辑过期标记
    expire_at = r.get(ttl_key)
    if expire_at and int(expire_at) > time.time():
        return  # 未过期,直接返回
    
    # 标记为正在刷新(防并发刷新)
    lock_key = f"refreshing:{key}"
    if r.set(lock_key, "1", nx=True, ex=30):  # 30秒分布式锁
        try:
            # 后台线程刷新数据
            def refresh():
                try:
                    new_data = refresh_func()
                    r.set(key, json.dumps(new_data))
                    r.set(ttl_key, int(time.time() + refresh_interval))
                finally:
                    r.delete(lock_key)
            
            threading.Thread(target=refresh, daemon=True).start()
        except Exception:
            r.delete(lock_key)

方案三:多级缓存 + 熔断降级

本地缓存(如Python的cachetools、Go的bigcache)作为L1,Redis作为L2,数据库作为L3。Redis不可用时降级到本地缓存:

from cachetools import TTLCache
import redis
import json

# L1本地缓存:容量1000,TTL 60秒
local_cache = TTLCache(maxsize=1000, ttl=60)
# L2 Redis
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

def get_data(key: str, db_query_func) -> dict | None:
    # L1: 本地缓存
    if key in local_cache:
        return local_cache[key]
    
    # L2: Redis
    try:
        cached = redis_client.get(key)
        if cached is not None:
            data = json.loads(cached)
            local_cache[key] = data  # 回填L1
            return data
    except redis.ConnectionError:
        pass  # Redis不可用,降级继续
    
    # L3: 数据库
    data = db_query_func(key)
    if data is not None:
        try:
            redis_client.setex(key, 3600, json.dumps(data))
        except redis.ConnectionError:
            pass  # Redis不可用,只回填L1
        local_cache[key] = data
    return data

Redis持久化方案对比与选型

Redis数据存在内存中,进程退出后数据丢失。持久化方案决定了数据恢复能力和性能开销:

RDB(快照):定期将内存数据全量写入磁盘dump.rdb文件。优点是文件紧凑、恢复速度快(直接加载二进制文件)、对性能影响小(fork子进程写)。缺点是两次快照之间的数据可能丢失、fork大内存实例时可能卡顿。

AOF(Append Only File):将每个写命令追加到日志文件。优点是数据安全性高(最多丢1秒数据)、文件可读性好。缺点是文件体积大、恢复速度慢(重放所有命令)、对性能有一定影响。

混合持久化(推荐):RDB + AOF结合。Redis 4.0+支持在AOF重写时将当前数据以RDB格式写入AOF文件头部,后续增量命令追加在后面:

# redis.conf 配置
# 开启AOF
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec    # 每秒刷盘,兼顾安全和性能

# 开启混合持久化
aof-use-rdb-preamble yes

# RDB配置(作为备份补充)
save 900 1        # 900秒内有1次修改则快照
save 300 10       # 300秒内有10次修改则快照
save 60 10000     # 60秒内有10000次修改则快照

恢复时先加载AOF文件头部的RDB数据(快速加载大部分数据),再重放尾部增量命令(补齐最新数据),恢复速度比纯AOF快数倍。

Redis持久化方案选型决策

根据业务场景选择持久化策略:

纯缓存场景:关闭持久化(save "",appendonly no),数据全可从数据库重新加载,追求极致性能。

会话存储/计数器:开启AOF,appendfsync everysec,最多丢1秒数据可接受。

排行榜/社交关系:混合持久化,RDB做定时全量快照便于快速恢复,AOF保证增量不丢。

消息队列:只开RDB,消息有消费确认机制,丢失可重发,不需要AOF的高开销。

主从复制场景:主节点关闭持久化(减少fork开销),从节点开启持久化(负责数据备份)。主节点宕机由从节点提升为主,数据从从节点的持久化文件恢复。主节点不做持久化是为了避免fork大内存实例导致的延迟毛刺——一个32GB内存的Redis实例fork时需要复制页表,可能产生数百毫秒的停顿。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-chuan-tou-yu-xue-beng-fang-hu-shi-zhan-bu/

(0)
小编小编
上一篇 2026年8月7日
下一篇 2026年8月7日

相关推荐

Redis缓存穿透与雪崩防护实战:布隆过滤器、多级缓存与熔断降级

Redis缓存穿透的成因与诊断

缓存穿透指大量请求查询一个在缓存和数据库中都不存在的key,请求绕过缓存直达数据库。典型场景:恶意爬虫用不存在的ID遍历接口、业务上线前预热遗漏的空值key、参数拼接错误导致的无效查询。穿透的杀伤力在于每个请求都绕过缓存层,在高并发下数据库连接池迅速耗尽。

诊断方法:监控Redis的keyspace_misses指标与数据库QPS,当misses指标突增且数据库QPS同步飙升时,大概率是穿透问题。另一个特征是慢查询日志中出现大量SELECT … WHERE id = ?返回0行的记录。

布隆过滤器防穿透实战

布隆过滤器(Bloom Filter)是一种空间高效的概率型数据结构,可以判断一个元素”一定不存在”或”可能存在”。在缓存前置布隆过滤器,查询请求先过布隆过滤器——如果判断key不存在,直接返回null不查数据库;如果判断可能存在,再走缓存→数据库的常规路径。

# Redis布隆过滤器实现(需RedisBloom模块)
// 1. 初始化:将所有合法ID加载到布隆过滤器
const redis = require('redis');
const client = redis.createClient();

async function initBloomFilter() {
    const allUserIds = await db.query('SELECT id FROM users');
    const pipeline = client.pipeline();

    for (const row of allUserIds) {
        // BF.ADD添加元素到布隆过滤器
        pipeline.call('BF.ADD', 'user:bloom', row.id.toString());
    }
    await pipeline.exec();
}

// 2. 查询时先检查布隆过滤器
async function getUser(userId) {
    // 先检查布隆过滤器
    const exists = await client.call('BF.EXISTS', 'user:bloom', userId.toString());
    if (!exists) {
        return null;  // 一定不存在,直接返回
    }

    // 可能存在,走缓存→数据库路径
    const cacheKey = `user:${userId}`;
    let data = await client.get(cacheKey);

    if (!data) {
        data = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
        if (data) {
            await client.set(cacheKey, JSON.stringify(data), 'EX', 3600);
        } else {
            // 防止穿透:缓存空值,短TTL
            await client.set(cacheKey, 'NULL', 'EX', 60);
        }
    } else if (data === 'NULL') {
        return null;
    }

    return data;
}

布隆过滤器存在误判率(false positive),误判时请求仍会穿透到数据库,但概率可控。通过调整BF.RESERVE的error_rate和capacity参数,误判率可降到0.01%以下。布隆过滤器不支持删除,如果业务需要删除元素,改用Counting Bloom Filter或在元素变更时重建过滤器。

缓存空值方案与短TTL策略

不依赖RedisBloom模块的轻量替代方案是缓存空值:当数据库查询返回空结果时,在Redis中写入一个特殊标记值(如”NULL”),设置短TTL(30-120秒)。后续相同key的请求命中缓存中的空值标记,直接返回不查数据库。

// 缓存空值方案
public <T> T getWithNullCache(String key, Class<T> type, Supplier<T> dbQuery) {
    String value = redis.get(key);

    if (value != null) {
        if ("NULL".equals(value)) {
            return null;  // 命中空值缓存
        }
        return JSON.parseObject(value, type);
    }

    // 缓存未命中,查数据库
    T data = dbQuery.get();

    if (data != null) {
        redis.setex(key, 3600, JSON.toJSONString(data));  // 正常数据1小时
    } else {
        redis.setex(key, 60, "NULL");  // 空值缓存60秒
    }

    return data;
}

空值缓存的TTL不能太长——如果数据库中新增了该key的数据,空值缓存会导致一段时间内用户看不到新数据。60-120秒是较为合理的折中值,业务对实时性要求高的场景可缩短到15秒。配合消息队列监听数据变更事件主动清除空值缓存,可进一步缩短不一致窗口。

缓存雪崩的成因与防护

缓存雪崩指大量key在同一时刻集中过期,或Redis集群整体不可用,导致请求全部涌向数据库。与穿透不同,雪崩的key是真实存在的,只是同时失效了。最常见的原因是批量设置缓存时使用了相同的TTL——所有key在TTL到期那一秒同时失效。

TTL随机偏移与多级缓存

防止key集中过期的核心手段是TTL加随机偏移。设置缓存时不使用固定TTL,而是在基准TTL基础上加一个随机值(基准TTL的10%-30%),打散过期时间点。

import random
import redis

r = redis.Redis()

def set_cache_with_jitter(key, value, base_ttl=3600):
    """设置缓存并加入TTL随机偏移"""
    jitter = random.randint(0, int(base_ttl * 0.3))  # 0~30%的随机偏移
    ttl = base_ttl + jitter
    r.setex(key, ttl, value)

# 批量预热时效果显著
# 10000个key,base_ttl=3600,偏移后过期时间分布在3600~4680秒
# 避免第3600秒同时过期10800个key

多级缓存是另一道防线。L1用进程内缓存(Caffeine/Guava Cache),L2用Redis。请求先查L1,miss再查L2,miss再查数据库。L1缓存没有网络开销,能扛住Redis短时间不可用的场景。L1的TTL设置比L2短(L1: 60s, L2: 3600s),L1过期后回源L2而非数据库。

// Java多级缓存实现(Caffeine + Redis)
@Configuration
public class MultiLevelCacheConfig {

    @Bean
    public Cache<String, String> localCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(60, TimeUnit.SECONDS)    // L1: 60秒
            .recordStats()
            .build();
    }
}

@Service
public class CacheService {

    @Autowired
    private Cache<String, String> localCache;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public String get(String key) {
        // L1: Caffeine本地缓存
        String value = localCache.getIfPresent(key);
        if (value != null) {
            return value;
        }

        // L2: Redis分布式缓存
        value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            localCache.put(key, value);  // 回填L1
            return value;
        }

        return null;  // 两级缓存均miss
    }

    public void put(String key, String value, long redisTtlSeconds) {
        localCache.put(key, value);
        redisTemplate.opsForValue().set(key, value, redisTtlSeconds, TimeUnit.SECONDS);
    }
}

Redis集群故障的熔断保护

当Redis集群整体不可用时(网络分区、主从切换、OOM),应用不应无限等待或反复重试——这会让线程池耗尽,拖垮整个服务。熔断器(Circuit Breaker)在检测到Redis连续失败N次后进入断路状态,后续请求跳过Redis直接查数据库或返回降级数据。一段时间后进入半开状态,放行少量请求探测Redis是否恢复,恢复则闭合断路器。

// Resilience4j熔断器配置
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)            // 失败率超50%触发断路
    .waitDurationInOpenState(Duration.ofSeconds(30))  // 断路30秒
    .permittedNumberOfCallsInHalfOpenState(5)         // 半开状态放行5次
    .slidingWindowType(SlidingWindowType.COUNTING)
    .slidingWindowSize(20)               // 滑动窗口20次调用
    .build();

CircuitBreaker circuitBreaker = CircuitBreaker.of("redis", config);

public String getWithCircuitBreaker(String key) {
    return CircuitBreaker.decorateSupplier(circuitBreaker, () -> {
        String value = redisTemplate.opsForValue().get(key);
        if (value == null) throw new RedisKeyNotFoundException();  // key不存在不算失败
        return value;
    }).get();
}

// 熔断后的降级逻辑
public String getWithFallback(String key) {
    Supplier<String> supplier = CircuitBreaker.decorateSupplier(circuitBreaker, () ->
        redisTemplate.opsForValue().get(key)
    );
    Supplier<String> fallback = SupplierUtils.decorateSupplierWithFallback(
        supplier, circuitBreaker, e -> {
            log.warn("Redis circuit breaker open, falling back to DB");
            return dbQuery(key);  // 降级查数据库
        }
    );
    return fallback.get();
}

热点key的识别与本地缓存加固

雪崩场景下还有一种容易被忽视的子类型——热点key失效。某个key的QPS占整体10%以上(如热门商品详情、热搜词条),该key过期的瞬间大量请求同时回源数据库。常规TTL偏移无法解决,因为偏移值相对于大流量只是几秒的延迟。

热点key的防护手段:一是在应用层做本地缓存,热点key永远有一份进程内副本;二是热点key设置不设过期时间(TTL=-1),通过数据变更事件主动更新缓存。结合Redis的CLIENT LIST和MONITOR命令可以识别热点key——连接数最多的client或出现频率最高的key即为热点。

# Redis热点key分析脚本
import redis
from collections import Counter

r = redis.Redis()

# 方法1: 使用redis-cli --hot-keys(需要maxmemory-policy为LFU)
# redis-cli --hot-keys

# 方法2: 短时间采样MONITOR输出统计key频率
def find_hot_keys(duration_seconds=10):
    """采样MONITOR输出,统计key访问频率"""
    key_counter = Counter()
    start = time.time()

    with r.monitor() as monitor:
        for command in monitor:
            if time.time() - start > duration_seconds:
                break
            if len(command['args']) > 1 and command['args'][0] in ('GET', 'HGET', 'HGETALL'):
                key = command['args'][1]
                key_counter[key] += 1

    return key_counter.most_common(20)

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-chuan-tou-yu-xue-beng-fang-hu-shi-zhan-bu/

(0)
小编小编
上一篇 2026年7月27日
下一篇 2026年7月27日

相关推荐