分布式系列之分布式锁

分布式锁

在单机多线程环境下,访问共享资源需要保证操作的原子性,而锁机制能提供原子性。在分布式系统下同样需要锁,这就是分布式锁。与前者不同的是,我们需要一个分布式的锁服务。对于分布式所服务的实现,一般可以用关系型数据库、Redis 和 ZooKeeper 等实现。以下三个属性是有效使用分布式锁所需的最低保证:

  • 互斥:在任何时候,只有一个客户端能获得锁。
  • 无死锁:客户端始终可以获得锁。
  • 容错能力:只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。

Redis的分布式锁服务

单实例正确示范

获取锁

SET命令

要获取锁,必须遵循以下方法:

SET resource_name my_random_value NX PX 30000								◠‿◠

该SET命令具有原子性,各项参数含义如下:

  • resource_name:key。
  • my_random_value: 必须全局唯一,这个随机数在释放锁时保证释放锁操作的安全性。
  • NX:只会在 key 不存在的时候给 key 赋值。
  • PX :通知 Redis 保存这个 key 30000ms。

释放锁

DEL命令

释放锁,就是把对应的key删掉,可能会使用DEL,来看看DEL会存在什么问题:

DEL key																		︶︵︶

在这里插入图片描述

问题主要为:一个客户端可能会删除另一个客户端的锁。

  1. 客户端A获得锁K,由于被阻塞的时间超过了该锁的有效时间,Redis自动删除过期的锁。
  2. 客户端B获得锁K,在执行任务的过程中,由于客户端A任务执行完成,删除锁。
  3. 在客户端B未退出之前,客户端C请求锁K,请求成功。

相当于两个线程同时拥有同一个锁,没有到达锁的互斥要求,所以不推荐用del命令删除锁。

Lua脚本

基本上,使用随机值是为了以安全的方式释放锁,并且脚本会告诉Redis:仅当key存在且存储在key上的值恰好是我期望的值时,才删除该密钥。这是通过以下Lua脚本完成的:

if redis.call("get",KEYS[1]) == ARGV[1] then								◠‿◠
    return redis.call("del",KEYS[1])
else
    return 0
end

使用上述脚本时,每个锁都由一个随机字符串“签名”,因此仅当该锁仍是持有该锁的客户端尝试将其删除的设置时,该锁才会被删除。

Jedis实现

Jedis使用阻塞的I/O和redis交互,基于上面获得锁和释放锁的Redis操作提供Jedis实现代码如下。

																			¯¯__¯¯
//加锁和过期自动释放锁														  
while (!"OK".equals(jedis.set("resource_name", "my_random_value", "NX", "PX", 30000))){
    try {
        //休眠一秒再次尝试获取锁
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        logger.error("#####exception=[{}]", e.getMessage());
    }
}
//执行到这里证明已经获得锁
//do something
//执行完后要释放锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', 		KEYS[1]) else return 0 end";
//返回删除key的个数
Object result = jedis.eval(script, Collections.singletonList("resource_name"), 				Collections.singletonList("my_random_value"));

Jedis实现有以下不足:

  • 无法支持重入

  • 如果需要续期,需要增加额外的代码量

Redisson实现(推荐)

Redission通过Netty支持非阻塞I/O,封装了锁的实现,RLock继承了java.util.concurrent.locks.Lock的接口,让我们像操作我们的本地Lock一样去操作Redission的Lock,基于Redission的实现代码如下:

设置单实例:

config.useSingleServer().setAddress("redis://"120.0.0.1:6379");

代码实现:

RLock lock = redissonClient.getLock("resource_name");						◠‿◠
//------------------------lock---------------------------------
//① 阻塞
// 获取锁并在10秒后自动解锁
lock.lock(10, TimeUnit.SECONDS);
lock.unlock();
//------------------------lock---------------------------------
//------------------------tryLock------------------------------
//② 阻塞100s后放弃请求锁
// 等待锁的获取时间长达100秒,获取锁并在10秒后自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
   try {
     //do something
   } finally {
       lock.unlock();
   }
}
//------------------------tryLock------------------------------

Redisson能够满足Jedis上述的不足,而且简化了代码量。【注】:lock和tryLock一般根据情况选择一个使用即可。

重入原理

定义:如果一个线程试图获取一个已经由它自己持有的锁,那个这个请求会成功。

redis中的数据结构:

在这里插入图片描述

Redisson使用hash结构存储锁的信息,其中hashkey为UUID(Redisson初始化生成的全局ID)+当前线程ID,hashvalue就是上锁次数。如果当前线程再次请求锁时(hashkey的值一样),hashvalue的值加1。

上锁逻辑:

在这里插入图片描述

续期原理

为什么需要续期?

看看下图这种情况:

在这里插入图片描述

在上图描述的情况中,当客户端A被阻塞时长超过锁的有效时间,客户端B获得锁,并更新了资源后退出,但此时客户端A的阻塞条件被打破,把客户端B更新的资源覆盖掉了,造成了数据出错。只是由于客户端A持有锁的有效时间过期造成的,这就需要给锁续期。

原理:持有锁的客户端Redisson实例处于活跃状态时延长锁的到期时间,当其崩溃的时候也能防止持有的锁一直保持,有自动释放机制。默认情况下,lockWatchdog超时为30秒,可以通过Config.lockWatchdogTimeout设置进行更改(分布式锁的超时时间为30秒)。

默认情况下,锁的有效期为30秒,如果业务再行到 internalLockLeaseTime/3 ,即10秒的时候还没执行完,就会进行一次续期,重新设置锁的有效期为30秒。当遇到宕机时,续期任务无法运行,30秒后锁自动释放。

小结

以上介绍的都是同步阻塞式的可重入锁实现,关闭异步实现和除了可重入锁以外的其他锁实现,比如Fair Lock、ReadWriteLock、Semaphore、CountDownLatch等,可参考Redisson官方文档的第8章。

到此,单实例的分布式锁基本功能已实现,前面提到的分布式锁所需的最低保证均已满足,但在线上环境Redis大多数为集群模式,那单实例的实现已无法满足互斥和容错能力,这时就需要引入RedLock算法。

集群模式正确示范

基于故障转移的实现

主从、哨兵等实现的是基于故障转移的,为什么不适用基于故障转移的实现呢?

当Master宕机后,slaver选举为Master之前,无法保证原Master上的锁已同步到slaver上,因为Redis复制是异步的

在这里插入图片描述

此模型存在明显的竞争条件:

  1. 客户端A获取主服务器中的锁。
  2. 在将密钥复制到Slaver之前,Master宕机。
  3. Slaver晋升为Master。
  4. 客户端B获取对相同资源K的锁定,而该资源K同时被客户端A锁定。安全违规!

RedLock(官方推荐)

为了获取锁,客户端执行以下操作:

  1. 它以毫秒为单位获取当前时间。

  2. 它尝试在所有N个实例中顺序使用所有实例中相同的键名和随机值来获取锁定。在第2步中,在每个实例中设置锁定时,客户端使用的超时时间小于总锁定自动释放时间,以便获取该超时时间。例如,如果自动释放时间为10秒,则超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点通信时保持阻塞:如果一个实例不可用,我们应该尝试与下一个实例尽快通信。

  3. 客户端通过从当前时间中减去在步骤1中获得的时间戳,来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例中获取锁时(N/2+1), 并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁

  4. 如果获取了锁,则其有效时间被视为初始有效时间减去在步骤3中计算的经过的时间。

  5. 如果客户端由于某种原因无法获取锁(无法锁定N/2+1实例或有效期为负),它将尝试解锁所有实例(甚至是它认为无法锁定的实例)。

    // 源码 failedLocksLimit:允许失败的客户端数量
    protected int failedLocksLimit() {
        return this.locks.size() - this.minLocksAmount(this.locks);
    }
    
    protected int minLocksAmount(List<RLock> locks) {
        return locks.size() / 2 + 1;
    }
    

代码实现:

RLock lock1 = redissonClient1.getLock("resource_name");						◠‿◠
RLock lock2 = redissonClient2.getLock("resource_name");
RLock lock3 = redissonClient3.getLock("resource_name");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
//------------------------lock---------------------------------
//① 阻塞
// 获取锁并在10秒后自动解锁
redLock.lock(10, TimeUnit.SECONDS);
redLock.unlock();
//------------------------lock---------------------------------

//------------------------tryLock---------------------------------
//② 阻塞100s后放弃请求锁
// 等待锁的获取时间长达100秒,获取锁并在10秒后自动解锁
try {
    redLock.tryLock(100, 10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    e.printStackTrace();
} finally {
    redLock.unlock();
}
//------------------------tryLock---------------------------------

【注】:和单实例一样,lock和tryLock一般根据情况选择一个使用即可。

至此,已给出RedLock的实现,如果需要了解更多信息,可参考Redis官方算法Redlock

MySQL的分布式锁服务

悲观锁

在MySQL中新建一张锁表,加锁时往数据库中添加一条记录,释放时删除这条数据。

  • 互斥?

    一个字段,锁标识,唯一性约束。

  • 超时释放?

    一个字段,记录锁的过期时间,定时任务过期清理。

  • 重入?

    一个字段,记录当前线程的ID;

  • 非阻塞?

    while循环,直到成功为止,或者等待超时返回。

我们可以得到如下一张表:

CREATE TABLE `lock_tbale` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `lock_flag` varchar(255) NOT NULL COMMENT '锁标识',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
  `thread_id` varchar(255) NOT NULL COMMENT '线程id',
  PRIMARY KEY (`id`),
  UNIQUE KEY `lock_flag` (`lock_flag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

加锁的SQL语句:

INSERT INTO lock_tbale(lock_flag,expire_time,thread_id) values
('lock_flag','2020-02-04 12:00:00','123456789');

释放锁的SQL语句:

DELETE FROM lock_tbale WHERE lock_flag = 'lock_flag';

重入的SQL语句:

SELECT * FROM lock_tbale WHERE thread_id = '123456789' AND lock_flag = 'lock_flag';

查询到有数据时表示可重入,没数据时在加锁。

悲观锁有着明显的缺点,悲观锁的实现依赖于数据库提供的唯一性约束来保存互斥,依赖于代码逻辑来做阻塞(while循环),当有大量流量请求同一个锁时,这些流量全部落在MySQL上,这无疑是毁灭性的灾难,所以悲观锁的实现不太适合高并发场景。

乐观锁

乐观锁的实现方式是在原来表的基础上添加一个版本号,更新数据前,先把这个版本号读出来,更新数据时加上这个版本号条件,只有当数据库里的版本号等于查询出来的版本号时,更新才成功,更新成功后加1,更新语句大概可以写成下面这样:

UPDATE table_name SET xxx = #{xxx},version=version+1 where version =#{version};

乐观锁也有着一些不知之处:

  • 资源表要添加一个version字段;
  • 如果一个操作涉及多张表,则每张表的版本号都得查一次;

小结

不论是悲观锁还是乐观锁,用MySQL来实现分布式锁都是不推荐的,其性能,可靠性,以及实现的复杂度上都没有良好的表现,所以,MySQL的实现只能作为一种备选。

未完待续…

GitHub:https://github.com/pikaxiao/SpringFamily

参考

  1. Redis官方文档.
  2. Redisson官方文档.
发布了6 篇原创文章 · 获赞 25 · 访问量 2503

猜你喜欢

转载自blog.csdn.net/qq_36011946/article/details/104140034