前言
本文围绕 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(加、释放锁),内容分别如下:
<?phpnamespaceapp\service;usePredis\Client;usethink\facade\Env;classRedis{/**
* @var Client $client
*/protected$client;publicfunction__construct(){$this->client=newClient(['host'=>Env::get('redis.host'),'port'=>Env::get('redis.port'),]);$this->client->auth(Env::get('redis.auth'));$this->client->select(Env::get('redis.database'));}}
<?phpnamespaceapp\service;classLockextendsRedis{// 锁的有效期(s)constLOCK_EXPIRE=3;// 加锁失败后的重试次数constLOCK_RETRY_MAX=3;// 休眠时间(s):申请加锁时,其他线程加的锁还未过期,休眠一段时间后再重试加锁constLOCK_SLEEP=0.3;/**
* @var string $lockKey 被当作锁的键
*/private$lockKey;publicfunction__construct($lockKey){parent::__construct();$this->lockKey=$lockKey;}/**
* 获取加锁脚本
* KEYS:被当作锁的键名
* ARGVS:锁的过期时间戳、锁的有效期、当前时间戳
*
* @return string
*/privatefunctiongetLockScript(){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
*/privatefunctiongetUnLockScript(){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;}privatefunctionacquire(){return$this->client->eval($this->getLockScript(),1,$this->lockKey,time()+self::LOCK_EXPIRE+1,self::LOCK_EXPIRE,time());}/**
* 加锁
*
* @return bool
*/publicfunctionlock(){// 加锁申请次数$retry=1;while(0!=($res=$this->acquire())&&(self::LOCK_RETRY_MAX>$retry)){// 其他线程加的锁还未过期,休眠一段时间后再重试加锁2==$res&&sleep(self::LOCK_SLEEP);$retry++;}return0==$res;}/**
* 释放锁
*/publicfunctionunlock(){$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 中申请加锁,而不是各自到不同的地方申请一把锁来操作同一个资源。
版权归原作者 November's chopin 所有, 如有侵权,请联系我们删除。