Redis总结(二)
本总结以黑马的视频为主要参考来源,请以视频为主
缓存穿透
如果客户端查找的数据,数据库和Redis
中都没有,这时候如果一直请求,Redis
缓存未命中,请求全会到数据库,这会对数据库造成巨大压力。解决办法是:
-
存 null
值,在Redis
中存入NULL
,再来访问时,直接返回,就不用访问数据库了 -
布隆过滤器
缓存雪崩
如果大量的key
同时过期,这时候所有的请求打到数据库,造成巨大压力
-
对不同的 key
设置不同的过期时间
缓存击穿
热点key
突然失效,大量的请求到数据库
-
互斥锁( Redis
的setnx
就是互斥的) -
逻辑过期
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
解决缓存击穿和缓存穿透的代码
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 将key value 保存起来, 并设置ttl
* @param key key
* @param value value
* @param time 时间
* @param unit 单位
*/
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(value), time, unit);
}
/**
* 逻辑过期的缓存
* @param key key
* @param value value
* @param time 逻辑过期的时间
* @param unit 单位
*/
public void setLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
//将value 对象封装为有逻辑过期时间的对象
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(
LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//加入redis
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(redisData));
}
/**
*
* @param prefix key 的前缀
* @param id id , 由于不知道这个id的类型, 所以用泛型
* @param type 操作对象的类型
* @param dbFallBack 查数据库的操作
* @param <T> type
* @param <K> id type
* @param time 过期时间
* @param unit 单位
* @return result
*/
public<T, K> T queryWithPassThrough(String prefix, K id, Class<T> type,
Function<K, T> dbFallBack,
Long time, TimeUnit unit) {
//查缓存
String key = prefix + id;
String cacheJson = stringRedisTemplate.opsForValue().get(key);
//redis 存在, 直接返回
if (StrUtil.isNotBlank(cacheJson)) {
return JSONUtil.toBean(cacheJson, type);
}
//redis 不存在
//是不是空值, 解决缓存穿透
//不是空值, 那就是redis加入的空字符串, 不用查数据库了, 这条数据不存在
if(cacheJson != null) {
//直接返回空
return null;
}
//是空值, 查数据库
//T result = getById(id);
//这个地方不能直接查数据, 因为不知道查哪个表, 所以交给调用者, 想到可以用函数式编程
//function可以解决
T reslut = dbFallBack.apply(id);
//数据库没查到, 没有这条数据
if(reslut == null) {
//加入redis, 解决缓存穿透
stringRedisTemplate.opsForValue().set(key, "",CACHE_NULL_TTL, TimeUnit.MINUTES );
return null;
}
//result != null 数据存在, 加入redis
this.set(key, reslut, time, unit);
return reslut;
}
/**
* 线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
*
* @param prefix key 的前缀
* @param id id 的类型不清楚, 所以用泛型
* @param type 操作对象的类型
* @param dbFallback 操作数据库
* @param time 逻辑过期时间
* @param unit 单位
* @return
* @param <T> Object type
* @param <K> id type
*/
public<T, K> T queryWithLogicalExpire(String prefix, K id, Class<T> type,
Function<K, T> dbFallback,
Long time, TimeUnit unit){
//1.从redis中查
//redis 的key是prefix + id
String key = prefix + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.不存在,直接返回
if (StrUtil.isBlank(shopJson)) {
return null;
}
//存在, 判断是否过期
//这时候需要反序列化json对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//
T reslut = JSONUtil.toBean((JSONObject) redisData.getData(), type);
//逻辑过期时间
LocalDateTime expireTime = redisData.getExpireTime();
//未过期, 返回
//expireTime 在当前时间之后, 说明没有过期
if (expireTime.isAfter(LocalDateTime.now())) {
return reslut;
}
//过期, 缓存重建
//获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(key);
//成功, 开启独立线程, 去缓存重建
if (isLock) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//重建缓存,
//1. 查数据库
T r = dbFallback.apply(id);
//2. 加入缓存
this.setLogicalExpire(key, r, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//如果没有成功获取锁, 返回的还是旧的
return reslut;
}
/**
* 得到互斥锁
* setnx操作,
* @param key key
* @return 得到时, 返回true
*/
private boolean tryLock(String key) {
// 尝试修改这个key的value, 如果没有key会修改成功, 如果有key, 不会修改
//也就是互斥锁
//后边的为有效期和单位
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//不要直接返回flag. 可能出现nullPoint
//直接返回flag时要解封装, 所以可能出现nullPoint
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key lockKey
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
}
秒杀相关 ——乐观锁实现
乐观锁在修改时会看是否和查到的版本号相等
select version from product where id = ${id};
update table set stock = stock - 1 ,
version = version + 1 where id = ${id} and version = version;
但是在这里我么可以优化,直接看剩余量不就行了吗?
update table set stock = stock - 1 where id = ${id} and stock = stock;
还可以简洁一点,就是直接判断剩余量大于0就行
update table set stock = stock - 1 where id = ${id} and stock > 0;
一人一单
逻辑就是查订单号和userId
看有没有,如果有记录,说明已经买过了,不可以重新购买。
这个是insert
逻辑,所以用悲观锁更合适
@Synchronized
就是悲观锁
问题
不能解决集群问题
分布式锁(Redis)
其实就是setnx
获取锁
private boolean tryLock(String key) {
// 尝试修改这个key的value, 如果没有key会修改成功, 如果有key, 不会修改
//也就是互斥锁
//后边的为有效期和单位
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//不要直接返回flag. 可能出现nullPoint
//直接返回flag时要解封装, 所以可能出现nullPoint
return BooleanUtil.isTrue(flag);
}
释放锁
/**
* 释放锁
* @param key lockKey
*/
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
问题:超时误删
线程1,获取锁成功,但是在执行业务时阻塞,导致锁超时释放;这时候线程2获取锁成功,线程1也活了,它把线程2的锁给删了。
解决:可以把setnx
的value
写为线程ID,释放锁的时候可以判断一下是不是当前线程。
// 获取锁
public boolean tryLock(Long timeoutSec) {
//将线程的id加前缀作为value加入redis
String value = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, value, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
public void unlock() {
//释放锁
String key = KEY_PREFIX + name;
String cacheValue = stringRedisTemplate.opsForValue().get(key);
long id = Thread.currentThread().getId();
String curValue = ID_PREFIX + id;
if (curValue.equals(cacheValue)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
问题:原子性
线程1获取锁成功,执行业务,判断也成功了,释放锁的时候阻塞了。然后超时释放了;这时候线程2获取锁成功,然后线程1阻塞回来,把线程2的锁给删了。
解决:说明释放锁的时候要有原子性,Lua
脚本
-- 锁的key
local key = KEYS[1]
-- 线程的标识
local threadId = ARGV[1]
-- 获取锁的线程id
local id = redis.call('get',key)
-- 比较,相同的话直接删掉
if(id == threadId) then
return redis.call('del', key)
end
return 0
/**
* 加载lua脚本
*/
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用Lua脚本释放锁
// 以前释放锁是判断加释放锁分开执行,
// 可能会误删, 现在是一行代码, 一块执行, 释放锁的过程不会阻塞
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
:clown_face:
目前还存在的问题
-
可重入性
同一个线程应该可以一直获取锁才对,但是目前是不可以的。
如果业务1的代码需要获取锁,而且调用了业务2,业务2也需要获取锁(明显是同一个线程),这时候,业务2要等到业务1释放锁,业务1也在等业务2获取锁。这就导致了死锁。
-
可重试性
如果一个线程获取锁失败,当前点代码是没办法重新尝试获取的。
-
超时释放
如果一个业务需要一段时间,不应该让他的锁释放。
-
主从一致: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
Redission
上述问题Redission
都解决了
配置
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
//单节点模式
config.useSingleServer().setAddress("redis://localhost:port")
.setPassword("password");
return Redisson.create(config);
}
}
获取锁
@Resource
private RedissionClient redissonClient;
void testRedisson() throws Exception{
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断获取锁成功
if(isLock){
try{
System.out.println("执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
底层实现,写死的Lua
脚本
KEYS[1] : 锁名称
ARGV[1]: 锁失效时间
**ARGV[2]**: id + ":" + threadId; 锁的小key
return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " + // 是否存在key
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " + //不存在, 创建&value 自增 1
"redis.call('pexpire', KEYS[1], ARGV[1]); " +// 重置有效期
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +// 是不是第二次来
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +//是, value + 1
"redis.call('pexpire', KEYS[1], ARGV[1]); " + // 重置有效期
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);", // 没有获取成功
释放锁
ARGV[2] = 锁失效时间
**ARGV[3]**: id + ":" + threadId; 锁的小key
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + //是否存在
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
-
可重入性
Redission
是用Hsah
来存的key
filed
ThreadIdvalue
该线程获取锁的次数 -
可重试性
开始追源码 :nerd_face:
watchdog
机制,如果没有传的话,默认是30s,建议不要传,后边会说为什么不建议RedissionLock
class如果没有传
leaseTime
的话会有一个默认的值,是30s继续进入方法,就是获取锁的具体实现
从上边可以看到如果获取成功返回的是null,如果失败是有值的
上边可以看到,如果ttl是null,说明获取成功了,直接返回true
current是进入获取锁之前的时间,time是传入的等待时间。
那么上边的代码的意思就是减去第一次获取消耗的时间,如果剩余等待时间变负数了,直接返回
false
。current继续赋值为当前时间
下边的意思释放锁的时候有
publish
消息,我们这个地方可订阅
然后又是看剩余等待时间用完了没,
没有的话去拿锁,tll = null
说明拿锁成功了,否则就继续判断剩余等待时间
看下图,如果ttl < time
,说明不需要等待time,ttl时超时释放的时间。
-
超时释放
获取锁成功后,会调用一个
scheduleExpirationRenewal
方法
ConcurrentHashMap
如果有当前的entryName
就不会加入,返回的是旧的entry
如果没有当前的entryName
就会加入,返回的是null
也就是说这个返回的一直是第一个entry
如果oldEntry != NULL
说明是第二次来
如果oldEntry == NULL
说明是第一次来
都会把线程id加入entry
oldEntry == NULL
调用renewExpiration
重置有效时间。
getName()返回的是id + name
,name
是我们传入的锁的名字
每隔internalLockLeaseTime / 3
的时间刷新一次,这个时间是看门狗,默认30s
释放锁的时候会调用cancelExpirationRenewal
删除entry
-
主从一致
调用的是
RedissonMultiLock
如果有三台
Redis
服务器,会同时向这三个服务器申请锁,就算有一个(或者可以设置最大限制)没有申请成功,就不会获得锁。
关注一下公众号呗!!!
本文由 mdnice 多平台发布