谈谈分布式锁

本文探讨了分布式锁的使用目的,包括两类操作共享资源的情况。详细介绍了Redis和ZooKeeper实现分布式锁的不同方案,如Redis的setnx和Redisson框架,以及ZooKeeper的临时顺序节点加watch机制。同时,文章比较了两者在性能和高并发场景下的优缺点,并提出了分布式锁的高并发优化策略,如分段加锁和避免使用分布式锁的方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1、为什么要使用分布式锁

使用分布式锁的目的,无外乎就是保证同一时间只有一个客户端可以对共享资源进行操作。
根据锁的用途还可以细分为以下两类:

  1. 允许多个客户端操作共享资源
    这种情况下,对共享资源的操作一定是幂等性操作,无论你操作多少次都不会出现不同结果。在这里使用锁,无外乎就是为了避免重复操作共享资源从而提高效率。
  2. 只允许一个客户端操作共享资源
    这种情况下,对共享资源的操作一般是非幂等性操作。在这种情况下,如果出现多个客户端操作共享资源,就可能意味着数据不一致,数据丢失。比如在购物系统中,对于用户下单来说,对一件商品的购买同一时间只能一个人操作,比如说秒杀商品 下单减库存,否则多个用户同时下单同时减库存容易出现问题,比如说超卖。

2、分布式锁的技术有哪些

常见的有redis来实现分布式锁和zookpeer来实现分布式锁两种方式。

3、Redis分布式锁的实现方案

3.1、setnx实现方式

这是最普通的实现方式,就是在 redis 里使用 setnx 命令创建一个 key,执行下面这个命令就算加锁。

SET key random_value NX PX 30000

  • random_value :value设置为随机值。
  • NX:表示只有 key 不存在的时候才会设置成功。(如果此时 redis 中存在这个 key,那么设置失败,返回 nil)
  • PX 30000:意思是 30s 后锁自动释放。别人创建的时候如果发现已经有了就不能加锁了。
    释放锁就是删除key,为了保证删除key的原子性,一般使用lua脚本进行删除。删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。

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

将value设置随机值的原因就是假如A获取到了锁,由于执行逻辑时间长,超过锁过期时间,此时B获取到了锁,A执行完后会释放锁,如果不设置随机值,就会释放B获取到的锁,这是不对的。

缺点:单机下使用,容易会有单点故障,没有可用性,假如有主从复制,那么主挂掉,还没将锁复制到从节点,此时从节点切换为主节点,别人就可以 set key,从而拿到锁。就会有两个线程持有锁。

3.2、Redisson框架实现方式

Redis推荐使用redlock算法实现分布式锁,实现框架有Redisson,在集群下可以使用。人家还支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构。

实现原理
在这里插入图片描述(1)加锁机制
咱们来看上面那张图,现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。紧接着,就会发送一段lua脚本到redis上,为啥要用lua脚本呢?
因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

myLock :{
”8743c9c0-0795-4907-87fd-6c719a6b4586:1 ”:1
}

上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。

接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。

好了,到此为止,ok,加锁完成了。

(2)锁互斥机制

那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?

首先会判断发现myLock这个锁key已经存在了。

接着再判断,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。

所以,客户端2会进入一个while循环,不停的尝试加锁。

(3)watch dog自动延期机制

客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?

这就用到了watch dog机制,只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

(4)可重入加锁机制

那如果客户端1都已经持有了这把锁了,客户端1再次加锁,就会执行可重入加锁的逻辑,他会用:
incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。

(5)释放锁机制

如果执行lock.unlock(),就可以释放分布式锁,每次释放锁都对myLock数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:“del myLock”命令,从redis里删除这个key。

然后客户端2就可以尝试完成加锁了。

这就是分布式锁Redisson框架的实现机制。

一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。

缺点:

其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的redis slave实例。

但是这个过程中一旦发生redis master宕机,还没来得进行异步复制,就进行了主备切换,redis slave变为了redis master。

接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。

此时就会导致多个客户端对一个分布式锁完成了加锁。

这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。

所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。

4、ZooKeeper分布式锁的实现方案

4.1、临时顺序节点加watch机制实现方式

zk一般使用curator去实现分布式锁——公平锁。

原理:向zk发起请求,在一个目录(/locks/pd_1_stock)下,创建一个临时节点,也是带有自己客户端的id。如果目录是空的,自己创建出来的节点就是第一个节点,那么加锁成功。如果成功执行则释放(节点删除)。如果宕机了,基于zk的心跳机制,那个临时节点也会被删除。第二个客户端请求锁时,也创建一个节点,如果不是第一节点,那么向上一节点加一个watcher监听器。如果上一节点被删除立马会感知到,然后在判断自己是不是第一节点,如果不是再监听上一级(公平实现)。完事后陷入等待,直到获取锁。
在这里插入图片描述
部分代码实现如下

public boolean tryLock() {
    //子节点
    String nodeName=LOCK_NAME+"/zlu";
    //創建子节点  默认匿名权限,临时顺序节点
    try {
        //Lock/zk_1
        CURRENTNODE.set(zooKeeper.get().create(nodeName, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL));

        //获取所有孩子节点排序
        List<String> list = zooKeeper.get().getChildren(LOCK_NAME, false);

        Collections.sort(list);

        String minNodeName = list.get(0);

        //如果当前节点是最小的节点,则反馈true 说明枷锁
        if(minNodeName.equals(CURRENTNODE)){

            return  true;

        }else{//否则 监听前一个节点

            String currentSimpleNodeName = CURRENTNODE.get().substring(CURRENTNODE.get().lastIndexOf("/") + 1);

            String preNodeName = list.get(list.indexOf(currentSimpleNodeName) - 1);

            //用于阻塞 等到前一个节点删除事件完成后,才返回true
            CountDownLatch count=new CountDownLatch(1);

            zooKeeper.get().exists(LOCK_NAME+"/"+preNodeName, new Watcher() {
                @Override
                public void process(WatchedEvent event) {
                    //建庭前一个节点的删除事件
                    if(event.getType().equals(Event.EventType.NodeDeleted)){
                        count.countDown();
                        System.out.println(Thread.currentThread().getName()+"被唤醒");
                    }
                }
            });
            System.out.println(Thread.currentThread().getName()+"被阻塞");
            count.await();
            return true;
        }

    } catch (KeeperException e) {
        e.printStackTrace();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return false;
}

@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
    return false;
}

@Override
public void unlock() {
    //-1表示忽略版本号 强制删除
    try {
        zooKeeper.get().delete(CURRENTNODE.get(),-1);
        CURRENTNODE.set(null);
        zooKeeper.get().close();
    } catch (InterruptedException e) {

    } catch (KeeperException e) {
        e.printStackTrace();
    }
}

缺点:Zk的分布式锁也会有一些缺点,极端情况下会出现脑裂问题,比如客户端A获取到了锁,但是由于网络原因,zk迟迟收不到A的心跳,则释放锁,此时,监听这个节点的客户端2加锁成功,两个客户端都认为自己持有锁,就会出现问题,脏数据等。

4.2、临时节点实现方式

如果使用临时节点+watch机制实现分布式锁——非公平锁,会是如下场景:
在这里插入图片描述
当几十个客户端争抢同一把基于zk的锁时,只有一个客户端能成功,并创建临时节点,然后别的客户端都回去监听这个节点,那么当释放锁时,zk会同时反向通知所有客户端又来重新争抢锁。

缺点:产生羊群效应,如果几十个客户端同时争抢一个锁,此时会导致任何一个客户端释放锁的时候,zk反向通知几十个客户端,几十个客户端又要发送请求到zk去尝试创建锁,所以大家会发现,几十个人要加锁,大家乱糟糟的,无序的。造成很多没必要的请求和网络开销,会加重网络的负载。

4.3、总结

综上所述,基于zk实现分布式锁的方案采用第一种curator实现的就好,通过监听上一级节点,降低了争抢次数,还实现了公平锁。

5、ZooKeeper分布式锁和redis分布式锁的对比

redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。

另外一点就是,如果是 redis 获取锁的那个客户端 出现 bug 挂了,那么只能等待超时时间之后才能释放锁;
而 zk 的话,因为创建的是临时 znode,只要客户端挂了,znode 就没了,此时就自动释放锁。

综上加锁的过程而言,zk比redis更加健壮。

但是zk做高并发的分布式锁比redis稍逊,因为zk不适合大规模集群部署
Zk的分布式锁的应用场景,主要高可靠,而不是太高并发的场景下。
在并发量很高,性能要求很高的场景下,推荐使用基于redis的分布式锁。

6、分布式锁的高并发优化方案

6.1、采用分段加锁方案

假如redis做分布式锁,单机抗几万并发没问题,如果并发量更大,可以使用如下方式:

假如有10万个并发请求,可以设置10台redis master,库存表中 库存字段分成10个 stock_o1,……stock_10,每个字段里面均匀存放库存量假如每个字段100个库存,一个请求过来,创建一个key product_商品id_stock_1到10的随机值 去请求redis机器,去锁定请求的库存字段,这样每台redis能够均匀的接受1万的请求

假如某个库存分段的库存量不够了,要买20个商品,锁定stock_05发现只有10个库存,不够 采用合并加锁的方式,再去锁定别的库存 发现stock_03有10个库存,加起来够了,就锁定这两个库存。

6.2、不用分布式锁方案

采用分段加锁太麻烦啦,我们可以采用使用nosql kv存储的方式,比如使用mongodb,redis(保证持久化),直接在redis中不停的扣减,当扣减到负数,超卖,就回滚刚才的扣减加回去,返回给用用通知,将扣减成功的请求 异步到mq中,库存系统拉取请求实现真正扣减库存。

6.3、总结

采用什么样的分布式锁需要根据具体业务具体场景进行分析。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值