没有不值得去解决的问题,也没有不值得去学习的技术!

Comparing Three Workarounds for Redis Locks When setnx Lacks Expiration

Since the SET command with options can already completely replace the functionality of SETNX, SETEX, PSETEX, in future versions, Redis may deprecate and eventually abandon these commands.

1. Reference: The process of implementing a Redis locking mechanism in Yii2.0, the core of which is using Redis setnx.

2. Generally, after successfully acquiring a lock, you execute the corresponding business logic and then delete the lock. However, if the business logic exits unexpectedly for some reason, resulting in the lock being created but not deleted, the lock will persist indefinitely. Therefore, you need to add an expiration time to the lock just in case.

3. Because Redis setnx does not have an expiration time feature. Option 1: Use Expire to set it, and we need to wrap both in Multi/Exec to ensure the atomicity of the request, so that setnx does not succeed while Expire fails. Also, the expiration time should only be set after the lock is successfully acquired. The Lua script is as follows:

Lua

local key = KEYS[1]
local value = KEYS[2]
local ttl = KEYS[3]

local ok = redis.call('setnx', key, value)

if ok == 1 then
redis.call('expire', key, ttl)
end

return ok

4. Since it requires using a Lua script, it is still a bit too cumbersome. In fact, since Redis 2.6.12, SET covers the functionality of SETEX, and SET itself already includes the ability to set an expiration time. This means the functionality we needed earlier can be implemented using SET alone. The code for Option 2 is as follows:

PHP

set($key, $value, array('nx', 'ex' => $ttl));

if ($ok) {
    // 业务逻辑代码
    $redis->del($key);
}

?>

5. However, the above implementation still has issues. Imagine that if the execution time of a request’s business logic code is relatively long, even longer than the lock’s validity period, the lock will expire during execution. At this point, another request will acquire the lock, but when the first request finishes executing, if it deletes the lock directly without making a judgment, it will mistakenly delete the lock created by the other request. Therefore, we need to introduce a random value when creating the lock. The optimized code for Option 2 is as follows:

PHP

set($key, $random, array('nx', 'ex' => $ttl));

if ($ok) {
    // 业务逻辑代码

    if ($redis->get($key) == $random) {
        $redis->del($key);
    }
}

?>

6. The process of implementing a Redis locking mechanism in Yii2.0 belongs to Option 3. Its core is using Redis setnx, and it does not use Expire to set an expiration time.

PHP
<?php
 
namespace common\logics\redis;
 
use Yii;
 
/**
 * This is the model class for table "{{%lock}}".
 *
 * @author Qiang Wang <shuijingwanwq@163.com>
 * @since 1.0
 */
class Lock extends \yii\redis\ActiveRecord
{

    /**
     * Redis模型的锁定实现
     * @param string $lockKeyName 锁定键名
     * 格式如下:
     *
     * 'game_category' //锁定键名,如比赛分类
     *
     * @param int $timeOut Redis锁定超时时间,单位为秒
     * 格式如下:3
     *
     * @return bool 成功返回真/失败返回假
     * 格式如下:
     *
     * true //状态,获取锁定成功,可继续执行
     * 
     * 或者
     * 
     * false //状态,获取锁定失败,不可继续执行
     *
     */
    public function lock($lockKeyName, $timeOut = 3)
    {
        // 设置锁定的过期时间,获取相关锁定参数
        $time = time();
        $lockKey = Yii::$app->params['redisLock']['keyPrefix'] . $lockKeyName;
        $lockExpire = $time + $timeOut;
        // 获取 Redis 连接,以执行相关命令
        $redis = Yii::$app->redis;
        // 获取锁定
        $executeCommandResult = $redis->setnx($lockKey, $lockExpire);
        // 返回0,表示已经被其他客户端锁定
        if ($executeCommandResult == 0) {
            // 防止死锁,获取当前锁的过期时间
            $lockCurrentExpire = $redis->get($lockKey);
            // 判断锁是否过期,如果已经过期
            if ($time > $lockCurrentExpire) {
                // 防止并发锁定,检查存储在 key 的旧值是否仍然是过期的时间戳,如果是,则获取锁定,否则返回假
                $executeCommandResult = $redis->getset($lockKey, $lockExpire);
                if ($lockCurrentExpire != $executeCommandResult) {
                    return false;
                }
            }

            // 返回0,表示已经被其他客户端锁定,且不存在死锁,返回假
            if ($executeCommandResult == 0) {
                return false;
            }

        }
        
        return true;
    }
    
    /**
     * 判断Redis模型的锁定是否存在
     * @param string $lockKeyName 锁定键名
     * 格式如下:
     *
     * 'game_category' //锁定键名,如比赛分类
     *
     * @return bool 锁定是否存在
     * 格式如下:
     *
     * true //状态:已存在
     * 
     * 或者
     * 
     * false //状态:不存在
     *
     */
    public function isLockExist($lockKeyName)
    {
        // 获取相关锁定参数
        $time = time();
        $lockKey = Yii::$app->params['redisLock']['keyPrefix'] . $lockKeyName;
        // 获取 Redis 连接,以执行相关命令
        $redis = Yii::$app->redis;
        // 获取锁定
        $executeCommandResult = $redis->get($lockKey);

        // 返回NULL,表示不存在锁定,否则表示存在
        if ($executeCommandResult === null) {
            return false;
        } else {
            // 如果存在锁定,判断锁是否过期,如果已经过期,则仍然认定为不存在锁定
            if ($time > $executeCommandResult) {
                // 如果已经过期,则释放锁定
                $redis->del($lockKey);
                return false;
            }
            
        }

        return true;
    }

    /**
     * Redis模型的释放锁定实现
     * @param string $lockKeyName 锁定键名
     * 格式如下:
     *
     * 'game_category' //锁定键名,如比赛分类
     *
     * @return integer 被删除的keys的数量
     * 格式如下:
     *
     * 1 //被删除的keys的数量
     * 
     * 或者
     * 
     * 0 //被删除的keys的数量
     *
     */
    public function unlock($lockKeyName)
    {
        // 获取相关锁定参数
        $lockKey = Yii::$app->params['redisLock']['keyPrefix'] . $lockKeyName;
        // 获取 Redis 连接,以执行相关命令
        $redis = Yii::$app->redis;
        // 释放锁定
        return $redis->del($lockKey);
    }
}


7. The specific approach is that when locking, the value is set to: current server time + expiration time. When acquiring a lock, even if it has already been locked by another client, to prevent deadlocks, it gets the expiration time of the current lock. By comparing it with the current server time, it determines whether it has expired. Then using getset, it is still possible to successfully acquire the lock. Overall, Option 3 has withstood the test in actual production environments and has no bugs.

8. Option 3 cannot solve the issue present in Option 2 at step 5. Generally speaking, under Option 3, if you want to avoid the issue from step 5, you can only try your best to avoid situations where the execution time is greater than the expiration time.

9. Moreover, regardless of whether it is Option 1, 2, or 3, there is another issue similar to the one in step 5: if the execution time of a request’s business logic code is relatively long, even longer than the lock’s validity period, the lock will expire during execution. At this point, another request will acquire the lock, and then the business logic code may be executed repeatedly or even in parallel. If the business logic code does not support repeated or parallel execution, new problems will arise. This ultimately leads to unexpected issues such as dirty data. The solution to this problem is to best add an implementation like a MySQL optimistic lock at the business logic code level. This avoids the issue of repeated or parallel execution and minimizes the probability of such problems occurring as much as possible.

10. Reference URL: http://www.redis.cn/commands/set.html . The code implementation logic of Option 3 is still too complex compared to Option 2. Furthermore, to avoid executing the direct locking process as much as possible, a method to check whether a lock exists was also implemented. I plan to adjust the locking implementation to Option 2 later when I have free time. The logic of Option 2 is simpler and more readable. Since the SET command with options can already completely replace the functionality of SETNX, SETEX, PSETEX, in future versions, Redis may deprecate and eventually abandon these commands. As shown in Figure 1

Since the SET command with options can already completely replace the functionality of SETNX, SETEX, PSETEX, in future versions, Redis may deprecate and eventually abandon these commands.
Figure 1

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系