PHP-使用Redis实现锁

33次阅读

共计 3252 个字符,预计需要花费 9 分钟才能阅读完成。

Last-Modified: 2019 年 5 月 10 日 15:31:41

参考链接

  • PHP 使用 Redis+Lua 脚本操作的注意事项
  • 《Redis 官方文档》用 Redis 构建分布式锁

锁实现的注意点

  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 解锁示例: 使用 lua 脚本

$key = "...";
$identification = "...";
// KEYS 和 ARGV 是 lua 脚本中的全局变量
$script = <<< EOF
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
then
    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 和 参数太多,分开写太麻烦了,就图省事方便,直接把变量拼接到脚本里面,结果发现内存不停的涨,很是抓狂,找了好久才发现是这么个原因。

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

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

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

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 实例的锁), 客户端会到每个节点释放锁(是每个, 即使之前认为加锁失败的节点)

正文完
 0