【redis】redis 锁

本文详细介绍了在Redis中利用`SETNX`命令实现分布式锁的算法和步骤,包括锁的有效期设定、重试机制以及释放锁的逻辑。通过PHP的Predis库进行操作,并提到了在阿里云Redis服务及集群模式下的注意事项。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

本文围绕 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(加、释放锁),内容分别如下:

<?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 的日志文件,跟踪加、释放锁的过程。


使用

  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 中申请加锁,而不是各自到不同的地方申请一把锁来操作同一个资源。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值