前言
在完成黑马点评登录认证功能的深入学习后,今天我将继续系统研究商户查询场景下的Redis缓存应用。在高并发环境下,数据库往往扛不住压力,合理使用缓存是提升系统性能的关键。
本篇将详细探讨缓存的基本概念、应用策略,并重点分析缓存穿透、雪崩、击穿 这三大经典问题及其解决方案。

一、今日完结任务
- ✅ 深入理解缓存的基本概念与应用场景
- ✅ 掌握Redis缓存的添加与更新策略
- ✅ 学习缓存穿透的原理与解决方案
- ✅ 分析缓存雪崩的成因与预防措施
- ✅ 实现缓存击穿的多种解决方案
- ✅ 实践缓存工具类的封装与应用
二、今日核心知识点总结
1. 什么是缓存?
1.1 缓存总体介绍
缓存(Cache) 是数据交换的缓冲区,是位于应用程序与数据源之间的临时存储层。它的核心价值在于:读写性能高,能够应对高并发问题。

1.2 为什么要使用缓存?
一句话:因为速度快,好用。
性能优势:
- 内存读写速度快:缓存数据存储于内存中,内存的读写性能远高于磁盘
- 降低数据库压力:缓存可以大大降低用户访问并发量带来的服务器读写压力
- 提升响应速度:直接从缓存获取数据,避免了复杂的数据库查询
业务必要性 :
企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的。
2. 添加商户缓存
2.1 缓存模型和思路
在商户查询场景中,原始代码直接查询数据库:
java
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
// 这里是直接查询数据库
return shopService.queryById(id);
}
标准缓存操作流程 :
查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。
是
否
是
否
查询请求
缓存是否存在
从缓存返回
查询数据库
数据是否存在
写入缓存
返回数据
返回空值
2.2 缓存实现代码
缓存查询时序图:
数据库 Redis Service Controller 客户端 数据库 Redis Service Controller 客户端 第一次查询:缓存未命中 第二次查询:缓存命中 1. 查询商户详情 2. 调用查询方法 3. 查询缓存 GET shop:1 4. 缓存不存在 5. 查询数据库 6. 返回商户数据 7. 写入缓存 SET shop:1 8. 返回商户数据 9. 返回响应 10. 再次查询同一商户 11. 调用查询方法 12. 查询缓存 GET shop:1 13. 返回缓存数据 14. 返回商户数据 15. 返回响应
具体实现代码:
java
@Override
public Result queryShopById(Long id) {
// 定义缓存key
String key = CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在!");
}
// 6. 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 7. 返回
return Result.ok(shop);
}
代码关键点解析:
-
JSONUtil的使用 :可以直接将JSON字符串转换为Bean对象
javaShop shop = JSONUtil.toBean(shopJson, Shop.class); -
空值判断:涉及到查询的操作最好判断是否为null,避免NPE
-
缓存键设计:使用统一的键前缀,便于管理和清理
3. 缓存更新策略
3.1 缓存更新的三种方式
缓存更新是redis为了节约内存而设计出来的机制,主要有三种方式:
3.2 数据库缓存不一致问题
问题根源 :
缓存的数据源来自于数据库,而数据库的数据是会发生变化的,如果当数据库中数据发生变化,而缓存却没有同步,就会产生一致性问题。
三种解决方案对比 :
-
Cache Aside Pattern(人工编码):
- 缓存调用者在更新完数据库后再去更新缓存
- 也称为双写方案
-
Read/Write Through Pattern(系统处理):
- 由系统本身完成,数据库与缓存的问题交由系统去处理
-
Write Behind Caching Pattern(异步处理):
- 调用者只操作缓存,其他线程去异步处理数据库
- 实现最终一致
3.3 主动更新策略的实现
更新策略选择 :
综合考虑采用方案一(Cache Aside Pattern),但需要处理好以下几个问题:
问题一:删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存 ✅
问题二:如何保证操作原子性?
- 单体系统:将缓存与数据库操作放在一个事务
- 分布式系统:利用TCC等分布式事务方案
问题三:先操作缓存还是先操作数据库?
应该先操作数据库,再删除缓存 ,原因分析:
数据库 Redis 线程2 线程1 数据库 Redis 线程2 线程1 错误方案:先删缓存,再更新数据库 结果:缓存中是旧数据 正确方案:先更新数据库,再删缓存 1. 删除缓存 2. 查询缓存(未命中) 3. 查询数据库(旧数据) 4. 写入缓存(旧数据) 5. 更新数据库(新数据) 1. 更新数据库 2. 删除缓存 3. 查询缓存(未命中) 4. 查询数据库(新数据) 5. 写入缓存(新数据)
具体实现代码:
java
@Override
@Transactional
public Result update(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();
}
重要提示:
- 涉及更新数据库的操作最好加
@Transactional注解,保证事务一致性 - 主动清理缓存时,Service实现应该加事务管理
4. 缓存穿透
4.1 问题定义与影响
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
问题影响:
- 数据库压力剧增
- 可能被恶意攻击利用
- 系统响应时间变长
4.2 解决方案对比
| 方案 | 实现原理 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空对象 | 将不存在的数据也缓存起来 | 实现简单,维护方便 | 额外内存消耗,可能短期不一致 |
| 布隆过滤 | 使用位数组判断数据是否存在 | 内存占用较少 | 实现复杂,存在误判可能 |
4.3 缓存空对象方案
核心思路 :
当查询的数据在数据库中也不存在时,我们把这个空结果也存入到redis 中,设置较短的过期时间 。
实现代码:
java
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回null
return null;
}
// 4. 不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 不存在,返回错误
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回null
return null;
}
// 6. 存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
}
关键设计点:
- 空值标识:使用空字符串""表示空值
- 较短TTL:为空值设置较短的过期时间,缓解内存压力
- 空值判断 :
shopJson != null表示命中了空值缓存
4.4 布隆过滤器方案
工作原理 :
布隆过滤器是一种概率型数据结构,通过一个庞大的二进制数组和多个哈希函数来判断元素是否存在。
布隆过滤器工作流程:
是
否
输入数据
哈希函数1
哈希函数2
哈希函数3
计算位置1
计算位置2
计算位置3
位数组
查询数据
哈希函数1
哈希函数2
哈希函数3
检查位置1
检查位置2
检查位置3
所有位置都为1?
可能存在
肯定不存在
布隆过滤器特点:
- 空间效率高:使用位数组,占用空间小
- 查询速度快:O(k)时间复杂度,k为哈希函数个数
- 存在误判:可能存在假阳性(判断存在实际不存在)
- 不支持删除:删除元素困难
5. 缓存雪崩
5.1 问题定义与影响
缓存雪崩 :在同一时段大量的缓存key同时失效 或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
问题特征:
- 大规模失效:大量key在同一时间过期
- 服务不可用:Redis集群宕机
- 连锁反应:数据库压力过大导致系统崩溃
5.2 解决方案
解决方案对比表 :
| 方案 | 实施方式 | 效果 | 成本 |
|---|---|---|---|
| 随机TTL | 给不同的Key的TTL添加随机值 | 分散过期时间 | 低 |
| Redis集群 | 利用Redis集群提高服务的可用性 | 高可用性 | 中 |
| 降级限流 | 给缓存业务添加降级限流策略 | 保护数据库 | 中 |
| 多级缓存 | 给业务添加多级缓存 | 缓冲冲击 | 高 |
5.3 核心解决方案详解
1. 随机TTL方案:
java
// 设置缓存时添加随机过期时间
private long getRandomTTL(long baseTTL) {
Random random = new Random();
// 在基础TTL上增加随机0-10分钟的偏移
long randomOffset = random.nextInt(10) * 60 * 1000L; // 随机0-10分钟
return baseTTL + randomOffset;
}
// 使用随机TTL设置缓存
stringRedisTemplate.opsForValue().set(
key,
JSONUtil.toJsonStr(shop),
getRandomTTL(CACHE_SHOP_TTL),
TimeUnit.MINUTES
);
2. Redis集群部署:
Redis主从集群
主从复制
主从复制
主从复制
监控
故障转移
客户端
Redis Sentinel
Master
Slave1
Slave2
Slave3
数据库
6. 缓存击穿
6.1 问题定义与影响
缓存击穿(热点Key问题): 一个被高并发访问 并且缓存重建业务较复杂 的key突然失效,无数的请求访问会在瞬间给数据库带来巨大的冲击。
问题特征:
- 热点Key:某个Key被高频访问
- 重建复杂:缓存重建需要复杂计算或耗时操作
- 瞬时失效:Key在同一时间过期
- 并发重建 :大量线程同时尝试重建缓存

6.2 解决方案对比
| 方案 | 核心思想 | 优点 | 缺点 |
|---|---|---|---|
| 互斥锁 | 只允许一个线程重建缓存 | 数据一致,实现简单 | 性能受影响,可能死锁 |
| 逻辑过期 | 不设置实际过期时间 | 性能好,无等待 | 实现复杂,数据可能不一致 |
6.3 互斥锁方案
工作原理 :
使用分布式锁保证同一时刻只有一个线程可以执行缓存重建操作,其他线程等待或重试。
互斥锁方案时序图:
数据库 Redis 线程3 线程2 线程1 数据库 Redis 线程3 线程2 线程1 1. 查询缓存(失效) 2. 获取锁成功 3. 查询数据库 4. 返回数据 5. 写入缓存 6. 释放锁 7. 查询缓存(失效) 8. 获取锁失败 9. 休眠重试 10. 查询缓存(已重建) 11. 返回缓存数据
解决方案如下:

实现代码:
java
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断命中的值是否是空值
if (shopJson != null) {
return null;
}
// 3. 实现缓存重建
// 3.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 3.2 判断是否获取成功
if (!isLock) {
// 3.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
// 3.4 成功,根据id查询数据库
shop = getById(id);
// 模拟重建的耗时
Thread.sleep(200);
if (shop == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 释放互斥锁
unlock(lockKey);
}
return shop;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
关键实现细节:
- 锁的获取 :使用
setIfAbsent实现分布式锁 - 锁的超时:设置合理的锁超时时间,防止死锁
- Boolean处理 :
setIfAbsent返回Boolean包装类,需要使用BooleanUtil.isTrue()处理null情况 - 重试机制:获取锁失败后休眠重试,避免频繁尝试
6.4 逻辑过期方案
小Tip:
逻辑过期用于解决热点key问题,需要提前!!在Redis中存储(预热)!! 对应的KV。
工作原理 :
不设置Redis的实际过期时间,而是在value中存储逻辑过期时间,由程序控制何时需要重建缓存。

逻辑过期方案时序图:
重建线程 数据库 Redis 线程3 线程2 线程1 重建线程 数据库 Redis 线程3 线程2 线程1 1. 查询缓存 2. 返回数据(含过期时间) 3. 判断已过期 4. 获取锁成功 5. 开启独立线程重建缓存 6. 返回旧数据 7. 查询数据库 8. 返回数据 9. 更新缓存 10. 释放锁 11. 查询缓存 12. 返回新数据
解决方案如下:

为了不修改原来的实体类,并且新增逻辑过期时间,我们新建:
RedisData实体类:
java
@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 存储的数据
}
实现代码:
java
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isBlank(json)) {
return null;
}
// 3. 命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 4.1 未过期,直接返回店铺信息
return shop;
}
// 4.2 已过期,需要缓存重建
// 5. 缓存重建
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// Double Check:再次检查缓存是否已更新
String latestJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(latestJson)) {
RedisData latestData = JSONUtil.toBean(latestJson, RedisData.class);
if (latestData.getExpireTime().isAfter(LocalDateTime.now())) {
unlock(lockKey);
return JSONUtil.toBean((JSONObject) latestData.getData(), Shop.class);
}
}
// 开启独立线程进行缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
// 6. 返回过期的商铺信息
return shop;
}
private void saveShop2Redis(Long id, Long expireSeconds) {
// 1. 查询店铺数据
Shop shop = getById(id);
// 2. 封装逻辑过期数据
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入Redis
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
JSONUtil.toJsonStr(redisData)
);
}
关键设计点:
- 逻辑过期时间:在数据中存储过期时间,而不是依赖Redis的TTL
- 独立线程重建:使用线程池异步重建缓存,不阻塞当前请求
- Double Check:获取锁后再次检查缓存状态,避免重复重建
- 脏数据返回:即使数据已过期,也返回旧数据,保证可用性
6.5 两种方案对比分析
| 对比维度 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|
| 数据一致性 | 强一致性 | 最终一致性 |
| 性能影响 | 串行执行,性能较差 | 并行执行,性能好 |
| 实现复杂度 | 简单 | 复杂 |
| 内存占用 | 无额外内存 | 需要存储过期时间 |
| 适用场景 | 数据一致性要求高 | 高并发,允许短暂不一致 |
三、遇到的问题
1. 问题描述:缓存重建时的并发问题
在实现缓存击穿解决方案时,遇到了多个线程同时重建缓存的问题:
问题现象:
- 热点Key过期后,大量并发请求到达
- 多个线程同时判断缓存失效
- 多个线程同时查询数据库并写入缓存
- 数据库压力剧增,可能被压垮
2. 问题分析
根本原因:
- 缓存失效判断和重建操作不是原子性的
- 多个线程可能同时进入重建逻辑
- 缺乏有效的并发控制机制
具体表现:
java
// 问题代码示例(存在并发问题)
public Shop queryShop(Long id) {
String key = CACHE_SHOP_KEY + id;
String shopJson = redisTemplate.opsForValue().get(key);
if (shopJson == null) {
// 多个线程可能同时进入这里
Shop shop = getById(id); // 多个线程同时查询数据库
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return shop;
}
return JSONUtil.toBean(shopJson, Shop.class);
}
3. 解决步骤
3.1 方案一:使用synchronized(单机有效)
java
public Shop queryShopWithSync(Long id) {
String key = CACHE_SHOP_KEY + id;
String shopJson = redisTemplate.opsForValue().get(key);
if (shopJson == null) {
synchronized (this) {
// Double Check
shopJson = redisTemplate.opsForValue().get(key);
if (shopJson == null) {
Shop shop = getById(id);
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return shop;
}
}
}
return JSONUtil.toBean(shopJson, Shop.class);
}
缺点:只适用于单机环境,集群环境下无效
3.2 方案二:使用Redis分布式锁(最终方案)
java
public Shop queryShopWithDistributedLock(Long id) {
String key = CACHE_SHOP_KEY + id;
String shopJson = redisTemplate.opsForValue().get(key);
if (shopJson == null) {
String lockKey = "lock:shop:" + id;
try {
// 尝试获取分布式锁
boolean locked = tryLock(lockKey);
if (locked) {
try {
// Double Check
shopJson = redisTemplate.opsForValue().get(key);
if (shopJson == null) {
Shop shop = getById(id);
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return shop;
}
} finally {
// 释放锁
unlock(lockKey);
}
} else {
// 获取锁失败,等待后重试
Thread.sleep(100);
return queryShopWithDistributedLock(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
return JSONUtil.toBean(shopJson, Shop.class);
}
四、今日实战收获
1. 缓存工具类的封装
为了复用缓存处理逻辑,项目封装了通用的缓存工具类:
java
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 方法1:设置缓存(带TTL)
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
// 方法2:设置逻辑过期缓存
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)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// 方法3:解决缓存穿透的查询
public <R, ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1. 从redis查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
return null;
}
// 3. 不存在,查询数据库
R r = dbFallback.apply(id);
// 4. 不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 5. 存在,写入redis
this.set(key, r, time, unit);
return r;
}
// 方法4:逻辑过期解决缓存击穿
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1. 从redis查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isBlank(json)) {
return null;
}
// 3. 反序列化数据
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
return r;
}
// 5. 已过期,需要缓存重建
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// Double Check
String latestJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(latestJson)) {
RedisData latestData = JSONUtil.toBean(latestJson, RedisData.class);
if (latestData.getExpireTime().isAfter(LocalDateTime.now())) {
unlock(lockKey);
return JSONUtil.toBean((JSONObject) latestData.getData(), type);
}
}
// 开启独立线程重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R newR = dbFallback.apply(id);
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
log.error("缓存重建失败", e);
} finally {
unlock(lockKey);
}
});
}
return r;
}
// 方法5:互斥锁解决缓存击穿
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type,
Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1. 从redis查询缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
return null;
}
// 3. 实现缓存重建
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4. 判断是否获取成功
if (!isLock) {
// 4.1 获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.2 获取锁成功,Double Check
shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, type);
}
// 5. 查询数据库
r = dbFallback.apply(id);
// 6. 不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 7. 存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 8. 释放锁
unlock(lockKey);
}
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
2. 缓存方案的选择策略
在实际项目中,需要根据业务场景选择合适的缓存方案:
选择决策树:
是
否
高
低
开始
数据是否热点?
一致性要求?
使用缓存穿透方案
使用互斥锁方案
使用逻辑过期方案
缓存空值 + 布隆过滤
注意死锁和性能
注意数据一致性
设置较短TTL
监控锁竞争
监控重建延迟
结束
实际应用示例:
java
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 解决缓存穿透
Shop shop = cacheClient.queryWithPassThrough(
CACHE_SHOP_KEY, id, Shop.class,
this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 互斥锁解决缓存击穿
// Shop shop = cacheClient.queryWithMutex(
// CACHE_SHOP_KEY, id, Shop.class,
// this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 逻辑过期解决缓存击穿
// Shop shop = cacheClient.queryWithLogicalExpire(
// CACHE_SHOP_KEY, id, Shop.class,
// this::getById, 20L, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在!");
}
return Result.ok(shop);
}
}
五、小知识点总结
1. JSON处理工具的使用技巧
-
JSON字符串转Bean:
javaShop shop = JSONUtil.toBean(shopJson, Shop.class); -
JSON字符串转List:
javaList<ShopType> shopTypes = JSONUtil.toList(typesJSON, ShopType.class); -
Bean转JSON字符串:
javaString jsonStr = JSONUtil.toJsonStr(shop); -
处理复杂JSON结构:
javaRedisData redisData = JSONUtil.toBean(json, RedisData.class); JSONObject data = (JSONObject) redisData.getData(); Shop shop = JSONUtil.toBean(data, Shop.class);
2. Redis相关
-
键设计规范:
- 使用冒号分隔层级:
业务:类型:ID - 保持一致性:相同业务的键使用相同前缀
- 可读性强:键名能清晰表达存储的内容
- 使用冒号分隔层级:
-
值序列化:
- 复杂对象使用JSON序列化
- 简单值直接存储字符串
- 考虑压缩大对象
-
过期时间设置:
- 热点数据设置较长TTL
- 临时数据设置较短TTL
- 使用随机TTL避免雪崩
3. 并发控制的关键点
-
锁的粒度:
- 尽量使用细粒度锁
- 避免全局锁影响性能
- 锁的key设计要唯一
-
锁的超时:
- 设置合理的锁超时时间
- 锁的时长比业务时间长一些,防止异常
- 使用
BooleanUtil.isTrue()处理包装类
-
异常处理:
- 确保锁最终被释放
- 处理中断异常
- 记录锁竞争情况
4. 性能优化建议
-
缓存预热:
- 热点数据提前加载到缓存
- 定时刷新缓存数据
- 监控缓存命中率
-
批量操作:
- 使用Pipeline批量操作Redis
- 批量查询数据库减少IO
- 合并多次操作为一次
-
监控告警:
- 监控缓存命中率
- 监控数据库QPS
- 设置合理的告警阈值
5. 代码质量保证
-
空值处理:
java// 涉及到查询的操作最好判断是否为null if (shopJson != null) { // 处理逻辑 } -
事务管理:
java@Transactional public Result update(Shop shop) { // 更新数据库 updateById(shop); // 删除缓存 redisTemplate.delete(CACHE_SHOP_KEY + shop.getId()); return Result.ok(); } -
日志记录:
- 记录缓存命中/未命中
- 记录缓存重建耗时
- 记录锁竞争情况
6. 安全注意事项
-
防攻击:
- 限制查询频率
- 验证输入参数
- 防止缓存污染
-
数据一致性:
- 使用事务保证操作原子性
- 实现最终一致性
- 处理异常情况下的数据一致
-
资源保护:
- 限制缓存大小
- 监控内存使用
- 实现优雅降级
总结
通过今天对商户查询缓存功能的深入学习,我系统掌握了缓存应用的核心技术和最佳实践。此时就可以运用存储在内存中的中间件Redis来进行缓存操作。
缓存是系统的"加速器"和"减震器",合理使用缓存可以极大提升系统性能和用户体验。然而,缓存也是一把双刃剑,设计不当可能引入新的问题。我们需要深入理解缓存的工作原理,掌握各种缓存问题的解决方案。
