浅谈分布式锁的实现

为什么要使用锁

业务在同一时刻只能有一个实例在运行,比如活动开奖、耗时数据库操作等场景,通常都以保证业务执行正常、节约服务器资源或者提高程序健壮性为目的。

引子 —— 文件锁实现锁

借助文件系统自带的锁机制 —— 排它锁 来实现,即一个进程在启动时获得一个文件的排它锁,并在自己的整个运行期间都保留句柄资源而不释放锁,使得另一个进程实例在启动时想要获得同一文件时失败,从而保证在 单台机器上同一时刻至多只有一个实例 在运行
PHP代码实现如下:

在这里讲一个本人在实践中遇到的一个小坑
上述示例代码中没有采用面向对象的程序设计,习惯面向对象编码的同学喜欢将上述程序过程整合到一个类里头,注意这个时候需要保证 $handle 在你的方法结束之后还没有被释放,如下面这样的写法就是有问题的:

因为 doLocker() 方法执行完毕之后,句柄 $handle 作为局部变量会被立即会收掉,所以排它锁也会被释放掉,进程锁就直接失效了。

  • 优点
    稳定!本人在不计其数的业务中使用过文件锁,两年多程序员职业生涯还没有在此方面翻过车。只要磁盘不出问题,文件锁还是很给力的;

  • 缺点
    从上述示例可见,文件锁依赖本次文件系统,只能在单个操作系统中产生作用。
    由于业务的横向扩展,通常情况下一套业务需要部署在多台服务器上,此时文件锁便不能满足需求了。
    如果要实现跨越操作系统的锁限制,则必须引入 外部存储方案;

几个分布式锁实现方案

从理论上来说,任何第三方的存储都能够实现本地文件系统功能。只不过各种操作需要走网络IO,在稳定性、速率上同本地文件系统会存在一定的差距。下面来研究一下几个比较常用的外部存储方案来实现分布式锁。

MySQL

论及最常用的外部存储方案,MySQL作为从大学时期就接触的数据库选型,必然首当其冲。使用数据库实现分布式锁也有两种方式:仅将数据库作为存储数据库排它锁

仅将数据库作为存储

  • 基本思路

    1. 定义锁的标识符,这个标识符作为数据库表中的表征字段(主键),以此字段查询表中是否存在对应记录,若存在,则说明存在锁,则稍后再来检查;否则执行下一步;
    2. 执行 INSERT 语句插入锁信息,此时可能有好几个进程同时执行插入语句,但是由于插入字段中会含有主键,所以只有会一条 INSERT语句执行成功,执行成功的实例获得排它锁,则开始执行自己的业务逻辑;其他执行失败的实例则稍后再来检查,从第一步重新开始;
    3. 业务逻辑执行完毕之后,执行 DELETE 语句删除掉数据库中的锁记录从而释放锁;
    4. 另外起一个维护锁的业务来定期删除掉数据库中过期的锁记录,防止因为程序意外退出而没有删除掉锁记录造成死锁;
  • 存在问题
    上述业务虽然能够满足简单的一些需求,但是还是存在问题:

    1. 需要保证MySQL服务的高可用;
    2. 必须使用轮询的方式去检查和获得锁,轮询间隔时间长了,业务执行中断到重新启动业务之间存在空档期变长了,不能忍受中断时间过长的业务不适用;轮询间隔短了,MySQL操作将会变得频繁,一旦业务增多,将会出现性能问题;
    3. 最后一步中的死锁清理存在误判风险,存在业务还未执行完毕锁就被清除的情况,从而导致同一时刻运行多个实例;

利用数据库的排它锁

采用数据库排它锁必须满足两个条件:

  1. 数据库表格使用 InnoDB 引擎;
  2. 必须使用到表格主键,否则会将整个表格都锁住;
  • 表格设计:
字段 类型 注释
name VARCHAR(100) Primary Key 名称
info VARCHAR(100) - 名称
created_at INT(10) UNSIGNED - 创建时间

构造语句如下:

CREATE TABLE `business_lock` (
    `name` VARCHAR(100) NOT NULL COMMENT '名称',
    `info` VARCHAR(100) NOT NULL COMMENT '信息',
    `created_at` INT(10) UNSIGNED NOT NULL COMMENT '创建时间',
    PRIMARY KEY (`name`)
)
COMMENT='业务锁'
ENGINE=InnoDB
;
  • 基本思路

    1. 假定锁名称为 lock ,则先到数据库中查询是否存在 name = 'lock' 的记录,不存在则 INSERT 一条,再向下执行;否则直接向下执行;
    2. 执行数据库语句:
      START TRANSACTION; SELECT * FROM `business_lock` WHERE `name` = 'lock' FOR UPDATE;
      
      执行之后,由于数据行锁的作用,只有有一个实例会直接返回记录信息,其他的实例都会进入阻塞状态;
    3. 执行业务逻辑,执行完毕之后,只用 COMMIT 语句释放行锁;
  • 优点

    1. 使用了MySQL行锁带来的阻塞特性,使得实例A挂掉,实例B马上可以接替A的工作,期间空档期会缩短;
    2. 不用轮询MySQL;
  • 缺点

    1. 业务增多并且MySQL的排它锁长期不释放,会导致MySQL的连接变多,占据大量的MySQL连接池资源;

Redis

除了MySQL这样的关系型数据库之外,我们用得最多的就是 RedisRedis 作为非关系型的内存数据库,在执行速度上比MySQL要快上不少。Redis实现分布式锁本质上和上面介绍的第一种MySQL方案是一样的。

单点 Redis

  • 基本思路

    1. 定义锁的标识符,并生成 token
    2. 以标识符作为 key 执行 setnx 设置值为 tokenexpire语句 (用LUA封装,保证原子性),若数据库中无记录,则会执行成功,表示实例获得锁;否则执行失败表示锁已经被其他实例已经获得锁,不继续向下执行;
    3. 执行业务逻辑,实例结束之前执行获取 key 对应的内容,如果内容和 token 相同,则执行删除(get 和 del 操作用LUA进行封装来保证原子性);
  • 优点

  1. 执行效率高,而且自带过期操作,开发友好;
  • 缺点
  1. 强依赖Redis,单点Redis风险高,挂掉之后造成实例都不会进行;
  2. 存在 key 过期之后实例还没执行完毕的情况,有概率在同一时刻会执行多个实例;

RedLock

Redis Distlock

Zookeeper

ZooKeeper是一个高可用的分布式数据管理与系统协调框架,在Paxos算法的加持之下,该框架在分布式的环境中可以保持非常强的数据一致性,从而可以帮助解决很多分布式问题。
我们可以简单地将它看成是一个远程的小文件服务,而每个小文件又支持状态变化的监听和通知,它的数据模型如下:

/-
 |--- locks/
      |--- mylock0000001
      |--- mylock0000002
 |--- service/
 |--- users/

上面根路径下的各种路径和节点都是由我们自己手动创建的。
在上面的每个节点上,我们都可以新增监听器,当zookeeper发现节点发生变化时(增、改、删),都会通知到监听它的客户端。

如何使用Zookeeper实现分布式锁

利用Zookeeper实现分布式锁也有两种基本思路:

  1. 利用节点名称唯一性实现共享所,和文件锁有些类似;
    由于和文件锁比较类似,原理不再赘述。不过要提的是,当节点(锁)被释放时,zookeeper会通知到所有监听这个节点的客户端,从而各个客户端开始竞争,最终只有一个客户端获得锁。虽然实现简单,但锁释放时唤醒了所有的客户端,产生了「惊群效应」,故在性能上不是很客观。
  2. 利用临时顺序节点实现共享锁;
    Zookeeper 还支持一个很厉害的特性:临时节点和顺序节点。
    临时节点顾名思义,就是临时创建的节点,客户端创建的该类节点,在客户端和服务连接断开时就会删除;
    而顺序节点就是在节点名称最后自动加上后缀,这个后缀在节点所在路径中时自增的。
    依靠这两种特性,聪明而伟大的开发者就想到了一种比较好的监听流程
 ↑:表示监听


Instance1 -> /locks/0000001
 ↑
Instance2 -> /locks/0000002
 ↑
Instance3 -> /locks/0000003
 ↑
Instance4 -> /locks/0000005
 ↑
......

上面这段示例中InstanceX表示运行实例,/locks/000000X为获得的节点路径,节点路径后缀最小的节点获得锁,其他的每一个实例都去监听所有节点中比自己次小(比自己小的节点中最大的节点)的节点的变化。
当1号实例释放了锁,那么2号实例就会得到通知,再扫描一下所有节点,判断到自己是最小的节点了,于是便获得了锁,后续的节点按照这个逻辑类推;
如果在1号实例释放前3号实例突然意外挂了,4号节点得到通知,扫描一下所有节点发现自己并不是最小的,于是开始监听2号节点的变化,所以整个监听链路是稳健的。
该方法每次锁释放时只会通知到一个客户端,所以不会有「惊群效应」。

总结

总之,设计分布式锁无非就是处理如下这三个方面的问题:

  1. 获得锁;
    • 数据库用行锁或者用唯一约束字段实现;
    • Redis用 setnx 实现;
    • Zookeeper用 最小节点 实现;
  2. 释放锁;
    • 正常情况下客户端会主动释放,如删掉数据库的某条数据,释放行锁等;
    • 异常情况下,需要借助过期锁清理机制释放锁,而zookeeper和客户端之间就存在心跳,如果客户端意外退出,心跳检测可以立即发现,从而服务端主动清锁;
  3. 释放锁通知:分为客户端主动获取 和 服务端主动告知,前者需要客户端做轮询操作,在时效性上不如后者;

从这几点看,zookeeper在实现分布式锁最为简单。
后续我会再研究一下zookeeper的性能。

你可能感兴趣的:(浅谈分布式锁的实现)