4. 缓存层设计:Redis集群模式、缓存穿透/击穿/雪崩解决方案、热点Key处理

各位同学,咱们今天聊缓存层设计。说实话,在抢单系统里,缓存层要是扛不住,那整个系统基本就废了。我见过太多项目,数据库被缓存搞垮的案例,嗯,咱们今天就把这些坑一个个填上。

4.1 Redis集群模式选型

先说说集群模式。我个人习惯用Redis Cluster,而不是主从+哨兵。为什么?抢单系统的写入量太大了,主从模式写压力全在master上,一旦master挂了,哨兵切换那几秒,系统直接不可用。

Redis Cluster的好处是:

  • 自动分片:16384个槽位,数据均匀分布
  • 去中心化:每个节点都保存元数据
  • 高可用:主节点挂了,从节点自动顶上

我在项目中遇到过一个问题:集群节点数太少,比如3主3从,某个主节点挂了,整个集群就只剩2个主节点,压力瞬间翻倍。所以我建议至少6主6从起步。

核心配置示例:

# redis.conf 关键配置
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
appendfsync everysec
# 每个主节点至少1个从节点
cluster-replica-validity-factor 10
cluster-migration-barrier 1
cluster-require-full-coverage no

注意最后一行 cluster-require-full-coverage no。默认是yes,意味着只要有一个槽位不可用,整个集群就拒绝服务。抢单系统里,我宁愿丢一部分数据,也不愿整个系统挂掉。

4.2 缓存穿透解决方案

缓存穿透,说白了就是请求的数据在缓存和数据库里都不存在。攻击者可以伪造大量不存在的ID,直接打到数据库。

我曾经被这个问题坑过一次。有个抢单活动,用户疯狂请求不存在的商品ID,数据库连接池瞬间被打满。后来怎么解决的?三个方案一起上:

  1. 布隆过滤器:把所有合法ID提前加载到布隆过滤器里。请求来了先过布隆,不存在直接返回。
  2. 缓存空对象:如果数据库查不到,也缓存一个空值,过期时间设短一点,比如30秒。
  3. 参数校验:前端传的ID必须符合规则,比如长度、格式、范围。

布隆过滤器代码片段:

// 初始化布隆过滤器,预计100万数据,误判率1%
BloomFilter<String> bloomFilter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000, 0.01);

// 预热:加载所有合法商品ID
for (Long id : allValidIds) {
    bloomFilter.put(String.valueOf(id));
}

// 请求拦截
public Order getOrder(Long id) {
    if (!bloomFilter.mightContain(String.valueOf(id))) {
        return null; // 直接返回,不查数据库
    }
    // 继续查缓存、查数据库
}

你想想看,布隆过滤器虽然可能有误判,但误判只会把合法请求放过去,不会把非法请求放进来。这个trade-off完全能接受。

4.3 缓存击穿解决方案

缓存击穿和穿透不一样。击穿是指某个热点key过期了,大量请求同时打到数据库。抢单系统里,最典型的就是某个爆款商品的库存key。

我记得有一次双十一,某个商品的库存key刚好在高峰期过期,瞬间几千个请求打到数据库,那叫一个酸爽。解决方案其实就两个:

  • 互斥锁:第一个请求发现key过期,加锁去查数据库,其他请求等待。锁可以用Redis的SETNX实现。
  • 逻辑过期:key永不过期,但value里存一个过期时间字段。后台异步更新。

注意:互斥锁方案在高并发下,如果锁的粒度太大,会导致大量请求阻塞。我建议用分段锁,比如把库存分成10个段,每个段一个key,分散压力。

// 互斥锁实现缓存击穿防护
public String getWithMutex(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    // 尝试获取锁
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
        try {
            // 双重检查,防止重复查库
            value = redis.get(key);
            if (value != null) {
                return value;
            }
            // 查数据库
            value = queryFromDB(key);
            redis.set(key, value, 60, TimeUnit.SECONDS);
            return value;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁,等待重试
        Thread.sleep(50);
        return getWithMutex(key); // 递归重试
    }
}

4.4 缓存雪崩解决方案

雪崩比击穿更可怕。大量key同时过期,或者Redis集群整体挂了,所有请求直接打到数据库。抢单系统要是遇到雪崩,基本就是宕机。

我总结了几条经验:

  1. 过期时间加随机值:比如基础过期时间60秒,再加一个0-30秒的随机值。避免大量key同时过期。
  2. 多级缓存:本地缓存(Caffeine)+ Redis + 数据库。本地缓存扛第一波,Redis扛第二波。
  3. 限流降级:如果Redis挂了,直接返回默认值或错误提示,不要查数据库。
  4. Redis高可用:集群模式+持久化,主从切换要快。

多级缓存架构:

// 本地缓存(Caffeine)
Cache<String, Order> localCache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(10, TimeUnit.SECONDS)
    .build();

// 查询流程
public Order getOrder(Long id) {
    // 1. 查本地缓存
    Order order = localCache.getIfPresent(id);
    if (order != null) return order;
    
    // 2. 查Redis
    order = redis.get("order:" + id);
    if (order != null) {
        localCache.put(id, order); // 回填本地缓存
        return order;
    }
    
    // 3. 查数据库(加锁防止击穿)
    order = queryFromDBWithLock(id);
    redis.set("order:" + id, order, 60 + random.nextInt(30));
    localCache.put(id, order);
    return order;
}

4.5 热点Key处理

热点key是抢单系统的老大难问题。某个商品突然爆火,所有请求都打同一个key,Redis单节点扛不住。

我遇到过最夸张的情况:一个热点key的QPS到了50万,Redis节点CPU直接100%。怎么解决的?

  • 本地缓存+热点探测:用Sentinel或自研的热点探测组件,发现某个key访问量超过阈值,自动将其缓存到本地。
  • key拆分:把一个key拆成多个子key,比如 stock:1001:0stock:1001:9,请求随机访问其中一个。
  • 读写分离:读请求走从节点,写请求走主节点。但注意,Redis Cluster的从节点可能数据有延迟。

热点key拆分示例:

// 库存key拆分成10个段
String[] keys = new String[10];
for (int i = 0; i < 10; i++) {
    keys[i] = "stock:" + productId + ":" + i;
}

// 扣减库存时,随机选一个段
int segment = ThreadLocalRandom.current().nextInt(10);
String key = keys[segment];
Long stock = redis.decr(key);
if (stock < 0) {
    // 这个段扣完了,尝试其他段
    for (int i = 0; i < 10; i++) {
        if (i == segment) continue;
        stock = redis.decr(keys[i]);
        if (stock >= 0) break;
    }
}

嗯,这里要注意:key拆分后,查询总库存需要把所有段加起来,这个操作要异步做,别在请求链路里搞。

4.6 知识体系总览

下面这张图,我把缓存层的核心逻辑串起来了。你仔细看看,每个环节都有对应的解决方案。

缓存层设计核心知识体系 用户请求 布隆过滤器(防穿透) 多级缓存层 本地缓存(Caffeine) → Redis Cluster 热点Key探测 + Key拆分 数据库 雪崩防护 过期时间随机化 限流降级 集群高可用 击穿防护 互斥锁(SETNX) 逻辑过期 双重检查

说白了,缓存层设计就是一场攻防战。攻击方是流量洪峰,防守方就是咱们这些方案。布隆过滤器挡第一波,多级缓存扛第二波,热点拆分和互斥锁处理极端情况。

最后提醒一句:别把所有希望都寄托在缓存上。缓存只是加速,不是万能的。数据库的限流、降级、熔断,该上还得上。我曾经见过一个团队,缓存层做得花里胡哨,结果数据库没做防护,一个缓存雪崩直接让数据库挂了48小时。

好了,缓存层设计就聊到这儿。下一章咱们讲消息队列的容错设计,那个坑更多,到时候再细聊。


专注资料整理