Redis 的锁定实现,基于 setnx 不具备过期时间的功能,弥补的方案一、二、三的对比分析
1、参考网址:https://www.shuijingwanwq.com/2017/01/08/1505/ ,在 Yii2.0 下实现 Redis 的锁定机制的流程,其核心是使用 Redis setnx。
2、一般来说,在加锁成功后,执行相应的业务逻辑,然后删除锁。但是,如果业务逻辑因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在。因此,需要给锁加一个过期时间以防万一。
3、由于 Redis setnx 不具备过期时间的功能。方案一:借助 Expire 来设置,同时我们需要把两者用 Multi/Exec 包裹起来以确保请求的原子性,以免 setnx 成功了 Expire 却失败了。并且只有当加锁成功后,才设置过期时间。Lua 脚本如下所示:
1 2 3 4 5 6 7 8 9 10 11 | 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、由于要使用到 Lua 脚本,还是过于麻烦了些。其实 Redis 从 2.6.12 起,SET 涵盖了 SETEX 的功能,并且 SET 本身已经包含了设置过期时间的功能,也就是说,我们前面需要的功能只用 SET 就可以实现。方案二的代码如下所示:
1 2 3 4 5 6 7 8 9 10 | <?php $ok = $redis ->set( $key , $value , array ( 'nx' , 'ex' => $ttl )); if ( $ok ) { // 业务逻辑代码 $redis ->del( $key ); } ?> |
5、但是如上实现仍然存在问题,设想一下,如果一个请求业务逻辑代码的执行时间比较长,甚至比锁的有效期还要长,导致在执行过程中,锁就失效了,此时另一个请求会获取锁,但前一个请求在执行完毕的时候,如果不加以判断直接删除锁,就会出现误删除其它请求创建的锁的情况,所以我们在创建锁的时候需要引入一个随机值。方案二的优化代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 | <?php $ok = $redis ->set( $key , $random , array ( 'nx' , 'ex' => $ttl )); if ( $ok ) { // 业务逻辑代码 if ( $redis ->get( $key ) == $random ) { $redis ->del( $key ); } } ?> |
6、在 Yii2.0 下实现 Redis 的锁定机制的流程 属于 方案三。其核心是使用 Redis setnx,且未使用 Expire 来设置过期时间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | <?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、其具体方案为加锁时,设置 value 的值为:当前服务器时间 + 过期时间。在加锁时,即使已经被其他客户端锁定,为了防止死锁,获取当前锁的过期时间。通过与当前服务器时间的比较,判断是否过期。再使用 getset,仍然有可能加锁成功。总体来看,方案三在实际的生产环境中经受了考验,不存在 Bug。
8、方案三 无法解决 方案二在步骤5中存在的问题。一般而言,在方案三下,如果要想避免步骤5中存在的问题。只能够尽量避免出现执行时间大于过期时间的情况了的。
9、且不论是方案一、二、三,还存在另外一个问题,与步骤5中的问题类似,即如果一个请求业务逻辑代码的执行时间比较长,甚至比锁的有效期还要长,导致在执行过程中,锁就失效了,此时另一个请求会获取锁,然后业务逻辑代码可能就会重复执行,甚至是并行执行,如果业务逻辑代码不支持重复执行与并行执行的话,就会产生新的问题。最终导致出现预期之外的脏数据之类的问题。此问题的解决方案为最好在业务逻辑代码层面再增加一个 MySQL 的乐观锁之类的实现。以避免出现重复或者并行执行的问题。以最大概率的降低此类问题出现的概率。
10、参考网址:http://www.redis.cn/commands/set.html 。方案三 的代码实现逻辑仍然过于复杂,相对于 方案二 而言。且为了尽量避免执行到直接加锁的流程,还实现了一个判断锁定是否存在的方法。计划后续有空余时间后,准备将 锁定实现 调整为 方案二。方案二的逻辑更为简单,可读性更好。由于SET
命令加上选项已经可以完全取代SETNX, SETEX, PSETEX的功能,所以在将来的版本中,redis可能会不推荐使用并且最终抛弃这几个命令。如图1
近期评论