在上一篇文章中,我们探讨了缓存的更新策略及其在分布式场景下的一致性挑战。今天,我们将视角深入到高并发场景下最常见的两个问题:缓存穿透 与缓存击穿 。
本文将结合黑马点评项目,拆解我们是如何通过封装通用的 CacheClient 工具类,利用空值缓存 、互斥锁 以及逻辑过期 这三种方法,优雅地应对高并发流量冲击的。
一、 拨开云雾:理解缓存问题的本质 在分布式系统中,高并发流量会瞬间冲垮后方脆弱的数据库。因此有缓存的保护是必要的,但缓存也不是万能的。我们需要清晰地认识到两种常见的缓存问题:
1. 缓存穿透 (Cache Penetration) 问题描述 :请求直接穿透了缓存层,打到了数据库,但数据库里也没这个数据。典型场景 :恶意用户使用脚本,不断请求 id = -1 或不存在的 UUID。后果 :Redis 命中率为 0,数据库压力激增,可能瞬间宕机。核心矛盾 :数据库中不存在的数据,正常情况下不会被写入缓存,导致后续请求依然无法命中。
2. 缓存击穿 (Cache Breakdown) 问题描述 :一个热点 Key (比如微博热搜)在缓存过期的瞬间,遭到海量并发访问。典型场景 :秒杀活动的商品详情页、突发热点新闻。后果 :在缓存重建完成前的几百毫秒内,成千上万的请求同时穿透 Redis 直击数据库。核心矛盾 :缓存重建需要时间(查库 + 写缓存),而在重建期间,并发请求没有被挡住。
二、 核心武器:封装 CacheClient 工具类 为了保持业务代码(Service 层)的整洁,避免在每个查询方法里都写一遍 try-catch 和锁逻辑,我们将缓存控制逻辑抽象封装到了 com.hmdp.utils.CacheClient 中。这不仅是代码复用的体现,更是“高内聚低耦合”设计思想的落地。
1. 应对缓存穿透:空值缓存策略 既然问题的根源是“数据库查不到,所以不写缓存”,那我们反其道而行之:查不到数据,我也给你存一个“空值” 。
当数据库返回 null 时,我们将一个特定的空值(如空字符串 "")写入 Redis,并赋予一个较短的 TTL(如 2 分钟)。后续请求再来,Redis 会返回这个“空值”,应用层识别后直接拦截,不再查询数据库。
代码实战 (queryWithCachePassThrough) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 public <R,ID> R queryWithCachePassThrough ( String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, type); } if (json != null ) { return null ; } R r = dbFallback.apply(id); if (r == null ) { stringRedisTemplate.opsForValue().set(key, "" , RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); return null ; } this .set(key, r, time, unit); return r; }
2. 应对缓存击穿:互斥锁策略 (Mutex Lock) 针对强一致性需求,我们采用互斥锁 方案。简单来说,就是串行化重建 。
当发现缓存失效时,并不是所有线程都去查库。而是让大家先去抢一个“锁”(Redis 的 SETNX)。抢到锁的那个“天选之子”负责查库重建,其他线程则休眠重试。
代码实战 (queryWithMutex) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 public <R,ID> R queryWithMutex ( String keyPrefix, String lockKeyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, type); } if (json != null ) { return null ; } String lockKey = lockKeyPrefix + id; R r = null ; try { boolean isLock = tryLock(lockKey); if (!isLock) { Thread.sleep(50 ); return queryWithMutex(keyPrefix, lockKeyPrefix, id, type, dbFallback, time, unit); } json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toBean(json, type); } r = dbFallback.apply(id); if (r == null ) { stringRedisTemplate.opsForValue().set(key, "" , RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); return null ; } this .set(key, r, time, unit); } catch (InterruptedException e) { throw new RuntimeException (e); } finally { unlock(lockKey); } return r; } private boolean tryLock (String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1" , 10 , TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private void unlock (String key) { stringRedisTemplate.delete(key); }
3. 应对缓存击穿:逻辑过期策略 (Logical Expiration) 互斥锁保证了数据一致性,但牺牲了吞吐量。对于秒杀等对响应时间极度敏感、且能容忍短暂数据不一致的场景,我们采用逻辑过期 。这是对待缓存击穿的第二种方案,也是我们项目最终的选择。
核心原理 :
永不过期 :Redis 物理 key 不设置 TTL,保证随时能取到数据(哪怕是旧的)。
逻辑时间 :在数据对象内部封装一个 expireTime 字段。
异步重建 :查询时判断是否逻辑过期。如果过期,尝试获取锁。获取锁成功的线程开启独立线程 去后台更新数据,自己直接返回旧数据;获取锁失败的线程,也直接返回旧数据。
代码实战 (queryWithLogicExpire) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10 );public <R,ID> R queryWithLogicExpire ( String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit) { String key = keyPrefix + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(json)) { return null ; } RedisData redisData = JSONUtil.toBean(json, RedisData.class); R r = JSONUtil.toBean((JSONObject) redisData.getData(), type); LocalDateTime expireTime = redisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())) { return r; } String lockKey = RedisConstants.LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); if (isLock) { CACHE_REBUILD_EXECUTOR.submit(() -> { try { R newR = dbFallback.apply(id); this .setWithLogicalExpire(key, newR, time, unit); } catch (Exception e) { throw new RuntimeException (e); } finally { unlock(lockKey); } }); } return r; }
结语 通过这篇笔记,不仅在项目里掌握了 Redis 缓存击穿与穿透的硬核解决方案,不过在分布式高并发的战场上,没有完美的架构,只有最适合业务场景的权衡。当然黑马点评作为学生项目,和企业级别的分布式缓存方案还有差距,所以这个主要是提供思路,也为面试积累一些实战经验。