上一篇:https://blog.csdn.net/fengxianaa/article/details/124389991
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 方法的时,用户名不重复
但是这只能对单机部署有效,如果你的服务是机器部署,如下图:
上图中
- 用户1 和 用户2,同时发起注册用户的请求,申请的用户名一样
- 后端是集群,
- 服务1 处理 用户1 的请求
- 服务2 处理 用户2 的请求
- 这样就算我们在代码用使用了synchronized ,用户名还是会重复存储
2. 解决方案
解决上述问题,就需要使用分布式锁
上图,如果用户名不存在,就加分布式锁,加锁成功后才能保存到数据库
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){
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";
}
结果:
加锁成功
加锁失败
3. 完善
但是,上面代码在特殊情况下还会出现问题
如果代码按照上图中的顺序执行,还是会产生数据重复的问题
- 服务 1,加锁成功后,保存数据库,但是线程阻塞(或者是处理业务的时间比较长),数据还未存到数据库,锁就过期了
- 这时服务 2,接到了一个请求,加锁成功,保存数据库
- 然后服务 1,继续运行,导致数据重复
解决:为分布式锁延期,加锁成功后,新建一个守护线程,监控锁的过期时间,在快要过期的时候,给锁延期
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("用户名已经存在!");
}
4. 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("用户名已经存在");
}
}
注意:上面代码有两个 tryLock 方法,一个带参数,一个不带参数
想要 watchdog 生效,那么在加锁的时候不能给锁指定过期时间
可以用下面的方法:
- tryLock()
- 直接申请锁,不需要任何参数
- tryLock(long waitTime, TimeUnit unit)
- 参数分别是:获取锁的最大等待时间(期间会重试),时间单位
结果:
3. RedissonMultiLock
截止目前我们的 redis 只有一个节点,实际上 redis 往往是集群或主从模式
问题:上图中,主节点提供写,从节点提供读,主从之间会有数据同步,但是数据同步会有延迟,这就可能出现问题
- 首先向 主节点 中写数据(加锁),成功后,从节点同步
- 就在同步时,主节点宕机,数据同步未成功
- 这时 Redis 有哨兵机制,会选择一个从节点作为新的主节点,但是它没有锁的数据
- 这就导致 redis 相当于没有锁
Redisson 的 RedissonMultiLock,可以解决这个问题
同时往多个 主节点 加锁,这在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();
}
5. 补充
上面的逻辑已经很完善了,但是,在极端的情况下,还是可能出现问题,比如
- 守护线程阻塞
- watchDog 阻塞
- 多个redis主节点同时宕机
等等
针对这种情况:凉拌
其实这就是告诉我们
- 线程可能随时随地的阻塞在任何位置
- 代码永远都是不完美的,我们所做的一起都是为了尽量避免出现问题,而不能保证毫无问题