Develop

Redis 缓存三大问题深度解析与解决方案:从原理到实战的完整指南

✎ -- 字 🕐 -- 分钟
字号

Redis 缓存三大问题深度解析与解决方案:从原理到实战的完整指南

缓存是性能优化的银弹,但围绕 Redis 的雪崩、穿透、击穿三大问题,每年都在生产环境重复制造着 P0 级故障。本文从现象描述到底层原理,再到完整的 Spring Boot / Node.js 代码实现,逐一击破这三类高频故障场景,并给出可落地的监控告警与最佳实践清单。

一、为什么必须重视缓存三大问题?

在正式开讲之前,先给一个直观的数字:据我维护的多个生产系统统计,约 70% 的 Redis 相关线上事故都可以归类到雪崩、穿透、击穿这三类。它们不是 Redis 本身的 Bug,而是设计缓存层时没有充分考虑异常路径导致的。

先简单回顾一下标准缓存读写流程:

// 读
data = cache.get(key)
if (data == null) {
    data = db.query(sql)
    cache.set(key, data, expire)
}
return data

// 写
cache.del(key)
db.update(sql)

这段代码看起来很完美,但每一个分支都可能是事故的入口。下面我们逐一拆解。

二、缓存雪崩(Cache Avalanche)

2.1 现象与成因

缓存雪崩指的是大量缓存在同一时刻集中过期失效,或者缓存服务本身宕机,导致所有请求穿透到数据库,DB 瞬时压力暴增,最终引发系统雪崩。

三大典型成因:

  1. 批量 key 集中过期:例如首页推荐商品列表的缓存统一设了 1 小时 TTL,每天凌晨整点缓存同时失效,请求洪峰直接打向 DB。
  2. Redis 实例宕机:主从切换瞬间、从节点还没完全同步数据时,所有读请求全部 miss。
  3. 周期性业务峰值:大促零点、秒杀开抢前几秒,请求量陡增,缓存预热不足。

2.2 解决方案

方案 1:过期时间加随机偏移

这是最简单也最有效的一招。把固定 TTL 改为基础时间 + 随机抖动:

// Java 写法
long base = 3600;       // 基础 1 小时
long jitter = ThreadLocalRandom.current().nextLong(0, 600); // 0-10 分钟随机
redis.setex(key, base + jitter, value);

// Node.js 写法
const base = 3600;
const jitter = Math.floor(Math.random() * 600);
await redis.set(key, value, 'EX', base + jitter);

这样原本同一时刻过期的 key 会被分散到 [3600s, 4200s] 区间内失效,请求曲线被"拉平"。

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

不要把鸡蛋全放在 Redis 一个篮子里:

层级存储TTL角色
L1JVM Caffeine / 进程内存30s抗瞬时热点
L2Redis Cluster10min主力缓存
L3数据库-兜底

配合熔断器(Sentinel / Resilience4j),当 Redis 不可用时直接走 L3 + 限流,避免把 DB 打死。

方案 3:缓存预热 + 永不过期后台刷新

对于强一致不敏感但可用性极敏感的数据(如首页推荐),可以使用"逻辑过期"方案:

// 缓存 value 同时记录 expireAt
type CachedItem struct {
    Data      interface{} `json:"data"`
    ExpireAt  int64       `json:"expire_at"` // 业务过期时间
}

func (s *Service) Get(key string) interface{} {
    raw, _ := s.rdb.Get(ctx, key).Bytes()
    var item CachedItem
    json.Unmarshal(raw, &item)

    if time.Now().Unix() > item.ExpireAt {
        // 异步刷新,不阻塞读
        go s.refresh(key)
    }
    return item.Data
}

这样 Redis 层面没有 TTL,靠后台异步任务 + 分布式锁保证只有一个节点真正回源。

三、缓存穿透(Cache Penetration)

3.1 现象与成因

缓存穿透指的是查询一个数据库中根本不存在的数据,由于缓存也不会命中,请求每次都会打到 DB,相当于缓存形同虚设。

典型场景:

  • 恶意攻击:循环请求 /user?id=-1/user?id=0/user?id=999999,数据库每次都返回 null;
  • 业务误用:前端传错 ID、爬虫走错接口;
  • 删库后业务未停服:所有历史 ID 都查不到。

3.2 解决方案

方案 1:空值缓存(Null Caching)

// Java
public User getUser(Long id) {
    String key = "user:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        if ("__NULL__".equals(cached)) return null;  // 空值哨兵
        return JsonUtil.parse(cached, User.class);
    }
    User user = userDao.findById(id);
    if (user == null) {
        redis.setex(key, 60, "__NULL__");  // 短 TTL 防止长期占位
    } else {
        redis.setex(key, 3600, JsonUtil.toJson(user));
    }
    return user;
}

注意空值的 TTL 要比正常值短得多(几十秒),否则真实数据写入时会被旧空值长期阻塞。

方案 2:布隆过滤器(Bloom Filter)

当数据规模非常大、命中率要求高时,布隆过滤器是更优解:

// 启动时 / 定时任务全量加载 ID 到 Bloom Filter
bf := bloom.NewWithEstimates(uint(totalUsers), 0.001) // 1 亿用户, 0.1% 误判率
for _, id := range loadAllUserIds() {
    bf.AddString(strconv.FormatInt(id, 10))
}

func (s *Service) GetUser(id int64) (*User, error) {
    if !bf.TestString(strconv.FormatInt(id, 10)) {
        return nil, ErrNotFound  // 直接拦截
    }
    return s.cacheGet(id)
}

布隆过滤器的优势:

  • 空间效率极高:10 亿 ID 仅需 ~1.7 GB 内存;
  • 查询时间 O(k),与数据量无关;
  • 代价是存在误判率(0.1% 误判意味着 1000 次非法查询会有 1 次穿透到 DB,通常可接受)。

推荐用 Redis Bloom(RedisBloom 模块)或 Redisson 客户端:

// Redisson 客户端
RBloomFilter<Long> bf = redisson.getBloomFilter("userIdBloom");
bf.tryInit(1_000_000_000L, 0.001);
bf.add(userId);
if (!bf.contains(userId)) return null;

方案 3:参数校验 + 限流

对入参做白名单校验(ID 必须为正整数、在合法范围内),从源头过滤非法请求;再叠加 Nginx / Sentinel 限流,即使被攻击也不会击穿到 DB。

四、缓存击穿(Cache Breakdown)

4.1 现象与与雪崩的区别

缓存击穿指的是某个热点 key(如爆款商品、明星微博、热门直播间信息)突然过期,此时大量并发请求同时打到 DB。

它和雪崩的区别:

维度雪崩击穿
失效 key 数量大量单个或极少
请求特征整片缓存层失效仅热点 key 失效
常见成因批量 TTL、Redis 宕机热点 key 自然过期

4.2 解决方案

方案 1:互斥锁(Mutex Lock)

只让一个线程回源,其他线程等待并重试:

// Java
public User getUser(Long id) {
    String key = "user:" + id;
    String lockKey = "lock:user:" + id;
    User user = redis.get(key, User.class);
    if (user != null) return user;

    // 抢锁回源
    if (redis.setnx(lockKey, "1", "EX", 10)) {
        try {
            user = userDao.findById(id);
            redis.setex(key, 3600, JsonUtil.toJson(user));
            return user;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没抢到锁,等一会再重试
        Thread.sleep(50);
        return getUser(id);
    }
}

注意锁必须有 TTL(防止持锁线程崩溃导致死锁),并且解锁必须是 Lua 脚本原子操作,避免误删别人的锁:

-- KEYS[1] = lockKey, ARGV[1] = threadId
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

方案 2:逻辑过期 + 异步刷新

在 2.2 节已经介绍过,对热点 key 同样适用。区别在于热点 key 的"逻辑过期"会更频繁触发,建议用单独的刷新线程池:

type HotItem struct {
    Data     interface{} `json:"data"`
    ExpireAt int64       `json:"expire_at"`
}

func (s *Service) GetHotItem(id int64) *HotItem {
    raw, _ := s.rdb.Get(ctx, fmt.Sprintf("hot:%d", id)).Bytes()
    var item HotItem
    json.Unmarshal(raw, &item)

    if time.Now().Unix() > item.ExpireAt {
        // 抢锁,失败的线程直接返回旧数据
        ok, _ := s.rdb.SetNX(ctx, fmt.Sprintf("lock:hot:%d", id), "1", 5*time.Second).Result()
        if ok {
            go func() {
                s.refreshHotItem(id)  // 异步回源
                s.rdb.Del(ctx, fmt.Sprintf("lock:hot:%d", id))
            }()
        }
    }
    return &item
}

五、三大问题对比速查表

问题触发条件核心对策推荐组合
雪崩 大量 key 同时过期或缓存层宕机 过期随机化、多级缓存、熔断降级 Jitter TTL + Caffeine + Sentinel
穿透 查询根本不存在的数据 空值缓存、布隆过滤器、参数校验 Redisson Bloom + 短 TTL 空值
击穿 单个热点 key 过期 互斥锁、逻辑过期、永不过期 SETNX + Lua 解锁 + 异步刷新

六、完整实战代码(Spring Boot)

下面给出一个融合了"互斥锁 + 逻辑过期 + 空值缓存"的通用 CacheTemplate:

@Component
public class CacheTemplate {
    @Autowired private StringRedisTemplate redis;

    public <T> T queryWithCache(String key, Class<T> clazz,
                                  long expireSec, long nullExpireSec,
                                  Supplier<T> dbLoader) {
        // 1. 查缓存
        String cached = redis.opsForValue().get(key);
        if (cached != null) {
            if ("__NULL__".equals(cached)) return null;
            return JsonUtil.parse(cached, clazz);
        }

        // 2. 互斥锁回源
        String lockKey = "lock:" + key;
        if (redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
            try {
                T data = dbLoader.get();
                long ttl = (data == null) ? nullExpireSec : expireSec;
                String val = (data == null) ? "__NULL__" : JsonUtil.toJson(data);
                redis.opsForValue().set(key, val, ttl, TimeUnit.SECONDS);
                return data;
            } finally {
                // Lua 原子解锁
                String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                redis.execute(new DefaultRedisScript<>(script, Long.class),
                              List.of(lockKey), "1");
            }
        } else {
            // 3. 等待 + 重试
            try { Thread.sleep(50); } catch (InterruptedException ignored) {}
            return queryWithCache(key, clazz, expireSec, nullExpireSec, dbLoader);
        }
    }
}

业务侧调用就非常简洁:

@Service
public class UserService {
    @Autowired private CacheTemplate cache;
    @Autowired private UserDao userDao;

    public User getById(Long id) {
        return cache.queryWithCache(
            "user:" + id, User.class,
            3600,   // 正常 TTL
            60,     // 空值 TTL
            () -> userDao.findById(id)
        );
    }
}

七、Node.js / Redis 实战

Node 生态下用 ioredis 同样可以实现:

// cache.js
const Redis = require('ioredis');
const redis = new Redis();

const NULL_SENTINEL = '__NULL__';

// Lua: 原子解锁
const UNLOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end`;

async function queryWithCache(key, ttl, nullTtl, loader) {
    const cached = await redis.get(key);
    if (cached === NULL_SENTINEL) return null;
    if (cached) return JSON.parse(cached);

    const lockKey = 'lock:' + key;
    const ok = await redis.set(lockKey, '1', 'EX', 10, 'NX');
    if (ok === 'OK') {
        try {
            const data = await loader();
            const val = data === null ? NULL_SENTINEL : JSON.stringify(data);
            await redis.set(key, val, 'EX', data === null ? nullTtl : ttl);
            return data;
        } finally {
            await redis.eval(UNLOCK_SCRIPT, 1, lockKey, '1');
        }
    } else {
        await new Promise(r => setTimeout(r, 50));
        return queryWithCache(key, ttl, nullTtl, loader);
    }
}

module.exports = { queryWithCache };
// userService.js
const { queryWithCache } = require('./cache');

async function getUser(id) {
    return queryWithCache(
        'user:' + id,
        3600,   // 正常 TTL
        60,     // 空值 TTL
        async () => db.user.findUnique({ where: { id } })
    );
}

八、监控与告警设计

三大问题的共性是缓存命中率下降 + 数据库 QPS 飙升,因此监控需要从两个维度同时观测:

指标采集方式告警阈值(参考)
Redis 命中率Redis INFO keyspace / 自定义 stats< 95% 持续 5 分钟
DB QPSPrometheus mysqld_exporter超基线 200%
慢查询Slow log / performance_schema> 100 条 / 分钟
缓存空值比例业务埋点> 10% 提示有穿透
热点 keyredis-cli --hotkeys / 自研单 key 占比 > 30%

推荐用 Grafana 配置仪表盘,把"命中率、QPS、慢查询、热点 key"四件套放在一起看,肉眼就能定位是雪崩、穿透还是击穿。

九、常见陷阱与最佳实践

结合多年踩坑经验,列出 10 条高频陷阱:

  1. 空值缓存 TTL 过长:导致真实数据写入后无法及时生效,建议不超过 5 分钟。
  2. 互斥锁无 TTL:一旦持锁线程崩溃,缓存将永远无法回源,必须设置 5-10 秒过期。
  3. 解锁使用 DEL 而非 Lua:可能被其他线程误删,导致锁失效。
  4. 雪崩只加 jitter 不做多级缓存:Redis 整体宕机时还是顶不住。
  5. 布隆过滤器忘记重建:数据删除后布隆里仍有标记,造成"幽灵数据",要定期 rebuild。
  6. 击穿方案选了"永不过期":数据更新后无法失效,必须搭配消息队列广播删除。
  7. 缓存层没有降级开关:Redis 故障时不能快速绕过,必须提供 cache.disable=true 的开关。
  8. 批量查询接口没考虑雪崩mget(key1, key2, ..., keyN) 一旦 key 集中过期全军覆没。
  9. 缓存与 DB 双写不一致:先改 DB 再删缓存 vs 先删缓存再改 DB?推荐延迟双删方案。
  10. 没有 key 命名规范:线上几千个 key 难管理,建议用 业务:实体:ID 三段式 + Tag 维度。

十、结语

雪崩、穿透、击穿并不是 Redis 的"特性",而是缓存设计上的缺失。掌握它们的本质,养成"任何 cache miss 路径都要有兜底"的肌肉记忆,配合完整的监控告警与多级缓存架构,缓存才能真正成为系统的稳定器而不是定时炸弹。

下一篇会拆解缓存与数据库一致性的"延迟双删"和"订阅 binlog"两种方案,欢迎持续关注。