黑马点评(二):商户缓存与Redis实战 —— 为什么说缓存是性能优化的第一把刀?
2026/9/19 18:43:15 网站建设 项目流程

一、引言:从一次“慢查询”说起

假设你打开一个电商App的首页,看到的是商家列表。如果每次加载都要等数据库查询完再渲染,你会发现页面一直在转圈圈。这就是典型的**“数据库成了瓶颈”**。

解决思路其实很朴素:把数据提前放在一个更快的地方,下次再来时直接取走。

这就是缓存

本文将基于《黑马点评》项目的“商户查询缓存”实战,带你从零到一理解并实现一套生产级缓存方案,涵盖:

  • 缓存的核心概念与更新策略
  • 缓存穿透、击穿、雪崩的解决方案
  • 互斥锁与逻辑过期的代码实现
  • 生产环境的最佳实践

二、缓存是什么?为什么它能提升性能?

2.1 一句话定义

缓存就是数据的“临时副本”,放在读写速度极快的存储介质(如内存)中,用于加速数据访问。

2.2 收益与成本

收益成本
✅ 降低数据库负载❌ 数据一致性问题(缓存与DB数据不同步)
✅ 降低响应时间(毫秒级返回)❌ 代码维护成本增加(缓存击穿/穿透/雪崩)
✅ 提升系统吞吐量❌ 运维成本(Redis集群、内存监控)

关键思想:TRADEOFF。并非所有业务都适合加缓存,需要对收益和成本做权衡。对于“商户类型”这种读多写少的数据,缓存收益远大于成本,是典型的适用场景。

三、缓存更新策略:怎么保证缓存与DB一致?

3.1 三种基本策略

策略核心思想适用场景
内存淘汰Redis 内存满时自动淘汰(LRU/LFU)作为兜底,不单独依赖
超时剔除给 Key 设置 TTL(过期时间)可容忍短暂不一致
主动更新数据变更时主动操作缓存对一致性要求较高

3.2 生产级方案:Cache Aside + TTL 兜底

一句话概括:查询以缓存优先,写入先更新数据库再淘汰缓存,最后设置过期时间保底。

读流程:

  1. 先查 Redis → 命中则直接返回
  2. 未命中则查 MySQL → 写入 Redis → 设置 TTL → 返回

写流程:

  1. 先更新 MySQL
  2. 再删除 Redis 缓存(不更新,只删除!)
  3. 下一次查询时,缓存未命中,自动从 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 EX10
  • NX:Key不存在时才设置(类似SETNX
  • EX 10:10秒自动释放(防止持有锁的线程崩溃导致的死锁)
  • unique_value:用UUID生成,释放时校验,防止误删他人的锁

5.3 完整代码(可直接复制)

@GetMapping("list")publicResultqueryTypeList(){// 1. 尝试从缓存获取StringcacheKey="shop:type:list";StringcachedJson=stringRedisTemplate.opsForValue().get(cacheKey);// 2. 缓存命中,直接返回if(StrUtil.isNotBlank(cachedJson)){List<ShopType>list=JSONUtil.toList(cachedJson,ShopType.class);returnResult.ok(list);}// 3. 缓存未命中 → 尝试获取互斥锁StringlockKey="lock:shop:type";StringlockValue=UUID.randomUUID().toString();// 唯一值,释放锁时校验try{// 3.1 尝试加锁(原子操作)BooleanisLocked=stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(isLocked)){// 3.2 获取锁成功 → 双重检查,防止在等待锁期间缓存已被其他线程重建StringdoubleCheck=stringRedisTemplate.opsForValue().get(cacheKey);if(StrUtil.isNotBlank(doubleCheck)){List<ShopType>list=JSONUtil.toList(doubleCheck,ShopType.class);returnResult.ok(list);}// 查询数据库List<ShopType>typeList=typeService.query().orderByAsc("sort").list();if(CollUtil.isNotEmpty(typeList)){// 写入缓存,设置30分钟过期stringRedisTemplate.opsForValue().set(cacheKey,JSONUtil.toJsonStr(typeList),30,TimeUnit.MINUTES);}else{// 防止缓存穿透:缓存空对象(短TTL)stringRedisTemplate.opsForValue().set(cacheKey,"[]",5,TimeUnit.MINUTES);}returnResult.ok(typeList);}else{// 3.3 获取锁失败 → 等待并重试(递归)Thread.sleep(50);returnqueryTypeList();// 递归重试}}catch(InterruptedExceptione){Thread.currentThread().interrupt();// 降级策略:直接查DBList<ShopType>typeList=typeService.query().orderByAsc("sort").list();returnResult.ok(typeList);}finally{// 3.4 释放锁:只释放自己加的锁StringcurrentLockValue=stringRedisTemplate.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 读写流程

读流程:

  1. 获取缓存,反序列化为RedisData对象
  2. 判断expireTime > System.currentTimeMillis()
    • 未过期 → 直接返回数据
    • 已过期 →返回旧数据(不阻塞!) + 异步线程去更新缓存

写流程(异步):

  1. 获取互斥锁(只允许一个线程去查库)
  2. 查数据库,更新dataexpireTime
  3. 释放锁

6.4 互斥锁 vs 逻辑过期 对比

维度互斥锁逻辑过期
一致性强(缓存与DB同步更新)弱(用户可能看到旧数据)
响应速度较差(等待锁的线程需阻塞)极快(永不阻塞)
代码复杂度较低较高(需维护时间戳+异步线程池)
适用场景库存、价格等强一致性场景店铺类型、文章详情等可容忍短暂不一致的场景

七、封装Redis工具类:让代码优雅复用

7.1 痛点

每个业务都要写一遍:

Stringjson=stringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(json)){...}// 加锁、查库、写缓存、释放锁...

重复代码散落在各个 Controller,难以维护。

7.2 解决方案

将“缓存操作”的脏活累活全部抽取到独立的RedisCacheClient工具类中。

核心封装方法:

  • queryWithPassThrough():缓存未命中 → 互斥锁查库 → 写缓存
  • queryWithLogicalExpire():逻辑过期 → 异步重建缓存
  • tryLock()/unlock():统一封装setIfAbsentdelete操作

封装后业务代码极度清爽:

@GetMapping("list")publicResultqueryTypeList(){List<ShopType>list=redisCacheClient.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(内存溢出),生产环境必崩。)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询