Redis分布式锁专题

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. 方案

使用 synchronized 并不能解决,集群下的数据安全问题,这时候就应该考虑分布式锁,比如:

上图中,服务1和服务2不再使用 synchronized 而是去Redis中set key,这样只有一个服务能够设置成功

原因:

  • Redis 中的 setnx 命令:当指定 key 不存在的时候,才能执行成功

  • Redis 服务端对应客户端发送的命令是同步执行的

  • 也就是说:如果同时有两个人执行 setnx test haha,只有一个会成功

这样就能保证集群下的数据安全问题,具体流程:

上图,如果用户名不存在,就加分布式锁,加锁成功后才能保存到数据库

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";
}

结果:

第一个页签,加锁成功

第二个页签,加锁失败

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("用户名已经存在!");
    }

问题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("用户名已经存在");
    }
}

结果:

注意,想要:watchdog 生效,那么在加锁的时候不能给锁指定过期时间,

可以用

  • tryLock()

  • 直接申请锁,不需要任何参数

  • tryLock(long waitTime, TimeUnit unit)

  • 参数分别是:获取锁的最大等待时间(期间会重试),时间单位

3. RedissonMultiLock

截止目前我们的 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();
}

6. 补充

上面的逻辑已经很完善了,但是,在极端的情况下,还是可能出现问题,比如

  • 守护线程阻塞

  • watchDog 阻塞

  • 多个redis主节点同时宕机

针对这种情况:凉拌

其实这就是告诉你们

  • 线程可能随时随地的阻塞在任何位置

  • 代码永远都是不完美的,我们所做的一起都是为了尽量避免出现问题,而不能保证毫无问题

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值