Redis 系列06--Redis 分布式锁专题

本文探讨了如何在用户类中实现唯一名称约束,初始采用数据库唯一索引,但遇到分布式部署时转而使用Redisson分布式锁。文章详细介绍了加锁、守护线程延时以及Redisson MultiLock在高可用场景的应用,最终通过Redisson简化并发控制并解决数据一致性问题。
摘要由CSDN通过智能技术生成

上一篇: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主节点同时宕机

等等

针对这种情况:凉拌

其实这就是告诉我们

  • 线程可能随时随地的阻塞在任何位置
  • 代码永远都是不完美的,我们所做的一起都是为了尽量避免出现问题,而不能保证毫无问题

以下是使用Spring Redis实现Redis分布式锁的例子: 首先,在pom.xml文件中添加以下依赖: ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> ``` 然后,创建一个Redis分布式锁的接口: ```java public interface RedisLock { /** * 获取锁 * * @param key 锁的key * @param expireTime 锁的过期时间 * @return 是否获取成功 */ boolean lock(String key, long expireTime); /** * 释放锁 * * @param key 锁的key */ void unlock(String key); } ``` 接着,实现Redis分布式锁的接口: ```java @Component public class RedisLockImpl implements RedisLock { private final RedisTemplate<String, String> redisTemplate; private final ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>(); @Autowired public RedisLockImpl(RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public boolean lock(String key, long expireTime) { Map<String, Integer> localLockers = getLocalLockers(); Integer count = localLockers.get(key); if (count != null) { localLockers.put(key, count + 1); return true; } Boolean result = redisTemplate.opsForValue().setIfAbsent(key, ""); if (!result) { return false; } redisTemplate.expire(key, expireTime, TimeUnit.MILLISECONDS); localLockers.put(key, 1); return true; } @Override public void unlock(String key) { Map<String, Integer> localLockers = getLocalLockers(); Integer count = localLockers.get(key); if (count == null) { return; } if (count > 1) { localLockers.put(key, count - 1); } else { localLockers.remove(key); redisTemplate.delete(key); } } private Map<String, Integer> getLocalLockers() { Map<String, Integer> localLockers = lockers.get(); if (localLockers != null) { return localLockers; } lockers.set(new HashMap<>()); return lockers.get(); } } ``` 最后,在需要使用分布式锁的地方,注入RedisLock接口,使用lock()方法获取锁,使用unlock()方法释放锁: ```java @Service public class UserService { private final RedisLock redisLock; @Autowired public UserService(RedisLock redisLock) { this.redisLock = redisLock; } public void updateUser(String userId) { String lockKey = "user_" + userId; boolean locked = redisLock.lock(lockKey, 5000); if (!locked) { throw new RuntimeException("获取锁失败"); } try { // 更新用户信息 } finally { redisLock.unlock(lockKey); } } } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值