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

How to Implement a Redis Locking Mechanism in Yii2.0

将获取锁定与释放锁定抽象为一个类文件,\common\models\redis\Lock.php

1. Set the lock expiration time: current Unix timestamp + Redis lock timeout, in seconds (3). Edit file: \common\config\params.php, as shown in Figure 1

PHP

    'lock' => [
        'keyPrefix' => 'lock:', //Redis锁定 key 前缀
        'timeOut' => 3, //Redis锁定超时时间,单位为秒
    ],

Set the lock expiration time: current Unix timestamp + Redis lock timeout, in seconds (3)
Figure 1

2. Get the relevant configuration parameters. Edit file: \api\models\redis\GameCategory.php, as shown in Figure 2

PHP

            // 设置锁定的过期时间
            $time = time();
            $lockKey = Yii::$app->params['lock']['keyPrefix'] . 'game_category';
            $lockExpire = $time + Yii::$app->params['lock']['timeOut'];

Get the relevant configuration parameters
Figure 2

3. Get the Redis connection to execute relevant commands. Edit file: \api\models\redis\GameCategory.php, as shown in Figure 3

PHP

            // 获取 Redis 连接,以执行相关命令
            $redis = Yii::$app->redis;

Get the Redis connection to execute relevant commands
Figure 3

4. Acquire the lock, as shown in Figure 4

PHP

            // 获取锁定
            $executeCommandResult = $redis->setnx($lockKey, $lockExpire);

Note: SETNX key value
Sets key to value if key does not exist; in this case, it is equivalent to the SET command. When key already exists, it does nothing. SETNX stands for “SET if Not eXists”.

Acquire the lock
Figure 4

5. Returning 0 indicates that the resource has already been locked by another client, as shown in Figures 5, 6, and 7

PHP

            // 返回0,表示已经被其他客户端锁定
            if ($executeCommandResult == 0) {
                // $fileName = microtime(true);
                // file_put_contents('./../runtime/0-' . $fileName . '.txt', '1');
                // 防止死锁,获取当前锁的过期时间
                $lockCurrentExpire = $redis->get($lockKey);
                // $fileName = microtime(true);
                // file_put_contents('./../runtime/6-' . $fileName . $lockCurrentExpire . '.txt', '1');
                // 判断锁是否过期,如果已经过期
                if ($time > $lockCurrentExpire) {
                    // $fileName = microtime(true);
                    // file_put_contents('./../runtime/1-' . $fileName . '.txt', '1');
                    // 释放锁定
                    // $redis->del($lockKey);
                    // 获取锁定
                    // $executeCommandResult = $redis->setnx($lockKey, $lockExpire);
                    // 防止并发锁定,检查存储在 key 的旧值是否仍然是过期的时间戳,如果是,则获取锁定,否则返回假
                    $executeCommandResult = $redis->getset($lockKey, $lockExpire);
                    if ($lockCurrentExpire != $executeCommandResult) {
                        // $fileName = microtime(true);
                        // file_put_contents('./../runtime/2-' . $fileName . '.txt', '1');
                        return ['status' => false, 'code' => 0, 'message' => ''];
                    }
                    // $fileName = microtime(true);
                    // file_put_contents('./../runtime/3-' . $fileName . '.txt', '1');
                }
                
                // 返回0,表示已经被其他客户端锁定,且不存在死锁,返回假
                if ($executeCommandResult == 0) {
                    // $fileName = microtime(true);
                    // file_put_contents('./../runtime/4-' . $fileName . '.txt', '1');
                    return ['status' => false, 'code' => 0, 'message' => ''];
                }

            }
            
            // $fileName = microtime(true);
            // file_put_contents('./../runtime/5-' . $fileName . '.txt', '1');

Returning 0 indicates that the resource has already been locked by another client
Figure 5
Returning 0 indicates that the resource has already been locked by another client
Figure 6
Returning 0 indicates that the resource has already been locked by another client
Figure 7

6. Release the lock, as shown in Figure 8
Note: DEL key [key …]
If a deleted key does not exist, it is simply ignored.

Release the lock
Figure 8

7. Regarding step 5, to determine whether the lock has expired: if it has already expired, comment out the lock release and lock acquisition. To prevent concurrent locking, you can perform the following test procedure for verification

8. First, comment out the lock release to simulate the problem: what happens if the client fails, crashes, or cannot release the lock? As shown in Figure 9

First, comment out the lock release to simulate the problem: what happens if the client fails, crashes, or cannot release the lock?
Figure 9

9. In the code section that determines whether the lock has expired, if it has already expired, adopt the first algorithm and uncomment all file_put_contents statements, as shown in Figure 10

In the code section that determines whether the lock has expired, if it has already expired, adopt the first algorithm and uncomment all file_put_contents statements
Figure 10

10. In Redis, execute the command: FLUSHDB to clear all keys, as shown in Figure 11

In Redis, execute the command: FLUSHDB to clear all keys
Figure 11

11. Execute the concurrent request test and set the number of threads to 10, as shown in Figures 12 and 13

Execute the concurrent request test and set the number of threads to 10
Figure 12
Execute the concurrent request test and set the number of threads to 10
Figure 13

12. Check the files generated in the \api\runtime directory: the number of files starting with 0, 4, and 6 is 9 respectively, and the number of files starting with 5 is 1, which is normal, as shown in Figure 14

Check the files generated in the \api\runtime directory: the number of files starting with 0, 4, and 6 is 9 respectively, and the number of files starting with 5 is 1, which is normal
Figure 14

13. In Redis, delete all business-related keys except lock:game_category, i.e., keys starting with game_category, to simulate that the lock has already expired, as shown in Figure 15

In Redis, delete all business-related keys except lock:game_category, i.e., keys starting with game_category, to simulate that the lock has already expired
Figure 15

14. Delete the files generated in the \api\runtime directory, as shown in Figure 16

Delete the files generated in the \api\runtime directory
Figure 16

15. Execute the concurrent request test and set the number of threads to 10, as shown in Figures 17 and 18

Execute the concurrent request test and set the number of threads to 10
Figure 17
Execute the concurrent request test and set the number of threads to 10
Figure 18

16. Check the files generated in the \api\runtime directory: the number of files starting with 0 and 6 is 10, the number of files starting with 1 and 4 is 8 respectively, and the number of files starting with 5 is 2, as shown in Figure 19

Note 1: The number of files starting with 5 is 2, but it should only be 1. A value greater than 1 indicates that the locking was unsuccessful
Note 2: When determining whether the lock has expired, if it has already expired, you cannot simply call DEL to release the lock and then acquire the lock based on SETNX, because there is a race condition here. When multiple clients detect an expired lock, release it, and then acquire it, they will all acquire the lock.

Check the files generated in the \api\runtime directory: the number of files starting with 0 and 6 is 10, the number of files starting with 1 and 4 is 8 respectively, and the number of files starting with 5 is 2
Figure 19

17. In the code section that determines whether the lock has expired, if it has already expired, adopt the second algorithm and uncomment all file_put_contents statements, as shown in Figure 20

In the code section that determines whether the lock has expired, if it has already expired, adopt the second algorithm and uncomment all file_put_contents statements
Figure 20

18. Repeat steps 13, 14, and 15. Check the files generated in the \api\runtime directory: the number of files starting with 0 and 6 is 10, the number of files starting with 1 is 7, the number of files starting with 2 is 6, the number of files starting with 3 is 1, the number of files starting with 4 is 3, and the number of files starting with 5 is 1, which is normal, as shown in Figure 21

Note 1: The number of files starting with 1 is 7, the number of files starting with 2 is 6, and the number of files starting with 3 is 1. The sum of the latter two exactly equals the former, indicating that among the 7 expired threads, only one acquired the lock. Ultimately, the number of files starting with 5 is also 1
Note 2: Due to the characteristics of GETSET, you can check whether the old value stored in the key is still the expired timestamp. If it is, the lock is acquired; otherwise, it returns false.

Repeat steps 13, 14, and 15. Check the files generated in the \api\runtime directory: the number of files starting with 0 and 6 is 10, the number of files starting with 1 is 7, the number of files starting with 2 is 6, the number of files starting with 3 is 1, the number of files starting with 4 is 3, and the number of files starting with 5 is 1, which is normal
Figure 21

19. Clean up all code that facilitated testing during development, such as file_put_contents, as shown in Figure 22

PHP

            // 设置锁定的过期时间,获取相关锁定参数
            $time = time();
            $lockKey = Yii::$app->params['lock']['keyPrefix'] . 'game_category';
            $lockExpire = $time + Yii::$app->params['lock']['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 ['status' => false, 'code' => 0, 'message' => ''];
                    }
                }
                
                // 返回0,表示已经被其他客户端锁定,且不存在死锁,返回假
                if ($executeCommandResult == 0) {
                    return ['status' => false, 'code' => 0, 'message' => ''];
                }

            }

Clean up all code that facilitated testing during development, such as file_put_contents
Figure 22

20. Abstract the lock acquisition and release into a class file, \common\models\redis\Lock.php, as shown in Figures 23 and 24

 

PHP
<?php namespace common\models\redis; use Yii; /** * This is the model class for table "{{%lock}}". * */ class Lock extends \yii\redis\ActiveRecord { /** * Redis模型的锁定实现 * @param string $lockKeyName 锁定键名 * 格式如下: * * 'game_category' //锁定键名,如比赛分类 * * @return integer 成功返回对象数组/失败返回错误信息 * 格式如下: * * [ * 'status' => true //状态
     * ]
     * 
     * 或者
     * 
     * [
     *     'status' => false, //状态
     *     'code' => 0, //返回码
     *     'message' => '', //说明
     * ]
     *
     */
    public function lock($lockKeyName)
    {
        // 设置锁定的过期时间,获取相关锁定参数
        $time = time();
        $lockKey = Yii::$app->params['lock']['keyPrefix'] . $lockKeyName;
        $lockExpire = $time + Yii::$app->params['lock']['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 ['status' => false, 'code' => 0, 'message' => ''];
                }
            }
 
            // 返回0,表示已经被其他客户端锁定,且不存在死锁,返回假
            if ($executeCommandResult == 0) {
                return ['status' => false, 'code' => 0, 'message' => ''];
            }
 
        }
    }
 
    /**
     * Redis模型的释放锁定实现
     * @param string $lockKeyName 锁定键名
     * 格式如下:
     *
     * 'game_category' //锁定键名,如比赛分类
     *
     * @return integer 被删除的keys的数量
     * 格式如下:
     *
     * 1 //被删除的keys的数量
     * 
     * 或者
     * 
     * 0 //被删除的keys的数量
     *
     */
    public function unlock($lockKeyName)
    {
        // 获取相关锁定参数
        $lockKey = Yii::$app->params['lock']['keyPrefix'] . $lockKeyName;
        // 获取 Redis 连接,以执行相关命令
        $redis = Yii::$app->redis;
        // 释放锁定
        return $redis->del($lockKey);
    }
}
 
Abstract the lock acquisition and release into a class file, \common\models\redis\Lock.php
Figure 23
Abstract the lock acquisition and release into a class file, \common\models\redis\Lock.php
Figure 24

21. Edit file: \api\models\redis\GameCategory.php, as shown in Figures 25, 26, and 27

 

PHP

            /* Redis模型的锁定实现 */
            $lockKeyName = 'game_category';
            $lock = new Lock();
            $lockResult = $lock->lock($lockKeyName);
            // 返回 false,表示已经被其他客户端锁定
            if ($lockResult['status'] === false) {
                return ['status' => false, 'code' => 0, 'message' => ''];
            }

Edit file: \api\models\redis\GameCategory.php
Figure 25
Edit file: \api\models\redis\GameCategory.php
Figure 25
Edit file: \api\models\redis\GameCategory.php
Figure 27

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

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

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

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

关于我  ·  GitHub  ·  邮件联系