为什么要使用锁
业务在同一时刻只能有一个实例在运行,比如活动开奖、耗时数据库操作等场景,通常都以保证业务执行正常、节约服务器资源或者提高程序健壮性为目的。
引子 —— 文件锁实现锁
借助文件系统自带的锁机制 —— 排它锁
来实现,即一个进程在启动时获得一个文件的排它锁,并在自己的整个运行期间都保留句柄资源而不释放锁,使得另一个进程实例在启动时想要获得同一文件时失败,从而保证在 单台机器上同一时刻至多只有一个实例 在运行
PHP代码实现如下:
<?php
$filename = __FILE__ . '.lock';
$handle = fopen($filename, 'w');
if (!flock($handle, LOCK_EX | LOCK_NB)) {
// 获取排它锁不成功,说明已经有其他进程获取到文件锁,视作已有实例在执行,当前进程退出
echo "当前已经有进程在执行,本进程不执行", PHP_EOL;
exit();
}
/* 业务逻辑 */
在这里讲一个本人在实践中遇到的一个小坑:
上述示例代码中没有采用面向对象的程序设计,习惯面向对象编码的同学喜欢将上述程序过程整合到一个类里头,注意这个时候需要保证 $handle
在你的方法结束之后还没有被释放,如下面这样的写法就是有问题的:
<?php
/* 这段示例代码是有问题的,是个错误示范,可不要在自己的项目中使用噢 */
class Locker {
public static function doLock() {
$filename = __FILE__ . '.lock';
$handle = fopen($filename, 'w');
if (!flock($handle, LOCK_EX | LOCK_NB)) {
// 获取排它锁不成功,说明已经有其他进程获取到文件锁,视作已有实例在执行,当前进程退出
echo "当前已经有进程在执行,本进程不执行", PHP_EOL;
exit();
}
}
}
Locker::doLocker();
/* 业务逻辑 */
因为 doLocker()
方法执行完毕之后,句柄 $handle
作为局部变量会被立即会收掉,所以排它锁也会被释放掉,进程锁就直接失效了。
优点
稳定!本人在不计其数的业务中使用过文件锁,两年多程序员职业生涯还没有在此方面翻过车。只要磁盘不出问题,文件锁还是很给力的;缺点
从上述示例可见,文件锁依赖本次文件系统,只能在单个操作系统中产生作用。
由于业务的横向扩展,通常情况下一套业务需要部署在多台服务器上,此时文件锁便不能满足需求了。
如果要实现跨越操作系统的锁限制,则必须引入 外部存储方案;
几个分布式锁实现方案
从理论上来说,任何第三方的存储都能够实现本地文件系统功能。只不过各种操作需要走网络IO,在稳定性、速率上同本地文件系统会存在一定的差距。下面来研究一下几个比较常用的外部存储方案来实现分布式锁。
MySQL
论及最常用的外部存储方案,MySQL作为从大学时期就接触的数据库选型,必然首当其冲。使用数据库实现分布式锁也有两种方式:仅将数据库作为存储 和 数据库排它锁;
仅将数据库作为存储
-
基本思路
- 定义锁的标识符,这个标识符作为数据库表中的表征字段(主键),以此字段查询表中是否存在对应记录,若存在,则说明存在锁,则稍后再来检查;否则执行下一步;
- 执行
INSERT
语句插入锁信息,此时可能有好几个进程同时执行插入语句,但是由于插入字段中会含有主键,所以只有会一条INSERT
语句执行成功,执行成功的实例获得排它锁,则开始执行自己的业务逻辑;其他执行失败的实例则稍后再来检查,从第一步重新开始; - 业务逻辑执行完毕之后,执行
DELETE
语句删除掉数据库中的锁记录从而释放锁; - 另外起一个维护锁的业务来定期删除掉数据库中过期的锁记录,防止因为程序意外退出而没有删除掉锁记录造成死锁;
-
存在问题
上述业务虽然能够满足简单的一些需求,但是还是存在问题:- 需要保证MySQL服务的高可用;
- 必须使用轮询的方式去检查和获得锁,轮询间隔时间长了,业务执行中断到重新启动业务之间存在空档期变长了,不能忍受中断时间过长的业务不适用;轮询间隔短了,MySQL操作将会变得频繁,一旦业务增多,将会出现性能问题;
- 最后一步中的死锁清理存在误判风险,存在业务还未执行完毕锁就被清除的情况,从而导致同一时刻运行多个实例;
利用数据库的排它锁
采用数据库排它锁必须满足两个条件:
- 数据库表格使用
InnoDB
引擎; - 必须使用到表格主键,否则会将整个表格都锁住;
- 表格设计:
字段 | 类型 | 键 | 注释 |
---|---|---|---|
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
;
-
基本思路
- 假定锁名称为
lock
,则先到数据库中查询是否存在name = 'lock'
的记录,不存在则INSERT
一条,再向下执行;否则直接向下执行; - 执行数据库语句:
执行之后,由于数据行锁的作用,只有有一个实例会直接返回记录信息,其他的实例都会进入阻塞状态;START TRANSACTION; SELECT * FROM `business_lock` WHERE `name` = 'lock' FOR UPDATE;
- 执行业务逻辑,执行完毕之后,只用
COMMIT
语句释放行锁;
- 假定锁名称为
-
优点
- 使用了MySQL行锁带来的阻塞特性,使得实例A挂掉,实例B马上可以接替A的工作,期间空档期会缩短;
- 不用轮询MySQL;
-
缺点
- 业务增多并且MySQL的排它锁长期不释放,会导致MySQL的连接变多,占据大量的MySQL连接池资源;
Redis
除了MySQL这样的关系型数据库之外,我们用得最多的就是 Redis
,Redis
作为非关系型的内存数据库,在执行速度上比MySQL要快上不少。Redis实现分布式锁本质上和上面介绍的第一种MySQL方案是一样的。
单点 Redis
-
基本思路
- 定义锁的标识符,并生成
token
; - 以标识符作为 key 执行
setnx
设置值为token
和expire
语句 (用LUA封装,保证原子性),若数据库中无记录,则会执行成功,表示实例获得锁;否则执行失败表示锁已经被其他实例已经获得锁,不继续向下执行; - 执行业务逻辑,实例结束之前执行获取 key 对应的内容,如果内容和
token
相同,则执行删除(get 和 del 操作用LUA进行封装来保证原子性);
- 定义锁的标识符,并生成
优点
- 执行效率高,而且自带过期操作,开发友好;
- 缺点
- 强依赖Redis,单点Redis风险高,挂掉之后造成实例都不会进行;
- 存在 key 过期之后实例还没执行完毕的情况,有概率在同一时刻会执行多个实例;
RedLock
Zookeeper
ZooKeeper是一个高可用的分布式数据管理与系统协调框架,在Paxos算法的加持之下,该框架在分布式的环境中可以保持非常强的数据一致性,从而可以帮助解决很多分布式问题。
我们可以简单地将它看成是一个远程的小文件服务,而每个小文件又支持状态变化的监听和通知,它的数据模型如下:
/-
|--- locks/
|--- mylock0000001
|--- mylock0000002
|--- service/
|--- users/
上面根路径下的各种路径和节点都是由我们自己手动创建的。
在上面的每个节点上,我们都可以新增监听器,当zookeeper发现节点发生变化时(增、改、删),都会通知到监听它的客户端。
如何使用Zookeeper实现分布式锁
利用Zookeeper实现分布式锁也有两种基本思路:
- 利用节点名称唯一性实现共享所,和文件锁有些类似;
由于和文件锁比较类似,原理不再赘述。不过要提的是,当节点(锁)被释放时,zookeeper会通知到所有监听这个节点的客户端,从而各个客户端开始竞争,最终只有一个客户端获得锁。虽然实现简单,但锁释放时唤醒了所有的客户端,产生了「惊群效应」,故在性能上不是很客观。 - 利用临时顺序节点实现共享锁;
Zookeeper 还支持一个很厉害的特性:临时节点和顺序节点。
临时节点顾名思义,就是临时创建的节点,客户端创建的该类节点,在客户端和服务连接断开时就会删除;
而顺序节点就是在节点名称最后自动加上后缀,这个后缀在节点所在路径中时自增的。
依靠这两种特性,聪明而伟大的开发者就想到了一种比较好的监听流程
↑:表示监听
Instance1 -> /locks/0000001
↑
Instance2 -> /locks/0000002
↑
Instance3 -> /locks/0000003
↑
Instance4 -> /locks/0000005
↑
......
上面这段示例中InstanceX表示运行实例,/locks/000000X
为获得的节点路径,节点路径后缀最小的节点获得锁,其他的每一个实例都去监听所有节点中比自己次小(比自己小的节点中最大的节点)的节点的变化。
当1号实例释放了锁,那么2号实例就会得到通知,再扫描一下所有节点,判断到自己是最小的节点了,于是便获得了锁,后续的节点按照这个逻辑类推;
如果在1号实例释放前3号实例突然意外挂了,4号节点得到通知,扫描一下所有节点发现自己并不是最小的,于是开始监听2号节点的变化,所以整个监听链路是稳健的。
该方法每次锁释放时只会通知到一个客户端,所以不会有「惊群效应」。
总结
总之,设计分布式锁无非就是处理如下这三个方面的问题:
- 获得锁;
- 数据库用行锁或者用唯一约束字段实现;
- Redis用
setnx
实现; - Zookeeper用
最小节点
实现;
- 释放锁;
- 正常情况下客户端会主动释放,如删掉数据库的某条数据,释放行锁等;
- 异常情况下,需要借助过期锁清理机制释放锁,而zookeeper和客户端之间就存在心跳,如果客户端意外退出,心跳检测可以立即发现,从而服务端主动清锁;
- 释放锁通知:分为客户端主动获取 和 服务端主动告知,前者需要客户端做轮询操作,在时效性上不如后者;
从这几点看,zookeeper在实现分布式锁最为简单。
后续我会再研究一下zookeeper的性能。