什么是缓存
定义
- 本质:数据交换的缓冲区,是存储数据的临时地方
- 核心特性:读写性能较高,常用于解决数据访问速度不匹配问题
- 计算机案例:CPU内部缓存解决CPU运算速度与内存/磁盘读写速度不匹配问题,缓存越大CPU性能通常越好
- 典型应用场景:浏览器缓存静态资源、应用层缓存(如Redis)、数据库缓存索引数据等
作用
- 降低后端负载:请求直接命中缓存可避免数据库查询,显著减轻数据库压力,特别是对复杂SQL查询效果更明显
- 提高读写效率:Redis读写延迟在微秒级(磁盘读写通常在毫秒级),响应时间缩短1000倍左右
- 应对高并发:通过内存快速响应请求,使系统能够支撑更高并发量,是大型互联网应用的必备方案
成本
- 数据一致性:数据库更新时缓存可能仍保留旧数据,需要额外机制保证一致性(如双写、失效策略等)
- 代码复杂度:需处理缓存穿透、击穿、雪崩等问题,业务逻辑复杂度显著增加
- 运维成本:
- 硬件成本:需要搭建Redis集群保证高可用
- 人力成本:集群部署、监控、维护需要专业团队
- 适用性评估:中小型企业用户量不大时可能得不偿失,需权衡收益与成本
添加Redis缓存

public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 从Redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 不存在,根据id查询数据库
Shop shop = getById(id);
// 判断结果
if (shop == null) {
// 数据库不存在,返回错误
return Result.fail("店铺不存在");
}
// 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}店铺类型缓存

public Result queryTypeList() {
String key = "cache:shop:type";
// 从Redis中查询
String shopTypeJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopTypeJson)) {
// 存在,直接返回
List<ShopType> shopTypeList = JSONUtil.toList(JSONUtil.parseArray(shopTypeJson), ShopType.class);
return Result.ok(shopTypeList);
}
// 不存在,根据id查询数据库
List<ShopType> typeList = query().orderByAsc("sort").list();
// 判断结果
if (typeList == null || typeList.size() == 0) {
// 数据库不存在,返回错误
return Result.fail("店铺类型列表不存在");
}
// 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList));
return Result.ok(typeList);
}缓存更新策略
内存淘汰
- 机制原理:Redis基于内存存储的特性,当内存不足时会自动触发淘汰机制,清除部分数据释放空间
- 一致性特点:
- 被动实现:当淘汰数据被查询时,会触发数据库查询重新加载,达到最终一致性
- 不可控性:淘汰时机和数据选择由Redis决定,可能导致长期数据不一致
- 适用场景:作为Redis默认机制存在,适合对一致性要求低的场景(如极少变更的店铺类型数据)
超时剔除
- 实现方式:通过Redis的expire命令设置TTL过期时间
- 一致性控制:
- 最终一致性:依赖过期时间长短控制,时间越短一致性越好(如30分钟优于1天)
- 非强一致:在TTL周期内仍可能出现数据不一致
- 维护成本:仅需在设置缓存时添加过期时间参数,实现简单
主动更新
Cache Aside Pattern
- 核心思想:由业务代码显式维护缓存一致性
- 实现方式:
- 读操作:先查缓存,未命中则查库并回写缓存
- 写操作:先更新数据库,再操作缓存(删除或更新)
- 优势:实现可控性强,适合大多数业务场景
Read/Write ThroughPattern
- 架构特点:将缓存与数据库封装为统一服务
- 调用方式:
- 对调用者透明,无需关心底层存储
- 服务内部保证缓存与数据库的原子操作
- 缺点:实现复杂度高,缺乏成熟开源方案
Write Behind CachingPattern
- 异步机制:
- 调用者仅操作缓存
- 独立线程异步持久化到数据库
- 性能优势:可合并多次写操作,提升IO效率
- 风险点:
- 缓存宕机可能导致数据丢失
- 存在较长时间的数据不一致窗口
存在问题
删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存(更常用)
如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
数据库和缓存的先后操作问题(线程安全)


- 先删除缓存再操作数据库
- 问题场景:
- 线程1删除缓存后,线程2查询未命中加载旧数据
- 线程1更新数据库后产生不一致
- 发生概率:较高(因数据库写操作耗时较长)
- 问题场景:
- 先删除数据库再操作缓存
- 异常情况:
- 缓存失效时查询线程加载旧数据
- 更新线程在极短时间内完成数据库更新和缓存删除
- 发生概率:极低(需在微秒级时间窗口内完成数据库写操作)
- 异常情况:
总结
- 低一致性需求:
- 采用Redis内存淘汰机制
- 可配合设置较长TTL时间
- 高一致性需求:
- 主方案:主动更新(Cache Aside Pattern)
- 兜底方案:设置合理TTL超时剔除
- 读写规范:
- 读流程:命中返回→未命中查库→回写缓存(带TTL)
- 写流程:先写数据库→再删缓存→保证操作原子性
实现商铺缓存与数据库的双写一致
在ShopServiceImpl中
增加过期时间
@Override
public Result queryById(Long id) {
//...
// 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}更新店铺信息
@Override
@Transactional
public Result updateShop(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 更新数据库
updateById(shop);
// 删除缓存
stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY + shop.getId());
return Result.ok();
}缓存穿透的解决方案
概念
- 缓存穿透定义:客户端请求的数据在缓存和数据库中都不存在的情况。
- 缓存穿透危害:导致所有请求直接访问数据库,可能压垮数据库。
- 缓存穿透特征:请求参数可能是伪造的,缓存无法建立。
- 缓存穿透场景:恶意用户并发请求不存在的数据。
- 缓存穿透原理:缓存未命中且数据库查询为空,无法回填缓存。
- 缓存穿透确认条件:缓存和数据库连续查询均为空结果。
解决思路

缓存空对象
- 缓存空对象定义:将数据库中不存在的查询结果以null值缓存到Redis中的解决方案。
- 实现思路:当Redis和数据库均未命中时,将null值写入缓存以阻止后续数据库查询。
- 核心优点:实现简单,代码容易编写,能有效减少数据库压力。
- 主要缺点:存在额外内存消耗和短期数据不一致性问题。
- 内存优化方案:为null值设置较短TTL(如2-5分钟)自动清除垃圾数据。
- 一致性解决方案:新增数据时主动更新缓存覆盖null值。
- 适用场景:对短期不一致性容忍度较高的业务场景。
布隆过滤
- 布隆过滤定义:一种基于概率的算法,用于判断数据是否存在。
- 布隆过滤原理:使用bit数组存储数据的哈希值二进制形式。
- 布隆过滤工作流程:客户端请求先经过布隆过滤判断,不存在则直接拒绝,存在才查询Redis。
- 布隆过滤特点:内存占用少(仅存储二进制位),但存在误判可能(存在判断不100%准确)。
- 布隆过滤优点:减少空数据缓存,节省存储空间。
- 布隆过滤缺点:实现复杂,存在误判导致缓存穿透风险。
- 布隆过滤应用:通常与Redis的bitmap结合使用简化开发。
主动方案
- 增强ID复杂度(如使用10-20位复杂ID)
- 加强参数格式校验(如过滤ID=0的请求)
- 用户权限管理和限流措施
代码实现
利用缓存空对象来实现

修改查询店铺信息的接口业务代码
public Result queryById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 从Redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否为空值
if (shopJson != null) {
// 此时为空字符串
return Result.fail("店铺不存在");
}
// 不存在,根据id查询数据库
Shop shop = getById(id);
// 判断结果
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 数据库不存在,返回错误
return Result.fail("店铺不存在");
}
// 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}缓存雪崩
概念
- 核心定义:在同一时段内大量缓存key同时失效或Redis服务宕机,导致请求直接打到数据库造成巨大压力
- 两种表现形式:
- 批量key同时过期(如几万到几十万key瞬间过期)
- Redis服务完全宕机(所有请求直接访问数据库)
- 危害程度比较:服务宕机情况比key批量失效更严重,会导致所有请求”裸奔”访问数据库
解决方案
key失效导致雪崩解决思路
- TTL随机化:
- 在批量导入数据时,给基础TTL值(如30分钟)添加随机数(如1-5分钟)
- 效果:使key过期时间分散在30-35分钟区间,避免同时失效
- 应用场景:特别适用于缓存预热时的批量数据导入场景
- 实现难度:技术简单,只需在设置TTL时添加随机数即可
redis宕机导致雪崩解决思路
- 高可用架构:
- 搭建Redis集群+哨兵机制
- 主从数据同步保证数据不丢失
- 哨兵监控实现故障自动转移(主节点宕机时自动选举新主)
- 技术要点:需要掌握Redis集群搭建和哨兵机制配置
- 学习路径:属于Redis高级课程内容,需后续专项学习
添加降级和限流策略
- 核心思想:牺牲部分服务保护数据库整体健康
- 具体措施:
- 快速失败:检测到Redis故障时立即拒绝请求
- 服务降级:返回兜底数据或错误提示
- 请求限流:控制打到数据库的请求量
- 技术实现:需要微服务架构支持(如Spring Cloud的Hystrix等组件)
- 典型场景:应对机房级故障等极端情况
添加多级缓存
- 防御层级:
- 浏览器缓存(静态数据)
- Nginx缓存(动态数据)
- JVM本地缓存(如Caffeine)
- Redis缓存
- 数据库
- 优势:类似”多层防弹衣”,单层失效仍有其他缓存保护
- 应用案例:京东等电商平台的商品详情页架构
- 学习资源:涉及Spring Cloud微服务和Redis高级课程内容
缓存击穿问题及解决方案
- 核心特征:指被高并发访问且缓存重建业务复杂的key突然失效,导致瞬间大量请求直接冲击数据库
- 与雪崩区别:不同于多个key同时失效的雪崩现象,击穿是单个热点key失效引发的问题
- 必要条件:
- 高并发访问:如活动商品同一时刻可能被数千请求访问
- 复杂重建逻辑:涉及多表关联查询或复杂运算,耗时可达数百毫秒
- 失效时间线:在缓存重建完成前的时间窗口内,所有请求都无法命中缓存
- 连锁反应:重建耗时越长,涌入数据库的请求量呈指数级增长
- 典型场景:活动商品缓存失效时,重建过程需要计算促销价格、库存等多维度数据


互斥锁
- 核心机制:
- 首个未命中线程获取分布式锁进行重建
- 其他线程获取锁失败后进入休眠重试循环(非无限重试)
- 关键节点:
- 锁释放前:所有线程查询均为未命中状态
- 锁释放后:后续请求可直接命中新建缓存
- 性能影响:重建期间所有并发线程处于阻塞状态,如重建耗时500ms,数千线程将同步等待
- 互斥锁优势:
- 内存效率:无额外字段存储开销
- 强一致性:保证返回数据绝对最新
- 实现简单:仅需基础锁机制
- 互斥锁缺陷:
- 性能瓶颈:线程等待导致吞吐量下降
- 死锁风险:多缓存访问可能产生循环依赖

public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
/**
* 缓存击穿解决方案
*
* @param id
* @return
*/public Shop queryWithMutex(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 从Redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 判断命中的是否为空值
if (shopJson != null) {
// 此时为空字符串
return null;
}
// 实现缓存重建
// 获取互斥锁
String lock = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lock);
if (!isLock) {
// 获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 不存在,根据id查询数据库
shop = getById(id);
// 模拟重建的耗时
Thread.sleep(200);
// 判断结果
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 数据库不存在,返回错误
return null;
}
// 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放互斥锁
unLock(lock);
}
return shop;
}
/**
* 尝试获取锁
*
* @param key
* @return
*/private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}tryLock为什么不直接返回flag?
在Java中,
stringRedisTemplate.opsForValue().setIfAbsent()方法返回的是一个Boolean包装类型,而不是原始的boolean类型。这个Boolean对象可能有三种状态:
true- 表示键不存在,成功设置了值false- 表示键已经存在,没有设置值null- 在某些异常情况下可能返回null如果直接返回
flag,当flag为null时,在某些情况下可能会导致NullPointerException。例如,如果这个返回值后续被拆箱为boolean类型,就会出现空指针异常。BooleanUtil.isTrue(flag)方法的作用是安全地检查一个
Boolean对象是否为true,它会处理null值的情况:
- 当
flag为Boolean.TRUE时,返回true- 当
flag为Boolean.FALSE时,返回false- 当
flag为null时,也返回false这等价于
Boolean.TRUE.equals(flag)的写法,但更加简洁易读。所以在这段代码中使用BooleanUtil.isTrue(flag)而不是直接返回
flag是为了避免潜在的空指针异常,并明确表达开发者的意图:只有当返回值明确为true时才返回true,其他情况(包括null和false)都返回false。这是一种更加安全和清晰的编程实践。
清除redis缓存后,使用apifox的自动化测试,可以看到控制台只有一条sql语句


逻辑过期
- 逻辑过期定义
- 存储结构:在value中嵌入过期时间字段(非Redis TTL)
- 永不过期设计:通过内存淘汰策略管理,理论上key永久有效
- 典型应用:活动期间预先加载热点数据,活动结束手动清除
- 过期时间字段作用
- 异步更新:发现逻辑过期后,由独立线程负责重建
- 旧数据兜底:主线程直接返回旧数据保证可用性
- 时间控制:如设置30分钟逻辑过期,实际数据更新延迟可控
- 逻辑过期优势:
- 高并发性:线程无需等待立即响应
- 系统可用:始终有数据返回(可能为旧值)
- 逻辑过期缺陷:
- 弱一致性:存在短暂数据不一致窗口
- 内存消耗:需存储额外过期字段
- 实现复杂度:需维护独立更新线程

封装RedisData存储需要缓存的数据,设置逻辑过期时间
package com.hmdp.utils;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 互斥锁解决缓存击穿
// Shop shop = queryWithMutex(id);
// 逻辑过期解决缓存击穿
Shop shop = queryWithLogincalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}// 线程池用于缓存重建
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期解决缓存击穿
*
* @param id
* @return
*/public Shop queryWithLogincalExpire(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 从Redis中查询
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(shopJson)) {
// 不存在
return null;
}
// 命中,需要先将json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// 获取到JSONObject对象,因为RedisData对象中data字段为Object类型
JSONObject jsonObject = (JSONObject) redisData.getData();
Shop shop = jsonObject.toBean(Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回店铺信息
return shop;
}
// 已过期,需要缓存重建
// 缓存重建,获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
if (tryLock(lockKey)) {
// 获取锁成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
return shop;
}
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 查询店铺数据
Shop shop = getById(id);
// 模拟耗时
Thread.sleep(200);
// 封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 写入Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}总结
- CAP权衡:
- 互斥锁选择CP(一致性优先)
- 逻辑过期选择AP(可用性优先)
- 选型建议:
- 金融交易等强一致性场景适用互斥锁
- 商品展示等高并发场景适用逻辑过期
封装Redis工具类
this::getById等价于id2->getById(id2)
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
Shop shop = cacheClient.queryWithPassThrough(RedisConstants.CACHE_SHOP_KEY,id,Shop.class,id2->getById(id2) ,RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = queryWithMutex(id);
// 逻辑过期解决缓存击穿
// Shop shop = queryWithLogincalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}@Component
@Slf4j
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
// 线程池用于缓存重建
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 尝试获取锁
*
* @param key
* @return
*/ private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
*
* @param key
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
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, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 从Redis中查询
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
// 存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否为空值
if (json != null) {
// 此时为空字符串
return null;
}
// 不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 判断结果
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 数据库不存在,返回错误
return null;
}
// 存在,写入redis
this.set(key, r, time, unit);
return r;
}
public <R, ID> R queryWithLogincalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 从Redis中查询
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {
// 不存在
return null;
}
// 命中,需要先将json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 获取到JSONObject对象,因为RedisData对象中data字段为Object类型
JSONObject jsonObject = (JSONObject) redisData.getData();
R r = jsonObject.toBean(type);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回店铺信息
return r;
}
// 已过期,需要缓存重建
// 缓存重建,获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
if (tryLock(lockKey)) {
// 获取锁成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R r1 = dbFallback.apply(id);
// 写入redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
return r;
}
}