0


【redis】redis 锁

前言

本文围绕 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

命令保证执行语句的原子性。

申请加锁前需约定锁的有效期、加锁失败后的重试次数、休眠时间(申请加锁时,其他线程加的锁还未过期,休眠一段时间后再重试加锁)。

申请加锁步骤:

  1. 申请加锁( SETNX string_key timestamp_expired ),如果加锁成功,设置锁的过期时间并返回成功。否则进入步骤 2;
  2. 如果加锁失败原因为 string_key 刚刚被其他进程删除(释放锁),返回步骤 1 重新申请加锁,否则进入步骤 3;
  3. 如果加锁失败原因为 string_key 还未过期(此时也会得到旧的过期时间戳),休眠一段时间后返回步骤 1 重新申请加锁,否则进入步骤 4;
  4. 执行 GETSET string_key timestamp_expired_new 命令,如果命令返回时间戳与旧的过期时间戳相等,加锁成功,设置锁的过期时间并返回成功。否则进入步骤 5;
  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 的日志文件,跟踪加、释放锁的过程。


使用

  1. Lock 类中的 lock()unlock() 方法需成对使用,即在业务逻辑执行结束后主动释放锁。
  2. 在阿里云 redis 服务中使用上述编程时,可能需要取消 LUA 脚本中对调用传参 KEYSARGV 的局部变量赋值。如 Lock 类 getLockScript() 方法中 local key_lock = KEYS[1] ,在需要调用 key_lock 的地方替换为 KEYS[1]
  3. 在 cluster 模式的 redis 集群(主从复制、哨兵、cluster)下,注意在加锁调用传参时 key 的格式。如要传递的键名为 lock_goods:300 ,可以修改为 {lock_goods}:300,这样,redis 集群在查找这个键时只会对字符串 lock_goods 进行哈希计算,从而得出存放此键的一个确定 slot 。即我们都要到一个 slot 中申请加锁,而不是各自到不同的地方申请一把锁来操作同一个资源。

标签: redis php

本文转载自: https://blog.csdn.net/ZopaulCode/article/details/128152349
版权归原作者 November's chopin 所有, 如有侵权,请联系我们删除。

“【redis】redis 锁”的评论:

还没有评论