为什么要使用分布式锁

为什么要使用分布式锁

使用分布式锁的目的,无外乎就是保证同一时间只有一个客户端可以对共享资源进行操作。

比如酒店的房间门锁,当你入住的时候,你需要先申请锁(钥匙),如果锁(钥匙)已经被其他人拿走,那么你将不能使用该房间资源;如果你申请到锁(钥匙)进入房间,那么再有别人想申请进入则不被允许;当你释放锁(钥匙,即办理退房)的时候,则其他人可以再次申请锁。

举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:

  • 线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。
  • 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  • 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  • 线程 1 继续执行,将库存数量减少 1 个,然后返回成功。
  • 线程 2 继续执行,将库存数量减少 1 个,然后返回成功。
  • 此时就发生了超卖问题,导致商品被多卖了一份。

为了保证共享资源被安全地访问,我们需要使用互斥操作对共享资源进行保护,即同一时刻只允许一个线程访问共享资源,其他线程需要等待当前线程释放后才能访问。这样可以避免数据竞争和脏数据问题,保证程序的正确性和稳定性。

在分布式系统中,不同的服务通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了,此时就需要分布式锁了。

分布式锁的几种特性

  • 互斥性:同一时刻只能有一个线程持有锁,分布式锁需要保证在不同节点的不同线程的互斥
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁
  • 锁超时:如果获取不到锁,不能无限期等待,防止死锁
  • 分布式:加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
  • 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响

常见分布式锁实现方案

  • 基于关系型数据库比如 MySQL 实现分布式锁。
  • 基于分ZooKeeper 实现分布式锁。
  • 基于 Redis 实现分布式锁。

基于数据库的实现方式

基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名 等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

(1)创建一个表:

sql 复制代码
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

(2)想要执行某个方法,就使用这个方法名向表中插入数据:

sql 复制代码
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

(3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:

ini 复制代码
delete from method_lock where method_name ='methodName';

使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化:

1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;

2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;

3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;

4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。

5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。

所以基本没有使用数据库实现分布式锁的。

基于 Redis 实现分布式锁

目前使用Redis分布式锁比较常见。

选用Redis实现分布式锁原因:

  • 有很高的性能;
  • 天然分布式

基于Redis命令

sql 复制代码
SET resource_name random_value NX PX 30000
  • random_value是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的。
  • NX表示只有当resource_name对应的key值不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • PX 30000表示这个锁有一个30秒的自动过期时间。当然,这里30秒只是一个例子,客户端可以选择合适的过期时间。

执行完业务代码后,可以通过下面的Redis Lua脚本来释放锁:

vbnet 复制代码
if redis.call("get",KEYS[1]) == ARGV[1] then
     return redis.call("del",KEYS[1])
 else
     return 0
 end

这段Lua脚本在执行的时候要把前面的value作为 ARGV[1] 的值传进去,把 lockkey 作为 KEYS[1] 的值传进去。

选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

关键点:

1、一定要保证设置指定 key 的值和过期时间是一个原子操作! 不然的话,依然可能会出现锁无法被释放的问题。

不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。

如何实现锁的优雅续期?这个下面会讲到。

2、设置一个随机字符串 value 是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。

假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:

  • 客户端1获取锁成功。
  • 客户端1在某个操作上阻塞了很长时间。
  • 过期时间到了,锁自动释放了。
  • 客户端2获取到了对应同一个资源的锁。
  • 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。
  • 之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。

3、Lua脚本

释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:获取、判断和删除,用Lua脚本来实现能保证这三步的原子性。

否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面第三个问题类似的执行序列:

  • 客户端1获取锁成功。
  • 客户端1访问共享资源。
  • 客户端1为了释放锁,先执行'GET'操作获取随机字符串的值。
  • 客户端1判断随机字符串的值,与预期的值相等。
  • 客户端1由于某个原因阻塞住了很长时间。
  • 过期时间到了,锁自动释放了。
  • 客户端2获取到了对应同一个资源的锁。
  • 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

Redisson分布式锁

Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。

Redisson 中的分布式锁自带自动续期机制,提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗) ,如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。

ini 复制代码
    redission.lock==>
    1. 使用hash对每个锁key(资源节点信息)进行赋值value(锁的次数)。实现可重入的加锁方式(对value进行加1操作)
    2. 如果加锁失败,判断是否超时,如果超时则返回false。
    3. 如果加锁失败,没有超时,那么需要在redisson_lock__channel+lockName的channel上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。
    4. 重试步骤1,2,3,直到最后获取到锁,或者某一步获取锁超时。
​
        if (redis.call('exists', KEYS[1]) == 0) then
            redis.call('hset', KEYS[1], ARGV[2], 1);
            redis.call('pexpire', KEYS[1], ARGV[1]);
            return nil;
        end;
        if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
            redis.call('hincrby', KEYS[1], ARGV[2], 1);
            redis.call('pexpire', KEYS[1], ARGV[1]);
            return nil;
        end;
        return redis.call('pttl', KEYS[1]);
     
    
    redission.unlock==>
    1. 通过lua脚本进行解锁,如果是可重入锁,只是减1。如果是非加锁线程解锁,那么解锁失败。
    2. 解锁成功需要在redisson_lock__channel+lockName的channel发布解锁消息,以便等待该锁的线程进行加锁
​
        if (redis.call('exists', KEYS[1]) == 0) then
            redis.call('publish', KEYS[2], ARGV[1]);
            return 1;
        end;
        if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
            return nil;
        end;
        local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
        if (counter > 0) then
            redis.call('pexpire', KEYS[1], ARGV[2]);
            return 0;
        else
            redis.call('del', KEYS[1]);
            redis.call('publish', KEYS[2], ARGV[1]);
            return 1;
        end;
        return nil;
​
        //renewExpiration() 方法包含了看门狗的主要逻辑:
        private void renewExpiration() {
         //......
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                //......
                // 异步续期,基于 Lua 脚本
                CompletionStage<Boolean> future = renewExpirationAsync(threadId);
                future.whenComplete((res, e) -> {
                    if (e != null) {
                        // 无法续期
                        log.error("Can't update lock " + getRawName() + " expiration", e);
                        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                        return;
                    }
​
                    if (res) {
                        // 递归调用实现续期
                        renewExpiration();
                    } else {
                        // 取消续期
                        cancelExpirationRenewal(null);
                    }
                });
            }
         // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
​
        ee.setTimeout(task);
    } 

使用 Redission是很简单的:

csharp 复制代码
@Autowired
private Redisson redisson;
​
    public String deductStock() {
        String lockKey = "lock:product_101";
 
        //获取锁对象
        RLock redissonLock = redisson.getLock(lockKey);
        //加分布式锁
        redissonLock.lock();  //  .setIfAbsent(lockKey, clientId, 30, TimeUnit.SECONDS);
        try {
            int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); 
            if (stock > 0) {
              //todo 业务
            } else {
                System.out.println("扣减失败,库存不足");
            }
        } finally {
            //解锁
            redissonLock.unlock();
        }
        return "end";
    }
相关推荐
prince0533 分钟前
Kafka 生产者和消费者高级用法
分布式·kafka·linq
六毛的毛43 分钟前
Springboot开发常见注解一览
java·spring boot·后端
AntBlack1 小时前
拖了五个月 ,不当韭菜体验版算是正式发布了
前端·后端·python
31535669131 小时前
一个简单的脚本,让pdf开启夜间模式
前端·后端
uzong1 小时前
curl案例讲解
后端
菜萝卜子2 小时前
【Project】基于kafka的高可用分布式日志监控与告警系统
分布式·kafka
一只叫煤球的猫2 小时前
真实事故复盘:Redis分布式锁居然失效了?公司十年老程序员踩的坑
java·redis·后端
大鸡腿同学3 小时前
身弱武修法:玄之又玄,奇妙之门
后端
轻语呢喃5 小时前
JavaScript :字符串模板——优雅编程的基石
前端·javascript·后端
MikeWe5 小时前
Paddle张量操作全解析:从基础创建到高级应用
后端