分布式锁和Redisson

为什么要用分布式锁

随着系统复杂度的升级,我们逐渐从单体应用拓展到分布式服务,单体应用中线程加锁已不能满足集群共享资源的控制。而分布式锁,就是解决了分布式系统中控制共享资源访问的问题。与单体应用不同的是,分布式系统中竞争共享资源的最小粒度从线程升级成了进程。

分布式锁应该具备的特性

  • 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
  • 高可用的获取锁与释放锁
  • 高性能的获取锁与释放锁
  • 具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误)
  • 具备锁失效机制,即自动解锁,防止死锁
  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

分布式锁的实现原理

分布式锁的实现原理主要是基于共享锁机制,即多个节点之间互相协作,共同持有一把锁,保证在同一时间只有一个节点可以访问该锁。当一个节点尝试获取锁时,其他节点会根据一定的规则来协商谁可以获取锁。如果一个节点被拒绝获取锁,那么其他节点就可以进入等待状态,直到该节点释放锁为止。

常见的分布式锁实现方式

常见的分布式锁的实现方式有:基于数据库实现分布式锁基于Zookeeper实现分布式锁基于reids实现分布式锁。

三种方式对比:

实现方式实现原理优点缺点
基于数据库1、利用数据库自身锁机制实现(例如排他锁)
2、基于唯一键实现
1、直接借助数据库,简单容易理解。
2、可靠性较高。(数据库团队保障)
1、操作数据库需要一定的开销,性能问题需要考虑。
2、强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
3、没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
4、只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
5、是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。
基于ZK基于ZK节点特性和watch机制实现性能好,稳定性高,较好的实现阻塞式锁1、需要手动维护ZooKeeper服务器
2、创建&销毁节点开销较大(由zk集群的Leader来执行,然后将数据同步到所有的Follower上)
3、因为可能存在网络抖动,客户端和ZK集群的session连接断了,zk集群以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了
基于Redis使用setnx,以及利用lua脚本控制命令的原子性适用于大规模的分布式系统,能够显著提高系统的性能。1、当节点数量增加时,Redis的存储空间会成为一个瓶颈。
2、实现相对复杂(但是有开源框架,哈哈)

基于数据库实现方式简介

1、基于独占锁

独占锁:也叫排它锁,Exclusive locks,简称x锁。事务改动一条记录时候,先要获取这个锁。For update 开启后另一个事务不可以读也不可以修改。

基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:

public void lock(){
    connection.setAutoCommit(false)
    int count = 0;
    while(count < 4){
        try{
            select * from lock where lock_name=xxx for update;
            if(结果不为空){
                //代表获取到锁
                return;
            }
        }catch(Exception e){
  
        }
        //为空或者抛异常的话都表示没有获取到锁
        sleep(1000);
        count++;
    }
    throw new LockException();
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。获得排它锁的线程即可获得分布式锁,当获得锁之后,可以执行方法的业务逻辑,执行完方法之后,释放锁connection.commit()。当某条记录被加上排他锁之后,其他线程无法获取排他锁并被阻塞。

2、基于数据库表的增删

基于数据库表增删是最简单的方式,首先创建一张锁的表主要包含下列字段:类的全路径名+方法名,时间戳等字段。

具体的使用方式:当需要锁住某个方法时,往该表中插入一条相关的记录。类的全路径名+方法名是有唯一性约束的,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。执行完毕之后,需要delete该记录。

基于ZK

基于zookeeper临时有序节点可以实现的分布式锁。每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。 (第三方库有 Curator,Curator提供的InterProcessMutex是分布式锁的实现。

基于Redis实现

命令简介

(1)setnx命令:set if not exists,当且仅当 key 不存在时,将 key 的值设为 value。若给定的 key 已经存在,则 SETNX 不做任何动作。

返回1,说明该进程获得锁,将 key 的值设为 value
返回0,说明其他进程已经获得了锁,进程不能进入临界区。
命令格式:setnx lock.key lock.value

(2)get命令:获取key的值,如果存在,则返回;如果不存在,则返回nil

命令格式:get lock.key

(3)getset命令:该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。

命令格式:getset lock.key newValue

(4)del命令:删除redis中指定的key

命令格式:del lock.key

简单实现

基于:命令 setnx

public boolean tryLock(long timeoutSec) {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

其中考虑的问题:

1、设置锁超时时间:解决了线程获取了锁之后,在执行任务的过程中挂掉,来不及显示地执行del命令释放锁,那么竞争该锁的线程都会执行不了,产生死锁的情况。

注:在命令层面 ,setnx和设置超时时间expire命令不是原子性的操作,假设某个线程执行setnx 命令,成功获得了锁,但是还没来得及执行expire 命令,服务器就挂掉了,这样一来,这把锁就没有设置过期时间了,变成了死锁,别的线程再也没有办法获得锁了。但是Redis的set命令支持同时设置超时时间和set if not exists。java代码层面无感知。
加锁示意图
2、持有锁的线程释放锁的时候,锁已经超时释放。对于有些业务复杂或者IO时间长,或者碰到了GC等,在任务执行完去释放锁的时候,当前线程A持有的锁已经超时释放了,而此时的锁由另一个线程B持有,这个时候线程A去释放锁,释放的将是B线程持有的锁。针对此种情况,我们就要给锁加一个标识,如上述代码片段中增加的threadId
超时释放异常
针对上述情况,我们引入了线程id作为锁标识,那么我们对锁的释放就要考虑到判断是否是当前线程持有的锁,再去释放,下面是简单实现:

public void unlock() {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 判断标示是否一致
    if(threadId.equals(id)) {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

3、原子性问题。上述代码可以看出get操作、判断和释放锁是两个独立操作,不是原子性。同样会产生锁误删问题。对于保证原子性我们可以接住lua脚本实现。代码片段如下:
Lua脚本:

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

完整代码:

public class SimpleRedisLock implements ILock {
 
    private String name;
    private StringRedisTemplate stringRedisTemplate;
 
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }
 
    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
 
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }
 
    @Override
    public void unlock() {
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
}

4、多线程同时持有公共资源的问题。虽然上述避免了线程A误删掉key的情况,但是同一时间有 A,B 两个线程在访问代码块,仍然是不完美的。怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续期”。

① 假设线程A执行了29 秒后还没执行完,这时候守护线程会执行 expire 指令,为这把锁续期 20 秒。守护线程从第 29 秒开始执行,每 20 秒执行一次。

② 情况一:当线程A执行完任务,会显式关掉守护线程。

③ 情况二:如果服务器忽然断电,由于线程 A 和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。

高可用

如果从“高可用”的层面来看,上述实现仍然是有所欠缺,也就是说当 redis 是单点的情况下,当发生故障时,则整个业务的分布式锁都将无法使用。

为了提高可用性,我们可以使用主从模式或者哨兵模式,但在这种情况下仍然存在问题,在主从模式或者哨兵模式下,正常情况下,如果加锁成功了,那么master节点会异步复制给对应的slave节点。但是如果在这个过程中发生master节点宕机,主备切换,slave节点从变为了 master节点,而锁还没从旧master节点同步过来,这就发生了锁丢失,会导致多个客户端可以同时持有同一把锁的问题。来看个图来想下这个过程:
高可用
如何避免这种情况?redis 官方给出了基于多个 redis 集群部署的高可用分布式锁解决方案:RedLock

RedLock算法

redLock的官方文档地址:https://redis.io/topics/distlock

算法原理:

现在假设有5个Redis主节点(大于3的奇数个),这样基本保证他们不会同时都宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:

(1)获取当前Unix时间,以毫秒为单位,并设置超时时间TTL

TTL 要大于 正常业务执行的时间 + 获取所有redis服务消耗时间 + 时钟漂移

(2)依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁,当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间TTL,这样可以避免客户端死等。比如:TTL为5s,设置获取锁最多用1s,所以如果一秒内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁

(3)客户端 获取所有能获取的锁后的时间 减去 第(1)步的时间,就得到锁的获取时间。锁的获取时间要小于锁失效时间TTL,并且至少从半数以上的Redis节点取到锁,才算获取成功锁

(4)如果成功获得锁,key的真正有效时间 = TTL - 锁的获取时间 - 时钟漂移。比如:TTL 是5s,获取所有锁用了2s,则真正锁有效时间为3s

(5)如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了。
(6)失败重试:当client不能获取锁时,应该在随机时间后重试获取锁;同时重试获取锁要有一定次数限制;
示意图:
RedLock算法

RedLock性能及崩溃恢复的相关解决方法

由于N个Redis节点中的大多数能正常工作就能保证Redlock正常工作,因此理论上它的可用性更高。前面我们说的主从架构下存在的安全性问题,在RedLock中已经不存在了,但如果有节点发生崩溃重启,还是会对锁的安全性有影响的,具体的影响程度跟Redis持久化配置有关:

(1)如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性;

(2)如果启动AOF永久化存储,事情会好些, 举例:当我们重启redis后,由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;但是由于AOF同步到磁盘的方式默认是每秒一次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以在锁完全有效性和性能方面要有所取舍;

(3)为了有效解决既保证锁完全有效性 和 性能高效问题:antirez又提出了“延迟重启”的概念,redis同步到磁盘方式保持默认的每秒1次,在redis崩溃单机后(无论是一个还是所有),先不立即重启它,而是等待TTL时间后再重启,这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响,缺点是在TTL时间内服务相当于暂停状态;

分布式框架Redisson

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),从而让使用者能够将精力更集中地放在处理业务逻辑上。

Redisson中的分布式锁,已经实现了对Redlock的封装。

唯一ID

唯一ID生成

加锁逻辑

redisson 加锁的核心代码非常容易理解,通过传入 TTL (Time To Live)与唯一 id,实现一段时间的加锁请求。下面是可重入锁的实现逻辑:
加锁逻辑

解锁逻辑

解锁逻辑

看门狗

在 Redisson 中,当一个分布式锁的获取过程出现问题时,例如获取锁的线程崩溃或者被其他线程阻塞,导致获取锁的过程一直进行下去,超过指定的超时时间,那么此时 Redisson 会认为分布式锁已经失效,触发看门狗机制进行调试和恢复。

看门狗的主要作用如下:

监控锁的有效期:当分布式锁超过指定的有效期时,看门狗会重复触发锁的断点,以便及时进行调试和恢复。
帮助用户解锁:在看门狗机制中,如果出现获取锁的线程崩溃或被中断的情况,Redisson 会自动帮助该用户解锁。这样可以保证用户始终可以使用锁,同时不会阻塞其他正在获取锁的用户。
在 Redisson 中,默认情况下看门狗的超时时间为 30 秒。可以通过修改 Config.lockWatchdogTimeout 来改变看门狗的超时时间。另外,Redisson 还提供了公平锁(Fair Lock)和读写锁(Read-Write Lock)等锁类型,可以根据实际需求选择合适的锁类型。
示意图:
看门狗
触发看门狗
触发看门狗
看门狗锁续期
看门狗续期

总结

分布式锁是分布式系统中重要的同步工具,用于解决并发访问共享资源时可能出现的问题,确保系统的可靠性和数据一致性。不同的分布式锁实现方式和技术选择取决于具体的应用需求和环境。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值