ARTICLE DETAIL

资讯详情

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

Redis缓存实战:店铺查询业务链路与穿透击穿雪崩应对

Redis缓存实战:店铺查询业务链路与穿透击穿雪崩应对 二刷黑马店铺项目的时候我给自己定了一条规矩凡是视频里一句话带过的中间件操作都必须亲手把完整业务链路走一遍。day02这个任务在课程表里只占一行——把店铺查询信息添加到Redis中的业务操作但真正动手写代码时你才会发现它根本不是一个简单的set操作。缓存key怎么设计、value用什么序列化方式存储、过期时间给多久、数据库更新后缓存要不要同步删、热点店铺的缓存失效瞬间几十个请求同时打过来数据库会不会被打崩这些问题任何一个都能让加个缓存变成加了个事故。这篇文章就把我二刷时整理的完整业务操作和踩坑记录放出来从环境准备到核心代码从缓存一致性到缓存穿透、击穿、雪崩的应对再到我实际排查过的问题现场。如果你正在二刷这个项目或者刚在自己的Spring Boot项目里引入Redis缓存这份记录可以直接对着操作。1. 二刷才看懂的缓存收益这个需求到底在解决什么问题1.1 第一遍刷课的状态跟着敲但没多想我第一次刷这个项目时刚到Spring Boot和MyBatis Plus的入门阶段整天忙着把视频里的代码敲出来并跑出结果。写到将店铺查询信息添加到Redis这一步时实际操作就是复制粘贴一段opsForValue().set()运行起来发现再一次查询确实变快了就觉得自己学会了。但那种会是很脆弱的只要有人问我为什么用Redis而不是直接把数据放在内存里缓存里的数据万一和数据库不一致怎么办我完全回答不上来。第二遍的时候我不再跟着视频一步步走而是先把任务描述拆开自己动手设计一遍。拆完之后发现这个看似普通的操作背后是一整套缓存读写链路查询时如何判断命中、未命中时如何回填、更新时如何让旧缓存失效、异常时如何保证数据可用。这些环节任何一个没处理好放到生产环境里都是事故现场。所以我强烈建议二刷的同学不要快进这一段一定要自己手写一遍而不是复制。1.2 缓存的本质把数据库的答案搬到更近的柜子用一个生活化的例子来理解缓存。假设你开了一家店店员每次都要去总部档案室查询热卖商品的库存而档案室负荷有限于是你在收银台旁边放了一块白板记录热门商品的库存。第一次有人问的时候店员还是去档案室查查完把答案写在白板上第二次开始店员先看一眼白板有就直接回答没有再去档案室查。Redis在这里就是那块白板。MySQL的查询链路很长网络连接、连接池分配、SQL解析、磁盘或缓冲池读取Redis则是纯内存操作配合复用连接通常能在毫秒级返回。把用户频繁查询的店铺信息存进Redis等于把从数据库查出来的答案预存到离应用更近的地方省掉大量重复的查库开销。尤其是店铺这种基础信息1000个人查看可能拿到的都是同一份数据不去缓存它纯粹是在让数据库做重复劳动。1.3 什么样的查询场景适合放进Redis通过店铺查询这个例子可以总结出判断某类数据是否适合加缓存的三个标准读多写少店铺基本信息很少被修改但会被用户反复查看。热点集中少数热门店铺占据了大多数查询流量。一致性要求可控即使缓存晚几秒更新用户不会有明显异常感知。店铺查询正好三条全占。反过来说如果数据写频率极高或者对一致性要求非常苛刻比如订单金额、消息未读数这类数据一上来就套这种缓存方案是不合适的。这个判断标准在面试里也经常被问到属于做技术选型时的基本功。2. 动手前先保证的三件套依赖、配置、序列化2.1 本地Redis怎么启动最快第一步是把Redis跑起来。开发阶段没必要纠结复杂的部署方案三选一即可Docker方式docker run -d --name redis -p 6379:6379 redis:7-alpine一条命令搞定我最推荐。Windows方式官方其实没有维护Windows版本可以到GitHub找社区维护的Windows构建包下载解压运行redis-server.exe。macOS方式brew install redis安装brew services start redis启动。启动后先不要急着写代码用redis-cli ping验证一下返回PONG说明服务就绪。很多同学后续报错连接不上最后都发现是Redis没启动、防火墙阻挡或者启动了但端口不是默认的6379。花十秒钟提前验证能省掉后面大量排查时间。2.2 Spring Boot项目里的连接配置在Spring Boot项目里引入依赖时注意当前用的版本。Spring Boot 2.x用spring.redis前缀Spring Boot 3.x改成了spring.data.redis这个差异经常导致配置不生效。spring: data: redis: host: localhost port: 6379 database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0这里特意把lettuce.pool写出来是因为Spring Boot默认虽然用的是Lettuce连接池但如果不显式配置连接池参数一旦并发量上来很容易出现连接耗尽。后面我会专门讲一个因为没配连接池导致超时的排查案例这里先记住连接池要配。2.3 序列化配置不在这一步踩坑就在排查时付出代价这是新手最容易被坑的一环。操作Redis有两种常见姿势第一种是直接用StringRedisTemplate它内部已经把key和value都按字符串处理配合JSON工具手动做对象和字符串的互转代码直观、可读性强学习项目里最推荐。第二种是用RedisTemplateString, Object如果不指定序列化器默认走JDK序列化存进Redis的内容会是一大串以\xAC\xED开头的二进制乱码你在Redis客户端里根本看不出来存的是什么排查的时候非常痛苦。如果项目中确实需要自定义RedisTemplate建议统一配置成String key JSON valueConfiguration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }要注意GenericJackson2JsonRedisSerializer序列化对象时会额外写入一个class字段用来记录对象的全限定名反序列化时才能还原类型。这个设计在数据里会多占一点空间但换来了能存对象的能力。如果只是存店铺信息这种简单场景我更建议直接用StringRedisTemplate加JSON字符串少一层隐式转换后面出问题也更好定位。3. 店铺查询加缓存的完整业务闭环读、回填、更新、失效3.1 先从查询开始Redis有则返回没有则查库回填一次完整的店铺查询流程可以拆成五步根据店铺id拼接出缓存key。用opsForValue().get(key)从Redis读取能读到就直接转为Shop对象返回。读不到说明缓存未命中去数据库查。数据库有数据就把店铺信息转为JSON写入Redis同时设置一个过期时间再返回。数据库也没数据返回null由调用方处理。代码写出来大概是这样的Service public class ShopService extends ServiceImplShopMapper, Shop { private final StringRedisTemplate stringRedisTemplate; private static final String CACHE_SHOP_KEY cache:shop:; private static final Long CACHE_SHOP_TTL 30L; public Shop queryById(Long id) { // 1. 查缓存 String key CACHE_SHOP_KEY id; String shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 2. 缓存未命中查数据库 Shop shop this.getById(id); if (shop null) { return null; } // 3. 回填缓存设置过期时间 stringRedisTemplate.opsForValue().set( key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return shop; } }这里用到了Hutool的JSONUtil和StrUtil如果项目里不想引入Hutool换成Jackson的ObjectMapper也一样。重点不是工具类而是先查缓存、未命中查库、命中回填这个链路顺序不能乱。3.2 缓存key设计别小看cache:shop:这几个前缀很多初学者随便写一个shop id就完事但这在真实项目里会出问题。我建议用业务前缀加冒号分隔的方式cache:shop:101。这样做有三个实际好处不同业务模块的数据天然隔离Redis里看起来就像目录层级定位问题一眼就能找到是哪块业务的数据。后续需要批量清理或统计时可以用SCAN cache:shop:*去匹配key不会误伤其他业务的数据。一旦某个key格式异常前缀能快速帮你判断是哪条业务链路写进去的。这也是为什么热词里反复出现缓存治理的原因——治理的第一步就是key规范。3.3 店铺更新后先改库再删缓存而不是更新缓存查询写了更新也要配套否则用户改完店铺信息后Redis里还是老数据这属于缓存和数据库不一致。业界最经典的做法是Cache Aside Pattern读的时候先读缓存读不到再读库并回填写的时候先写数据库再删除缓存。为什么是删除缓存而不是更新缓存我见过很多人在这里想不明白。简单说更新缓存存在并发覆盖风险。两个请求同时更新同一个店铺线程A把新值写入缓存线程B后写入但值更旧最后缓存里留下的是旧值而删除缓存就不存在这个问题删掉之后让下一次查询去数据库重新加载缓存里最终一定是数据库的最新值。public Result updateShop(Shop shop) { if (shop.getId() null) { return Result.fail(店铺id不能为空); } // 1. 先更新数据库 this.updateById(shop); // 2. 再删除缓存 stringRedisTemplate.delete(CACHE_SHOP_KEY shop.getId()); return Result.ok(); }这段代码只要两步但顺序不能反过来。如果先删缓存再更新数据库中间有个时间窗口别的请求会把还没更新的旧数据又缓存起来造成缓存和数据库不一致。3.4 查不到数据的空值缓存还有一个被很多人忽略的细节如果查询的店铺id在数据库中不存在那每次请求都会绕过缓存直接打到数据库。正常情况下没问题可一旦有人恶意遍历id数据库压力会瞬间飙升。这就引出了缓存穿透问题。应对方式很简单数据库也没查到数据时仍然往Redis写入一个空值比如空字符串并且把TTL设短一点5分钟就够了。if (shop null) { stringRedisTemplate.opsForValue().set(key, , 5L, TimeUnit.MINUTES); return null; }这样同一个不存在的id再次查询时会从Redis拿到空字符串不再打到数据库。我需要特别提醒的是空值缓存最大的副作用是占用key空间所以TTL要短并且最好只针对确实可能被反复查询的不存在id做而不是对所有查询无条件做。4. 热点店铺缓存怎么应对穿透、击穿、雪崩三兄弟缓存面试题里最常出现的三个概念在这个店铺查询场景里全都能对上。4.1 缓存穿透查一个根本不存在的店铺上面说的空值缓存就是防穿透的经典手段。除此之外还有两个基础但有效的措施参数校验如果传入的店铺id小于等于0直接返回失败根本不用进缓存和数据库。布隆过滤器在缓存前面再加一层Bitmap结构快速判断id是否存在不存在就直接挡掉。学习项目里可以不引入但面试时要能说出这个方案。空值缓存适合学习项目快速落地布隆过滤器适合生产环境做前置拦截两种方案各有应用场景。4.2 缓存击穿热点key失效瞬间的并发风暴如果一个店铺是爆款大量用户同时查看它恰好在这个时刻缓存过期了那么所有请求会瞬间冲进数据库。这就是缓存击穿。核心思路是只让一个请求去查数据库其他请求等待结果。比较常用的方案是互斥锁。利用Redis的setIfAbsent实现一个简单的分布式锁只有第一个请求能设置成功这个锁key其他请求设置失败后短暂休眠并重试等第一个请求把缓存重建好后再读。public Shop queryWithMutex(Long id) { String cacheKey CACHE_SHOP_KEY id; String lockKey LOCK_SHOP_KEY id; // 1. 先查缓存 String shopJson stringRedisTemplate.opsForValue().get(cacheKey); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } try { // 2. 尝试获取锁10秒过期 Boolean isLocked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(isLocked)) { // 3. 没拿到锁说明别的线程正在重建缓存休眠后重试 Thread.sleep(50); return queryWithMutex(id); } // 4. 拿到锁后再次查缓存防止在等待锁期间缓存已被重建 shopJson stringRedisTemplate.opsForValue().get(cacheKey); if (StrUtil.isNotBlank(shopJson)) { return JSONUtil.toBean(shopJson, Shop.class); } // 5. 真正查数据库 Shop shop this.getById(id); if (shop null) { stringRedisTemplate.opsForValue().set(cacheKey, , 5, TimeUnit.MINUTES); return null; } // 6. 回填缓存 stringRedisTemplate.opsForValue().set( cacheKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return shop; } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(缓存重建被中断, e); } finally { // 7. 释放锁只释放自己加的锁 stringRedisTemplate.delete(lockKey); } }这个代码里有两个细节值得说。一是拿到锁之后要再次查缓存这叫二次检查防止线程A等锁期间线程B已经把缓存重建好了线程A却还傻乎乎去查库。二是锁一定要在finally里释放不然中间抛异常锁就永远不释放后续所有请求都会卡在重试上。4.3 缓存雪崩大批key集中过期的连锁反应击穿是一个热点key失效雪崩是大量key在同一时间段失效。假设所有店铺缓存都设置成统一的30分钟过期那么它们会在同一批时间点集体失效数据库在那几秒内会收到海量查询。最简单的规避方法就是给TTL加随机扰动long ttl CACHE_SHOP_TTL ThreadLocalRandom.current().nextLong(5, 15); stringRedisTemplate.opsForValue().set(key, json, ttl, TimeUnit.MINUTES);这样不同店铺的过期时间被分散开不会出现整点过期的瞬间压力。更进一步的做法是逻辑过期value里存一个过期时间字段查询发现逻辑过期后先返回旧数据再异步触发一个线程去后台重建缓存。这个方案能彻底避免击穿和雪崩但实现复杂度会高不少学习项目里先把随机TTL和互斥锁用好就够了。5. 二刷时的排查现场序列化乱码、连接超时与可视化验证5.1 乱码为什么Redis客户端里看到的是一堆\xAC\xED有一次我配置好了RedisTemplate的序列化器但某个查询接口返回的数据在Redis Desktop Manager里仍然是一长串乱码开头是\xAC\xED\x00\x05。排查后发现那个Service里注入的RedisTemplate是直接用Autowired注入的绕过了我在RedisConfig里自定义的Bean实际用的是默认的JdkSerializationRedisSerializer。这个问题的教训是项目里如果同时存在多个RedisTemplate使用点一定要确保都用同一个配置学习阶段干脆统一用StringRedisTemplate它只处理字符串天然没有JDK序列化的坑配合JSON工具手动转换出问题也一眼就能看出来。5.2 查不到数据两套缓存链路在打架有同学后台问我店铺id在数据库明明有记录第一次查询接口却返回了null。最终排查发现他在Service方法上加了Spring Cache的Cacheable(shop)注解同时又在方法内部手动操作了Redis缓存。等于存在两套缓存机制第一次查询没有命中手动缓存数据库返回了数据但Cacheable把这结果缓存了第二次查询时Cacheable命中直接把上一轮的结果返回。如果第一次查的是一个不存在的数据那么第二次就会一直返回null。这个排查现场给我的经验是学习项目里保持一条缓存链路就够了。要么完全手动操作Redis缓存要么用Spring Cache注解不能混着来。两套机制叠加不仅逻辑混乱排查时还会互相干扰。5.3 Lettuce连接超时突然出现的RedisCommandTimeoutException二刷到后面我做了一个简单的并发循环测试结果控制台开始刷RedisCommandTimedOutException具体报错长这样io.lettuce.core.RedisCommandTimeoutException: Command timed out after 3 second(s)最初我以为是Redis服务挂了但redis-cli ping完全正常。后来才反应过来这是Lettuce连接池没配置导致的默认连接数上限不够高并发下连接池被占满新请求拿不到连接只能排队等到超时。解决办法就是把连接池参数配上并且在YAML里把timeout设置成合理的数值比如3秒spring: data: redis: timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0这个错误在线上特别常见字符串里那串nested exception is io.lettuce.core.RedisCommandTimeoutException值得每个用Redis的人记住。5.4 用可视化客户端验证缓存数据与TTL排查离不开看数据。强烈建议装一个Redis可视化客户端比如Another Redis Desktop Manager、Redis Desktop Manager或者直接命令行redis-cli也足够。我做验证时最常用的三个操作# 查看某个key的value redis-cli get cache:shop:1 # 查看key的剩余过期时间单位秒 redis-cli ttl cache:shop:1 # 按前缀扫描出所有相关key redis-cli --scan --pattern cache:shop:*这三个命令能覆盖80%的日常验证需求数据有没有写进去、TTL还剩多少、key格式是不是按预期设计的。如果你正在二刷这个项目我的建议是不要急着看下一节先把这个模块的查询接口用循环模拟一下并发请求观察数据库和Redis收到的流量变化再手动改一次店铺信息看一眼Redis里的旧数据是怎么失效的。把这一整套流程跑通之后你才算真正吃透了店铺查询信息添加到Redis这个业务操作背后的东西。
返回列表