title: redis缓存的应用
date: 2025-03-06 16:34:51
tags: redis
categories: redis教程
简单的缓存策略
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result querygetById(Long id) {
//1.从Redis内查询商品缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(shopJson)){
//手动反序列化
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//2.不存在就根据id查询数据库
Shop shop = getById(id);
if(shop==null){
return Result.fail("商户不存在!");
}
//3.数据库数据写入Redis
//手动序列化
String shopStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,shopStr,CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
}
缓存更新策略
缓存更新策略的最佳方案:
- 低一致性需求:使用Redis自带的内存淘汰机制
- 高一致性需求:主动更新,超时剔除的方式作为斗地方案
- 读操作
- 缓存命中就直接返回
- 缓存未命中则查询数据库
- 写操作:
- 先写数据库,然后再删除缓存
- 要确保数据库与缓存操作的原子性
- 读操作
操作缓存和数据库时需要考虑的三个问题
- 删除缓存还是更新缓存?
- 每次更新数据库的同时更新缓存:若数据库更新了 100 次,期间没有任何查询请求,此时缓存的更新就是无效操作。
- 数据库更新就删除缓存:数据库更新后缓存被删除,此时数据库无论更新多少次,缓存都不会做任何操作。直到有查询请求,缓存才会将数据库中的数据写入到缓存中。
- 如何保证缓存和数据库的操作同时成功或失败?
- 单体系统:将缓存与数据库操作放在一个事务。
- 分布式系统:利用 TCC 等分布式事务方案。
- 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库。假设缓存为 10,数据库为 10。(t1、t2、t3 代表三个时刻)
- t1:线程 1 删除缓存,并更新数据库为 20。t2:线程 2 查询缓存未命中,从数据库中查询并写入缓存。✔️
- t1:线程 1 删除缓存。t2:线程 2 查询缓存未命中,从数据库中查询并写入缓存。t3:线程 t1 更新数据库为 20。❌
- **先操作数据库,再删除缓存。**假设缓存为 10,数据库为 10。(t1、t2、t3、t4 代表四个时刻)
- t1:线程 1 更新数据库为 20,删除缓存。t2:线程 2 查询缓存未命中,从数据库中查询并写入缓存。✔️
- t1:线程 1 查询缓存未命中,从数据库中查询。t2:线程 2 更新数据库为 20,删除缓存。t3:线程 1 写入缓存。❌(这种方式出现的概率很小,缓存写入的速度很快。更可能出现的情况是:线程 1 写入缓存后,线程 2 更新数据库然后将缓存删除)
- 先删除缓存,再操作数据库。假设缓存为 10,数据库为 10。(t1、t2、t3 代表三个时刻)
@Override
public Result update(Shop shop) {
if(shop.getId()==null){
return Result.fail("店铺id不能为空!");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
String key = CACHE_SHOP_KEY + shop.getId();
stringRedisTemplate.delete(key);
return Result.ok();
}
缓存穿透问题
缓存空对象方案
客户端请求的数据在 Redis 和数据库中都不存在,为了防止不断的请求:将 空值 缓存到 Redis 中并且设置 TTL 时间后,返回给该请求。
-
缓存中包含过多的 空值,会造成额外的内存消耗。(设置 TTL 可以缓解)
-
可能造成短期的不一致:第一次请求的数据在 Redis 和数据库中都不存在,缓存空对象后,数据库中新增了该请求对应的数据
@Override
public CommonResult<Shop> getShopById(Long id) {
ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
String shopKey = CACHE_SHOP_KEY + id;
// 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
String shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
if (StringUtils.isNotBlank(shopJsonInRedis)) {
return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
}
// 命中空值
if (shopJsonInRedis != null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
}
// 2. 从 Redis 中未查询到数据,则从数据库中查询
Shop shop = this.getById(id);
// 若数据中也查询不到,则缓存空值后返回提示信息
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", TTL_TWO, TimeUnit.MINUTES);
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
}
// 3. 将从数据库中查询到的数据存入 Redis 后返回
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_TWO, TimeUnit.HOURS);
return CommonResult.success(shop);
}
布隆过滤器方案
布隆过滤器(Bloom Filter):一个很长的二进制数组(初始化值为 0),通过一系列的 Hash 函数判断该数据是否存在。 布隆过滤器的运行速度快、内存占用小,但是存在误判的可能。
- 存储数据时经过 n 个 hash 函数,计算出 n 个 hash 值,hash 值映射后得到 n 个索引,设置索引处的值为 1。(若当前索引处值已经为 1,则不需要任何操作)
- 查询数据时也会经过 n 个 hash 函数,计算出 n 个 hash 值,hash 值映射后得到 n 个索引,判断索引处的值是否为 1。
- 查询 Anthony:经过 hash 算法得到的 hash 值映射后数组下标为 0、2、6,下标对应的值没有全为 1,数组中不存在该元素。
- 查询 Coco:经过 hash 算法得到的 hash 值映射后数组下标为 0、2、6,下标对应的值都为 1,数组中可能存在该元素。
缓存雪崩问题:
解决方案:
- 给不同的Key的TTL添加随机值
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
缓存击穿问题
缓存击穿问题,也叫 热点 Key 问题;就是一个被 高并发访问 并且 缓存中业务较复杂的 Key 突然失效,大量的请求在极短的时间内一起请求这个 Key 并且都未命中,无数的请求访问在瞬间打到数据库上,给数据库带来巨大的冲击。
缓存击穿整体过程:
- 一个线程查询缓存,未命中,查询数据库并重建缓存(缓存重建业务比较复杂,时间长)。
- 在这个重建缓存的过程中,大量的请求穿过缓存直接请求数据库并重建缓存,导致性能下降。
解决方案:互斥锁(一致性)、逻辑过期(可用性)
互斥锁方案
synchronized
- 查询缓存,存在则直接返回。
- 不存在:执行 synchronized 代码块。
- 先查缓存,存在则直接返回。(若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存)
- 查询数据库,重建缓存。
@SneakyThrows
@Override
public CommonResult<Shop> getShopById(Long id) {
ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
String shopKey = CACHE_SHOP_KEY + id;
// 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
String shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
if (StringUtils.isNotBlank(shopJsonInRedis)) {
return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
}
// 命中空值
if (shopJsonInRedis != null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
}
// 2. 从 Redis 中未查询到数据,则从数据库中查询。(synchronized)
Shop shop = new Shop();
synchronized (ShopServiceImpl.class) {
// 3. 再次查询 Redis:若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
if (StringUtils.isNotBlank(shopJsonInRedis)) {
return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
}
// 4. 查询数据库,缓存空值避免缓存穿透,重建缓存。
shop = this.getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", TTL_TWO, TimeUnit.MINUTES);
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
}
// 模拟缓存重建延迟
Thread.sleep(100);
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_TWO, TimeUnit.HOURS);
}
return CommonResult.success(shop);
}
用redis的setnx来充当分布式锁
/**
* 获取互斥锁
*/
public boolean tryLock(String key) {
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", TTL_TWO, TimeUnit.SECONDS);
return Boolean.TRUE.equals(result);
}
/**
* 释放互斥锁
*/
public void unlock(String key) {
stringRedisTemplate.delete(key);
}
@SneakyThrows
@Override
public CommonResult<Shop> getShopById(Long id) {
ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
String shopKey = CACHE_SHOP_KEY + id;
String lockKey = LOCK_SHOP_KEY + id;
// 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
String shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
if (StringUtils.isNotBlank(shopJsonInRedis)) {
return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
}
// 命中空值
if (shopJsonInRedis != null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
}
// 2. 从 Redis 中未查询到数据,尝试获取锁后从数据库中查询。
Shop shop = new Shop();
boolean tryLock = tryLock(lockKey);
try {
// 2.1 未获取到锁则等待一段时间后重试(通过递归调用重试)
if (BooleanUtil.isFalse(tryLock)) {
Thread.sleep(50);
this.getShopById(id);
}
// 2.2 获取到锁:查询数据库、缓存重建。
if (tryLock) {
// 3. 再次查询 Redis:若多个线程执行到获取锁处,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
shopJsonInRedis = stringRedisTemplate.opsForValue().get(shopKey);
if (StringUtils.isNotBlank(shopJsonInRedis)) {
return CommonResult.success(JSONUtil.toBean(shopJsonInRedis, Shop.class));
}
// 4. 查询数据库,缓存空值避免缓存穿透,重建缓存。
shop = this.getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", TTL_TWO, TimeUnit.MINUTES);
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "该商铺不存在");
}
// 模拟缓存重建延迟
Thread.sleep(100);
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), TTL_TWO, TimeUnit.HOURS);
}
} finally {
// 5. 释放锁
unlock(lockKey);
}
return CommonResult.success(shop);
}
逻辑过期方案
无需考虑缓存雪崩(Redis 宕机除外)、缓存穿透问题:缓存何时过期通过代码控制而非 TTL。需要进行数据预热,缓存未命中时直接返回空。
-
先查询缓存,未命中则直接返回。
-
命中则判断缓存是否过期,未过期则直接返回。
-
过期:获取锁。
- 未获取到锁:直接返回。
- 获取到锁:开启一个新的线程后直接返回,这个线程负责重建缓存后释放锁。
存储到 Redis 中的 Key 永久有效,过期时间通过代码控制而非 TTL。Redis 存储的数据需要带上一个逻辑过期时间,即 Shop 实体类中需要一个逻辑过期时间属性。新建一个 RedisData,该类包含两个属性 expireTime 和 Data,对原来的代码没有入侵性。
缓存预热(将热点数据提前存储到 Redis 中)
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
/**
* 缓存预热(将热点数据提前存储到 Redis 中)
*/
public void saveHotDataIn2Redis(Long id, Long expireSeconds) {
Shop shop = this.getById(id);
ThrowUtils.throwIf(shop == null, ErrorCode.NOT_FOUND_ERROR, "该数据不存在");
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
# Redis 中存储的数据会多一个 expireTime 的值
{
"expireTime": 1681660099861,
"data": {
"id": 1,
"name": "101茶餐厅",
"typeId": 1,
...
}
}
逻辑过期
/**
* 缓存预热(将热点数据提前存储到 Redis 中)
*/
public void saveHotDataIn2Redis(Long id, Long expireSeconds) throws InterruptedException {
Shop shop = this.getById(id);
ThrowUtils.throwIf(shop == null, ErrorCode.NOT_FOUND_ERROR, "该数据不存在");
// 模拟缓存重建延迟,让一部分线程先执行完毕,在此期间会短暂的不一致
Thread.sleep(200);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
private static final ExecutorService ES = Executors.newFixedThreadPool(10);
@SneakyThrows
@Override
public CommonResult<Shop> getShopById(Long id) {
ThrowUtils.throwIf(id == null, ErrorCode.PARAMS_ERROR);
String shopKey = CACHE_SHOP_KEY + id;
String lockKey = LOCK_SHOP_KEY + id;
// 1. 先从 Redis 中查询数据,未命中则直接返回
String redisDataJson = stringRedisTemplate.opsForValue().get(shopKey);
if (StringUtils.isBlank(redisDataJson)) {
return CommonResult.success(null);
}
// 2. 判断是否过期,未过期则直接返回
RedisData redisData = JSONUtil.toBean(redisDataJson, RedisData.class);
JSONObject jsonObject = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(jsonObject, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return CommonResult.success(shop);
}
// 3. 未获取到锁直接返回
boolean tryLock = tryLock(lockKey);
if (BooleanUtil.isFalse(tryLock)) {
return CommonResult.success(shop);
}
// 4. 获取到锁:开启一个新的线程后返回旧数据。(这个线程负责查询数据库、重建缓存)
// 此处无需 DoubleCheck,因为未获取到锁直接返回旧数据,能保证只有一个线程执行到此处
ES.submit(() -> {
try {
// 查询数据库、重建缓存
this.saveHotDataIn2Redis(id, 3600 * 24L);
} catch (Exception e) {
log.error(e.getMessage());
} finally {
unlock(lockKey);
}
});
return CommonResult.success(shop);
}
总结Redis Cache工具类:
@Component
@Slf4j
public class CacheClient {
private static final ExecutorService ES = Executors.newFixedThreadPool(10);
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁
*/
public boolean tryLock(String key) {
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", TTL_TWO, TimeUnit.SECONDS);
return BooleanUtil.isTrue(result);
}
/**
* 释放锁
*/
public void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 数据预热(将热点数据提前存储到 Redis 中)
*
* @param key 预热数据的 Key
* @param value 预热数据的 Value
* @param expireTime 逻辑过期时间
* @param timeUnit 时间单位
*/
public void dataWarmUp(String key, Object value, Long expireTime, TimeUnit timeUnit) {
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime)));
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 将 Java 对象序列化为 JSON 存储到 Redis 中并且设置 TTL 过期时间
*
* @param key String 类型的键
* @param value 序列化为 JSON 的值
* @param time TTL 过期时间
* @param timeUnit 时间单位
*/
public void set(String key, Object value, Long time, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, timeUnit);
}
/**
* 解决缓存穿透问(缓存空值)
*
* @param keyPrefix Key 前缀
* @param id id
* @param type 实体类型
* @param function 有参有返回值的函数
* @param time TTL 过期时间
* @param timeUnit 时间单位
* @param <R> 实体类型
* @param <ID> id 类型
* @return 设置某个实体类的缓存,并解决缓存穿透问题
*/
public <R, ID> R setWithCachePenetration(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
String key = keyPrefix + id;
// 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
String jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(jsonStr)) {
return JSONUtil.toBean(jsonStr, type);
}
// 命中空值
if (jsonStr != null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
// 2. 从 Redis 中未查询到数据,则从数据库中查询
R result = function.apply(id);
// 若数据中也查询不到,则缓存空值后返回提示信息
if (result == null) {
stringRedisTemplate.opsForValue().set(key, "", TTL_TWO, TimeUnit.MINUTES);
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
// 3. 将从数据库中查询到的数据存入 Redis 后返回
this.set(key, result, time, timeUnit);
return result;
}
/**
* 解决缓存击穿问题(synchronized)
*/
public <R, ID> R setWithCacheBreakdown4Synchronized(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
String key = keyPrefix + id;
// 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
String jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(jsonStr)) {
return JSONUtil.toBean(jsonStr, type);
}
// 命中空值
if (jsonStr != null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
// 2. 从 Redis 中未查询到数据,则从数据库中查询。(synchronized)
R result = null;
synchronized (CacheClient.class) {
// 3. 再次查询 Redis:若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(jsonStr)) {
return JSONUtil.toBean(jsonStr, type);
}
// 4. 查询数据库、缓存空值避免缓存穿透、重建缓存。
result = function.apply(id);
if (result == null) {
stringRedisTemplate.opsForValue().set(key, "", TTL_TWO, TimeUnit.MINUTES);
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
this.set(key, result, time, timeUnit);
}
return result;
}
/**
* 解决缓存击穿问题(setnx)
*/
public <R, ID> R setWithCacheBreakdown4SetNx(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
String key = keyPrefix + id;
String lockKey = LOCK_SHOP_KEY + id;
// 1. 先从 Redis 中查询数据,存在则将其转换为 Java 对象后返回
String jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(jsonStr)) {
return JSONUtil.toBean(jsonStr, type);
}
// 命中空值
if (jsonStr != null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
// 2. 从 Redis 中未查询到数据,尝试获取锁后从数据库中查询。
R result = null;
boolean tryLock = tryLock(lockKey);
try {
// 2.1 未获取到锁则等待一段时间后重试(通过递归调用重试)
if (BooleanUtil.isFalse(tryLock)) {
Thread.sleep(50);
this.setWithCacheBreakdown4SetNx(keyPrefix, id, type, function, time, timeUnit);
}
// 2.2 获取到锁:查询数据库、缓存重建。
if (tryLock) {
// 3. 再次查询 Redis:若多个线程执行到同步代码块,某个线程拿到锁查询数据库并重建缓存后,其他拿到锁进来的线程直接查询缓存后返回,避免重复查询数据库并重建缓存。
jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotBlank(jsonStr)) {
return JSONUtil.toBean(jsonStr, type);
}
// 4. 查询数据库、缓存空值避免缓存穿透、重建缓存。
result = function.apply(id);
if (result == null) {
stringRedisTemplate.opsForValue().set(key, "", TTL_TWO, TimeUnit.MINUTES);
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
this.set(key, result, time, timeUnit);
}
} catch (Exception e) {
log.error(e.getMessage());
} finally {
unlock(lockKey);
}
return result;
}
/**
* 解决缓存击穿问题(逻辑过期时间)
*/
public <R, ID> R setWithCacheBreakdown4LogicalExpiration(String keyPrefix, ID id, Class<R> type, Function<ID, R> function, Long time, TimeUnit timeUnit) {
String key = keyPrefix + id;
String lockKey = LOCK_SHOP_KEY + id;
// 1. 先从 Redis 中查询数据,未命中则直接返回
String jsonStr = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isBlank(jsonStr)) {
return null;
}
// 2. 判断是否过期,未过期则直接返回
RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);
JSONObject jsonObject = JSONUtil.parseObj(redisData.getData());
R result = JSONUtil.toBean(jsonObject, type);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return result;
}
// 3. 未获取到锁直接返回
boolean tryLock = tryLock(lockKey);
if (BooleanUtil.isFalse(tryLock)) {
return result;
}
// 4. 获取到锁:开启一个新的线程后返回旧数据。(这个线程负责查询数据库、重建缓存)
// 此处无需 DoubleCheck,因为未获取到锁直接返回旧数据,能保证只有一个线程执行到此处
ES.submit(() -> {
try {
this.dataWarmUp(key, function.apply(id), time, timeUnit);
} finally {
unlock(lockKey);
}
});
return result;
}
}