PHP 使用 Redis 实现分布式锁

Last-Modified: 2019年6月5日15:59:34

参考链接

锁实现的注意点

  1. 互斥: 任意时刻, 只能有一个客户端获得锁
  2. 不会死锁: 客户端持有锁期间崩溃, 没有主动解除锁, 能保证后续的其他客户端获得锁
  3. 锁归属标识: 加锁和解锁的必须是同一个客户端, 客户端不能解掉非自己持有的锁(锁应具备标识)

如果是Redis集群, 还得考虑具有容错性: 只要大部分Redis节点正常运行, 客户端就可以加锁和解锁.

以下只考虑 Redis单机部署的 场景.

如果是Redis集群部署, 可以使用

加锁

php 加锁示例

$redis = new Redis();
$redis->pconnect("127.0.0.1", 6379);
$redis->auth("password");    // 密码验证
$redis->select(1);    // 选择所使用的数据库, 默认有16个

$key = "...";
$value = "...";
$expire = 3;

// 参数解释 ↓
// $value 加锁的客户端请求标识, 必须保证在所有获取锁清秋的客户端里保持唯一, 满足上面的第3个条件: 加锁/解锁的是同一客户端
// "NX" 仅在key不存在时加锁, 满足条件1: 互斥型
// "EX" 设置锁过期时间, 满足条件2: 避免死锁
$redis->set($key, $value, ["NX", "EX" => $expire])

执行上面代码结果:

  1. $key 对应的锁不存在, 进行加锁操作
  2. $key 对应的锁已存在, 什么也不做

加锁容易错误的点:

  • 使用 setnxexpire 的组合

    原因: 若在 setnx 后脚本崩溃会导致死锁

$value 客户端标识的:

  • 简单点就用 毫秒级unix时间戳 + 客户端标识(大部分情况下够用了)
  • 使用其他算法确保生成唯一随机值

connect 与 pconnect

在php中, 若使用 pconnect 连接redis, 则在当前脚本声明周期结束后, 与redis建立的连接仍会保留, 直到对应fpm进程的生命周期结束, 同时在下一次请求时, fpm会重用该连接.

即该连接的生命周期是 fpm 进程的生命周期, 而非一次php脚本的执行.

若代码使用 pconnect, close 的作用仅是使当前php脚本不能再进行redis请求, 并没有真正关闭与redis的连接, 连接在后续请求中仍然会被重用.

pconnect函数在线程版本中不能被使用

PHP 使用 Redis 实现分布式锁

PHP 使用 Redis 实现分布式锁

上图中, php-fpm 与redis建立的连接并未随请求结束后马上断开

解锁

php解锁示例: 使用lua脚本

$key = "...";
$identification = "...";
// KEYS 和 ARGV 是lua脚本中的全局变量
$script = <<< EOF
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
EOF;
# $result = $redis->eval($script, [$key, $identification], 1);
// 返回结果 >0 表示解锁成功
// php中参数的传递顺序与标准不一样, 注意区分
// 第2个参数表示传入的 KEYS 和 ARGV, 通过第3个参数来区分, KEYS 在前, ARGV 在后
// 第3个参数表示传入的 KEYS 的个数
$result = $redis->evaluate($script, [$key, $identification], 1);

使用Lua脚本的原因:

  • 避免误删其他客户端加的锁

    eg. 某个客户端获取锁后做其他操作过久导致锁被自动释放, 这时候要避免这个客户端删除已经被其他客户端获取的锁, 这就用到了锁的标识.
  • lua 脚本中执行 getdel 是原子性的, 整个lua脚本会被当做一条命令来执行
  • 即使 get 后锁刚好过期, 此时也不会被其他客户端加锁
eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

由于 script 执行的原子性, 所以不要在script中执行过长开销的程序,否则会验证影响其它请求的执行。

解锁容易错误的点:

  • 直接 del 删除键

    原因: 可能移除掉其他客户端加的锁(在自己的锁已过期情况下)

  • get判断锁归属, 若符合再 del

    原因: 非原子性操作, 若在 get 后锁过期了, 此时别的客户端进行加锁操作, 这里的 del 就会错误的将其他客户端加的锁解开.

Redis 中使用 Lua 脚本的注意点

↓ 这一段内容转载自 https://blog.csdn.net/zhouzme...

注意点:

  1. Redis 会把所有执行过的脚本都缓存在内存中
  2. Redis 在重启的时候会释放掉之前保存的脚本
  3. Lua 脚本中所需要用到的键名以及参数一定要使用 KEYS 和 ARGV 来替换,千万不要写死在代码中,除非你百分百确定每次请求时他们是固定不变的值,特别是涉及到 时间,随机数的,一定要用参数代入,因为 Redis 每次使用 script 都会校验脚本缓存中是否已存在相同脚本,否则就会存储到缓存中,如果你的脚本很长,且每次请求存在不同的变量值,则会生成无数多个脚本缓存,你将会发现Redis占用的内存会唰唰唰的往上涨,我一开始因为key 和 参数太多,分开写太麻烦了,就图省事方便,直接把变量拼接到脚本里面,结果发现内存不停的涨,很是抓狂,找了好久才发现是这么个原因。

    PHP 使用 Redis 实现分布式锁

义变量一定要使用局部变量, 即 local var = 1, 局部变量只在所定义的块(指控制结构, 函数或chunk等)内有效, 使用局部变量可以避免命名冲突 并且访问更快(lua中局部变量和全局变量存储方式是不一样的)

  1. 如果Lua脚本写的比较长,非本地或局域网的情况下,建议使用 SHA 签名的方法来调用,这样节省带宽,但对性能似乎没什么直接的提升。这里对小白普及下我理解的原理就是 Redis 会把每个脚本都生成唯一签名,把脚本作为函数体,并使用该签名作为脚本的函数名放到缓存中,所以后面调用就只需要传一个 SHA 签名就可以调用该函数了,精简很多了。同一个脚本生成的签名都是相同的,所以SHA签名可以先在本地生成,然后在服务器上 script load 一次脚本,程序中只需保存和使用该签名即可。另外需要注意的是,脚本如果被改动哪怕一个换行或一个空格(这些容易被忽略或误操作)都必须重新 load 来获取新的 SHA

    注意:获取 SHA 签名是单独的功能,不要放在你的正常流程中,当本地开发时就可以生成SHA,把字符串写死在流程中。同样的脚本,Reids是始终生成相同的签名的。

    PHP 使用 Redis 实现分布式锁

  2. 通过 eval 带入的 ARGV 参数如果原来是数字的,会被转换为字符串,如果你的逻辑中需要判断该变量 > 0 或 < 0 之类的数字判断则必须进行字符串到数字的转换,使用 tonumber() 方法 if (tonumber(ARGV[1]) > 0) then return 1; end;
  3. 我测试了几个 lua script 与 PIPELINE 处理对比,发现 script 的效率一般比 PIPELINE 高 30% ~ 40% 左右

    PHP 使用 Redis 实现分布式锁

Redis集群分布式锁

Redis 集群相对单机来说, 需要考虑一个 容错性, 设计上更为复杂

由于这个我也从未实践过, 先贴一个官方的教程贴压压惊

https://github.com/antirez/re...

对应的翻译: http://ifeve.com/redis-lock/

RedLock 算法

官方给出了一个 RedLock 算法

情景: 当前有N个完全独立的Redis master节点, 分别部署在不同的主机上

客户端获取锁的操作:

  1. 使用相同key和唯一值(作为value)同时向这N个redis节点请求锁, 锁的超时时间应该 >> 超时时间(考虑到请求耗时), 若某个节点阻塞了了应尽快跳过
  2. 计算步骤1消耗的时间, 若总消耗时间超过超时时间, 则认为锁失败. 客户端需在大多数(超过一半)的节点上成功获取锁, 才认为是锁成功.
  3. 如果锁成功了, 则该锁有效时间就是 锁原始有效时间 - 步骤1消耗的时间
  4. 如果锁失败了(超时或无法获取超过一半 N/2 + 1 实例的锁), 客户端会到每个节点释放锁(是每个, 即使之前认为加锁失败的节点)

相关推荐