分布式锁概念、实现、源码解读简易版

一、数据库实现分布式锁

1、悲观锁

    如果获取锁失败,就一直阻塞等待。
    比如,有一张资源锁表:

CREATE TABLE `resource_lock`
 `id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的资源名',
   `owner` varchar(64) NOT NULL DEFAULT '' COMMENT '锁拥有者',
    `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
     `update_time` timestamp NOT NULL DEFAULT '' COMMENT '自动生成保存数据时间'
     PRIMARY KEY(`id`),
     UNIQUE KEY `uid_resource_name` (`resource_name`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET = utf8 COMMENT = '锁定中的资源';

    resource_name 锁定的资源名必须有唯一索引。必须添加事务,查询和更新操作保证原子性,在一个事务里完成。

伪代码实现:

@Transcation
public void lock(String name){
 ResourceLock rlock = exeSql("select * from resource_lock where resource_name = name for update");
   if(rlock ! = null)  {
      exeSql("insert into resource_lock(resource_name,owner,count)
value (name,'ip',0)");
   }
 }

    可以看到,使用 for update 锁定资源,如果执行成功,会执行后续的插入数据命令,直到事务提交,执行结束;否则,会一直阻塞着。
    如果在数据库客户端工具上测试效果,当 在一个终端执行了 for update,不提交事务,在另外的终端上执行相同条件的 for update ,会被阻塞,(转圈圈😵)。虽然也能实现分布式锁的效果,但是存在性能瓶颈。

悲观锁优缺点:
优点:

  • 简单易用,易理解,保障数据强一致性。
  • 在 RR 事务级别,select 的 for update 操作是基于 间隙锁 gap lock 实现的,是一种悲观锁的实现方式,所以存在阻塞问题。
  • 在高并发清空下,大量请求进来,会导致大部分请求进行排队,影响数据库稳定性,也会耗费服务的 CPU 等资源;还会造成占用过多的应用线程,导致业务无法正常响应。
  • 如果优先获取锁的线程因为某些原因,一直没有释放掉锁,可能会导致死锁的发生;而且锁长时间不释放还会占用数据库连接。
  • MySQL 数据库会做查询优化(即便使用了索引),优化时发现全表扫描效率更高,可能会把 行锁 升级为 表锁。
  • 不支持可重入性,并且超时等待时间是全局的,不能随意改动。
        
2、乐观锁

    如果获取锁失败,会重试或退出,不会一直阻塞等待。类似于 CAS 机制。
    相比 悲观锁 中表的字段,添加了 “状态” 和 “版本号”,通过 版本号 或者 时间戳,来保证多线程同时间操作共享资源的有序性和正确性。

CREATE TABLE `resource` (
  `id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `resource_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的资源名',
  
  `share` varchar(64) NOT NULL DEFAULT '' COMMENT '状态',
    `version` int(4) NOT NULL DEFAULT '' COMMENT '版本号',
    
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT '' COMMENT '自动生成保存数据时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_resource_name` (`resource_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定的资源';

伪代码实现:

  Resource resource = exeSql("select * from resource where resource_name = xxx");
  boolean succ = exesql("update resource set version = 'newVersion' ... where      resource_name = xxx and version = 'oldVersion'");

  while(! succ)  {
	   //发起重试
}

    可以看到,如果版本号不一致,就会更新失败,通过 while 循环去重新获取版本号并重试,直到更新成功。(根据具体需要,使用 if 也行)

乐观锁优缺点
优点:

  • 简单易用,保障数据一致性。
    缺点:
  • (在更新数据状态的一些情景下,如果不考虑幂等性,可以直接使用行锁来保证数据一致性:update table set state = 1 where id = xxx and state =0 )加行锁会使性能有开销。
  • 高并发情景下,线程内的 自旋 会耗费一定的 CPU 资源。
        

二、基于 Zookeeper 实现分布式锁

    Zookeeper 是一种提供 分布式服务协调 的中心化服务,是以 Paxos 算法为基础实现的,是 Google 的 Chubby 的一个开源的实现,是 Hadoop 和 Hbase 的重要组件。
    Zookeeper 数据节点 和 文件目录类似,(它俩还有一个共同的特点 就是 同一个目录下 文件名称不能重复 😄)同时具有 Watch 机制,基于这两个特性,得以实现分布式锁的功能。

1、数据节点

    Zookeeper 提供一个多层级的节点命名空间,节点称为 Znode,节点是可以存储数据的,每个节点都用一个以斜杠 / 分隔的路径来表示,而且每个节点都有父节点 (根节点除外),非常类似于文件系统。可以使用 create /hjiajia nice 来创建节点,这个命令标识,在 根目录 下创建一个 hjiajia 节点,值是 nice。
    节点类型可以分为 持久节点 PERSISTENT 【只要创建了这个节点,不论 Zookeeper 的客户端是否断开连接,服务器都会记录这个节点】、临时节点 EPHEMERAL 【客户端一旦断开连接,就不再保存这个节点】,每个节点还能被标记为有序性 SEQUENTIAL ,一旦节点被标记为有序性 ,那么整个节点就具有顺序自增的特点。
    节点类型还有一种分类:

  • 持久性节点
  • 持久性顺序节点
  • 临时性节点
  • 临时性顺序节点
        一般可以组合这几类节点 来创建我们所需要的节点,比如,创建一个持久节点作为父节点,在 父节点 下面创建临时节点,并标记临时节点为有序。
        
2、Watch 机制

    Zookeeper 还提供了另外一个重要的特性 Watcher 事件监听器。
    Zookeeper 允许客户端在指定节点上注册Watcher ,并且在一些特定事件 比如 数据改变、被删除、子目录节点增加或删除 ,触发的时候,Zookeeper服务端会将事件通知给客户端
在这里插入图片描述
    首先,我们需要建立一个父节点,节点类型为持久节点 PERSISTENT,如上图中的 /locks/lock_name1 节点,每当需要访问共享资源时,就会在父节点下建立相应的顺序子节点,节点类型为临时节点 EPHEMERAL,且标记为 有序性 SEQUENTIAL,并且以 临时节点名称 + 父节点名称 + 顺序号 组成特定的名字,如 /0000000001 、/0000000002、/0000000003 作为临时有序节点。

加锁流程

     在建立子节点后,对 父节点下目所有 以 临时节点名称 name 开头的子节点进行排序,判断 刚刚建立的子节点顺序号是否是最小的节点,如果是,则获取锁,等 调用完共享资源后, 删除该节点,关闭 zk,进而可以触发监听事件(如果发生客户端宕机的情况 ,临时节点也会自动删除,然后触发监听器),释放该锁; 如果不是,说明之前已经有节点获取到锁了,则 调用 Object.wait() 或者 wait(timeout)阻塞 等待,并且 获得该节点的上一顺序节点,比如 /0000000002 的 上一顺序节点 是 /0000000001,在 /0000000001 节点上注册 Watcher,Watcher 会调用 Object.notifyAll() 来解除阻塞。也就是说,如果获取不到锁,只需要添加一个监听器,这样不用一直轮询,性能损耗较小。


💖
    这里需要理解的是,是 客户端 在 节点上注册监视器,比如:
    有 100 台服务器,服务器 1 创建了节点 /hjiajia,成功了,服务器获取到锁;接下来服务器 2 再去创建同样节点,就会失败,这时它只能监听这个节点的变化。
    等到服务器 1 处理完业务,删除了节点后,服务器 2 就会接到通知,可以去创建相同的节点了,就会获取锁,处理业务,然后 删除节点,接下来的 98 台服务器都是类似的步骤。注意,这么多服务器并不是挨个去执行以上操作,而是并发的,当服务器 1 创建成功,那么剩下的 99 个服务器 就都会注册监听这个节点,等通知,依次类推。
    然而这样可能出现死锁,如果服务器 1 创建了节点后宕机了,还没删除呢,那么剩下的 99 个服务器搁那一直等通知,就完球了。所以,”临时节点“ 就发挥了作用,对于临时节点,客户端一旦断开,就会丢失,这样的话,服务器 1 一旦宕机,节点就会被自动删除,后续其他服务器就可以继续创建节点了。然后还有一个问题,这个节点一有变化,就会通知剩下 99 个节点,但是最终只会有一个服务器创建成功,剩下那么多服务器还是需要等待监听,效率不高,所以 ”顺序性“就发挥作用了,不必让 99 个服务器监听一个节点,某一个服务器只是监听自己前面一个节点。
    假设 100 个服务器同时发来请求,这个时候会在 /hjiajia 节点下创建 100 个临时顺序性节点 /hjiajia/000000001, /hjiajia/000000002,一直到 /zkjjj/000000100,这个编号就相当于 已经给它们设置了获取锁的先后顺序了。当 001 节点处理完毕,删除节点后,002 收到通知,去获取锁,开始执行,执行完毕,删除节点,通知 003… …以此类推。


    
    源码:

// 加锁
// InterProcessMutex 是 Curator 实现的可重入锁
InterProcessMutex lock = new InterProcessMutex(client, lockPath);

	// (一)
if ( lock.acquire(maxWait, waitUnit) )
{
    try 
    {
        // do some work inside of the critical section here
    }
    finally
    {
        lock.release();
    }
}

(一)acquire():

public void acquire() throws Exception
    {			// (二)
            if ( !internalLock(-1, null) )
            {
                    throw new IOException("Lost connection while trying to acquire lock: " + basePath);
            }
    }

(二)internalLock:

private boolean internalLock(long time, TimeUnit unit) throws Exception
    {
            /*
                 Note on concurrency: a given lockData instance
                 can be only acted on by a single thread so locking isn't necessary
            */

            Thread currentThread = Thread.currentThread();
			// (三)
            LockData lockData = threadData.get(currentThread);
            if ( lockData != null )
            {
                    // re-entering
                    lockData.lockCount.incrementAndGet();
                    return true;
            }

            String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
            if ( lockPath != null )
            {
                    LockData newLockData = new LockData(currentThread, lockPath);
                    threadData.put(currentThread, newLockData);
                    return true;
            }

            return false;
    }
// ... 其他代码略

    代码中的注释译为:关于并发性的提醒:给定的 lockData 实例只能由单个线程操作,所以没必要锁定。
    可以看到,可重入锁是记录在 ConcurrentMap<Thread,LockData> threadData 这个 Map 里面的。如果 (三) 处 threadData.get(currentThread) 不为 null,就给 lockCount 加锁次数加 1。
    

解锁流程

    如果 可重入锁 次数减 1 后,加锁次数不为 0 则直接返回,否则 删除当前节点 和 threadDataMap 里面的可重入锁的数据。
    
    Zookeeper 存在的缺点是:如果有大量的客户端频繁地申请加锁、释放锁,对于 ZK 集群的压力会比较大。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值