1. 问题
有一个 User 类,name字段需要唯一
public class User {
public int id;
public String name;//需要整保证唯一
public int age;
//get set 省略
}
最初的做法:利用数据库唯一约束(这种方法不详细介绍)
但如果 User 类中再加个字段 dr,表示数据是否删除,如下:
public class User {
public int id;
public String name;//需要整保证唯一
public int age;
public int dr;// 1:表示一删除,0:表示未删除
//get set 省略
}
这时候数据库唯一索引就不能用了,想让 name 保持唯一,就只能在代码中加锁,比如:
public void addUser(User user){
//1. 根据用户名查询User对象
User oldUser = getUserByName(user.getName());
if(oldUser == null){
//2. 对用户名进行加锁
synchronized (user.getName().intern()){
//3. 再次根据用户名查询User对象
oldUser = getUserByName(user.getName());
if(oldUser == null){
//4. 保存用户
String sql = "insert into user(id,name,age) values(?,?,?)";
jdbcTemplate.update(sql, user.getId(), user.getName(), user.getAge());
}else{
throw new IllegalArgumentException("用户名已经存在");
}
}
}else{
throw new IllegalArgumentException("用户名已经存在");
}
}
上面的代码用 synchronized 对 用户名 加锁,这样能保证多线程调用 addUser 方法的时,用户名不重复
但是这只能对单机部署有效,如果你的服务是集群部署,如下图:
![](https://i-blog.csdnimg.cn/blog_migrate/fb9618ecc46ec38b34add432a24b1344.png)
上图中
用户1 和 用户2,同时发起注册用户的请求,申请的用户名一样
后端是集群,服务1 处理 用户1 的请求,服务2 处理 用户2 的请求,这样就算我们在代码用使用了**synchronized **,用户名还是会重复存储
2. 方案
使用 synchronized 并不能解决,集群下的数据安全问题,这时候就应该考虑分布式锁,比如:
![](https://i-blog.csdnimg.cn/blog_migrate/ae3471ec456ac34cf394e47bff61c0d0.png)
上图中,服务1和服务2不再使用 synchronized 而是去Redis中set key,这样只有一个服务能够设置成功
原因:
Redis 中的 setnx 命令:当指定 key 不存在的时候,才能执行成功
Redis 服务端对应客户端发送的命令是同步执行的
也就是说:如果同时有两个人执行 setnx test haha,只有一个会成功
这样就能保证集群下的数据安全问题,具体流程:
![](https://i-blog.csdnimg.cn/blog_migrate/42f1d67fd474f242c8ec144f6afb3538.png)
上图,如果用户名不存在,就加分布式锁,加锁成功后才能保存到数据库
public void addUser(User user){
//1. 根据用户名查询User对象
User oldUser = getUserByName(user.getName());
if(oldUser == null){
//2. 如果用户名不存在,加分布式锁
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(user.getName() + "_userName_lock", "lock", 60, TimeUnit.SECONDS);
if(lock){
Thread.sleep(10 * 1000);//加锁成功后休眠10秒
String sql = "insert into user(id,name,age) values(?,?,?)";
jdbcTemplate.update(sql, user.getId(), user.getName(), user.getAge());
//3. 释放锁,解锁的代码要写到finally中
stringRedisTemplate.delete(user.getName() + "_userName_lock");
}else{
//4. 加锁失败
throw new IllegalArgumentException("服务器繁忙,请稍后再试!");
}
}else{
throw new IllegalArgumentException("用户名已经存在!");
}
}
private User getUserByName(String name) {
List<User> list = jdbcTemplate.query("select * from user where name= ?",
new Object[]{name}, new BeanPropertyRowMapper<>(User.class));
if(CollectionUtils.isEmpty(list)){
return null;
}
return list.get(0);
}
Controller
@GetMapping("/save")
public String save(@RequestParam int id, @RequestParam String name, @RequestParam int age){
try{
User user = new User();
user.setId(id);
user.setName(name);
user.setAge(age);
userService.addUser(user);
}catch (Exception e){
return e.getMessage();
}
return "success";
}
结果:
第一个页签,加锁成功
![](https://i-blog.csdnimg.cn/blog_migrate/6d7031838fa1a136b49d34bf5d9d7e76.png)
第二个页签,加锁失败
![](https://i-blog.csdnimg.cn/blog_migrate/6c74abf59968b98d788509740f17fbbd.png)
3. 完善
但是,上面代码在特殊情况下还会出现问题,比如:
![](https://i-blog.csdnimg.cn/blog_migrate/d1c1470acff7c4b49a532205da21a8fb.png)
如果代码按照上图中的顺序执行,还是会产生数据重复的问题
服务 1,加锁成功后,保存数据库,但是线程阻塞(或者是处理业务的时间比较长),数据还未存到数据库,锁就过期了
这时服务 2,接到了一个请求,加锁成功,保存数据库
然后服务 1,继续运行,导致数据重复
解决:为分布式锁延期,加锁成功后,新建一个守护线程,监控锁的过期时间,在快要过期的时候,给锁延期
![](https://i-blog.csdnimg.cn/blog_migrate/eb21b38866b3f5ffbc67ceef28071ddb.png)
代码:
public void addUser(User user){
//1. 根据用户名查询User对象
User oldUser = getUserByName(user.getName());
if(oldUser == null){
//2. 如果用户名不存在,加分布式锁
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(user.getName() + "_userName_lock", "lock", 20, TimeUnit.SECONDS);
if(lock){
//3. 新开一个守护线程,为分布式锁延期
Thread t = new Thread(() -> {
try{
while (true){
//每5秒查看一下锁的过期时间,如果小于10秒,延期
Thread.sleep(5 * 1000);
Long expire = stringRedisTemplate.getExpire(user.getName() + "_userName_lock");
if(expire == -2){//表示key不存在
break;
}
if(expire < 10){
//重新设置失效时间
stringRedisTemplate.expire(user.getName() + "_userName_lock", 20, TimeUnit.SECONDS);
}
}
}catch (Exception e){}
});
t.setDaemon(true);//当主线程结束后,守护线程自动结束
t.start();
String sql = "insert into user(id,name,age) values(?,?,?)";
jdbcTemplate.update(sql, user.getId(), user.getName(), user.getAge());
//3. 释放锁,解锁的代码要写到finally中
stringRedisTemplate.delete(user.getName() + "_userName_lock");
}else{
//4. 加锁失败
throw new IllegalArgumentException("服务器繁忙,请稍后再试!");
}
}else{
throw new IllegalArgumentException("用户名已经存在!");
}
问题1:为什么使用守护线程?
不一定非得使用守护线程,只要你能保证给锁延期的线程可以正常结束就行
问题2:为什么要设置过期时间?
为了避免出现特殊情况后,锁一直留在redis中,比如:断电、删除锁失败
4. 插入一个网上的说法
其实上面的代码在极端情况下也是可能出现问题的,比如:
假如有 2 个线程A、B,一前一后执行上述的代码
A 线程正常执行,但是执行业务时间比较长,而且守护线程阻塞或者延期失败,导致锁自动过期
这时候 B 线程过来加锁,然后 A 线程继续执行,最后释放锁
但其实 A 线程加的锁已经自动过期了,它释放的是B线程加的锁
针对这种情况,网上有种说法:
在执行 setIfAbsent 方法时,给key设置一个唯一值(那么 2 个线程设置的值是不同的)
在释放锁时,先判断value对不对(就是判断这个锁是不是自己加的)
但是本人一直对这种说法持质疑态度,我们加锁的目的:避免多个线程同时执行这段代码
但上面的说法明显是支持多线程同时执行,违背了我们加锁的初衷,而且也没有意义
5. Redisson(了解)
上面的代码经过完善后,可以看出已经很复杂了,为了解决这个问题,我们引入 Redisson
Redisson 也是 Redis 的一个客户端,宗旨是促进使用者对Redis的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上,不过目前只是演示加锁功能。
它提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。
如果一个锁的有效期是30秒,那么看门狗会在过期时间剩余20秒的时候,重新延期至30秒。
锁的有效期通过修改 Config.lockWatchdogTimeout 指定。
官方地址:https://github.com/redisson/redisson/wiki
1. Maven 依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.0</version>
</dependency>
2. 代码
配置类
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 添加redis地址
config.useSingleServer().setAddress("redis://192.168.56.103:6379");
// 设置锁的超时时间,单位:毫秒
// 我们设置的是20秒,watchdog会在过期时间剩余14秒的时候,延期
config.setLockWatchdogTimeout(20000);
// 创建 RedissonClient 对象
return Redisson.create(config);
}
Service
@Autowired
private RedissonClient redissonClient;
public void addUser(User user) throws InterruptedException {
//1. 根据用户名查询User对象
User oldUser = getUserByName(user.getName());
if(oldUser == null){
//2. 如果用户名不存在,加分布式锁
RLock lock = redissonClient.getLock(user.getName() + "_userName_lock");
// 尝试获取锁,3个参数分别是:获取锁的最大等待时间(期间会重试),锁的过期时间,时间单位
// boolean locked = lock.tryLock(1,20, TimeUnit.SECONDS);
boolean locked = lock.tryLock();
if(locked){
Thread.sleep(20000);//模拟业务阻塞
String sql = "insert into user(id,name,age) values(?,?,?)";
jdbcTemplate.update(sql, user.getId(), user.getName(), user.getAge());
//3. 释放锁,一般放在 finally 中
lock.unlock();
}else{
//4. 加锁失败
throw new IllegalArgumentException("服务器繁忙,请稍后再试!");
}
}else{
throw new IllegalArgumentException("用户名已经存在");
}
}
结果:
![](https://i-blog.csdnimg.cn/blog_migrate/72173272e71f1bc3a419cdfb3088ed43.png)
注意,想要:watchdog 生效,那么在加锁的时候不能给锁指定过期时间,
可以用
tryLock()
直接申请锁,不需要任何参数
tryLock(long waitTime, TimeUnit unit)
参数分别是:获取锁的最大等待时间(期间会重试),时间单位
3. RedissonMultiLock
截止目前我们的 redis 只有一个节点,但实际上 redis 往往是集群或主从模式
![](https://i-blog.csdnimg.cn/blog_migrate/3d2f7b53de050963c6a5fd8bcd59a302.png)
上图,主节点提供写,从节点提供读,主从之间会有数据同步,但是数据同步会有延迟,这就可能出现问题
首先向 主节点 中写数据(加锁),成功后,从节点同步
就在同步时,主节点宕机,数据同步未成功,
这时 Redis 有哨兵机制,会选择一个从节点作为新的主节点,但是它没有锁的数据
。。。。。。
Redisson 的 RedissonMultiLock,可以解决这个问题
![](https://i-blog.csdnimg.cn/blog_migrate/7902a9672f8b1bef7e3f9a143a4f5494.png)
同时往多个 主节点 加锁,这在Redisson中成为:联锁,这样只要不是所有主节点一起宕机,就没问题
代码,再加两个 RedisClient 配置
@Bean
public RedissonClient redissonClient2(){
// 配置类
Config config = new Config();
// 添加redis地址
config.useSingleServer().setAddress("redis://192.168.56.102:6379");
// 设置看门狗检查时间,单位:毫秒
config.setLockWatchdogTimeout(20000);
// 创建 RedissonClient 对象
return Redisson.create(config);
}
@Bean
public RedissonClient redissonClient3(){
// 配置类
Config config = new Config();
// 添加redis地址
config.useSingleServer().setAddress("redis://192.168.56.101:6379");
// 设置看门狗检查时间,单位:毫秒
config.setLockWatchdogTimeout(20000);
// 创建 RedissonClient 对象
return Redisson.create(config);
}
@Autowired
private RedissonClient redissonClient;
@Autowired
private RedissonClient redissonClient2;
@Autowired
private RedissonClient redissonClient3;
public void test() throws InterruptedException {
RLock lock1 = redissonClient.getLock("business_lock_key");
RLock lock2 = redissonClient2.getLock("business_lock_key");
RLock lock3 = redissonClient3.getLock("business_lock_key");
//创建联锁
RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2, lock3);
boolean locked = multiLock.tryLock();
//TODO 业务处理
//释放锁
multiLock.unlock();
}
6. 补充
上面的逻辑已经很完善了,但是,在极端的情况下,还是可能出现问题,比如
守护线程阻塞
watchDog 阻塞
多个redis主节点同时宕机
针对这种情况:凉拌
其实这就是告诉你们
线程可能随时随地的阻塞在任何位置
代码永远都是不完美的,我们所做的一起都是为了尽量避免出现问题,而不能保证毫无问题