一、高并发下缓存失效问题
1、缓存穿透
说明:指查询一个一定不存在的数据,由于缓存数据不存在,需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。
风险:利用不存在的数据进行攻击,数据库瞬间压力增大,最终导致崩溃
解决:
2、缓存雪崩
说明:当缓存服务器重启或者大量缓存集中在某一时间段失效,发生大量的缓存穿透,所有的查询都集中在数据库上,数据库瞬间压力过重雪崩。
解决:
3、缓存击穿
说明:对于设置了过期时间的 key 在大量请求同时进来前正好失效,那么所有对这个 key 的数据查询都集中在数据库中,称之为缓存击穿。
解决:
4、本地锁
说明:本地锁也就是使用
synchronized
或者使用JUC
中的Lock
对代码进行加锁。示例:假如一个商品服务部署到8台服务器上,当8w请求同时进来,由于负载均衡机制,8w请求被平均分到8台服务器,this为当前实例对象,由于SpringBoot所有的组件都在容器中都是单例的,一个项目一个容器,8台服务器相当于有8个容器,每一个this代表当前实例的对象,也就是每一个this都是不同的锁,最终也就是加了8把锁,因此,在分布式下有几台机器,就会有几台机器的线程进来,相当于有8个线程同时去查数据库。
缺点:本地锁只能锁住当前进程,分布式下锁不住所有的服务。
实例代码:
@Override
public Map<String, List<User>> getUserJson(){
//从缓存中获取数据
String jsonString = stringRedisTemplate.opsForValue().get("list");
//判断是否为空,为空就入缓存
if (StringUtils.isEmpty(jsonString)) {
log.info("缓存不命中,查询数据库......");
Map<String, List<User>> userJsonFromDb = getUserJsonFromDb();
return userJsonFromDb;
}
log.info("缓存命中,直接返回......");
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
/**
* 从数据库获取分类数据
* @return
*/
public Map<String, List<User>> getUserJsonFromDb() {
synchronized (this) {
//拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
String jsonString = stringRedisTemplate.opsForValue().get("list");
//缓存不为空,直接返回数据
if (!StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
log.info("查询了数据库......");
//查询所有用户数据
List<User> list = this.list();
Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
//将信息放入缓存中
stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
return listMap;
}
}
结果分析:
通过Jmter模拟大量请求访问,1和2为两个线程,此时线程1进入getUserJsonFromDb方法获得锁,发现没有缓存信息,就查询数据库,并将查询到的结果放入缓存中,这一系列动作都是原子操作的,线程2只能等待线程1解锁才能向下执行,因此“查询了数据库…”会被打印一次,本地锁实现完成。
二、分布式锁简单实现
1、概述
定义:在分布式系统下,在高并发的场景下,我们为了
协调资源不被随意修改而做的对系统共享资源的保护,保证数据正确性。
示例:用户下单后减库存,当用户A对商品A下单并创建了新的订单,库存系统同步去减库存,若此时用户B也对商品A进行下单,但是在分布式系统下用户A和用户B可能请求的是不同的服务器,那么B此时拿到的库存数量可能还是之前的数量,这就可能导致超卖的情况发生。
2、实现原理
示例:还是拿8个商品服务举例,本地锁的情况下还是会查8次数据库,但是在使用分布式锁后,就只会有一个拿到锁并执行业务逻辑,其他的就必须原地等待,直到锁的释放。
利用redis的setnx、expire、getset、del这4个命令对应的提供的API函数接口实现:
1、
setnx
:是『SET if Not Exists』(如果不存在,则 SET)的简写
命令格式:SETNX key value
使用:只在键 key 不存在的情况下,将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作。
返回值:命令在设置成功时返回 1 ,设置失败时返回 0 。
2、
getset
命令格式:GETSET key value
使用:将键 key 的值设为 value ,并返回键 key 在被设置之前的旧的value。
返回值:如果键 key 没有旧值, 也即是说, 键 key 在被设置之前并不存在, 那么命令返回 nil 。当键 key 存在但不是字符串类型时,命令返回一个错误。
3、
expire
命令格式:EXPIRE key seconds
使用:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。
返回值:设置成功返回 1 。 当 key 不存在或者不能为 key 设置生存时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的生存时间),返回 0 。
4、
del
3、分布式锁实现一
向 Redis 中添加一个 lockKey 锁标志位,如果添加成功则能够继续向下执行业务操作,最后再释放此标志位
代码实现:
@Override
public Map<String, List<User>> getUserJson(){
//从缓存中获取数据
String jsonString = stringRedisTemplate.opsForValue().get("list");
//判断是否为空,为空就查库入缓存
if (StringUtils.isEmpty(jsonString)) {
log.info("缓存不命中,查询数据库......");
Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
return userJsonFromDb;
}
log.info("缓存命中,直接返回......");
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
/**
* 使用分布式锁方式一
* @return
*/
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
//设置锁标志位
String lockKey = "lock";
//占分布式锁,向redis添加一个锁标志位
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1111");
if (lock) {
//拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
String jsonString = stringRedisTemplate.opsForValue().get("list");
//缓存不为空,直接返回数据
if (!StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
log.info("查询了数据库......");
//查询所有用户
List<User> list = this.list();
Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
//入缓存
stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
//执行完业务删除锁
stringRedisTemplate.delete(lockKey);
return listMap;
} else {
//加锁失败重试,可以设置休眠时间,避免频繁执行
return getUserJsonFromDbWithRedisLock();//自旋的方式
}
}
缺陷:
锁标志位设置成功后,如果业务代码异常或者程序在页面过程中宕机,没有执行到删除锁的逻辑,就会造成死锁
。
解决办法:
设置锁的自动过期,即使没有删除锁,锁也会自动过期。
4、分布式锁实现二
向 Redis 中添加一个 lockKey 锁标志位,并且设置自动过期时间
代码实现:
@Override
public Map<String, List<User>> getUserJson(){
//从缓存中获取数据
String jsonString = stringRedisTemplate.opsForValue().get("list");
//判断是否为空,为空就查库入缓存
if (StringUtils.isEmpty(jsonString)) {
log.info("缓存不命中,查询数据库......");
Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
return userJsonFromDb;
}
log.info("缓存命中,直接返回......");
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
/**
* 使用分布式锁方式二
* @return
*/
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
//设置锁标志位
String lockKey = "lock";
//占分布式锁,向redis添加一个锁标志位
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1111");
if (lock) {
//设置锁的过期时间30s,此处可能会出现还没有设置过期时间的情况下,服务已经宕机了,还是会发生死锁
stringRedisTemplate.expire(lockKey,30,TimeUnit.SECONDS);
//拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
String jsonString = stringRedisTemplate.opsForValue().get("list");
//缓存不为空,直接返回数据
if (!StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
log.info("查询了数据库......");
//查询所有用户
List<User> list = this.list();
Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
//入缓存
stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
//执行完业务删除锁
stringRedisTemplate.delete(lockKey);
return listMap;
} else {
//加锁失败重试,可以设置休眠时间,避免频繁执行
return getUserJsonFromDbWithRedisLock();//自旋的方式
}
}
缺陷:
锁标志位设置成功后,正要去设置过期时间,这时,恰好遇到程序宕机,又出现死锁
。
解决方法:
这是因为设置锁标志位和设置过期时间不是一个原子性
,要么一起成功或一起失败。
5、分布式锁实现三(原子锁)
从redis2.6.12版开始,redis为set命令增加(set [key] NX/XX EX/PX [expiration])
EX(seconds):设置key的过期时间,单位是秒
PX(milliseconds):设置key的过期时间,单位是毫秒
NX:只有key不存在的时候才会设置key的值
XX:只有key存在的时候才会设置key的值
# 加锁 SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
代码实现:
@Override
public Map<String, List<User>> getUserJson(){
//从缓存中获取数据
String jsonString = stringRedisTemplate.opsForValue().get("list");
//判断是否为空,为空就查库入缓存
if (StringUtils.isEmpty(jsonString)) {
log.info("缓存不命中,查询数据库......");
Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
return userJsonFromDb;
}
log.info("缓存命中,直接返回......");
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
/**
* 使用分布式锁方式三
* @return
*/
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
//设置锁标志位
String lockKey = "lock";
//占分布式锁,向redis添加一个锁标志位,并设置自动过期时间,保证添加标志位和设置过期时间是一个原子性
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1111",60,TimeUnit.SECONDS);
if (lock) {
//拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
String jsonString = stringRedisTemplate.opsForValue().get("list");
//缓存不为空,直接返回数据
if (!StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
log.info("查询了数据库......");
//查询所有用户
List<User> list = this.list();
Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
//入缓存
stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
//执行完业务删除锁
stringRedisTemplate.delete(lockKey);
return listMap;
} else {
//加锁失败重试,可以设置休眠时间,避免频繁执行
return getUserJsonFromDbWithRedisLock();//自旋的方式
}
}
缺陷:
加锁以及设置过期时间确实保证了原子性;假如有两个线程进来了,线程A加锁成功,线程B等待,此时,线程A执行的业务逻辑时间很长,超过了锁的过期时间,这时锁标志位过期释放了,线程B就设置锁成功,然而此时线程A业务执行完成后,执行释放锁代码,顺手把线程B持有的锁释放了。
解决方法:
加锁的时候给锁加一个唯一标识身份的值,每个线程只能释放和自己匹配的锁。
6、分布式锁实现四
向 Redis 中添加一个 lockKey 锁标志位,设置自动过期时间,并设置唯一身份id
代码实现:
@Override
public Map<String, List<User>> getUserJson(){
//从缓存中获取数据
String jsonString = stringRedisTemplate.opsForValue().get("list");
//判断是否为空,为空就查库入缓存
if (StringUtils.isEmpty(jsonString)) {
log.info("缓存不命中,查询数据库......");
Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
return userJsonFromDb;
}
log.info("缓存命中,直接返回......");
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
/**
* 使用分布式锁方式四
* @return
*/
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
//设置锁标志位
String lockKey = "lock";
//设置唯一身份id
String lockValue = UUID.randomUUID().toString();
//占分布式锁,向redis添加一个锁标志位,并设置自动过期时间与唯一身份id,保证添加标志位和设置过期时间是一个原子性
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,60,TimeUnit.SECONDS);
if (lock) {
//拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
String jsonString = stringRedisTemplate.opsForValue().get("list");
//缓存不为空,直接返回数据
if (!StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
log.info("查询了数据库......");
//查询所有用户
List<User> list = this.list();
Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
//入缓存
stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
//判断是否是同一身份id,但是此处会出现问题
if (Objects.equals(stringRedisTemplate.opsForValue().get(lockKey),lockValue)) {
//执行完业务删除锁
stringRedisTemplate.delete(lockKey);
}
return listMap;
} else {
//加锁失败重试,可以设置休眠时间,避免频繁执行
return getUserJsonFromDbWithRedisLock();//自旋的方式
}
}
缺陷:
加锁时保证了原子性,但是在解锁的时候,判断身份和删除锁并不是原子操作的,所以还会存在误删。假如有两个线程进来了,线程A执行到判断用户身份成功,此时刚好线程A获得锁的时间过期,删除锁逻辑出现延迟,线程B立即获得锁,由于身份确认了,线程A继续执行删除锁操作,就会释放了线程B的锁(误删)。
解决方法:
解决这种非原子操作的方式只能将判断元素值和删除标志位当作一个原子操作,使用Lua
语言编写脚本传到Redis中执行。
7、分布式锁实现五(使用Lua)
由于del操作并没有提供原子命令,因此会出现误删;但是在Redis 2.6 推出了脚本功能, 允许开发者使用 Lua 语言编写脚本传到 Redis 中执行,很好的解决了del操作非原子问题。
使用 Lua 脚本的好处:
代码实现:
@Override
public Map<String, List<User>> getUserJson(){
//从缓存中获取数据
String jsonString = stringRedisTemplate.opsForValue().get("list");
//判断是否为空,为空就查库入缓存
if (StringUtils.isEmpty(jsonString)) {
log.info("缓存不命中,查询数据库......");
Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
return userJsonFromDb;
}
log.info("缓存命中,直接返回......");
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
/**
* 使用分布式锁方式五
* @return
*/
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
//设置锁标志位
String lockKey = "lock";
//设置唯一身份id
String lockValue = UUID.randomUUID().toString();
//占分布式锁,向redis添加一个锁标志位,并设置自动过期时间与唯一身份id,保证添加标志位和设置过期时间是一个原子性
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,60,TimeUnit.SECONDS);
if (lock) {
log.info("获取分布式锁成功......");
//拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
String jsonString = stringRedisTemplate.opsForValue().get("list");
//缓存不为空,直接返回数据
if (!StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
log.info("查询了数据库......");
//查询所有用户
List<User> list = this.list();
Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
//入缓存
stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
//使用Lua脚本解锁
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
//执行脚本
Long execute = stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(lockKey), lockValue);
return listMap;
} else {
log.info("获取分布式锁失败,重试......");
//加锁失败重试,可以设置休眠时间,避免频繁执行
return getUserJsonFromDbWithRedisLock();//自旋的方式
}
}
总结:在加锁和删除锁的时候都保证了原子性,分布式锁实现完成。
8、抽取分布式锁工具类
package com.itan.lock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* @Description: 分布式锁工具类
*/
@Slf4j
public class RedisLock {
private RedisTemplate<String, Object> redisTemplate;
/**
* 默认锁的有效时间(s)
*/
public static final int EXPIRE = 60;
/**
* 默认请求锁的超时时间(ms 毫秒)
*/
private static final long TIME_OUT = 0;
/**
* 默认请求间隔时间(ms 毫秒)
*/
private static final long WAIT_MILLI_SPER = 10;
/**
* 解锁的lua脚本
*/
public static final String UNLOCK_LUA;
static {
StringBuilder sb = new StringBuilder();
sb.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] ");
sb.append("then ");
sb.append(" return redis.call(\"del\",KEYS[1]) ");
sb.append("else ");
sb.append(" return 0 ");
sb.append("end ");
UNLOCK_LUA = sb.toString();
}
/**
* 锁标志对应的key
*/
private String lockKey;
/**
* 锁对应的值
*/
private String lockValue;
/**
* 锁的有效时间(s)
*/
private int expireTime = EXPIRE;
/**
* 请求锁的超时时间(ms)
*/
private long timeOut = TIME_OUT;
/**
* 使用默认的锁过期时间和请求锁的超时时间
* @param redisTemplate
* @param lockKey 锁的key(Redis的Key)
*/
public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey + "_lock";
}
/**
* 使用默认的请求锁的超时时间,指定锁的过期时间
* @param redisTemplate
* @param lockKey 锁的key(Redis的Key)
* @param expireTime 锁的过期时间(单位:秒)
*/
public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey, int expireTime) {
this(redisTemplate, lockKey);
this.expireTime = expireTime;
}
/**
* 使用默认的锁的过期时间,指定请求锁的超时时间
* @param redisTemplate
* @param lockKey 锁的key(Redis的Key)
* @param timeOut 请求锁的超时时间(单位:毫秒)
*/
public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey, long timeOut) {
this(redisTemplate, lockKey);
this.timeOut = timeOut;
}
/**
* 锁的过期时间和请求锁的超时时间都是用指定的值
* @param redisTemplate
* @param lockKey 锁的key(Redis的Key)
* @param expireTime 锁的过期时间(单位:秒)
* @param timeOut 请求锁的超时时间(单位:毫秒)
*/
public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey, int expireTime, long timeOut) {
this(redisTemplate, lockKey, expireTime);
this.timeOut = timeOut;
}
/**
* 尝试获取锁 超时返回
* @return
*/
public boolean tryLock() {
boolean result = setRedisLock(lockKey,expireTime);
long waitMax = timeOut;
long waitAlready = 0;
/**
* 获取失败,重试
*/
while (!result && waitAlready < waitMax ){
try {
Thread.sleep(WAIT_MILLI_SPER);
waitAlready += WAIT_MILLI_SPER;
}catch (Exception e){
result = false;
}
result = setRedisLock(lockKey,expireTime);
}
return result;
}
/**
* 设置锁
* @param key 锁的key(Redis的Key)
* @param expire 锁的过期时间(单位:秒)
* @return
*/
private boolean setRedisLock(String key, long expire) {
try {
RedisCallback<Boolean> callback = (connection) -> {
// 生成随机key
lockValue = UUID.randomUUID().toString();
return connection.set(key.getBytes(), lockValue.getBytes(), Expiration.seconds(expire), RedisStringCommands.SetOption.SET_IF_ABSENT);
};
return redisTemplate.execute(callback);
}catch (Exception e){
log.error("set redis error", e);
return false;
}
}
/**
* 解锁
*/
public Boolean unlock() {
RedisCallback<Boolean> callback = (connection) -> connection.eval(UNLOCK_LUA.getBytes(), ReturnType.BOOLEAN ,1, lockKey.getBytes(StandardCharsets.UTF_8), lockValue.getBytes(StandardCharsets.UTF_8));
return redisTemplate.execute(callback);
}
}
调用代码:
@Autowired
private RedisTemplate redisTemplate;
@Override
public Map<String, List<User>> getUserJson(){
//从缓存中获取数据
String jsonString = stringRedisTemplate.opsForValue().get("list");
//判断是否为空,为空就查库入缓存
if (StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
return userJsonFromDb;
}
log.info("缓存命中,直接返回......");
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
/**
* 使用分布式锁工具类
* @return
*/
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
//设置锁标志位
String lockKey = "lock";
RedisLock redisLock = new RedisLock(redisTemplate,lockKey,30);
try {
//获得锁
if (redisLock.tryLock()) {
//拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
String jsonString = stringRedisTemplate.opsForValue().get("list");
//缓存不为空,直接返回数据
if (!StringUtils.isEmpty(jsonString)) {
Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
return resultMap;
}
log.info("查询了数据库......");
//查询所有用户
List<User> list = this.list();
Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
//入缓存
stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
return listMap;
}
} finally {
//解锁
redisLock.unlock();
}
return null;
}
三、分布式锁Redisson
1、概述
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(
BitSet
,Set
,Multimap
,SortedSet
,Map
,List
,Queue
,BlockingQueue
,Deque
,BlockingDeque
,Semaphore
,Lock
,AtomicLong
,CountDownLatch
,Publish / Subscribe
,Bloom filter
,Remote service
,Spring cache
,Executor service
,Live Object service
,Scheduler service
) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。中文文档
2、整合使用
1、导入Maven依赖
<!-- 使用redisson作为分布式锁,分布式对象等功能框架 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
2、Redisson配置类
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
/**
* @Author: yeyanbin
* @Date: 2021/1/29
* Redisson配置类
*/
@Configuration
public class RedissonConfig {
/**
* 所有对Redisson的使用都是通过RedissonClient对象
* @return
* @throws IOException
*/
@Bean(destroyMethod="shutdown")
public RedissonClient redisson() throws IOException {
Config config = new Config();
//单节点模式
SingleServerConfig singleServerConfig = config.useSingleServer();
//设置redis地址,"rediss://"来启用SSL安全连接,"redis://"普通连接
singleServerConfig.setAddress("redis://8.139.86.156:6379");
//设置redis密码,用于节点身份验证
singleServerConfig.setPassword("123456");
//设置数据库,默认为0
singleServerConfig.setDatabase(10);
return Redisson.create(config);
}
}
3、可重入锁(Reentrant Lock)
基于 Redis 的 Redisson 分布式可重入锁
RLock
,Java对象实现了java.util.concurrent.locks.Lock
接口。同时还提供了 异步(Async)、反射式(Reactive)和 RxJava2标准的接口。
Redisson - Lock 锁测试(不带超时时间):
@Slf4j
@RestController
public class WebController {
@Autowired
private RedissonClient redissonClient;
@RequestMapping("/test")
public String test(){
//获取锁,只要锁的名字一样就是同一把锁
RLock lock = redissonClient.getLock("my_lock");
//加锁,阻塞式等待,默认加的锁都是30s时间
lock.lock();
try {
System.out.println("加锁成功,执行业务...当前线程号:" + Thread.currentThread().getId());
//休眠15秒,模拟超时
Thread.sleep(15000);
} catch (Exception e) {
} finally {
System.out.println("解锁成功...当前线程号:" + Thread.currentThread().getId());
//解锁
lock.unlock();
}
return "test";
}
}
测试分析:
1、情景一启动测试访问接口:http://localhost:8093/test
结果:加锁成功
2、情景二启动测试多访问几次接口:http://localhost:8093/test
结果:当第一个在执行的时候,第二个请求在一直等待,说明是阻塞式锁,同时查看redis中缓存的信息,发现缓存的时间是30s,运行期间自动给锁续上新的30s。
3、情景三启动测试,复制一个启动类并设置启动端口号,模拟分布式,访问这两个上面的接口,运行期间,停掉其中的一个服务,模拟服务宕机
结果:当服务一宕机后,服务二接口调用一直等待,当宕机时间到达30s后,服务二的接口获取锁成功,并解锁成功。
结论:
Redisson - Lock 锁测试(带超时时间):
@Slf4j
@RestController
public class WebController {
@Autowired
private RedissonClient redissonClient;
@RequestMapping("/test")
public String test(){
//获取锁,只要锁的名字一样就是同一把锁
RLock lock = redissonClient.getLock("my_lock");
//设置超时时间10s,但是得注意自动解锁时间一定要大于业务的时间
lock.lock(10, TimeUnit.SECONDS);
try {
System.out.println("加锁成功,执行业务...当前线程号:" + Thread.currentThread().getId());
//休眠15秒,模拟超时
Thread.sleep(15000);
} catch (Exception e) {
} finally {
System.out.println("解锁成功...当前线程号:" + Thread.currentThread().getId());
//解锁
lock.unlock();
}
return "test";
}
}
测试分析:
1、启动测试访问接口:http://localhost:8093/test
结果:设置锁的时间为10s,但是业务执行时间超过10s,当超过10s,锁会自动过期,锁不会自动续期,解锁的时候抛出异常
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id
下载并进入源码分析原因:
1、点击
lock
进入RedissonLock
中lock
方法的实现
2、再次进入
lock
方法,发现调用了tryAcquire
方法
3、进入
tryAcquire
方法获取锁,发现里面调用tryAcquireAsync
方法,再进入tryAcquireAsync
方法
4、未设置超时时间,调用的
lock
方法会给leaseTime
设置默认值-1
5、由于设置了超时时间,执行步骤3中条件判断不等于
-1
的代码中,发现调用了tryLockInnerAsync
方法,进入tryLockInnerAsync
6、如果没有设置超时时间,就会跳过步骤3中条件判断不等于
-1
代码逻辑,向下执行代码
7、执行
tryLockInnerAsync
占锁,另外通过onComplete
方法进行监听,如果发生异常,也就是e!=null
直接返回,没有就会调用scheduleExpirationRenewal
方法,根据方法名知道这个与定时任务有关,进入这个调度方法
8、进入
renewExpiration
方法
总结:
4、读写锁(ReadWriteLock)
基于 Redis 的 Redisson 分布式可重入读写锁
RReadWriteLock
,Java对象实现了java.util.concurrent.locks.ReadWriteLock
接口。其中读锁和写锁都继承了RLock
接口。分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。我们都知道,如果负责储存这个分布式锁的 Redis 节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson 内部提供了一个监控锁的看门狗,它的作用是在 Redisson 实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改
Config.lockWatchdogTimeout
来另行指定。另外 Redisson 还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
读写锁示例:
@Slf4j
@RestController
public class WebController {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@ResponseBody
@RequestMapping("/write")
public String writeLock(){
//获取读写锁对象
RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
String s = "";
//获得写锁
RLock rLock = lock.writeLock();
try {
//改数据加写锁
rLock.lock();
s = UUID.randomUUID().toString();
Thread.sleep(30000);
stringRedisTemplate.opsForValue().set("writeValue",s);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//解锁
rLock.unlock();
}
return s;
}
@ResponseBody
@RequestMapping("/read")
public String readLock(){
//获取读写锁对象
RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
String s = "";
//获得读锁
RLock rLock = lock.readLock();
try {
//读数据加读锁
rLock.lock();
s = stringRedisTemplate.opsForValue().get("writeValue");
} catch (Exception e) {
e.printStackTrace();
} finally {
//解锁
rLock.unlock();
}
return s;
}
}
测试分析:
1、启动测试,并且向redis中存一个数据
writeValue="hello"
2、访问读数据接口
http://localhost:8093/read
,发现能正确读出数据3、当访问写数据接口
http://localhost:8093/write
,再访问读数据接口,发现读数据接口一直在等待,这是因为写数据接口中有一个休眠30s,只有当写数据业务都完成,读数据接口才会返回数据。并且redis
中有一个写锁占位。
几种模式:
读 + 读模式:相当于无锁,并发读,只会在redis中记录好所有当前的读锁,他们都会同时加锁成功。
写 + 读模式:需要等待写锁释放。
写 + 写模式:阻塞方式,需要等待上一个写锁释放,才能进行写操作。
读 + 写模式:有读锁,写锁也必须等待。
总结:
5、信号量(Semaphore)
基于 Redis 的 Redisson 的分布式信号量(
Semaphore
)Java对象RSemaphore
采用了与java.util.concurrent.Semaphore
相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
信号量示例:
@Slf4j
@RestController
public class WebController {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@ResponseBody
@RequestMapping("/reduce")
public String reduce() throws InterruptedException {
//获取一个信号量对象
RSemaphore count = redissonClient.getSemaphore("count");
//占位,传人几个表示占几个位,默认是一个
count.acquire(3);
//尝试占位,成功true,失败false
// boolean b = count.tryAcquire();
return "ok";
}
@ResponseBody
@RequestMapping("/add")
public String add() throws InterruptedException {
//获取一个信号量对象
RSemaphore count = redissonClient.getSemaphore("count");
//释放占位,默认释放一个,传入几个就释放几个
count.release(2);
return "ok";
}
}
测试分析:
1、启动测试,并且向redis中存一个数据
count=10
2、访问
reduce
接口,发现redis
中count
值减少了3
,表示占位成功。3、访问
add
接口,发现redis
中count
值增加了2
,表示释放成功。
6、闭锁(CountDownLatch)
基于Redisson的Redisson分布式闭锁(
CountDownLatch
)Java对象RCountDownLatch
采用了与java.util.concurrent.CountDownLatch
相似的接口和用法。
闭锁示例:
@Slf4j
@RestController
public class WebController {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@ResponseBody
@RequestMapping("/lockDoor")
public String lockDoor() throws InterruptedException {
//获取闭锁
RCountDownLatch door = redissonClient.getCountDownLatch("door");
//设置总数
door.trySetCount(5);
//等待闭锁都完成
door.await();
return "锁门成功...";
}
@ResponseBody
@RequestMapping("/go")
public String go() throws InterruptedException {
//获取闭锁
RCountDownLatch door = redissonClient.getCountDownLatch("door");
//计数减一
door.countDown();
return "下班了...";
}
}
测试分析:
1、启动测试,调用
lockDoor
接口发现一直等待,查看redis
中的值为5
。2、连续调用
5
次go
接口,发现lcokDoor
接口立马返回锁门成功...
7、缓存数据一致性问题
1、双写模式:更新完数据库,同时写入缓存
分析
:写完数据库再写缓存,假设有两个并发进来了,被负载到两台机器,线程A先执行了,将数据库中数据修改了,但是由于各种原因出现了延迟卡顿,这时线程B修改了数据库,同时修改了缓存,这时线程A又继续执行了写缓存的操作,这样就会造成数据不一致问题,出现了短暂性脏数据问题。
解决
:将写数据操作和写缓存操作放到锁里面执行。
2、失效模式:当更新完数据库后,删除相关的缓存
分析
:数据更新修改数据库,然后删缓存,假设有三个并发进来了,被负载到三台机器,线程A将数据中数据更改,然后删除缓存后,线程B进来后将数据也修改了,但是由于机器负载比较重,处理能力比较慢,出现延迟卡顿等问题,这时线程C读缓存发现没有数据,就去读数据库(读到的是线程A修改后的数据即老数据),这时如果线程C出现卡顿,刚好线程B执行了删缓存操作,然后线程C又更新了缓存(此时缓存中存放的数据是线程A修改后的数据即老数据),如果线程C没有出现卡顿,在线程B执行删缓存操作之前执行了就更新了数据,虽然是老数据,但是线程B会执行删缓存操作,缓存中就没有数据就不会出现脏数据问题。
解决
:可以加锁来解决
缺点
:虽然都能通过加锁的方式来解决,但是加锁以后系统就会显得笨重,但是如果是经常修改的数据,去加锁的话,就会变得非常的慢。
8、缓存数据一致性解决方案
无论是双写模式还是失效模式,都会到这缓存不一致的问题,即多个实例同时更新会出事,怎么办?
1、如果是用户维度数据(订单数据、用户数据),这并发几率很小,几乎不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
2、如果是一些基础数据,也可以不用考虑这个问题,如果考虑数据问题可以去使用 canal 订阅,binlog 的方式。Canel是阿里的一个开源中间件,可以模拟成数据库的从服务器,数据库中有数据更新,Canel就会同步更新数据,然后更新redis中数据。
3、缓存数据 + 过期时间也足够解决大部分业务对缓存的要求
4、通过加锁保证并发读写,写 + 写的时候按照顺序排好队,读 + 读无所谓,所以适合读写锁,(业务不关心脏数据,允许临时脏数据可忽略)。
总结:
1、能放入缓存的数据本来就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前的最新值即可。
2、不应该过度设计,增加系统的复杂性。
3、遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点也无所谓。
四、SpringCache整合Redis
1、说明
关于SpringCache的介绍请参考《SpringBoot与缓存》本篇是对《SpringBoot》的一个补充。
2、整合SpringCache简化缓存开发
1、导入Maven依赖
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--redis依赖commons-pool-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--使用SpringCache-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
2、配置yml
spring:
# 配置使用redis作为缓存
cache:
type: redis
redis:
# 指定缓存数据的存活时间(毫秒)
time-to-live: 6000
# 是否使用缓存前缀
use-key-prefix: true
# 设置缓存key的前缀,如果没有设置就默认使用缓存的名字作为前缀
key-prefix: CACHE_
# 是否缓存空值,防止缓存穿透
cache-null-values: true
3、启动类上加注解@EnableCaching
开启基于注解的缓存
4、配置类
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @Date: 2021/1/31
*/
@Configuration
//开启基于注解的缓存
@EnableCaching
//让CacheProperties类的绑定生效
@EnableConfigurationProperties(CacheProperties.class)
public class CacheRedisConfig {
/**
* 配置文件上的设置没有生效的原因:
* 1、原来和配置文件绑定的配置类是这样子的
* @ConfigurationProperties(prefix = "Spring.cache")
* 2、设置生效
* @EnableConfigurationProperties(CacheProperties.class)
* @param cacheProperties
* @return
*/
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 设置key的序列化
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
// 设置value序列化 ->JackSon
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
//获取所有redis的配置
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
5、使用请参考《SpringBoot与缓存》
3、不足之处
1、读模式:
缓存穿透:查询一个null数据,解决:缓存空数据:配置cache-null-values=true
缓存击穿:大量并发进来同时查询一个正好过期的数据,解决:加锁,默认是无加锁,可以使用
sync=true
控制并发读缓存雪崩:大量的key同时过期,解决:加上随机时间,配置time-to-live: 6000
2、写模式:
3、总结: