redis应用系列一:分布式锁正确实现姿势
原创
©著作权归作者所有:来自51CTO博客作者叫我峰兄的原创作品,请联系作者获取转载授权,否则将追究法律责任
实现分布式锁常见有三种实现方式:
- 基于数据库
- 基于缓存(redis)分布式锁,
- 基于 Zookeeper 实现分布式锁
以下是他们在可靠性、性能、复杂性三个维度的对比
评判维度 比较
评判维度
| 比较
|
可靠性
| Zookeeper > 缓存 > 数据库
|
性能
| 缓存 > Zookeeper >= 数据库
|
复杂性
| Zookeeper >= 缓存 > 数据库
|
由于 redis 高性能,在许多密集型的业务场景中是运用最多,因此以下介绍基于 redis 分布式锁的实现
分析
Why
- 安全性(互斥性):在任意时刻,当且仅当只有一个客户端能持有锁
- 活性 A(无死锁):即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁
- 同一性:加锁和解锁必须保证为同一个客户端
- 活性 B (容错性):只要大部分的 Redis 节点正常运行,客户端就可以加锁和解锁
what
when
where
who
- 库存竞争:给标识库存的唯一属性加锁作为 key
- 工单 / 任务竞争:给工单 / 任务 加锁作为 key
How
- 没锁可以加锁
- 有锁加锁失败
- 给锁设置过期时间
- 解锁和加锁是同一个用户
How much
How feel
常见加锁方式
示例 1
public function lock($lockKey, $requestId, $expireTime)
{
$redis = Redis::connection();
$result = $redis->setnx($lockKey, $requestId);
if ($result) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
$redis->expire($lockKey, $expireTime);
}
}
此处乍一看这种方式并没有什么问题,
But 由于是两条 redis 命令,So 不具有原子性;
试想如果程序在执行完第一句 setnx 命令之后突然挂掉,那么会发生死锁,和设计原则相违背。
因此不是最优解
示例 2
function lock2($lockKey, $requestId, $expireTime)
{
$expires = microtime(true) + $expireTime;
$redis = Redis::connection();
// 如果当前锁不存在,返回加锁成功
$result = $redis->setnx($lockKey, microtime(true));
if ($result) {
return true;
}
// 如果锁存在,获取锁的过期时间
$currenExpires = $redis->get($lockKey);
if ($currenExpires && $currenExpires < microtime(true)) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
$oldExpires = $redis->getset($lockKey, $expires);
if ($oldExpires && $oldExpires == $currenExpires) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}
// 其他情况,一律返回加锁失败
return false;
}
那么这段代码问题在哪里?
由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步;
当锁过期的时候,如果多个客户端同时执行 getset 方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖;
锁不具备拥有者标识,即任何客户端都可以解锁。
因此此锁安全性没法保证,不满足设计原则第一条
示例 3
$lockKey 锁
* @param $requestId 请求标识
* @param $expireTime 超期时间
* @return bool
*/
public function lock3($lockKey, $requestId, $expireTime)
{
$ret = Redis::set($lockKey, $requestId, 'PX', $expireTime, 'NX');
if ($ret) {
return true;
}
return false;
}
此锁既满足了安全性,又有活性,并且满足同一性(解锁中体现),同时实现简单,是一种最优解
常见解锁方式
示例 1
function releaseLock($lockKey)
{
$redis = Redis::connection();
$redis->del($lockKey);
}
这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的
示例 2
function releaseLock1($lockKey, $requestId)
{
$redis = Redis::connection();
$result = $redis->get($lockKey);
// 判断加锁与解锁是不是同一个客户端
if ($result == $requestId) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
$redis->del($lockKey);
}
}
这种解锁方法没有多大毛病,但是存在一个问题,有误删锁的可能性
比如 A 客户端加锁,执行一段事件后进行解锁操作,在执行 del 锁之前锁过期,这时候客户端 B 加锁成功,接着客户端 A 执行 del 锁就会将客户端 B 的锁删除,没有保证同一性
示例3
function releaseLock13($lockKey, $requestId)
{
$luaScript = <<<EOF
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
EOF;
// 利用lua脚本,保证原子性
$res = Redis::eval($luaScript, 1, $lockKey, $requestId);
if ($res) {
return true;
}
return false;
}
此种方法利用 lua 脚本,保证原子性,是一种最优解
完整实现#
trait RedisMutexLock{
/**
* 获取分布式锁(加锁)
* @param lockKey 锁key
* @param requestId 客户端请求标识
* @param expireTime 超期时间,毫秒,默认15s
* @param isNegtive 是否是悲观锁,默认否
* @return 是否获取成功
*/
public function tryGetDistributedLock($lockKey, $requestId, $expireTime = 15000, $isNegtive = false)
{
if ($isNegtive) {//悲观锁
/**
* 悲观锁 循环阻塞式锁取,阻塞时间为2s
*/
$endtime = microtime(true) * 1000 + $this->acquireTimeout * 1000;
while (microtime(true) * 1000 < $endtime) { //每隔一段时间尝试获取一次锁
$acquired = Redis::set($lockKey, $requestId, 'PX', $expireTime, 'NX');
if ($acquired) { //获取锁成功,返回true
return true;
}
usleep(100);
}
//获取锁超时,返回false
return false;
} else {//乐观锁
/**
* 乐观锁只尝试一次,成功返回true,失败返回false
*/
$ret = Redis::set($lockKey, $requestId, 'PX', $expireTime, 'NX');
if ($ret) {
return true;
}
return false;
}
}
/**
* 解锁
* @param $lockKey 锁key
* @param $requestId 客户端请求唯一标识
*/
public function releaseDistributedLock($lockKey, $requestId)
{
$luaScript = <<<EOF
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
EOF;
$res = Redis::eval($luaScript, 1, $lockKey, $requestId);
if ($res) {
return true;
}
return false;
}
}
使用
use RedisMutexLock;
public function __construct()
{
define("REQUEST_ID", md5(uniqid(env('APP_NAME'), true)) . rand(10000, 99999));
$this->requestId = $_SERVER['x_request_id'] ?? REQUEST_ID;
}
// 抢单
public function addOrder()
{
// 订单加锁
$lock = $this->tryGetDistributedLock($this->redisOrderKey, $this->requestId);
if (!$lock) {
return ['error' => 1900001];
}
try {
// TODO 处理业务
} catch (\Exception $e) {
// 异常处理
} finally {
// 处理完释放锁
$this->releaseDistributedLock($this->redisOrderKey, $this->requestId);
}
}