php redis 分布式锁,php redis 锁

之前在写支付回调的时候,因为第三方支付的回调机制有问题,存在并发回调的情况。如果对回调的订单不加锁的话,会造成一笔订单重复处理的情况。

在 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 版本以前,需要 SETNXEXPIRE 两个命令实现锁。

  • SETNX 是 SET if Not eXists 的简写,翻译过来就是当 key 不存在时才 set。

  • EXPIRE 的作用就是设置指定 key 的存活时间,单位为秒。

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
# 设置10秒后过期
127.0.0.1:6379> expire lock-key 10
(integer) 1


虽然基本上实现了锁,但是由于使用了两个命令,需要发出两次网络请求并等待响应,两次请求都有可能被意外情况打断,不是原子操作。

关于原子操作的解释: 所谓原子操作是指不会被进程(线程)调度机制打断的操作;只要开始执行,就不会被打断,直到执行完毕。

举个例子:

  • 客户端告诉 Redis 说我要用 SETNX 设置个 key。

  • Redis 收到请求并设置成功了。

  • 客户端还没来得及发出设置过期时间的请求就挂掉了。

  • 这时候 key 不会过期,导致其它客户端没办法获取到锁,造成了死锁。

为了避免这种情况可以使用 Lua 脚本来代替,只发出一次网络请求,保证了客户端只要发出了请求,Redis 在执行 Lua 脚本的时候运行正常,就一定能设置锁成功。

为了能够更加方便的实现锁,在 Redis 2.6.12 后,SET 命令新增了 NX、EX 等参数,只需要一条命令就能设置 key、value 及过期时间。


1
SET key value NX EX 10


  • NX: 只有在 key 不存在时才设置成功,作用等同于 SETNX。

  • EX: key 多少秒后过期,作用等同于 EXPIRE。

释放锁

在过期时间前处理完业务逻辑后,需要提前释放锁,最简单的办法就是使用 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
{
   /**
    * @var Redis
    */
   private $redis;

   /**
    * @var string
    */
   private $token;

   /**
    * @var string
    */
   private $prefix = 'redis-lock';

   /**
    * RedisLock constructor.
    * @param Redis $redis
    */
   public function __construct(Redis $redis = null)
   {
       $this->redis = $redis ?: $this->createRedis();
       $this->token = md5(uniqid(spl_object_hash($this), true));
   }

   /**
    * 获取一个锁.
    * @param string $name
    * @param int $seconds
    * @return bool
    */
   public function get(string $name, int $seconds)
   {
       $args = [$this->buildKey($name), $this->token, $seconds];

       // Redis 2.6.12 版本以后支持 SET 命令支持 NX、EX 选项
       // SET key value NX EX seconds
       // $this->redis->set($this->buildKey($name), $this->token, ['NX', 'EX' => $seconds]);

       return (bool) $this->redis->eval(self::getLockLuaScript(), $args, 1);
   }

   /**
    * 释放锁.
    * @param string $name
    * @return bool
    */
   public function release(string $name)
   {
       $args = [$this->buildKey($name), $this->token];

       return (bool) $this->redis->eval(self::getReleaseLuaScript(), $args, 1);
   }

   /**
    * 强制释放锁.
    * @param string $name
    * @return bool
    */
   public function forceRelease(string $name)
   {
       return (bool) $this->redis->del($this->buildKey($name));
   }

   /**
    * 构造缓存 key.
    * @param $name
    * @return string
    */
   protected function buildKey($name)
   {
       return sprintf('%s%s', $this->prefix, $name);
   }

   /**
    * 创建 Redis 实例.
    * @return Redis
    */
   protected function createRedis()
   {
       $redis = new Redis();

       $redis->connect('127.0.0.1', 6379);

       return $redis;
   }

   /**
    * 获取加锁的 Lua 脚本.
    * @return string
    */
   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;
   }

   /**
    * 获取释放锁的 Lua 脚本.
    * @return string
    */
   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();
// 持有锁 1 秒
$result = $lock->get('siege-test', 1);
// 抢到锁返回状态码 200,否则返回 500
http_response_code($result ? 200 : 500);
// 延迟 0.9 秒,方便看效果
usleep(900000);


压测命令:


1
siege -c 100 http://127.0.0.1:8081


文章已经经过本人测试稳定,原作者是何湘辉,他的博客地址https://her-cat.com/,文章中使用lua是为了支持低版本redis,高版本redis也可以直接php调用redis_eval执行

访客
邮箱
网址

通用的占位符缩略图

人工智能机器人,扫码免费帮你完成工作


  • 自动写文案
  • 自动写小说
  • 马上扫码让Ai帮你完成工作
通用的占位符缩略图

人工智能机器人,扫码免费帮你完成工作

  • 自动写论文
  • 自动写软件
  • 我不是人,但是我比人更聪明,我是强大的Ai
Top