分布式锁实现

分布式之分布式锁

1. 分布式锁

为了解决集群中多主机上不同线程之间的同步,需要在分布式系统中有类似于单主机下用于进程/线程同步的锁,也即分布式锁

1.1 基于MySQL

1.1.1 关键点

通过使用innodb提供的行锁来保证互斥性,来作为不同主机上线程的同步

1.1.2 可重入悲观锁实现

1)建表
create table if not exists lock_table(
    `id` int primary key ,
    `resource_name` varchar(10) ,
    `locker` varchar(20),
    `reentrant_cnt` int,
    `create_time` bigint(20),
    `update_time` bigint(20),
    unique key(resource_name))
    ENGINE=InnoDB DEFAULT CHARSET=utf8;

其中resource_name也即资源的名称,locker代表上锁者,reentrant_cnt代表重入次数

2)上锁

假设client_1对资源a上锁,流程如下

select * from lock_table where resource_name = 'a' for update;

这里先检查是否存在a记录,如果

  • 存在

    比较记录中的locker是否是client_1的ip,如果

    • 不是

      等待一段时间后,重新尝试读取

    • update lock_table set reentrant_cnt = reentrant_cnt+1 where resource_name = 'a';

  • 不存在

    insert into lock_table(resource_name,locker,reentrant_cnt) value('a','ip:port',1);

3)解锁

假设client_1对资源a解锁,流程如下

select * from lock_table where resource_name = 'a' for update;

这里先检查是否存在a记录,这里查到是自己的记录,如果cnt的值

  • 为1

    delete from lock_table where resource_name = 'a'

  • 大于1

    update lock_table set reentrant_cnt = reentrant_cnt-1 where resource_name = 'a';

1.1.3 优劣

    • 容易实现
    • 只使用mysql就可以完成
    • 锁超时

      需要手动管理锁的超时

    • 性能差

      需要开启事务来处理,并发量受限于MySQL的最大连接数

1.2 基于 zookeeper

1.2.1 关键点

使用zookeeper的watch机制来满足互斥性

1)watch机制定义

类比于观察者模式,支持如下监听事件

  • NodeCreated 节点创建:exits()
  • NodeDataChanged 节点数据改变:exits()getData()
  • NodeDeleted 节点删除:exits()getData()getChildren()
  • NodeChildrenChanged 子节点改变:getChildren()

后面的方法均为操作,可以指定标志位代表是否开启监听,对应于命令为加入-w参数get -w /root/...

2)watch机制使用流程
  • 客户端注册监听事件及回调函数到客户端的watchManager
  • 发起开启监听的读操作,假设调用getData()
  • 服务端zookeeper注册该watch事件
  • 当对应的节点数据改变、删除时,zookeeper内的事件被消耗,告知所有监听此节点的客户端
  • watchManager触发对应事件的回调函数

1.2.2 可重入悲观锁实现

1)上锁

假设client_1想要对资源resource_1上锁,流程如下

  • 创建如下的树结构

    /root  ----> /locks  ----> resource_1 
    	  |             |                
    	  |             |                 
    	   ----> ...     ----> resource_2
    	  |
    	  |
    	 ...
    
  • client_1在resource_1下创建节点,假设为

    • 加读锁

      则创建为ip1_port1_readlock_reentrantcnt_lockcnt,reentrantcnt代表重入次数,lock_cnt代表resource_1被上的读锁次数,最后的序号为zookeeper给出的,之后,client_1调用getChildren查出resource_1的所有子节点,如果

      • 之前没有写锁,只有读锁且序号(lock_cnt)小于自己或没有读锁,则上锁成功
      • 否则,上锁失败,对上一个写请求的节点注册watch
    • 加写锁,则创建为ip1_port1_writelock_reentrantcnt_lockcnt,reentrantcnt代表重入次数,lock_cnt代表resource_1被上的读锁次数,最后的序号为zookeeper给出的,之后,client_1调用getChildren查出resource_1的所有子节点,如果

      • 之前没有任何锁或均为自己的上锁记录,则上锁成功
      • 否则,上锁失败,对上一个节点注册watch

当注册的监听事件触发时,就代表上锁成功

上过锁的示例图如下

/root  ----> /locks  ----> resource_1 ----> /ip1_port1_readlock_reentrantcnt_lockcnt-000000
	  |             |                |
	  |             |                 ----> /ip2_port2_writelock_reentrantcnt_lockcnt-000001
	   ----> ...     ----> resource_2
	  |
	  |
	 ...
2)解锁

只需要删除对应路径下的节点即可

1.1.3 优劣

    • 容易实现
    • 创建节点时均为临时节点,当会话超时节点会被删除
    • 由于cp特性,可以保证集群内强一致性
    • 集群压力

      集群应对不了过大的客户端并发连接数

    • 性能差

      相较于基于缓存的分布式锁,性能较差

1.3 基于Redis

1.3.1 关键点

通过利用redis的缓存特性,主要为存取快及支持过期机制,来实现分布式锁

1.3.2 悲观锁实现

1)上锁

假设client_1想要对资源a上锁,流程如下

  • 调用setnx a ip:port,如果

    • 失败

      则a已经被占用

    • 成功则上锁完成

  • 调用expire a timeout来设定锁的过期时间

2)解锁
  • 判断a是否存在,有可能时间过长已经过期
  • 如果存在,del a

1.3.3 改进

上面方案存在如下问题

  • 原子性

    如果setnx和expire没有一次执行完,或者expire没有执行成功,此时client挂掉,则不会执行del,这样就产生了死锁

  • 可重入

    上面方案只可以标记是否占用,不可以标记重入次数

  • 阻塞

    如果set失败,则需要客户端周期性尝试

  • 过期时间过短

    假设client_1对a上锁后,执行了很长时间,超过了a的过期时间,且client_2在过期时间后,就可以成功对a上锁,之后client_1执行结束,就会释放掉不属于自己的锁

因而提出下面改进

  • 原子性

    使用set a value ex 5 nx来保证命令的原子性

  • 可重入

    改为使用hash结构,用value表示重入次数

  • 阻塞

    使用redis的发布订阅模式,来达到异步通知,不需要循环尝试

  • 过期时间过短

    还是刚才的例子,可以让client_1创建守护线程在过期时间达到前,检查是否还占用锁,如果占用则延长过期时间

# 参考

再有人问你分布式锁,这篇文章扔给他 - 掘金 (juejin.cn)

ZooKeeper Watch 机制源码解析 - Spongecaptain 的个人技术博客

14.0 Zookeeper 分布式锁实现原理 | 菜鸟教程 (runoob.com)

分布式锁用Redis还是Zookeeper? - 知乎 (zhihu.com)

分布式锁的实现之 redis 篇 | 小米信息部技术团队 (xiaomi-info.github.io)

上一篇:SpringBoot读取Resource下文件的几种方式


下一篇:【Resource】【ResourceLoader】【ResourcePatternResolver】学习