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

资讯详情

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

黑马点评(二):商户缓存与Redis实战 —— 为什么说缓存是性能优化的第一把刀?

黑马点评(二):商户缓存与Redis实战 —— 为什么说缓存是性能优化的第一把刀? 一、引言从一次“慢查询”说起假设你打开一个电商App的首页看到的是商家列表。如果每次加载都要等数据库查询完再渲染你会发现页面一直在转圈圈。这就是典型的**“数据库成了瓶颈”**。解决思路其实很朴素把数据提前放在一个更快的地方下次再来时直接取走。这就是缓存。本文将基于《黑马点评》项目的“商户查询缓存”实战带你从零到一理解并实现一套生产级缓存方案涵盖缓存的核心概念与更新策略缓存穿透、击穿、雪崩的解决方案互斥锁与逻辑过期的代码实现生产环境的最佳实践二、缓存是什么为什么它能提升性能2.1 一句话定义缓存就是数据的“临时副本”放在读写速度极快的存储介质如内存中用于加速数据访问。2.2 收益与成本收益成本✅ 降低数据库负载❌ 数据一致性问题缓存与DB数据不同步✅ 降低响应时间毫秒级返回❌ 代码维护成本增加缓存击穿/穿透/雪崩✅ 提升系统吞吐量❌ 运维成本Redis集群、内存监控关键思想TRADEOFF。并非所有业务都适合加缓存需要对收益和成本做权衡。对于“商户类型”这种读多写少的数据缓存收益远大于成本是典型的适用场景。三、缓存更新策略怎么保证缓存与DB一致3.1 三种基本策略策略核心思想适用场景内存淘汰Redis 内存满时自动淘汰LRU/LFU作为兜底不单独依赖超时剔除给 Key 设置 TTL过期时间可容忍短暂不一致主动更新数据变更时主动操作缓存对一致性要求较高3.2 生产级方案Cache Aside TTL 兜底一句话概括查询以缓存优先写入先更新数据库再淘汰缓存最后设置过期时间保底。读流程先查 Redis → 命中则直接返回未命中则查 MySQL → 写入 Redis → 设置 TTL → 返回写流程先更新 MySQL再删除 Redis 缓存不更新只删除下一次查询时缓存未命中自动从 DB 重建⚠️为什么是“先更新DB再删缓存”如果先删缓存再更新DB在高并发下可能刚删完缓存另一个线程就把旧数据查进缓存了导致数据脏读。3.3 更新缓存的“铁律”只删除不更新更新缓存容易产生并发脏写问题删除后让下一次查询重建更安全。设置TTL兜底即使删除缓存失败TTL到期后也会自动淘汰最终保证一致性。四、缓存三大杀手穿透、击穿、雪崩4.1 缓存穿透现象查询一个不存在的数据缓存和数据库都没有导致每次请求都打到DB。解决方案方案做法优缺点缓存空对象对不存在的Key缓存null或[]设置短TTL简单但浪费内存可接受布隆过滤器先将所有存在的Key存到BloomFilter查询前先过滤内存占用小但存在误判率4.2 缓存击穿现象某个热点Key如首页商铺列表突然过期大量请求同时打到DB。解决方案互斥锁只允许一个线程查DB重建缓存。具体实现见第五章代码。4.3 缓存雪崩现象大量Key在同一时间同时过期或Redis宕机导致所有请求直冲DB。解决方案过期时间加随机偏移比如30分钟 ± 5分钟搭建Redis集群主从哨兵多级缓存本地缓存 Redis五、互斥锁解决缓存击穿完整实现5.1 核心思想当缓存未命中时只允许一个线程去查数据库并重建缓存其他线程等待重试直到缓存被重建完成。5.2 Redis实现互斥锁的关键命令# 原子性加锁设置过期时间防死锁SET lock_key unique_value NX EX10NXKey不存在时才设置类似SETNXEX 1010秒自动释放防止持有锁的线程崩溃导致的死锁unique_value用UUID生成释放时校验防止误删他人的锁5.3 完整代码可直接复制GetMapping(list)publicResultqueryTypeList(){// 1. 尝试从缓存获取StringcacheKeyshop:type:list;StringcachedJsonstringRedisTemplate.opsForValue().get(cacheKey);// 2. 缓存命中直接返回if(StrUtil.isNotBlank(cachedJson)){ListShopTypelistJSONUtil.toList(cachedJson,ShopType.class);returnResult.ok(list);}// 3. 缓存未命中 → 尝试获取互斥锁StringlockKeylock:shop:type;StringlockValueUUID.randomUUID().toString();// 唯一值释放锁时校验try{// 3.1 尝试加锁原子操作BooleanisLockedstringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(isLocked)){// 3.2 获取锁成功 → 双重检查防止在等待锁期间缓存已被其他线程重建StringdoubleCheckstringRedisTemplate.opsForValue().get(cacheKey);if(StrUtil.isNotBlank(doubleCheck)){ListShopTypelistJSONUtil.toList(doubleCheck,ShopType.class);returnResult.ok(list);}// 查询数据库ListShopTypetypeListtypeService.query().orderByAsc(sort).list();if(CollUtil.isNotEmpty(typeList)){// 写入缓存设置30分钟过期stringRedisTemplate.opsForValue().set(cacheKey,JSONUtil.toJsonStr(typeList),30,TimeUnit.MINUTES);}else{// 防止缓存穿透缓存空对象短TTLstringRedisTemplate.opsForValue().set(cacheKey,[],5,TimeUnit.MINUTES);}returnResult.ok(typeList);}else{// 3.3 获取锁失败 → 等待并重试递归Thread.sleep(50);returnqueryTypeList();// 递归重试}}catch(InterruptedExceptione){Thread.currentThread().interrupt();// 降级策略直接查DBListShopTypetypeListtypeService.query().orderByAsc(sort).list();returnResult.ok(typeList);}finally{// 3.4 释放锁只释放自己加的锁StringcurrentLockValuestringRedisTemplate.opsForValue().get(lockKey);if(lockValue.equals(currentLockValue)){stringRedisTemplate.delete(lockKey);}}}5.4 代码关键点拆解细节点为什么这么做setIfAbsent(lockKey, lockValue, 10, SECONDS)原子性加锁设置超时防止死锁UUID作为锁值释放锁时校验防止误删其他线程的锁双重检查Double Check获取锁后二次查缓存避免重复查库Thread.sleep(50) 递归重试获取锁失败的线程等待避免CPU空转finally中释放锁确保无论业务是否异常锁最终都会被释放六、扩展方案逻辑过期极致性能6.1 什么是逻辑过期物理过期你平时用的EXPIRE是 Redis 在 TTL 到期后自动删除 Key。逻辑过期是Key永远不过期不设TTL但在 Value 里存一个时间戳由业务代码判断是否“逻辑过期”。6.2 存储结构{data:店铺类型的JSON数据,expireTime:1736668800000// 逻辑过期时间戳毫秒}6.3 读写流程读流程获取缓存反序列化为RedisData对象判断expireTime System.currentTimeMillis()未过期 → 直接返回数据已过期 →返回旧数据不阻塞 异步线程去更新缓存写流程异步获取互斥锁只允许一个线程去查库查数据库更新data和expireTime释放锁6.4 互斥锁 vs 逻辑过期 对比维度互斥锁逻辑过期一致性强缓存与DB同步更新弱用户可能看到旧数据响应速度较差等待锁的线程需阻塞极快永不阻塞代码复杂度较低较高需维护时间戳异步线程池适用场景库存、价格等强一致性场景店铺类型、文章详情等可容忍短暂不一致的场景七、封装Redis工具类让代码优雅复用7.1 痛点每个业务都要写一遍StringjsonstringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(json)){...}// 加锁、查库、写缓存、释放锁...重复代码散落在各个 Controller难以维护。7.2 解决方案将“缓存操作”的脏活累活全部抽取到独立的RedisCacheClient工具类中。核心封装方法queryWithPassThrough()缓存未命中 → 互斥锁查库 → 写缓存queryWithLogicalExpire()逻辑过期 → 异步重建缓存tryLock()/unlock()统一封装setIfAbsent和delete操作封装后业务代码极度清爽GetMapping(list)publicResultqueryTypeList(){ListShopTypelistredisCacheClient.queryWithLogicalExpire(CACHE_KEY,LOCK_KEY,()-typeService.query().orderByAsc(sort).list(),30L,TimeUnit.MINUTES);returnResult.ok(list);}八、总结这三点最重要1. 缓存的本质是“用空间换时间”用内存的高读写速度换取数据库的IO压力降低代价是需要处理数据一致性问题。2. 选择缓存策略的核心是“TRADEOFF”对一致性要求高 → 互斥锁牺牲性能保证一致对性能要求高 → 逻辑过期牺牲一致换取极致性能3. 三个必须记住的命令/模式SET key value NX EX seconds原子性加锁设置过期时间先更新DB再删缓存而不是更新缓存永远设置TTL作为最终一致性的兜底4. 生产环境的三条铁律宁可暂时读到旧数据也不能让系统崩掉→ 优先采用逻辑过期方案所有缓存都必须设置 TTL物理或逻辑永远不要用Executors创建线程池→ 改用自定义ThreadPoolExecutor(因为它底层用了无界队列或无限线程数高并发下会导致 OOM内存溢出生产环境必崩。)
返回列表