尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java后端Redis缓存:穿透、击穿、雪崩的防御实战与代码实现

Java后端Redis缓存:穿透、击穿、雪崩的防御实战与代码实现 线上监控告警弹出来的时候我正在改一个无关紧要的接口。一看 MySQL 的 CPU 直接飙到 90%慢查询日志刷刷刷往外冒我第一反应是“有人把数据库当缓存打穿了”。这件事最后定位下来是几种缓存失效问题叠加的结果有不存在的 key 在被恶意扫描有热点 key 在过期瞬间被并发线程同时回源还有一批批量写入的缓存 key 用了同一个固定过期时间到期后集体失效。Java 服务里的 Redis 看似扛住了读请求实际上所有流量都穿透到了数据库这一层。这三个问题就是缓存圈子里常说的“三大劫难”——缓存穿透、缓存击穿、缓存雪崩。很多人面试的时候能背出来定义可真上了生产环境遇到告警还是容易搞混更别说给出一个能落地的防御方案。这篇博文我想结合自己这些年做 Java 后端、维护 Redis 缓存的实际经历把三个问题的本质、防御方案、代码实现和线上翻车教训一次说透。适合刚接触分布式缓存的读者也适合那些“会背八股文但没实际防过”的开发者。1. 穿透、击穿、雪崩每天都在线上上演先给它们“验明正身”先别急着上布隆过滤器、分布式锁这些方案。很多团队方案做反就是因为一开始就没分清三个问题的区别。我见过有人把击穿当穿透防给所有热点 key 都套上了空值缓存结果热点 key 过期时照样把数据库压垮也见过有人把雪崩当击穿救单点加互斥锁结果 Redis 一宕机锁本身都拿不到数据库直接被打爆。1.1 缓存穿透拿不存在的 key 猛敲数据库缓存穿透指查询的 key 在缓存中不存在在数据库里也不存在。每次请求都会绕过缓存直接落到数据库上。举个例子一个商品详情接口正常商品 key 是product:2001缓存里没有就查库查到了就回填缓存。但如果有人恶意遍历product:9999、product:9998这种根本不存在的 ID数据库每次查到的都是空记录于是缓存永远没有被写入的机会下一次相同请求又会继续穿透到库。数据库白挨打缓存却一点忙都帮不上。穿透的最大特点是“key 本身是坏的”或者“数据源没有这个 key”。它和并发量并不必然绑定哪怕每秒只有几百个请求只要 request 都是针对不存在的 key数据库也会被反复查询慢查询数量剧增。1.2 缓存击穿热点 key 过期的那一瞬间缓存击穿指某个热点 key 在缓存过期的一瞬间大量并发请求同时发现缓存没值于是同时去数据库回源。本来一个 key 过期是很正常的事但因为它是热点 key同一时刻可能有几千甚至上万个请求都在等它重建数据库瞬时压力就爆了。我经历过一个真实案例某活动页展示的配置数据是热点 key过期时间设在凌晨 0 点整。活动一上线大家同时抢购0 点那一刻缓存正好全部失效所有请求在同一秒打到数据库主库连接数瞬间被打满。击穿的关键特征是“单个 key 过期 高并发同时回源”。它影响范围通常集中在那几个热 key 上一旦缓存重建完成压力就迅速回落。所以它的危害是突刺式的时间窗口很短但杀伤力非常大。1.3 缓存雪崩批量过期或整个缓存不可用缓存雪崩比击穿要更严重它分两种情况第一大量缓存 key 在同一时间段集中过期。比如你有一个批处理任务凌晨把一批数据写入缓存统一设置了 3600 秒过期。下一小时这批 key 同时到期相当于数据库同时要被大量回源请求轰炸。击穿是单个 key 的“独木桥塌了”雪崩是整片缓存“集体放假”。第二Redis 本身不可用。Redis 宕机、网络分区、集群节点故障都会导致所有请求直接绕过缓存全部压到数据库上。这个时候数据库面对的可不只是几个热点 key而是全量请求瞬间就能把数据库打挂甚至引发数据库重启后的连锁故障。1.4 一个表格看清三者的区别问题数据特征触发时机影响范围主要防线穿透key 在缓存和数据库都不存在每次请求都可能触发单个 key但可持续骚扰空值缓存、布隆过滤器、参数校验击穿key 存在但缓存恰好过期热点 key 过期瞬间集中在少数热 key互斥锁、逻辑过期雪崩大量 key 或整个 Redis 不可用批量过期 / 缓存宕机全量请求过期时间错峰、高可用、熔断降级最常见的误区是面试时能说出“击穿是一个 key雪崩是很多 key”但上线时根本分不清流量属于哪种。我的判断方法很简单——先看 Redis 活着没有再看失效 key 的范围最后看请求的 key 是否存在于数据库。这三步走完基本能定性。2. 缓存穿透怎么防空值缓存、布隆过滤器并不是二选一对付穿透业界方案其实就那么几个空值缓存、布隆过滤器、入口参数校验和限流。关键不在于知道这几个名词而在于知道它们各自的边界以及组合使用的先后顺序。2.1 空值缓存最简单也最容易被忽略的方案空值缓存的思想很直白数据库查询结果为空时也把这个“空结果”写进缓存只是过期时间设置得短一点。这样同一个不存在的 key 第二次再来缓存就能直接返回空不会再穿透到数据库。public String getProduct(String productId) { String cacheKey product: productId; String cacheValue redis.get(cacheKey); if (cacheValue ! null) { return cacheValue; } String dbValue productDao.queryById(productId); if (dbValue null) { // 空值也缓存设置较短的过期时间比如 60 秒 redis.set(cacheKey, EMPTY_PLACEHOLDER, 60, TimeUnit.SECONDS); return null; } redis.set(cacheKey, dbValue, expireSeconds()); return dbValue; }这里有几个细节很容易翻车。第一空值占位符要选好。有人直接把null往 Redis 里塞有些客户端序列化后根本存不了或者读出来变成字符串 null判断逻辑就乱了。我习惯用一个业务不会出现的常量比如EMPTY_PLACEHOLDER读取时先判空值占位符再返回真正的空结果。第二空值缓存的过期时间不能太长。因为“数据库里没有这个 key”不代表永远没有万一数据后续被写入空值缓存还在有效期内就会导致新数据不能被及时读到。一般 30 到 120 秒比较合理具体看业务对一致性的容忍度。第三空值缓存只能防“重复穿透”挡不住“遍历穿透”。如果攻击者每秒构造一万个不同的不存在 ID空值缓存根本来不及生效因为每个 key 都只来了一次。所以它适合防御正常业务中的少量非法请求不太适合单独硬扛恶意流量。2.2 布隆过滤器用可控误判换来真正的“防穿透”布隆过滤器的思路是在缓存前面加一道“准入检查”把所有可能存在的数据特征比如商品 ID放入一个位图结构。查询时先问布隆过滤器“这个 key 存在吗”如果它说不存在那一定不存在直接拦截掉如果它说可能存在再放行去查缓存和数据库。布隆过滤器的核心是“宁可错杀不可放过”。它有误判率但误判方向是“把不存在的误判为可能存在”绝不会“把存在的误判为不存在”。也就是说它偶尔会放进来几个本不该放的请求但绝不会误杀正常数据。Java 项目里最简单的做法是用 Guava 的 BloomFilterBloomFilterString idFilter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 100_0000, // 预期元素数量 0.01); // 期望误判率 1% // 初始化时加载已有数据 for (Long id : productDao.getAllIds()) { idFilter.put(String.valueOf(id)); } // 查询前先判断 String productId request.getParameter(productId); if (!idFilter.mightContain(productId)) { return Response.notFound(); }误判率 1% 意味着每一百个不存在的 key大约有一个会漏进来打到数据库。对于大多数业务完全可以接受。误判率越低位数组就越大内存占用越高。1% 误判率一千万条数据约占 11MB 左右性能损耗可以忽略。布隆过滤器最大的坑是“动态数据”。如果过滤器在应用启动时只加载了一次后续新增的商品 ID 没有及时 put 进去那这些新增数据会被误判为不存在导致正常请求被拦截。我后来采用的方案是自定义过滤器管理组件每次新增合法数据时同步调用 put 方法同时每天凌晨用全量数据重建一次位图避免长期运行后的偏差累积。2.3 参数校验和限流从源头掐掉病态流量不管空值缓存还是布隆过滤器能做的都是“缓存层防御”。更提前的一道防线是参数校验。很多穿透流量实际上是低级扫描比如productId-1、productIdabcdef、productId999999999。在 Web 层直接把非法参数挡掉数据库根本不用见到这些请求。具体做法很简单ID 必须是正整数且在合理范围内分页参数必须限制大小涉及用户维度的查询先校验用户状态。限流则是最后一道兜底。即使有布隆过滤器和空值缓存也保不齐误判流量和恶意流量同时进来。所以在网关层或者服务层做接口级限流比如单机 QPS 限到 2000超过的返回统一错误信息。限流的意义不是防住所有穿透而是保证数据库不会因为穿透流量被完全压垮。3. 热点 key 击穿怎么救互斥锁和“逻辑过期”是两条口碑路线击穿的场景很明确一个高热点 key 的缓存刚好过期大量并发线程同时发现缓存没值一起去数据库回源。要做的事情也很明确让这些并发请求中只有一个线程去数据库查询其他人要么等要么拿到旧值。3.1 为什么固定过期时间是击穿的温床如果一个 key 的过期时间设置成固定值比如所有商品统一 30 分钟那么当这批 key 同时写入后它们的过期时间也会非常集中。当它们的过期时间重叠时就很容易引发雪崩。而单个热点 key 如果过期时间落在业务高峰期就会引发击穿。所以我在实际项目中很少用“固定过期时间”而是用“基础过期时间 随机抖动”。比如基础 30 分钟再随机加 0 到 300 秒。private long expireSeconds() { return BASE_EXPIRE_SECONDS ThreadLocalRandom.current().nextInt(300); }这样处理之后即便一批 key 同时写入它们的过期时间也会被错开从源头上缓解了集中失效的问题。但注意随机抖动只是降低概率并不能杜绝热点 key 在某一瞬间过期。真正的击穿还是要靠锁来防。3.2 互斥锁只允许一个请求去重建缓存互斥锁的核心是让“重建缓存”这个动作变成临界区操作。当发现缓存没值时先尝试获取一把分布式锁只有一个线程能拿到锁其他线程进入等待或重试。用 Redis 实现这个锁要注意原子性。早期很多人用setnx再单独设置过期时间两步操作之间一旦发生异常锁就永远不会释放直接死锁。正确做法是用一条命令同时设置 key 和过期时间public String getHotValue(String key) { // 先读缓存 String value redis.get(key); if (value ! null !PLACEHOLDER.equals(value)) { return value; } String lockKey lock:hot: key; String lockValue UUID.randomUUID().toString(); boolean locked redis.opsForValue() .setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS); if (locked) { try { // 拿到锁后二次检查避免等待期间缓存已经被别人重建 value redis.get(key); if (value ! null !PLACEHOLDER.equals(value)) { return value; } String dbValue productDao.queryById(key); String valueToCache (dbValue null) ? PLACEHOLDER : dbValue; redis.set(key, valueToCache, expireSeconds()); return valueToCache; } finally { // 只删除自己持有的锁防止误删别人的锁 if (lockValue.equals(redis.get(lockKey))) { redis.delete(lockKey); } } } // 拿不到锁说明别人正在重建缓存等待后重试 try { Thread.sleep(50); return getHotValue(key); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return defaultValue(); } }这里有几个细节可以说说。锁必须带超时时间。数据库查询一旦超过锁的持有时间锁自动释放其他线程会重新抢锁这可能引起多次重建。解决思路是用看门狗机制自动续期Redisson 的tryLock就内置了看门狗每 10 秒检查一次任务没完成就续期。如果不想引入 Redisson至少要保证数据库查询耗时远小于锁超时时间并把查询控制在几百毫秒以内。删除锁时必须校验持有者。如果不校验线程 A 的锁超时释放后线程 B 拿到新锁并执行完删除锁操作而 A 最后执行 finally 时直接把 B 的锁删掉了后面线程 C 又能抢锁锁就失去了意义。加一个lockValue校验能避免这种误删。递归重试在极端场景下可能造成栈溢出。我上面的示例里用了递归实际生产我更推荐写成循环重试两三次后如果还没有拿到值直接返回降级数据避免线程无限等待。3.3 “逻辑过期”打法把过期判断从 Redis 搬到应用层互斥锁的痛点是“热点 key 过期瞬间总有请求要等锁”。对极高并发的场景来说等锁即使只有几十毫秒也可能拖慢整体响应。逻辑过期的思路是不让 key 在 Redis 里真正过期而是把过期时间作为业务字段存在 value 里。查询时发现逻辑过期后先返回旧值再异步触发一个线程去更新缓存。class CacheItem { Object data; // 真正的业务数据 long expireAt; // 逻辑过期时间 long updateTime; // 实际写入时间 }比如 value 里存{data: 商品详情, expireAt: 1699999999}查询时读取到expireAt已经过了说明缓存逻辑上过期了但物理 key 还在。这时请求直接返回data的旧值同时提交一个异步任务去数据库查询并重建缓存。这种方案的优点是极端并发下不会出现“大量请求同时被堵在锁上”的情况用户体验更好。缺点是数据一致性变差用户在缓存重建完成前看到的是一瞬间前的旧数据。而且异步重建后要小心并发更新覆盖问题最好配合版本号或者 CAS 操作。我的经验是允许短期脏读的业务用逻辑过期一致性要求高的业务用互斥锁重查。没有哪个方案是银弹关键是匹配业务诉求。4. 雪崩防线要往上叠批量过期错峰、Redis 高可用与熔断降级雪崩的处理和穿透、击穿完全不是一个量级。穿透和击穿是“某个 key 的事”雪崩是“整片缓存的事”。应对雪崩必须从两个维度同时入手一是不让大量 key 集中失效二是即便缓存不可用了也要让业务系统能活下来。4.1 过期时间不是随便设的错峰是基本素养批量过期最常见的原因是缓存写入任务使用了统一 TTL。一个批处理脚本跑完给一万个 key 都设置了 3600 秒过期一个小时后这一万个 key 同时失效数据库瞬间就要处理一万个回源请求。错峰处理有几个思路。第一个是过期时间加随机抖动前面提到过。这是最基础的做法成本极低收益极高。第二个是“分批预热”。大批量刷新缓存时不要一个任务全部刷完而是把数据分成若干批每批间隔几十秒执行。这样缓存的实际过期时间就会错开回源流量也会被摊平。第三个是“主动续期代替过期重建”。对于数据变化不频繁但读取量大的配置类数据可以启动一个定时任务在缓存过期前提前刷新而不是等它过期后再由请求触发重建。比如一个 key 的 TTL 是 30 分钟可以每 10 分钟主动刷新一次保证缓存永远不过期。这个方案叫“缓存预热”非常适合字典数据、配置数据这类变更频率低的场景。4.2 Redis 不可用时应用层要能“软着陆”如果 Redis 直接宕机无论你怎么错峰请求都会全部打到数据库。这时候决定系统能不能扛住的是应用层的熔断和降级策略。熔断当检测到 Redis 异常率高或连接池耗尽时直接对缓存读取操作熔断不再等待 Redis 超时快速失败。否则在高并发下每次 Redis 超时都要等几百毫秒线程被大量占住很快整个应用就 OOM 或者线程池拒绝新请求。降级缓存不可用时接口可以返回兜底数据。比如商品详情页可以直接返回一个已经静态化的默认页面或者返回上次缓存在本地的一个快照。我在项目里用得比较多的是本地缓存兜底。Redis 之上加一层 Caffeine 本地缓存热点数据在 JVM 内存里再存一份。Redis 挂了以后本地缓存还能扛住一小段时间虽然数据可能不是最新的但至少接口不报错。CacheString, Object localCache Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); public Object getValue(String key) { // 先用本地缓存 Object local localCache.getIfPresent(key); if (local ! null) return local; // 再用 Redis try { Object remote redis.get(key); if (remote ! null) { localCache.put(key, remote); return remote; } } catch (Exception e) { // Redis 不可用时记录日志继续走本地或数据库 } // 最后走数据库但要做限流 return dao.query(key); }加双缓存后有一个副作用本地缓存和 Redis 数据可能不一致。解决思路是数据变更时主动失效本地缓存或者把本地缓存 TTL 设得比 Redis 短很多宁可让本地缓存快点失效也不要让它把旧数据长期留在 JVM 里。4.3 Redis 高可用是必需品不是可选项生产环境别再用单节点 Redis 了至少上主从加哨兵。主节点挂了哨兵自动把从节点提升为主节点应用重连后继续提供服务。建议开启appendonly yes持久化重启后能恢复数据。数据量更大、读写吞吐更高的项目可以考虑 Redis Cluster多分片分摊压力。但集群也有自己的坑比如 key 都集中在一个槽上就不是真正意义上的负载均衡再比如使用mget批量查询时需要用 hash tags 指定 key 在同一个槽。这些都要在架构设计时提前考虑。需要强调一点Redis 高可用只能解决“Redis 宕机后快速恢复”的问题不能解决“恢复前那几秒数据库被压垮”的问题。所以高可用必须和上面的熔断降级一起用否则就是平时看着好好的故障一来照样雪崩。5. 组合落地的 Java 骨架从缓存读取到过期策略的一次成型前面几章把三个问题分别讲了实际开发里没人会只防一个。真正经得起考验的方案是组合拳入口参数校验 → 布隆过滤器拦截不存在的 key → 缓存读取 → 缓存未命中时加互斥锁重建 → 空值缓存占位 → 过期时间带随机抖动 → Redis 不可用时本地缓存兜底。5.1 一个可复用的 CacheService 骨架下面是一个简化的服务骨架把前面提到的方案组合到一起。实际项目里建议把缓存逻辑从业务代码中拆出来统一封装成组件而不是散落在各 Service 里。Service public class CacheService { private static final String PLACEHOLDER EMPTY_PLACEHOLDER; private final RedisTemplateString, String redisTemplate; private final BloomFilterString idFilter; private final ProductDao productDao; private final CacheString, Object localCache; // 读缓存主链路 public String getProduct(String productId) { // 第一步参数校验 if (!validateId(productId)) { return null; } // 第二步布隆过滤器 if (!idFilter.mightContain(productId)) { return null; } // 第三步本地缓存 Object local localCache.getIfPresent(productId); if (local ! null) { return local.toString(); } // 第四步Redis 缓存 String cacheKey product: productId; String cached null; try { cached redisTemplate.opsForValue().get(cacheKey); } catch (Exception e) { // Redis 异常继续走本地缓存或数据库但要记录日志 log.error(redis get error, e); } if (cached ! null !PLACEHOLDER.equals(cached)) { localCache.put(productId, cached); return cached; } // 第五步互斥锁重建 String lockKey lock: cacheKey; String lockValue UUID.randomUUID().toString(); boolean locked Boolean.TRUE.equals(redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS)); if (locked) { try { // 二次检查 cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null !PLACEHOLDER.equals(cached)) { return cached; } String dbValue productDao.queryById(productId); String valueToCache (dbValue null) ? PLACEHOLDER : dbValue; long expire BASE_EXPIRE_SECONDS ThreadLocalRandom.current().nextInt(300); redisTemplate.opsForValue().set(cacheKey, valueToCache, expire, TimeUnit.SECONDS); if (dbValue ! null) { localCache.put(productId, dbValue); } return dbValue; } finally { if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } } // 第六步拿不到锁等待后重读缓存 try { Thread.sleep(30); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return redisTemplate.opsForValue().get(cacheKey); } }注意这个骨架为了可读性省略了一些细节比如 Redis 连接异常的降级逻辑、布隆过滤器的动态更新、异步刷新任务。真正应用时我会把 Redis 操作统一包装成方法并在调用处加上监控埋点。5.2 过期时间策略基础 抖动 分级一个健壮的缓存系统过期时间不应该是一刀切。按照业务容忍度我习惯分成几档业务类型基础过期时间是否加抖动说明基础字典数据30 分钟加更新少读多商品详情5 分钟加允许分钟级延迟用户会话类2 小时不加一致性要求较高报表统计1 小时加允许一定延迟加抖动的方式上面代码里已经有体现nextInt(300)表示基础时间上随机增加最多 300 秒。对于 TTL 本来就只有 60 秒的 key抖动范围不要太大否则有些 key 会提前很久过期反而又回到穿透风险里。5.3 上线前的验证动作和压测指标方案写完了代码写了一堆直接上生产是不行的。上线前我习惯做三件事第一模拟穿透。写一个测试脚本请求一批不存在的 ID观察数据库 QPS 是否没有明显上升。如果数据库 QPS 跟着测试流量走说明布隆过滤器配置有问题或没有生效。第二模拟热点 key 过期。手动删除某个热点 key 的缓存用压测工具同时打 1000 个请求观察数据库回源请求数是否只有 1 个其余请求是否都在等待锁或读取旧缓存。第三模拟 Redis 宕机。关掉 Redis 后观察应用的表现确认能快速降级到本地缓存和数据库限流而不是线程池被卡死。压测指标重点关注三个数据库 QPS、接口 P99 延迟、应用线程池活跃线程数。三个指标只要有一个失控就说明方案还没有完全闭环。6. 上线之后才是真正的考试三个让我记忆深刻的“翻车”现场再完善的方案上线后也会遇到意想不到的问题。这里分享三个我真实经历过的反例希望能帮读者避开。6.1 布隆过滤器误杀正常用户差点被投诉电话打爆有一次我为用户维度的查询接口加了布隆过滤器启动时加载全量用户 ID。上线后运营反馈新注册的用户查询不到数据。定位后发现原因很简单过滤器在应用启动时加载了一次之后就再没更新新注册用户压根不在过滤器里被误判为“一定不存在”。这暴露了我没有考虑动态数据的更新机制。后来我把过滤器改成了独立组件数据新增时同步调用 put 方法并且每两小时用数据库全量重建一次。重建期间要确保过滤器的读操作不受影响我直接把过滤器对象引用替换为新建对象让正在执行的请求继续用旧引用。这招很简单但很有效。6.2 锁删太快互斥锁形同虚设另一个问题是互斥锁的误删。早期代码没有判断锁持有者统一执行redis.delete(lockKey)。场景是这样线程 A 持锁执行数据库查询查询超过了锁的 3 秒超时时间锁自动释放线程 B 拿到锁开始重建线程 A 最后执行 finally 删锁把 B 的锁删了线程 C 又拿到锁再次重建。结果一个热点 key 的过期竟然触发了三次回源。这次之后我学乖了所有新增锁都带上 owner 标识删锁前先 get 再比较。说到底这不是什么高深问题纯粹是对并发细节不够敏感写了一个“看着对”却经不起推敲的代码。6.3 没有监控埋点的防御方案出了故障像盲人摸象最惨的一次是缓存雪崩后我们花了两个小时才定位到原因。不是方案没做对而是没人知道当时的缓存命中率是多少、回源 QPS 是多少、Redis 错误率是多少。所有信息都要靠临时写脚本去抓。从那以后我的所有缓存操作都加了基础监控缓存命中率、回源次数、Redis 读写耗时、本地缓存命中率、热点 key Top 10。这些指标放到看板上下次再出问题扫一眼就能判断是不是击穿、穿透还是雪崩不用再靠猜。最后分享一个我自己的习惯每次上线缓存方案都先在灰度机器上观察一个小时真实流量曲线再逐步放量到全量。别嫌慢缓存问题是典型的“平时不冒泡、出问题就是大事”慢一点稳一点比什么都强。
返回列表