之前在写支付回调的时候,因为第三方支付的回调机制有问题,存在并发回调的情况。如果对回调的订单不加锁的话,会造成一笔订单重复处理的情况。
在 Laravel 中使用基于 Redis 的锁非常简单,只需要使用 Cache::lock()
就可以创建和管理锁。
更多使用方法: https://learnku.com/docs/laravel/6.x/cache/5160#atomic-locks
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 use Illuminate \Support \Facades \Cache ;$orderSn = '123456' ; $lock = Cache::lock("pay_callback:{$orderSn}" , 10 ); if (!$lock->get()) { exit ('failed' ); } $lock->release();
如果用 Redis 作为缓存驱动的话,这段代码的锁就是用 Redis 的 SET 命令加上 NX 和 EX 参数实现的。
本文的例子是单机 分布式锁,多 Redis 实例的分布式锁参考: https://redis.io/topics/distlock 。
什么是分布式锁 多个进程(线程)请求共享资源时,为了保证共享资源在任意时间间隔内只有一个进程(线程)可以进行操作。
为了能够有效使用分布锁,应该具备的三个条件:
互斥性: 在任意时间间隔内,只有一个客户端可以持有锁。
无死锁: 即使锁定资源的客户端崩溃或发生其它意外,其它客户端也始终有可能获取锁。
容错: 只要大多数 Redis 节点还在运行,客户端就可以获取和释放锁。
Redis 分布式锁的原理 在 Redis 2.6.12 版本以前,需要 SETNX 、EXPIRE 两个命令实现锁。
SETNX 命令保证 key 只会被设置成功一次。
1 2 3 4 127.0.0.1:6379> SETNX lock-key val (integer ) 1 127.0.0.1:6379> SETNX lock-key val (integer ) 0
可以看到第二次设置 lock-key 时返回 0,表示没有被设置成功。
这时 lock-key 的过期时间为 -1,表示不会过期。
1 2 127.0.0.1:6379> ttl lock-key (integer ) -1
EXPIRE 命令保证了即使客户端抢到锁后挂掉了,在到达指定的过期时间后依然可以被其它客户端获取,不会造成死锁。
1 2 3 127.0.0.1:6379> expire lock-key 10 (integer ) 1
虽然基本上实现了锁,但是由于使用了两个命令,需要发出两次网络请求并等待响应,两次请求都有可能被意外情况打断,不是原子操作。
关于原子操作的解释: 所谓原子操作是指不会被进程(线程)调度机制打断的操作;只要开始执行,就不会被打断,直到执行完毕。
举个例子:
为了避免这种情况可以使用 Lua 脚本来代替,只发出一次网络请求,保证了客户端只要发出了请求,Redis 在执行 Lua 脚本的时候运行正常,就一定能设置锁成功。
为了能够更加方便的实现锁,在 Redis 2.6.12 后,SET 命令新增了 NX、EX 等参数,只需要一条命令就能设置 key、value 及过期时间。
释放锁 在过期时间前处理完业务逻辑后,需要提前释放锁,最简单的办法就是使用 DEL 命令删除。但是这种方法太过于粗暴了,而且会产生问题,比如 A 抢到了锁,然后开始处理自己的业务逻辑,这时候如果 B 直接用 DEL 命令将 A 的锁删除了,就会导致 A 访问的共享资源不安全。这样肯定是不行的,谁创建的锁就只能由谁来释放。
所以在设置锁的时候,需要生成一个唯一的字符串作为当前进程(线程)的 token,然后再将 token 设置为锁的值。在释放锁的时候,需要判断当前进程(线程)的 token 是否等于锁的 token,如果一致再使用 DEL 删除 key。
Lua 脚本示例:
1 2 3 4 5 6 if redis.call("get" ,KEYS[1 ]) == ARGV[1 ]then return redis.call("del" ,KEYS[1 ]) else return 0 end
代码实现 代码比较简单,就是将 Redis 的几个命令封装了一下。
需要安装 Redis 扩展,也可以改用 predis/predis。
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 class RedisLock { private $redis; private $token; private $prefix = 'redis-lock' ; public function __construct (Redis $redis = null) { $this ->redis = $redis ?: $this ->createRedis(); $this ->token = md5(uniqid(spl_object_hash($this ), true )); } public function get (string $name, int $seconds) { $args = [$this ->buildKey($name), $this ->token, $seconds]; return (bool) $this ->redis->eval(self ::getLockLuaScript(), $args, 1 ); } public function release (string $name) { $args = [$this ->buildKey($name), $this ->token]; return (bool) $this ->redis->eval(self ::getReleaseLuaScript(), $args, 1 ); } public function forceRelease (string $name) { return (bool) $this ->redis->del($this ->buildKey($name)); } protected function buildKey ($name) { return sprintf('%s%s' , $this ->prefix, $name); } protected function createRedis () { $redis = new Redis(); $redis->connect('127.0.0.1' , 6379 ); return $redis; } public static function getLockLuaScript () { return <<<LUA if redis.call("setnx",KEYS[1],ARGV[1]) > 0 then return redis.call('expire' ,KEYS[1 ],ARGV[2 ]) else return 0 ; end LUA; } public static function getReleaseLuaScript () { return <<<LUA if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del" ,KEYS[1 ]) else return 0 end LUA; } }
测试代码 PHP 脚本测试
1 2 3 4 5 6 7 8 9 10 11 12 $lock = new RedisLock(); $lock2 = new RedisLock(); $lock_name = 'order' ; echo "锁的名称:" .$lock_name.PHP_EOL;echo "lock1 第1次获取锁:" .json_encode($lock->get($lock_name, 10 )).PHP_EOL;echo "lock1 第2次获取锁:" .json_encode($lock->get($lock_name, 10 )).PHP_EOL;echo "lock2 第1次获取锁:" .json_encode($lock2->release($lock_name)).PHP_EOL;echo "lock2 尝试释放取锁:" .json_encode($lock2->release($lock_name)).PHP_EOL;echo "lock1 尝试释放取锁:" .json_encode($lock->release($lock_name)).PHP_EOL;echo "lock2 第2次获取锁:" .json_encode($lock2->get($lock_name, 10 )).PHP_EOL;
运行结果:
1 2 3 4 5 6 7 锁的名称:order lock1 第1次获取锁:true lock1 第2次获取锁:false lock2 第1次获取锁:false lock2 尝试释放取锁:false lock1 尝试释放取锁:true lock2 第2次获取锁:true
使用 siege 压测 index.php:
1 2 3 4 5 6 7 8 9 require_once 'RedisLock.php' ;$lock = new RedisLock(); $result = $lock->get('siege-test' , 1 ); http_response_code($result ? 200 : 500 ); usleep(900000 );
压测命令:
1 siege -c 100 http://127.0.0.1:8081
文章已经经过本人测试稳定,原作者是何湘辉,他的博客地址https://her-cat.com/,文章中使用lua是为了支持低版本redis,高版本redis也可以直接php调用redis_eval执行