Redis实战篇 | 本地缓存的三种实现与分布式缓存、多级缓存架构、穿透雪崩击穿

张开发
2026/4/4 8:41:52 15 分钟阅读
Redis实战篇 | 本地缓存的三种实现与分布式缓存、多级缓存架构、穿透雪崩击穿
一、为什么要引入缓存1.缓存的本质与实现就像山地自行车的“避震器”缓存是数据交换的缓冲区。俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码本地缓存的三种实现方式例如:例1:static final MapK,V map new HashMap(); // 单线程数据结构 例2:static final ConcurrentHashMapK,V map new ConcurrentHashMap(); // 多线程数据结构 例3:static final CacheK,V USER_CACHE Caffeine.newBuilder().build(); // 专业本地缓存1HashMapstatic final MapK,V map new HashMap();(最原始的本地缓存)这是最基础的Java集合实现利用HashMap的键值对特性来暂存数据。本质一个单纯的非线程安全的数据结构。优势极其轻量、速度最快没有加锁机制单线程环境下读写性能极高。零学习成本Java原生API随手即写。劣势非线程安全致命弱点在高并发多线程环境下同时读写会导致数据丢失、报错ConcurrentModificationException甚至在JDK 1.7及以前版本会引发死循环导致CPU 100%。无缓存管理机制没有过期时间TTL、没有淘汰策略如LRU。放进去的数据如果不手动remove()会一直占用内存极易导致OOM内存溢出。适用场景单线程环境下的数据暂存。只读缓存在系统启动时一次性加载字典数据后续运行过程中绝对不会再被修改。2ConcurrentHashMapstatic final ConcurrentHashMapK,V map new ConcurrentHashMap();(并发安全的本地缓存)为了解决HashMap并发报错的问题引入了ConcurrentHashMap。这也是很多初级项目中常用的缓存方案。本质Java并发包JUC提供的高性能、线程安全的哈希表。优势高并发安全JDK 1.8 之后采用了 CAS synchronized锁粒度细化到头节点分段锁思想的升级版在保证线程安全的同时支持高并发读写。无外部依赖依然是JDK自带类不需要引入第三方库。劣势依然缺乏缓存管理能力它本质上还是个“容器”而不是“缓存框架”。它不知道哪个Key过期了也不知道在内存满了时该删除谁。OOM风险依然存在作为全局静态变量如果不写定时任务去清理数据只增不减最终撑爆JVM内存。适用场景高并发场景下需要频繁读写的共享状态/映射表例如保存当前在线用户的WebSocket Session。数据量完全可控且通常伴随着业务逻辑会有明确的put和remove操作的场景。3Caffeinestatic final CacheK,V USER_CACHE Caffeine.newBuilder().build();(现代高性能本地缓存之王)这是目前 Java 生态中最优秀的本地缓存框架。它的 API 设计很大程度上借鉴了 Google Guava Cache但在底层算法和性能上进行了全面的颠覆和升级。本质基于W-TinyLFU算法构建的极高性能、线程安全的现代内存缓存框架。核心优势极致的读写性能在多线程并发读写测试中Caffeine 的性能通常远超 Guava Cache甚至在某些场景下优于基础的ConcurrentHashMap因为它使用了 RingBuffer 环形缓冲区和异步批量处理机制来降低锁竞争。极高的缓存命中率杀手锏传统缓存多用 LRU最近最少使用淘汰算法容易被偶发的突发流量把真正的热点数据“挤出去”。Caffeine 采用的是独创的W-TinyLFU算法它兼顾了访问频率LFU和访问时间LRU极其聪明地保留真正的“热点数据”。强大的缓存生命周期管理容量驱逐maximumSize(10000)按条数或按权重驱逐。时间驱逐expireAfterWrite写入后过期、expireAfterAccess访问后过期甚至支持自定义灵活的过期策略。防缓存击穿机制通过build(CacheLoader)结合自动刷新refreshAfterWrite可以保证在高并发下如果一个热点 Key 过期了只有一个线程会去查数据库其他线程要么等待要么返回老值配合异步刷新完美解决并发击穿问题。劣势引入第三方依赖需要引入com.github.ben-manes.caffeine:caffeine。依然存在单机局限性数据保存在当前 JVM 堆内存或直接内存中集群部署时各个服务器之间的缓存数据是不共享、不一致的。适用场景一切需要单机本地缓存的场景的完美选择例如权限缓存、字典表缓存、本地短时热点数据。构建**多级缓存架构L1本地缓存 L2分布式缓存**的 L1 层首选即Caffeine Redis 的黄金组合。4本地缓存与分布式缓存维度基础结构HashMap并发结构ConcurrentHashMap本地缓存Caffeine分布式缓存Redis核心定位单线程数据结构多线程数据结构专业高性能本地缓存专业分布式缓存中间件线程安全❌ 否 (极度危险)✅ 是 (CAS分段锁)✅ 是 (RingBuffer优化锁)✅ 是 (服务端单线程排队)淘汰算法❌ 无 (易OOM)❌ 无 (易OOM)✅W-TinyLFU(极高命中率)✅ 支持多种 (LRU/LFU/随机等)过期时间(TTL)❌ 无❌ 无✅ 原生支持极为丰富的过期策略✅ 原生支持防缓存击穿❌ 无❌ 无✅ 自带异步加载与自动刷新机制✅ 需写代码实现(互斥锁/逻辑过期)读写速度极快单线程下快多线程下极快本地内存级别极低延迟较快受限于网络IO和序列化耗时分布式支持❌ 否❌ 否❌ 否 (数据存在各机器本地JVM)✅是 (跨JVM、跨机器共享)适用场景方法内部的临时局部变量保存系统中的持久性并发状态单机热点数据缓存 / 多级缓存L1集群共享数据 / 多级缓存L2实战推荐度⛔严禁做全局缓存⚠️需手动管理生命周期极高Spring默认推荐极高微服务标配通过比较上述三种本地缓存的实现方式与分布式缓存Redis我们可以大概了解二者的不同。在这里需要注意的是严格来说Map和ConcurrentHashMap只是容器因为它们不像Caffeine和Redis一样具备自我管理的生命周期容量上限过期剔除、智能淘汰。如果你的系统是单体架构或者数据对一致性要求不高且需要极低的响应延迟直接上Caffeine如果你的系统是分布式集群或者需要多台机器状态同步上Redis。在最极致的高并发大厂实战中往往是Caffeine 挡第一波Redis 挡第二波最后兜底查 MySQL。在本篇中我们着重讲解Redis的缓存实现在后续的高级篇的分布式缓存和多级缓存中会再次提到Redis和Caffeine。2.缓存引入利弊分析1优点数据库如MySQL的并发处理能力有限通常几千并发即达瓶颈在高并发场景下直接查库会导致数据库瘫痪。引入缓存可以降低数据库读写压力提高系统的响应速度和吞吐量。2弊端但是缓存也会增加代码复杂度和运营的成本等尤其是数据一致性成本它体现在并发风险、异常处理、开发复杂度、排查难度。为了保证一致性你必须额外选择缓存更新策略、处理并发竞争实现重试 / 补偿 / 兜底、增加监控缓存命中率、脏数据比例、增加日志、可观测性写降级、熔断、回查机制。通常都是做最终一致性的业务妥协3.多级缓存多级缓存就是充分利用请求处理的每个环节分别添加缓存减轻Tomcat压力提升服务性能浏览器访问静态资源时优先读取浏览器本地缓存访问非静态资源ajax查询数据时访问服务端请求到达Nginx后优先读取Nginx本地缓存如果Nginx本地缓存未命中则去直接查询Redis不经过Tomcat如果Redis查询未命中则查询Tomcat请求进入Tomcat后优先查询JVM进程缓存如果JVM进程缓存未命中则查询数据库PS在多级缓存架构中Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑因此这样的nginx服务不再是一个反向代理服务器而是一个编写业务的Web服务器了Nginx编程会用到OpenResty框架结合Lua语言。因此这样的业务Nginx服务也需要搭建集群来提高并发再有专门的nginx服务来做反向代理另外我们的Tomcat服务将来也会部署为集群模式。二、添加商户缓存基础实现逻辑拦截查询请求优先查询Redis缓存。若缓存命中则直接返回数据。若缓存未命中则查询数据库。将数据库的查询结果写入Redis并返回给用户。三、缓存更新策略缓存更新是redis为了节约内存而设计出来的一个东西主要是因为内存数据宝贵当我们向redis插入太多数据此时就可能会导致缓存中的数据过多所以redis会对部分数据进行更新或者把他叫为淘汰更合适。内存淘汰redis自动进行当redis内存达到咱们设定的max-memery的时候会自动触发淘汰机制淘汰掉一些不重要的数据(可以自己设置策略方式)超时剔除当我们给redis设置了过期时间ttl之后redis会将超时的数据进行删除方便咱们继续使用缓存主动更新我们可以手动调用方法把缓存删掉通常用于解决缓存和数据库不一致问题。解决一致性问题通常有以下三种方式但目前方式二与方式三都没有比较好的服务实现因此我们下面采用方案一人工双写。采用方案一调用者操作缓存和数据库时有三个问题需要提前考虑1.删除还是更新缓存更新缓存每次更新数据库都更新缓存无效写操作较多。如果多次更新数据库但却不进行查询就会导致缓存压根就得不到使用也就是白更新缓存了。删除缓存更新数据库时让缓存失效查询时再更新缓存。相较于每次更新数据库都更新缓存的方案这种方案在多数时间内都是执行一次删除缓存操作就完事了只有在调用者查询时即真正要用到缓存了再做数据同步更新缓存更加高效。选择删除缓存。因为更新缓存会产生大量的无效写操作可能更新了多次才被读一次延迟加载删除后等下一次查询再写入性能更好。2.如何保证原子性单体系统将缓存与数据库操作放在一个事务分布式系统利用TCC等分布式事务方案将操作数据库和操作缓存放在同一个事务中。3.先操作缓存还是先操作数据库1先操作缓存线程2的查询缓存和写入缓存都是对缓存的操作速度非常快而线程1更新数据库的操作是对数据库的操作速度比较慢因此线程2的两个操作及其可能在并发情况下插入到线程1的两个操作之间导致线程2写入旧数据到缓存中而线程1又在数据库中更新了旧数据致使最终数据库与缓存数据不一致的情况。2先操作数据库线程2的更新数据库操作速度较慢而线程1的两次操作都是对缓存的操作速度较快。因此图示的异常情况线程1查询数据库返回的值是旧的因为线程2后续更新了数据库的值所以当线程1把旧值写入缓存后就造成了缓存与数据库数据不一致并不容易出现线程1两个操作之间的衔接时间非常短线程2几乎很难插进去执行个更新数据库的操作。四、缓存三大经典问题及解决方案1. 缓存穿透定义客户端请求的数据在缓存和数据库中都不存在。导致每次请求都直接打到数据库恶意攻击时容易压垮数据库。解决方案缓存空对象项目采用即使数据库查不到也把空值如空字符串存入Redis并设置较短的过期时间。优点是简单缺点是消耗内存且可能引起短期不一致。布隆过滤器利用哈希算法加位图在请求进入Redis前先判断数据是否存在。优点是省内存缺点是实现复杂且存在误判率。2. 缓存雪崩定义同一时间段内大量缓存的Key同时失效或者Redis服务宕机导致瞬间大量请求直达数据库。解决方案给不同的Key设置过期时间时加上一个随机值打散过期时间。利用Redis集群保证高可用。给业务添加多级缓存或降级限流策略。3. 缓存击穿定义一个被高并发访问且缓存重建逻辑较复杂的热点Key突然失效过期瞬间无数请求发现缓存没命中同时去查数据库并尝试重建缓存压垮数据库。解决方案互斥锁Mutex Lock原理查缓存没命中时不立刻查库而是利用Redis的setnx尝试获取一把分布式锁。拿到锁的线程去查库重建缓存拿不到的线程休眠重试。优缺点优点是保证了数据的一致性实现较简单缺点是互相等待导致性能下降且有死锁风险。逻辑过期Logical Expiration原理不给Key设置实际的TTL而是在Value中存入一个“逻辑过期时间”字段。发现过期时当前线程直接返回旧数据同时开启一个独立新线程去获取互斥锁并异步查库重建缓存。优缺点优点是线程不阻塞并发性能极高缺点是牺牲了短期的一致性重构完成前返回的都是脏数据且实现较复杂。五、封装Redis工具类为了避免在不同的业务如查询商铺、查询其他信息中重复写解决穿透和击穿的逻辑实战中会基于StringRedisTemplate封装一个通用的CacheClient客户端。主要包含的方法普通的存入带TTL的缓存。存入带有逻辑过期时间的缓存。封装解决缓存穿透的通用查询方法利用函数式编程传入数据库查询逻辑。封装解决缓存击穿的通用查询方法逻辑过期 / 互斥锁方案。Slf4j Component public class CacheClient { private final StringRedisTemplate stringRedisTemplate; private static final ExecutorService CACHE_REBUILD_EXECUTOR Executors.newFixedThreadPool(10); public CacheClient(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } public void set(String key, Object value, Long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit); } public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) { // 设置逻辑过期 RedisData redisData new RedisData(); redisData.setData(value); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); // 写入Redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); } public R,ID R queryWithPassThrough( String keyPrefix, ID id, ClassR type, FunctionID, R dbFallback, Long time, TimeUnit unit){ String key keyPrefix id; // 1.从redis查询商铺缓存 String json stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isNotBlank(json)) { // 3.存在直接返回 return JSONUtil.toBean(json, type); } // 判断命中的是否是空值 if (json ! null) { // 返回一个错误信息 return null; } // 4.不存在根据id查询数据库 R r dbFallback.apply(id); // 5.不存在返回错误 if (r null) { // 将空值写入redis stringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return null; } // 6.存在写入redis this.set(key, r, time, unit); return r; } public R, ID R queryWithLogicalExpire( String keyPrefix, ID id, ClassR type, FunctionID, R dbFallback, Long time, TimeUnit unit) { String key keyPrefix id; // 1.从redis查询商铺缓存 String json stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isBlank(json)) { // 3.存在直接返回 return null; } // 4.命中需要先把json反序列化为对象 RedisData redisData JSONUtil.toBean(json, RedisData.class); R r JSONUtil.toBean((JSONObject) redisData.getData(), type); LocalDateTime expireTime redisData.getExpireTime(); // 5.判断是否过期 if(expireTime.isAfter(LocalDateTime.now())) { // 5.1.未过期直接返回店铺信息 return r; } // 5.2.已过期需要缓存重建 // 6.缓存重建 // 6.1.获取互斥锁 String lockKey LOCK_SHOP_KEY id; boolean isLock tryLock(lockKey); // 6.2.判断是否获取锁成功 if (isLock){ // 6.3.成功开启独立线程实现缓存重建 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); } }); } // 6.4.返回过期的商铺信息 return r; } public R, ID R queryWithMutex( String keyPrefix, ID id, ClassR type, FunctionID, R dbFallback, Long time, TimeUnit unit) { String key keyPrefix id; // 1.从redis查询商铺缓存 String shopJson stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { // 3.存在直接返回 return JSONUtil.toBean(shopJson, type); } // 判断命中的是否是空值 if (shopJson ! null) { // 返回一个错误信息 return null; } // 4.实现缓存重建 // 4.1.获取互斥锁 String lockKey LOCK_SHOP_KEY id; R r null; try { boolean isLock tryLock(lockKey); // 4.2.判断是否获取成功 if (!isLock) { // 4.3.获取锁失败休眠并重试 Thread.sleep(50); return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit); } // 4.4.获取锁成功根据id查询数据库 r dbFallback.apply(id); // 5.不存在返回错误 if (r null) { // 将空值写入redis stringRedisTemplate.opsForValue().set(key, , CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return null; } // 6.存在写入redis this.set(key, r, time, unit); } catch (InterruptedException e) { throw new RuntimeException(e); }finally { // 7.释放锁 unlock(lockKey); } // 8.返回 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); } }Resource private CacheClient cacheClient; Override public Result queryById(Long id) { // 解决缓存穿透 Shop shop cacheClient .queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES); // 互斥锁解决缓存击穿 // Shop shop cacheClient // .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES); // 逻辑过期解决缓存击穿 // Shop shop cacheClient // .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS); if (shop null) { return Result.fail(店铺不存在); } // 7.返回 return Result.ok(shop); }

更多文章