ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis商户缓存实战:穿透、雪崩、击穿与序列化全解析

Redis商户缓存实战:穿透、雪崩、击穿与序列化全解析 当时跟着教程把店铺查询改造为缓存查询时我踩了一个特别经典的坑第一次用RedisTemplate把Shop对象塞进Redis读出来的时候控制台打了一屏二进制乱码\xAC\xED\x00\x05t...这样的东西直接把我整懵了。后来才发现是默认序列化器的问题换成StringRedisTemplate加JSON序列化才顺起来。这个模块就是黑马点评里的商户缓存实战也是整个项目里最值得反复复盘的一部分——表面上是给查询加个缓存实际上连着缓存更新策略、穿透雪崩击穿、序列化方案、工具类封装一条线下来几乎把Redis缓存的核心知识点都摸了一遍。这篇文章是我完成这部分实战后的个人总结不按教程顺序流水账式复述而是按我踩坑理解的顺序来梳理缓存流程怎么搭、更新策略为什么这么选、三种经典缓存问题怎么落地、序列化为什么会出乱码、最后怎么把散落的逻辑收拢成通用工具类。适合正在学Redis、准备把黑马点评写进简历的朋友尤其是对缓存这块只停留在背八股阶段、想看看真实业务代码长什么样的同学。1. 商户缓存到底在缓存什么从一次店铺查询的改造说起1.1 原始查询为什么扛不住黑马点评的业务模型不算复杂商户、用户、优惠券、签到、好友关注这些模块商户查询是其中被访问频率最高的接口之一。用户在首页刷店铺列表、点进店铺详情每一个动作背后都是一次对Shop表的查询。在没有缓存的时候每一次请求都要走一遍完整的数据库链路建立连接、解析SQL、走索引、回表查数据、返回结果集。单次查询几十毫秒看起来不慢但扛不住QPS上去。数据库的连接数是有限的MySQL默认的连接池撑死一两百个并发磁盘IO也有瓶颈。更重要的是一个店铺的详情数据是典型的读多写少场景——用户反反复复看同一家店但店铺信息很少变动。把这种数据每次都从数据库里捞一遍本质上是巨大的浪费。缓存的思路很直接既然同一个数据被反复读那就把它放到离应用更近、读取更快的地方。Redis把数据存在内存里单线程模型下读写都是微秒级比走一次MySQL的磁盘IO快几个数量级。商户缓存模块要解决的核心问题就是让查店铺这个高频接口不再依赖数据库。1.2 第一次改造查缓存、没命中再查DB、回填黑马点评里商户缓存的第一版逻辑非常朴素就是经典的Cache Aside Pattern的读操作部分先查Redis命中了直接返回没命中就查数据库查到之后写回Redis并设置过期时间查不到就返回失败。我当时写出来的代码大致是这样的public Result queryById(Long id) { // 1. 从Redis查询店铺缓存 String key CACHE_SHOP_KEY id; String shopJson stringRedisTemplate.opsForValue().get(key); // 2. 缓存命中直接返回 if (StrUtil.isNotBlank(shopJson)) { Shop shop JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 3. 缓存未命中查询数据库 Shop shop getById(id); if (shop null) { return Result.fail(店铺不存在); } // 4. 写入Redis并设置过期时间 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES); // 5. 返回 return Result.ok(shop); }这段代码现在看很基础但它把缓存操作的核心流程走通了先缓存、后数据库、未命中回填、设置TTL兜底。第4步那个TTL是非常关键的设计它保证了缓存不会永久占内存也给了数据一个自杀式的自我修复机会——就算某次缓存和数据库不一致了最迟30分钟后缓存过期数据会被重新从数据库加载实现最终一致。1.3 代码里几个容易被忽略的细节这段代码里有几个细节做的时候不觉得后来回头看才发现都挺重要。第一个是StrUtil.isNotBlank(shopJson)的判断逻辑。它判断的是字符串非空但如果缓存里存的是一个空字符串这个判断会直接走false也就是说空字符串和没有缓存会落到同一个分支里。后面讲到缓存穿透的解决方案时这个细节就会变成关键——缓存空值恰恰就是要用空字符串来区分没查到和查到了但不存在两种情况。第二个是为什么用StringRedisTemplate而不是RedisTemplate。StringRedisTemplate的key和value都是String类型序列化器是固定的String序列化器而RedisTemplate默认用的是JdkSerializationRedisSerializer。我会在第4章详细讲这个坑。简单说用StringRedisTemplate配JSON字符串是所有方案里最简单、最不容易出幺蛾子的。第三个是为什么要用JSON字符串而不是直接存二进制对象。JSON的好处是跨语言、可读性强而且序列化后的体积比JDK序列化小很多。用JSONUtil.toJsonStr(shop)把Shop对象变成JSON字符串存进Redis的就是一段人眼可以读懂的文本排查问题的时候直接用Redis客户端就能看到数据长什么样。这一点在后来的调试中给过我很大的帮助。2. 缓存更新策略我为什么接受主动更新TTL兜底这套组合2.1 先理清三种主流更新策略缓存不是永恒不变的数据库里的数据一旦更新缓存里的旧数据就成了脏数据。怎么让缓存和数据库保持同步业界有几套经典策略我简单梳理一下选择背后的逻辑。Cache Aside旁路缓存这是最主流的方式也是黑马点评在用的。读的时候先读缓存读不到再读数据库然后回填写的时候先更新数据库然后删除缓存。业务代码显式控制缓存操作逻辑最简单也最灵活。Read Through / Write Through把缓存当成一个代理层应用不直接操作缓存和数据库而是通过缓存组件统一处理。对应用来说缓存是透明的但实现复杂度高一般需要引入专门组件或自己封装中间件。Write Behind异步写回写操作只更新缓存然后异步批量写回数据库。性能最高但数据一致性风险最大——如果数据库写入失败内存里的数据就丢了。黑马点评这个项目选Cache Aside是非常务实的决定。它不引入额外组件、代码量可控、一致性由业务自己掌控最重要的是对于这个体量的项目来说Cache Aside完全够用。2.2 主动更新为什么是更新数据库后删缓存Cache Aside的写操作有一个经典问题先删缓存再更新数据库还是先更新数据库再删缓存执行顺序到底怎么选如果先删缓存再更新数据库极端情况下会发生这样的事线程A删掉了缓存还没来得及更新数据库线程B来查数据缓存没命中从数据库里读到了旧数据并回填到缓存。等线程A更新完数据库缓存里躺着的还是线程B回填的那份旧数据。这就是先删后更最容易踩的坑。所以主流方案是先更新数据库再删除缓存。这里面的逻辑其实很朴素更新数据库是必然要做的正事删除缓存是一个失效动作把失效放在最后可以让脏数据窗口最小化。如果删缓存失败了缓存里还是旧数据但因为有TTL兜底最迟过期后会被重新加载最终还是能回到一致状态。我在黑马点评里看到的具体实现就是在修改店铺的Service方法中先执行数据库update再删除对应缓存public Result updateShop(Shop shop) { Long id shop.getId(); if (id null) { return Result.fail(店铺id不能为空); } // 1. 更新数据库 updateById(shop); // 2. 删除缓存 stringRedisTemplate.delete(CACHE_SHOP_KEY id); return Result.ok(); }2.3 删缓存失败怎么办延迟双删与重试先更新数据库再删缓存虽然把脏窗口压缩得很小但依然有一个隐患删缓存这步如果失败了怎么办比如Redis突然抖动或者网络闪断缓存里就会一直躺着一份旧数据直到TTL过期。30分钟的不一致时间对于商户详情这种数据来说其实已经很难接受了。业界常用的补救手段里有消息队列重试和延迟双删。消息队列的做法是删缓存失败时把需要删除的缓存key扔进MQ后台消费者拿到消息后再尝试删除直到成功为止。延迟双删的做法是先删缓存、更新数据库、sleep一段时间后再删一次缓存用来覆盖在第一次删除后、更新数据库之前这个窗口期内可能被回填的旧缓存。但延迟双删的sleep多久是个很难拍脑袋定的参数而且它本质上是在用概率去换一致性。在我个人的实践里最靠谱的还是做好监控和重试把删除缓存失败的情况记录下来靠MQ补偿再有TTL做最后一道防线。黑马点评项目里没有讲这么深但面试问到缓存和数据库一致性怎么保证时这一整套思路是能拉开差距的回答。3. 穿透、雪崩、击穿三个看起来很像的缓存故障3.1 缓存穿透恶意请求把数据库打穿缓存穿透指的是请求查询一个缓存和数据库里都不存在的数据。比如有人恶意构造一个不存在的店铺id比如-1频繁发起查询。缓存里自然不可能有每次请求都会打到数据库数据库查不到也回填不了缓存就这样一层层打穿下去。黑马点评的解法是缓存空值查询数据库后如果发现店铺不存在就把一个空字符串也写进Redis并且设置一个较短的TTL比如2分钟。这样下一次再查这个不存在的id缓存命中的是空字符串直接返回店铺不存在数据库就被保护住了。实现上要处理一个关键分支就是我在1.3里提到的那个StrUtil.isNotBlank判断public Shop queryWithPassThrough(Long id) { String key CACHE_SHOP_KEY id; String shopJson stringRedisTemplate.opsForValue().get(key); // 缓存命中且非空直接返回 if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 缓存命中的是空字符串说明数据不存在 if (shopJson ! null) { return null; } // 缓存未命中查数据库 Shop shop getById(id); if (shop null) { // 缓存空值TTL设置为2分钟 stringRedisTemplate.opsForValue().set(key, , 2L, TimeUnit.MINUTES); return null; } // 查到数据回填缓存 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES); return shop; }注意这里shopJson null和shopJson 是两个不同的分支。缓存里没有key时get返回null缓存了空字符串时返回的是。这个分支判断是缓存空值方案能否正确的关键。3.2 缓存雪崩一大批key同时失效缓存雪崩和缓存穿透完全不同它不是针对某个不存在的数据而是大量key在同一时间失效导致大批请求同时落到数据库。比如我缓存了100个店铺都设置了30分钟TTL如果它们恰好在同一时刻被写入、同一时刻过期那么过期瞬间就会有一波并发的数据库请求砸过来。解法很朴素让过期时间错开。比如在设置TTL时加上一个随机值// 给TTL加一个1~10分钟的随机数 long ttl 30L new Random().nextInt(10); stringRedisTemplate.opsForValue().set(key, json, ttl, TimeUnit.MINUTES);这在代码里就一行的事但对数据库的意义是非凡的。原本可能同时过期的100个key现在过期时间散落在30到40分钟之间数据库收到的并发压力就被平摊了。另一个思路是热点数据不设置过期时间用逻辑过期后面讲击穿时会提到替代物理过期但这属于更进阶的方案黑马点评里针对雪崩主要是靠随机TTL。3.3 缓存击穿热点key过期的致命瞬间缓存击穿是指一个热点key过期的瞬间大量并发请求同时打到数据库。注意它不是不存在的数据也不是大面积key同时过期而是单个热点key过期这个极小的时间窗口内并发量被瞬间放大。黑马点评提供了两种落地解法我都亲手实现过。第一种是互斥锁。用Redis的setnx命令实现一个分布式锁查询逻辑变成这样缓存未命中时先尝试获取锁拿到锁的线程去查数据库并回填缓存拿不到锁的线程先休眠一小段再重新查缓存。这样可以保证同一时刻只有一个线程在查数据库重建缓存public Shop queryWithMutex(Long id) { String key CACHE_SHOP_KEY id; String shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 尝试获取互斥锁 String lockKey LOCK_SHOP_KEY id; boolean isLock stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10L, TimeUnit.SECONDS); if (!isLock) { // 没拿到锁休眠后重查缓存 Thread.sleep(50); return queryWithMutex(id); } try { // 二查缓存防止在等待锁期间别的线程已经回填 shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 查数据库 Shop shop getById(id); if (shop null) { stringRedisTemplate.opsForValue().set(key, , 2L, TimeUnit.MINUTES); return null; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES); return shop; } finally { // 释放锁 stringRedisTemplate.delete(lockKey); } }第二种是逻辑过期。这个方案要更巧妙一些缓存key不设置物理TTL永远不会过期但存入的value里带上一个逻辑过期时间。查询时发现逻辑时间已过期就返回旧数据同时起一个后台线程去更新缓存。这样用户永远能拿到数据哪怕略微过时数据库的压力也被摊到了后台线程上。逻辑过期方案的存储结构一般这样组织// RedisData是个包装类包含过期时间和实际数据 RedisData redisData new RedisData(); redisData.setExpireTime(LocalDateTime.now().plusMinutes(30L)); redisData.setData(shop); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));查询时取出数据判断逻辑过期时间是否早于当前时间。如果没过期直接返回如果过期了加锁另起线程重建缓存当前线程先返回旧数据。互斥锁和逻辑过期各有利弊。互斥锁能保证绝对的一致性但会有线程阻塞等待可能增加响应时间逻辑过期不会阻塞体验好但返回的可能是过时数据。我后面在面试里被问到时我的总结是对一致性要求高的场景选互斥锁对可用性要求高的场景选逻辑过期。4. 序列化的坑RedisTemplate默认序列化器把我坑惨了4.1 一屏乱码从哪来回到开头那个场景。我一开始用的是RedisTemplateString, Shop往Redis里set进去一个Shop对象再用Redis Desktop Manager打开看发现key和value全是一堆\xAC\xED\x00\x05t...这样的二进制乱码。原因就是RedisTemplate默认使用JdkSerializationRedisSerializer这个序列化器会把Java对象通过JDK原生的ObjectOutputStream变成字节数组然后再以二进制形式存进Redis。JDK序列化的结果本身就带一大串类描述信息体积又大又不可读。更坑的是存进去的时候按照对象序列化来取出来的时候如果类型对不上直接抛ClassCastException。4.2 解决方案StringRedisTemplate加JSON黑马点评项目里的写法非常干脆直接用StringRedisTemplate它内部的key和value序列化器都是String类型不会产生二进制乱码。然后配合Hutool的JSONUtil手动把Java对象转成JSON字符串存进去取出来时再转回对象。// 存 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES); // 取 String shopJson stringRedisTemplate.opsForValue().get(key); Shop shop JSONUtil.toBean(shopJson, Shop.class);这样做的好处有三个一是Redis里存的数据可读性极强打开可视化工具一眼就能看到店铺的name、id这些字段排查问题非常直观二是JSON是跨语言的将来如果换Go、Python的客户端来读这批缓存数据照样能解析三是序列化体积比JDK序列化小很多节省内存和网络带宽。4.3 LocalDateTime这个隐藏炸弹用JSON方案也不代表完全没坑。Shop对象里有个createTime字段类型是LocalDateTime如果直接用Jackson的默认配置去序列化可能会报错或者输出一坨奇怪的时间格式。Hutool的JSONUtil对LocalDateTime的处理还算友好能直接转成标准时间字符串。但如果你自己搭的是Spring Boot默认的ObjectMapper就得手动注册JavaTimeModule还要配置日期格式。这个点算是一个很好的面试话题因为很多人知道RedisTemplate序列化会出问题但说不出为什么能提到JDK序列化的体积问题、跨语言问题以及LocalDateTime这类Java 8时间类型的序列化坑就说明你确实实操过而不是背过答案。5. 把缓存逻辑抽成CacheClient一次不想再复制粘贴的重构5.1 逻辑散落带来的痛做到第三遍的时候我发现一个问题缓存穿透、缓存击穿、逻辑过期每个方案都是一大段相似的代码散落在各个Service方法里。每写一个查询方法都要把查缓存、判断空值、查DB、回填、处理异常这一整套逻辑复制一遍。一旦要改一个通用逻辑比如把空值TTL从2分钟改成3分钟就要去每个方法里手工改非常容易漏。这个时候就自然会想到封装一个通用的缓存工具类把查询逻辑和缓存处理逻辑分离开来。5.2 CacheClient的核心设计黑马点评最终封装出的CacheClient工具类核心思想是用函数式接口把查数据库这个行为作为参数传进来。Java里的FunctionLong, T可以代表输入一个id返回一个Shop或任意类型这样的方法引用这也是整个封装的灵魂。我理解的几个核心方法queryWithPassThrough处理缓存穿透传入id、类型、数据库查询函数以及TTLpublic T T queryWithPassThrough(String keyPrefix, Long id, ClassT type, FunctionLong, T 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; } T data dbFallback.apply(id); if (data null) { stringRedisTemplate.opsForValue().set(key, , 2L, TimeUnit.MINUTES); return null; } stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data), time, unit); return data; }调用时就变得非常清爽Shop shop cacheClient.queryWithPassThrough( CACHE_SHOP_KEY, id, Shop.class, this::getById, 30L, TimeUnit.MINUTES);queryWithMutex处理缓存击穿代码结构就是在前面第3章那段互斥锁逻辑基础上把查DB替换成dbFallback.apply(id)。queryWithLogicalExpire逻辑过期版本的查询方法内部处理RedisData的解析、过期判断、加锁、开启新线程重建缓存。封装完之后Service层不再是几百行的缓存逻辑堆砌每段业务代码都很干净。也是从这时候开始我才真正理解了函数式接口在把行为参数化这件事上的实用价值。以前看FunctionT, R总觉得抽象直到用它封装了缓存工具类才体会到这种设计模式不是为了炫技是真的能减少大量重复代码。5.3 封装之后暴露出的新问题封装不是银弹反而暴露了几个新的细节问题这里值得提一下。一个是重建缓存得用线程池。在逻辑过期方案里过期后要另起线程去查数据库并回填缓存。如果用new Thread(...)直接开线程在高并发下会不断创建新线程浪费资源。我在实践里是用了一个固定大小的线程池来提交重建任务。另一个是防止多个线程同时重建。虽然加了互斥锁但重建过程本身如果很慢也可能出现拿锁线程还没写完其他线程又去拿锁的情况。所以在查询逻辑里拿到锁之后要再查一次缓存确认没有别的线程已经重建完成这就是双重检查。这个细节在代码里看起来只是多了一次get但实际是防重复重建的关键。6. 面试官追问时这些点我是这么组织的黑马点评之所以成为简历上的高频项目不仅是因为它用了Redis更因为它把缓存相关的核心问题都踩了一遍。我把这段时间复习面试题时整理的点写在这里作为一个阶段性的复盘。6.1 高频追问清单面试官常问我的回答要点缓存穿透是什么怎么解决查询不存在的数据缓存空值短TTL或者用布隆过滤器前置拦截缓存雪崩是什么怎么解决大量key同时过期TTL加随机值、热点数据不过期、多级缓存缓存击穿和雪崩有什么区别击穿是单个热点key过期瞬间的并发雪崩是大面积key同时过期互斥锁和逻辑过期怎么选一致性要求高选互斥锁延迟容忍度高选逻辑过期从实现复杂度说互斥锁更简单为什么先更新数据库再删缓存先删缓存会扩大脏数据窗口后删缓存配合TTL兜底能保证最终一致RedisTemplate为什么乱码默认JDK序列化器改为StringRedisTemplateJSON6.2 我会重点展开的为什么有一个追问我几乎每次都会被问到缓存空值方案有什么缺点这个问题的隐藏点在于面试官想看你有没有想过方案的边界。缓存空值的缺点其实不少一是大量不存在的key会占用Redis内存虽然TTL短但架不住恶意构造的key多二是空值的TTL和正常数据的TTL不一样管理起来要小心三是布隆过滤器虽然能从根本上过滤不存在的key但存在误判率而且实现复杂度比缓存空值高不少。我的回答逻辑是业务体量决定技术选型。黑马点评这种体量的项目用缓存空值成本最低、效果直接如果有海量恶意请求打进来再上布隆过滤器也不迟因为布隆过滤器是内存里的位图结构判断一个key是否存在是O(k)的常数时间非常快。还有逻辑过期为什么能解决击穿这个问题。我的理解是逻辑过期把数据过期从缓存层拿到了业务层控制。缓存永远不删除但业务上知道这份数据已经过时了。过时之后用户的请求继续拿到旧数据由后台线程偷偷把新数据换上。用户感知不到延迟数据库也不会在某个瞬间被打爆。6.3 做完这个模块我最大的体会整个商户缓存模块做下来我最大的收获还不是API用得更熟了——真正有价值的是想明白了缓存本质上是拿一致性换性能这件事。缓存空值让不存在的数据被暂存逻辑过期让过时的数据继续服务延迟双删甚至是在容忍短暂的不一致来换取数据库的稳定。每一套方案都不是完美的都是在特定业务约束下做的取舍。面试的时候能把每个方案的代价说出来比把每个方案的优点背得滚瓜烂熟更有说服力。如果你也正在写黑马点评我的建议是别只停留在把代码跑通多想想每一步选择背后的原因TTL为什么要设置30分钟空值为什么是2分钟而不是直接不缓存先更DB再删缓存和反过来到底差在哪把这些想明白了简历上的商户缓存三个字才算是真正长在你身上。
返回列表