前言
本文围绕 redis 的 SETNX
命令展开对“锁”的研究与实现。多个进程同时对 redis 执行 SETNX string_key timestamp_expired
命令,只有一个进程会成功,其余都会失败。上述命令中的 string_key 代表被当作锁的 redis 键名,其类型为 String,timestamp_expired 代表该键的过期时间戳,下同。
软件版本
- windows 10
- php 7.4.14 nts
- thinkphp 6.1.0
- redis 6.2.7
- predis/predis 2.0.3(php第三方扩展包)
锁算法
算法中使用 redis 的 EVAL
命令保证执行语句的原子性。
申请加锁前需约定锁的有效期、加锁失败后的重试次数、休眠时间(申请加锁时,其他线程加的锁还未过期,休眠一段时间后再重试加锁)。
申请加锁步骤:
- 申请加锁(
SETNX string_key timestamp_expired
),如果加锁成功,设置锁的过期时间并返回成功。否则进入步骤 2; - 如果加锁失败原因为 string_key 刚刚被其他进程删除(释放锁),返回步骤 1 重新申请加锁,否则进入步骤 3;
- 如果加锁失败原因为 string_key 还未过期(此时也会得到旧的过期时间戳),休眠一段时间后返回步骤 1 重新申请加锁,否则进入步骤 4;
- 执行
GETSET string_key timestamp_expired_new
命令,如果命令返回时间戳与旧的过期时间戳相等,加锁成功,设置锁的过期时间并返回成功。否则进入步骤 5; - 锁被其他进程删除了或者被其他进程抢先执行了
GETSET string_key timestamp_expired_new
命令,返回步骤 1 重新申请加锁。
释放锁:
如果 string_key 键未被删除且还未过期,执行删除键操作( DEL string_key
)。
实现
主要类文件有 2 个,为文件 Redis.php(获取 redis 操作客户端) 和 Lock.php(加、释放锁),内容分别如下:
<?php
namespace app\service;
use Predis\Client;
use think\facade\Env;
class Redis
{
/**
* @var Client $client
*/
protected $client;
public function __construct()
{
$this->client = new Client([
'host' => Env::get('redis.host'),
'port' => Env::get('redis.port'),
]);
$this->client->auth(Env::get('redis.auth'));
$this->client->select(Env::get('redis.database'));
}
}
<?php
namespace app\service;
class Lock extends Redis
{
// 锁的有效期(s)
const LOCK_EXPIRE = 3;
// 加锁失败后的重试次数
const LOCK_RETRY_MAX = 3;
// 休眠时间(s):申请加锁时,其他线程加的锁还未过期,休眠一段时间后再重试加锁
const LOCK_SLEEP = 0.3;
/**
* @var string $lockKey 被当作锁的键
*/
private $lockKey;
public function __construct($lockKey)
{
parent::__construct();
$this->lockKey = $lockKey;
}
/**
* 获取加锁脚本
* KEYS:被当作锁的键名
* ARGVS:锁的过期时间戳、锁的有效期、当前时间戳
*
* @return string
*/
private function getLockScript()
{
return <<<LUA
-- 日志
local function log_debug(msg)
redis.log(redis.LOG_DEBUG, string.format("[申请加锁]%s", msg))
end
log_debug('开始')
-- 被当作锁的键名
local key_lock = KEYS[1]
-- 锁的过期时间戳
local key_lock_expired = tonumber(ARGV[1])
-- 锁的有效期
local key_lock_expire = tonumber(ARGV[2])
-- 当前时间戳
local time_now = tonumber(ARGV[3])
local res = redis.call('SETNX', key_lock, key_lock_expired)
-- 获取锁成功
if (res == 1) then
redis.call('EXPIRE', key_lock, key_lock_expire + 1)
log_debug('成功')
return 0
end
local expired = redis.call('GET', key_lock)
-- 锁被其他进程删除
if (not expired) then
log_debug('锁被其他进程删除,请再重新申请')
return 1
end
expired = tonumber(expired)
-- 其他进程加的锁还未过期
if (expired > time_now) then
log_debug('锁还未过期,请休眠后再申请')
return 2
end
local key_lock_expired_new = key_lock_expired + key_lock_expire + 1
local expired_old = tonumber(redis.call('GETSET', key_lock, key_lock_expired_new))
if (expired_old == expired) then
redis.call('EXPIRE', key_lock, key_lock_expire + 1)
log_debug('锁过期,getset加锁成功')
return 0
end
-- 锁被其他进程删除或被其他进程抢占先机执行了 redis 的 getset 方法
log_debug('锁被其他进程删除或抢占先机,请再重新申请')
return 3
LUA;
}
/**
* 获取释放锁脚本
* KEYS:被当作锁的键名
* ARGVS:当前时间戳
*
* 客户端连接 redis server 加锁成功后,可能会出现以下情况:
* 1. 客户端挂掉,锁自动过期;
* 2. 客户端执行业务时间过长,锁过期后被其他进程再加锁;
* ……
*
* @return string
*/
private function getUnLockScript()
{
return <<<LUA
-- 被当作锁的键名
local key_lock = KEYS[1]
-- 当前时间戳
local time_now = tonumber(ARGV[1])
-- 锁的过期时间戳
local key_lock_expired = redis.call('GET', key_lock)
if (key_lock_expired and tonumber(key_lock_expired) <= time_now) then
redis.call('DEL', key_lock)
end
LUA;
}
private function acquire()
{
return $this->client->eval(
$this->getLockScript(),
1,
$this->lockKey,
time() + self::LOCK_EXPIRE + 1,
self::LOCK_EXPIRE,
time()
);
}
/**
* 加锁
*
* @return bool
*/
public function lock()
{
// 加锁申请次数
$retry = 1;
while (0 != ($res = $this->acquire()) && (self::LOCK_RETRY_MAX > $retry)) {
// 其他线程加的锁还未过期,休眠一段时间后再重试加锁
2 == $res && sleep(self::LOCK_SLEEP);
$retry++;
}
return 0 == $res;
}
/**
* 释放锁
*/
public function unlock()
{
$this->client->eval($this->getUnLockScript(), 1, $this->lockKey, time());
}
}
可以打开 redis server 的日志文件,跟踪加、释放锁的过程。
使用
- Lock 类中的
lock()
和unlock()
方法需成对使用,即在业务逻辑执行结束后主动释放锁。 - 在阿里云 redis 服务中使用上述编程时,可能需要取消 LUA 脚本中对调用传参
KEYS
和ARGV
的局部变量赋值。如 Lock 类getLockScript()
方法中local key_lock = KEYS[1]
,在需要调用key_lock
的地方替换为KEYS[1]
。 - 在 cluster 模式的 redis 集群(主从复制、哨兵、cluster)下,注意在加锁调用传参时 key 的格式。如要传递的键名为
lock_goods:300
,可以修改为{lock_goods}:300
,这样,redis 集群在查找这个键时只会对字符串lock_goods
进行哈希计算,从而得出存放此键的一个确定 slot 。即我们都要到一个 slot 中申请加锁,而不是各自到不同的地方申请一把锁来操作同一个资源。