redis的缓存策略
主动更新有三种模式
1.旁路缓存模式(使用较多)
2.读写穿透
3.异步缓存写入
操作缓存和数据库时有三个问题需要考虑:
1.删除缓存还是更新缓存?
更新缓存:每次更新数据库都更新缓存,无效写操作较多
删除缓存:更新数据库时让缓存失效,查询时再更新缓存(优选)
2.如何保证缓存与数据库的操作的同时成功或失败?
单体系统,将缓存与数据库操作放在一个事务
分布式系统,利用TCC等分布式事务方案
3.先操作缓存还是先操作数据库?
先删除缓存,再操作数据库
先操作数据库,再删除缓存
由于redis的读写操作时间是远小于数据库的读写时间,所以相较而言使用方案二更好。但是两者都会有出现缓存和数据库数据不一致的情况,但可以通过设置TTL来进行优化。
总结:
缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
缓存命中则直接返回
缓存未命中则查询数据库,并写入缓存,设定超时时间
写操作:
先写数据库,然后再删除缓存
要确保数据库与缓存操作的原子性
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种 :
缓存空对象
优点:实现简单,维护方便
缺点:额外的内存消耗可能造成短期的不一致
布隆过滤
优点:内存占用较少,没有多余的key
缺点: 实现复杂存在误判可能
下面是一个实例
public Result queryById(Long id) {
//1.根据id从redis中查询数据
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在 直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
if(shopJson!=null){
return Result.fail("无此数据!");
}
//4.不存在 查询数据库
Shop shop = getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("无此数据!");
}
//5.存在 写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
当检查到该数据库中不存在该数据时,为它缓存一个空对象stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
防止被请求多次被直接打到数据库。
总结
当然像布隆过滤和缓存空置都是一些被动防御的手段,我们也可以采取一些主动的措施:
在缓存null值,布隆过滤的基础上增强id的复杂度,避免被猜测id规律,做好数据的基础格式校验,加强用户权限校验,做好热点数据的限流。
缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性
给缓存业务添加降级限流策略
给业务添加多级缓存
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
互斥锁
优点:没有额外的内存消耗,保证一致性,实现简单
缺点:线程需要等待,性能受影响,可能有死锁风险
逻辑过期
优点:线程无需等待,性能较好
缺点:不保证一致性,有额外内存消耗,实现复杂
下面是一个实例
互斥锁实现
public Shop queryWithPassThrough(Long id) {
//1.根据id从redis中查询数据
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在 直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if (shopJson != null) {
return null;
}
//4.实现缓存重建
//4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2.判断是否获取成功
if (isLock) {
//4.3.失败 则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功 根据id查询数据库
shop = getById(id);
//5.不存在 查询数据库
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.存在 写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unLock(lockKey);
}
return shop;
}
设置逻辑过期时间实现
public Shop queryWithLogicalExpire(Long id) {
//1.根据id从redis中查询数据
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否存在
if (StrUtil.isBlank(shopJson)) {
//3.不存在 直接返回空
return null;
}
//4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(key);
//6.2.判断是否获取锁成功
if (isLock) {
//6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//重建缓存
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
分布式锁
在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。
举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:
- 线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。
- 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
- 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
- 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。
- 线程 2 继续执行,将库存数量减少 1 个,然后返回成功。此时就发生了超卖问题,导致商品被多卖了一份。
这时候我们所谓的超卖就出现了,数据库的数据可能就出现了负数的情况。由于JDK自带的本地锁(ReentrantLock、synchronized)只能针对一个JVM进程内的多线程来实现加锁行为,而我们的大多数项目都是分布式系统,在这种情况下,JDK自带的加锁方案就无法对我们的想法进行满足。
如何使用Redis实现分布式锁
无论是本地锁还是分布式锁,其核心都在于“互斥”
在redis中有个关键字“SETNX”(SET IF NOT EXISTS),如果key不存在,即对此key设置value,如果key存在,那什么也不做。
127.0.0.1:6379> SETNX lock 1
(integer 1)
127.0.0.1:6379> SETNX lock 2
(integer 0)
127.0.0.1:6379> DEL lock
(integer 1)
Lua脚本
当然,为了保证加锁和释放锁的原子性,我们一般使用Lua脚本来对此进行操作,Redis可以完美适配Lua语言。
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这种事最简单的上放锁操作,同时也伴随一定的安全问题。比如如果某次解锁的逻辑挂掉了,可能就会导致后面的线程都拿不到资源的情况。所有我们需要给锁设置一个过期时间,来防止放锁失败的情况。
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
但这样的话,如果过期时间到了,但是我们的逻辑还没走完,导致锁提前释放掉了,同样伴随着问题。如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
Redisson
那有没有一种两全其美的办法呢,答案是有的,对于 Java 开发的小伙伴来说,已经有了现成的解:决方案:Redisson 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址:Redis官方文档
Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
在redisson种有个方法叫做 getLockWatchdogTimeout()的方法,它返回的时间就是看门狗续锁的时间(默认30s)
//默认 30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;
public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
this.lockWatchdogTimeout = lockWatchdogTimeout;
return this;
}
public long getLockWatchdogTimeout() {
return lockWatchdogTimeout;
}
下面是它的主要逻辑
private void renewExpiration() {
//......
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//......
// 异步续期,基于 Lua 脚本
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null) {
// 无法续期
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// 递归调用实现续期
renewExpiration();
} else {
// 取消续期
cancelExpirationRenewal(null);
}
});
}
// 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
下面是它的异步续期实现方法
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认)
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
Redisson可重入锁
local key=KEYS[1];--锁的key
local threadId = ARGV[1];--线程唯一标识
local releaseTime = ARGV[2];--锁的自动释放时间
--判断是否存在
if(redis.call('exists',key)== 0) then-不存在,获取锁
redis.call('hset',key, threadId,"1');--设置有效期
redis.call('expire', key, releaseTime);
return 1;--返回结果
end;
--锁已经存在,判断threadId是否是自己
if(redis.call('hexists',key,threadId)== 1) then
-- 不存在,获取锁,重入次数+1
redis.call('hincrby', key, threadId,'1');
--设置有效期
redis.call('expire', key, releaseTime);
return 1;--返回结果
end;
return0;--代码走到这里,说明获取锁的不是自己,获取锁失败
消息队列
消息队列(Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
- 消息队列:存储和管理消息,也被称为消息代理(MessageBroker)
- 生产者:发送消息到消息队列
- 消费者:从消息队列获取消息并处理消息
Redis提供了三种不同的方式来实现消息队列:
- list结构:基于List结构模拟消息队列
- Pubsub:基本的点对点消息模型
- Stream:比较完善的消息队列模型
基于List结构模拟消息队列
消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。
队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。
//消费者
127.0.0.1:6379> BRPOP l1 20
//生产者
127.0.0.1:6379> LPUSH l1 e1
(integer) 2
//消费者
127.0.0.1:6379> BRPOP l1 20
1) "l1"
2) "e1"
(10.32s)
127.0.0.1:6379> BRPOP l1 20
1) "l1"
2) "e2"
基于List的消息队列有哪些优缺点?
优点:
- 利用Redis存储,不受限于JVM内存上限
- 基于Redis的持久化机制,数据安全性有保证
- 可以满足消息有序性
缺点:
- 无法避免消息丢失
- 只支持单消费者
基于Pubsub的消息队列
Pubsub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
- SUBSCRIBE channel[channe]:订阅一个或多个频道
- PUBLISH channel msg:向一个频道发送消息
- PSUBSCRlBE pattern[pattern]:订阅与pattern格式匹配的所有频道
基于Pubsub的消息队列有哪些优缺点?
优点:
- 采用发布订阅模型,支持多生产、多消费
缺点:
- 不支持数据持久化
- 无法避免消息丢失
- 消息堆积有上限,超出时数据丢失