Redis缓存策略实战:穿透击穿雪崩问题与布隆过滤器防护方案

Redis作为缓存层是高并发系统中最常见的架构模式,但缓存穿透、缓存击穿、缓存雪崩三类故障会在流量高峰时直接击穿到数据库层,导致服务不可用。这三类问题的防护需要从缓存策略设计、数据预热、自动恢复机制三个层面系统解决,布隆过滤器是其中性价比最高的防护手段之一。

缓存穿透:查询不存在的数据

缓存穿透指大量请求查询数据库中不存在的key,Redis中自然也没有,每次请求都穿透到数据库。常见场景是恶意攻击或爬虫扫描:

// 缓存穿透漏洞 - 不缓存null值
public User getUserById(Long id) {
    String key = "user:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        return "NULL".equals(cached) ? null : JSON.parseObject(cached, User.class);
    }
    User user = userMapper.selectById(id);
    if (user == null) {
        redis.setex(key, 60, "NULL");  // 修复:缓存空值短TTL
        return null;
    }
    redis.setex(key, 3600, JSON.toJSONString(user));
    return user;
}

缓存空值能阻挡大部分穿透,但占用Redis内存且过期后仍是穿透窗口。布隆过滤器从源头过滤不存在的key:

// 布隆过滤器方案:Redisson实现
RBloomFilter userFilter = redisson.getBloomFilter("user:bloom");
userFilter.tryInit(10_000_000L, 0.0001); // 1000万元素, 0.01%误判率, 约17MB

public void onUserCreated(User user) {
    userMapper.insert(user);
    userFilter.add(user.getId());
    redis.setex("user:" + user.getId(), 3600, JSON.toJSONString(user));
}

public User getUserById(Long id) {
    // 第一道防线:布隆过滤器
    if (!userFilter.contains(id)) {
        return null; // 不存在则一定不存在(无假阴性)
    }
    // 第二道防线:Redis缓存
    String key = "user:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        return "NULL".equals(cached) ? null : JSON.parseObject(cached, User.class);
    }
    // 第三道防线:数据库
    User user = userMapper.selectById(id);
    if (user == null) {
        redis.setex(key, 60, "NULL"); // 误判时缓存空值
        return null;
    }
    redis.setex(key, 3600, JSON.toJSONString(user));
    return user;
}

布隆过滤器的空间效率极高:1000万元素、0.01%误判率仅需17MB内存。假阳性概率可控,假阴性概率为零。

缓存击穿:热点Key过期

缓存击穿指单个热点key过期瞬间,大量并发请求同时穿透到数据库。与穿透的区别是key在数据库中存在,只是Redis恰好过期:

// 互斥锁方案
public Product getHotProductWithMutex(Long id) {
    String key = "product:hot:" + id;
    String lockKey = "lock:product:" + id;

    String cached = redis.get(key);
    if (cached != null) return JSON.parseObject(cached, Product.class);

    // 尝试获取分布式锁
    String requestId = UUID.randomUUID().toString();
    Boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);
    if (locked != null && locked) {
        try {
            // 双重检查
            cached = redis.get(key);
            if (cached != null) return JSON.parseObject(cached, Product.class);

            Product product = productMapper.selectById(id);
            redis.setex(key, 300, JSON.toJSONString(product));
            return product;
        } finally {
            // Lua脚本释放锁(保证原子性)
            String script = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
            redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        }
    } else {
        Thread.sleep(50);
        return getHotProductWithMutex(id); // 重试
    }
}

缓存雪崩:大量Key同时过期

缓存雪崩与击穿的区别是规模——大量key同一时间过期,或Redis节点宕机:

// 修复TTL随机化
public void preloadProducts(List products) {
    Random random = new Random();
    for (Product p : products) {
        int ttl = 300 + random.nextInt(120); // 300-420秒随机TTL
        redis.setex("product:" + p.getId(), ttl, JSON.toJSONString(p));
    }
}

// 多级缓存架构
public class MultiLevelCache {
    private final CaffeineCache localCache; // L2: 本地缓存
    private final RedisTemplate redis;      // L1: Redis
    private final ProductMapper mapper;      // L0: 数据库

    public Product getProduct(Long id) {
        String key = "product:" + id;
        // L2本地缓存
        Product cached = localCache.getIfPresent(key);
        if (cached != null) return cached;
        // L1 Redis
        String val = redis.opsForValue().get(key);
        if (val != null) {
            Product p = JSON.parseObject(val, Product.class);
            localCache.put(key, p);
            return p;
        }
        // L0 数据库
        Product product = mapper.selectById(id);
        if (product != null) {
            redis.opsForValue().set(key, JSON.toJSONString(product), 300, TimeUnit.SECONDS);
            localCache.put(key, product);
        }
        return product;
    }
}

布隆过滤器的数学原理与容量规划

import math

def bloom_filter_stats(n, p):
    m = -n * math.log(p) / (math.log(2) ** 2)
    k = (m / n) * math.log(2)
    return int(m / 8), math.ceil(k)

for n, p in [(1_000_000, 0.001), (10_000_000, 0.0001), (100_000_000, 0.00001)]:
    mem_kb, k = bloom_filter_stats(n, p)
    print(f"n={n:>10}  p={p}  mem={mem_kb/1024:.1f}KB  k={k}")

# n=1000000    p=0.001    mem=179.7KB   k=10
# n=10000000   p=0.0001   mem=1797.2KB  k=14
# n=100000000  p=0.00001  mem=26853KB   k=17

误判率从0.1%降到0.001%内存翻倍但仍是可接受开销。实际项目中0.01%误判率已是够用防护。

热点Key发现与动态防护

// 滑动窗口计数检测热点Key
public class HotKeyDetector {
    private final Map counters = new ConcurrentHashMap<>();
    private final long threshold = 1000;

    @Scheduled(fixedRate = 10_000)
    public void slideWindow() {
        counters.entrySet().removeIf(e -> {
            if (e.getValue().sum() > threshold) {
                promoteToHotKey(e.getKey());
                return true;
            }
            return false;
        });
    }

    public boolean isHotKey(String key) {
        counters.computeIfAbsent(key, k -> new LongAdder()).increment();
        return counters.get(key).sum() > threshold;
    }
}

熔断降级兜底方案

// Resilience4j熔断器保护数据库
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .slowCallRateThreshold(80)
    .slowCallDurationThreshold(Duration.ofMillis(500))
    .minimumNumberOfCalls(50)
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .build();

CircuitBreaker breaker = CircuitBreaker.of("dbQuery", config);

public Product getProductWithFallback(Long id) {
    Supplier supplier = () -> {
        String key = "product:" + id;
        String cached = redis.get(key);
        if (cached != null) return JSON.parseObject(cached, Product.class);
        Product product = productMapper.selectById(id);
        if (product != null) redis.setex(key, 300, JSON.toJSONString(product));
        return product;
    };
    Supplier decorated = CircuitBreaker.decorateSupplier(breaker, supplier);
    try {
        return decorated.get();
    } catch (Exception e) {
        Product fallback = localCache.getIfPresent("product:" + id);
        return fallback != null ? fallback : getDefaultProduct(id);
    }
}

缓存系统的稳定性设计遵循防御纵深原则:布隆过滤器挡住不存在的key查询,Redis缓存挡住大部分请求,互斥锁挡住热点key并发穿透,多级缓存挡住Redis故障,熔断器挡住数据库过载。每一层防护都是下一层的缓冲,缺失任何一层都可能在极端流量下引发级联故障。

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

赞 (0)
小编小编
上一篇 2026年9月16日
下一篇 2026年9月16日

相关推荐

Redis缓存策略实战:穿透、击穿、雪崩的三层防御体系搭建

Redis缓存策略实战:穿透、击穿、雪崩的三层防御体系搭建

Redis缓存是数据库高可用架构中的标配组件,但”用Redis”和”用好Redis”之间差了一整套防御体系的设计。缓存穿透、击穿、雪崩这三个问题不是理论上的可能,而是每个高流量系统在生产环境中都会遇到的真实故障。这篇文章从防御体系的角度,讲清楚每层防御的原理、实现和适用边界。

缓存穿透:查询不存在的数据,请求直达数据库

穿透场景:大量请求查询一条不存在的数据(比如ID=-1的商品),Redis没有缓存,每次都打到数据库。攻击场景下这个问题尤其严重。

第一层防御:布隆过滤器

把所有可能被查询的合法key放入布隆过滤器,请求先过布隆过滤器,不存在的直接拒绝:

# Redis布隆过滤器实现(需要RedisBloom模块)
# 添加元素
BF.ADD valid_product_ids 10001
BF.ADD valid_product_ids 10002

# 批量添加
BF.MADD valid_product_ids 10003 10004 10005

# 检查元素是否存在
BF.EXISTS valid_product_ids 10001   # 返回1
BF.EXISTS valid_product_ids 99999   # 返回0
// Java应用层布隆过滤器(Guava)
BloomFilter<Long> productFilter = BloomFilter.create(
    Funnels.longFunnel(), 
    1000000,    // 预期元素数量
    0.01        // 误判率1%
);

// 启动时加载所有合法ID
productRepo.findAllIds().forEach(productFilter::put);

// 查询前先过滤
public Product getProduct(long id) {
    if (!productFilter.mightContain(id)) {
        return null;  // 布隆过滤器说不存在,直接返回
    }
    // 布隆过滤器说可能存在,走正常查询流程
    return queryWithCache(id);
}

布隆过滤器的局限:有误判率(说存在但实际不存在),且不能删除元素。适合key集合相对稳定的场景。

第二层防御:空值缓存

对于布隆过滤器误判或动态数据集无法预加载的场景,缓存空值是兜底方案:

def get_product(product_id):
    cache_key = f"product:{product_id}"
    
    # 先查缓存
    cached = redis.get(cache_key)
    if cached is not None:
        if cached == "NULL":
            return None  # 空值缓存命中
        return json.loads(cached)
    
    # 缓存未命中,查数据库
    product = db.query("SELECT * FROM products WHERE id = ?", product_id)
    
    if product is None:
        # 缓存空值,TTL设置较短(防止数据后续被创建)
        redis.setex(cache_key, 60, "NULL")
    else:
        # 正常缓存
        redis.setex(cache_key, 3600, json.dumps(product))
    
    return product

空值缓存的TTL必须短(30-120秒),否则合法数据被创建后用户看不到。对攻击场景,短TTL的空值缓存已经足够挡住绝大部分流量。

缓存击穿:热点key过期瞬间,大量请求涌入数据库

击穿场景:某个热点key(比如秒杀商品详情)过期的那一秒,成千上万并发请求同时发现缓存不存在,全部打到数据库。

防御一:逻辑过期(不设TTL,数据中嵌入过期时间)

// 逻辑过期方案:Redis中永不过期,过期时间写在数据里
public Product getProductWithLogicalExpire(Long id) {
    String cacheKey = "product:" + id;
    String json = redis.get(cacheKey);
    
    if (json == null) {
        // 真正不存在,查数据库并缓存
        return queryAndCache(id);
    }
    
    ProductCacheData data = JSON.parseObject(json, ProductCacheData.class);
    
    if (data.getExpireTime().isBefore(LocalDateTime.now())) {
        // 逻辑过期——只允许一个线程重建缓存
        String lockKey = "lock:" + cacheKey;
        if (redis.setnx(lockKey, "1", 10, SECONDS)) {
            try {
                // 获取锁成功,查数据库更新缓存
                Product product = db.queryProduct(id);
                data.setData(product);
                data.setExpireTime(LocalDateTime.now().plusHours(1));
                redis.set(cacheKey, JSON.toJSONString(data));
            } finally {
                redis.del(lockKey);
            }
        }
        // 其他线程返回旧数据(不阻塞)
    }
    
    return data.getData();
}

逻辑过期的优势:永远不会出现所有线程同时穿透到数据库的情况。获取锁失败的线程返回旧数据,对大多数业务场景可接受。代价是数据有一小段时间的不一致窗口。

防御二:互斥锁重建

对一致性要求更高的场景,获取不到锁的线程等待而不是返回旧数据:

def get_product_with_mutex(product_id):
    cache_key = f"product:{product_id}"
    lock_key = f"lock:{cache_key}"
    
    while True:
        cached = redis.get(cache_key)
        if cached:
            return json.loads(cached)
        
        # 尝试获取重建锁
        if redis.set(lock_key, "1", nx=True, ex=5):
            try:
                product = db.query_product(product_id)
                redis.setex(cache_key, 3600, json.dumps(product))
                return product
            finally:
                redis.delete(lock_key)
        else:
            # 等待50ms后重试
            time.sleep(0.05)

缓存雪崩:大量key同时过期或Redis节点宕机

雪崩场景:批量缓存key设置了相同TTL,到期时全部同时过期;或者Redis主节点宕机导致大面积缓存失效。

防御一:TTL随机偏移

# 设置缓存时,TTL加随机偏移防止同时过期
import random

def set_cache_with_jitter(key, value, base_ttl=3600):
    jitter = random.randint(-300, 300)  # ±5分钟偏移
    ttl = max(60, base_ttl + jitter)    # 下限保护
    redis.setex(key, ttl, value)

防御二:多级缓存架构

# L1: 本地缓存(JVM/Caffeine,进程级)
# L2: Redis集群(分布式缓存)
# L3: 数据库

def get_product_multi_level(product_id):
    # L1本地缓存
    local_cached = local_cache.get(f"product:{product_id}")
    if local_cached:
        return local_cached
    
    # L2 Redis
    redis_cached = redis.get(f"product:{product_id}")
    if redis_cached:
        local_cache.put(f"product:{product_id}", redis_cached, 300)  # 本地5分钟
        return json.loads(redis_cached)
    
    # L3 数据库
    product = db.query_product(product_id)
    if product:
        redis.setex(f"product:{product_id}", 3600, json.dumps(product))
        local_cache.put(f"product:{product_id}", product, 300)
    return product

多级缓存的核心价值:当Redis整体不可用时,本地缓存还能扛住一段时间,给Redis恢复争取窗口。本地缓存的容量有限,只缓存热点数据,TTL要短(5分钟以内),防止本地缓存和Redis数据长时间不一致。

Redis缓存策略的监控指标与告警阈值

防御体系搭建完,还需要持续监控验证有效性:

# Redis关键监控指标
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"
# 命中率 = hits / (hits + misses),低于95%需要排查

# 监控缓存穿透率
redis-cli info stats | grep "keyspace_misses"

# 监控Redis内存使用
redis-cli info memory | grep "used_memory_human"

# 监控慢查询
redis-cli slowlog get 10

告警规则建议:

  • 缓存命中率持续5分钟低于90% → 告警,检查穿透或过期策略
  • Redis内存使用超过maxmemory的85% → 告警,需要扩容或淘汰策略调整
  • 慢查询超过10ms → 告警,检查大key或复杂命令

数据库运维中,Redis缓存策略不是独立的技术选型,而是和MySQL慢查询诊断、数据备份恢复策略一起构成整体的数据访问层防御。缓存解决的是读压力,分库分表方案解决的是存储压力,两者配合才能应对真正的流量洪峰。

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

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

相关推荐