分布式锁实现方式浅析

分布式锁

通俗解释#

单机情况下,多个线程间同时访问某个共享资源,通过synchronize/Lock进行对共享资源加锁,如果在分布式系统方式下,共享资源的竞争则是在不同进程间进行,因此必须引入,分布式锁。

使用场景#

  • 效率性:使用分布式锁可以避免不同节点重复相同的工作
  • 正确性:分布式锁可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失

特性#

  • 互斥性:最基本的特性,对同一资源同一时间需要保持对不同节点不同线程的互斥
  • 可重入性:同一个节点上的同一个线程获取了锁之后可以再次获取这个锁
  • 锁超时:防止死锁
  • 高效,高可用:
  • 支持阻塞和非阻塞:
  • 支持公平和非公平锁:公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。

实现方式#

MySQL#

  1. 创建表

    CREATE TABLE `resource_lock` (
        `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
        `resource_name` varchar(128) NOT NULL DEFAULT '' COMMENT '资源名称',
        'node_info' varchar(128) DEFAULT '0' COMMENT '节点信息',
        'count' int(11) NOT NULL DEFAULT '0' COMMENT  '锁的次数,统计可重入锁',
        'desc' varchar(128) DEFAULT NULL COMMENT '额外的描述信息',
        `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
        `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
        PRIMARY KEY ('id'),
        UNIQUE KEY 'un_resource_name' ('资源名称')
    ) ENGINE=InnoDB DEFAULT CHARSET = utf8mb4;
    
  2. lock
    先进行查询,如果有值,那么需要比较 node_info 是否一致,这里的 node_info 可以用机器 IP 和线程名字来表示,如果一致那么就加可重入锁 count 的值,如果不一致那么就返回 false 。如果没有值那么直接插入一条数据。伪代码如下:

    // 添加事务,原子性
    @Transaction
    public void lock() {
        if (select * from resource_lock where resource_name = 'xxx' for update;) {
            // 判断节点信息是否一致
            if (currentNodeInfo == resultNodeInfo) {
                // 保住锁的可重入性
                update resource_lock set count = count + 1 where resource_name = 'xxx';
                return true;
            } else {
                return false;
            }
        } else {
            // 插入新数据
            insert into resourceLock;
            return true;
        }
    }
    
  3. tryLock
    伪代码如下:

    public boolean tryLock(long timeOut) {
        long stTime = System.currentTimeMillis();
        long endTimeOut = stTime + timeOut;
    
        while (endTimeOut > stTime) {
            if (mysqlLock.lock()) {
                return true;
            }
    
            // 休眠3s后重试
            LockSupport.parkNanos(1000 * 1000 * 1000 * 1);
            stTime = System.currentTimeMillis();
        }
        return false;
    }
    
  4. unlock
    伪代码如下:

    @Transaction
    public boolean unlock() {
        // 查询是否有数据
        if (select * from resource_lock where resource_name = 'xxx' for update;) {
            // count为1那么可以删除,如果大于1那么需要减去1。
            if (count > 1) {
                update count = count - 1;
            } else {
                delete;
            }
        } else {
            return false;
        }
    }
    
  5. 定时清理

    启动一个定时任务,当这个锁远超过任务的执行时间,没有被释放我们就可以认定是节点挂了然后将其直接释放。

ZK#

ZooKeeper是以Paxos算法为基础分布式应用程序协调服务。Zk的数据节点和文件目录类似,所以我们可以用此特性实现分布式锁。

基本实现步骤如下

​ 1、客户端尝试创建一个znode节点,比如/lock。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode已存在),获取锁失败。

​ 2、持有锁的客户端访问共享资源完成后,将znode删掉,这样其它客户端接下来就能来获取锁了。

​ 注意:这里的znode应该被创建成ephemeral的(临时节点)。这是znode的一个特性,它保证如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这保证了锁一定会被释放。

假如按照下面的顺序执行:

1、客户端1创建了znode节点/lock,获得了锁。

2、客户端1进入了长时间的GC pause。

3、客户端1连接到ZooKeeper的Session过期了。znode节点/lock被自动删除。

4、客户端2创建了znode节点/lock,从而获得了锁。

5、客户端1从GC pause中恢复过来,它仍然认为自己持有锁。

由上面的执行顺序,可以发现最后客户端1和客户端2都认为自己持有了锁,冲突了。所以说,用ZooKeeper实现的分布式锁也不一定就是安全的,该有的问题它还是有。zk的watch机制

ZooKeeper有个很特殊的机制--watch机制。这个机制可以这样来使用,比如当客户端试图创建 /lock 节点的时候,发现它已经存在了,这时候创建失败,但客户端不一定就此对外宣告获取锁失败。

客户端可以进入一种等待状态,等待当/lock节点被删除的时候,ZooKeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。

单节点Redis#

01使用描述

SET resource_name my_random_value NX PX 30000

注意,在上面的SET命令中:

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

最后,当客户端完成了对共享资源的操作之后,执行下面的Redis Lua脚本来释放锁:

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

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

02关键总结

  1. 过期时间
    必须设置过期时间,否则获取锁成功后,网络出问题,导致它不能跟redis通信,那么它一直保持锁,其他线程永远无法获取这个锁
    而且这个时间被称为锁的有效时间,获得锁的线程必须在这个时间内完成队共享资源的访问。

  2. 获取锁

    SETNX resource_name my_random_value
    EXPIRE resource_name 30
    

    虽然这两个命令和前面算法描述中的一个SET命令执行效果相同,但却不是原子的。如果客户端在执行完SETNX后崩溃了,那么就没有机会执行EXPIRE了,导致它一直持有这个锁。

  3. 随机值

    第三个问题,设置一个随机字符串 my_random_value 是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:

    • 客户端1获取锁成功。
    • 客户端1在某个操作上阻塞了很长时间。
    • 过期时间到了,锁自动释放了。
    • 客户端2获取到了对应同一个资源的锁。
    • 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。
    • 之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了。
  4. Lua脚本
    释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:获取、判断和删除,用Lua脚本来实现能保证这三步的原子性。

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

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

集群Redis#

​ 基于单Redis节点的分布式锁在failover的时候会产生解决不了的安全性问题,因此antirez提出了新的分布式锁的算法Redlock,它基于N个完全独立的Redis节点(通常情况下N可以设置成5),详细实现过程自己查Redlock资料。

自研#

暂且不表

文章参考通俗讲解分布式锁:场景和使用方法 - Java知音号 - 博客园 (cnblogs.com)

posted @   秦一居  阅读(38)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
点击右上角即可分享
微信分享提示
主题色彩