翼度科技»论坛 编程开发 PHP 查看内容

深入理解PHP+Redis实现分布式锁的相关问题

10

主题

10

帖子

30

积分

新手上路

Rank: 1

积分
30
概念

PHP使用分布式锁,受语言本身的限制,有一些局限性。

  • 通俗理解单机锁问题:自家的锁锁自家的门,只能保证自家的事,管不了别人家不锁门引发的问题,于是有了分布式锁。
  • 分布式锁概念:是针对多个节点的锁。避免出现数据不一致或者并发冲突的问题,让每个节点确保在任意时刻只有一个节点能够对公共资源进行操作,单机的锁只能够单节点使用,多节点防不住。
  • 核心原理:分布式锁的核心原理,就是在每个节点执行时,先去一个公共的地方判断是否持有锁,如果有锁就说明资源被占用,没锁就可以持有该资源。
  • 通俗举例:多个部门,开部门会议,需要占用会议室的位置,发现会议室门关着,不知道里面有没有人,此时门外面有个牌子说明是会议中,还是会议结束,离老远就知道会议室是不是被占用了,避免会议竞争引起的错乱。
应用场景


  • 分布式排它:保证只有一个节点被访问,常用于秒杀,等并发问题的处理。
  • 分布式任务调度:在分布式任务调度系统中,多个节点可能会竞争执行同一个任务,使用分布式锁可以确保只有一个节点能够执行该任务,避免重复执行和冲突。
  • 并发下数据库事务幻读问题:并发下的MySQL事务当中,插入数据前先判断有没有,没有再插入,从而避免重复,但是其它事务未提交,就检测不到(RR的隔离级别导致的),但是插入相同数据,又会导致唯一约束起作用从而报错,添加分布式锁,从而避免报错。(这场景适用于唯一约束冲突报错很多的场景功能,否则使用了会影响性能)。
分布式锁的特点


  • 互斥性,相同时间,只能有一个节点会获取该锁,其它节点要么等待要么直接返回失败。
  • 可重入(单个节点可重复获取该锁且不会发生阻塞),PHP的语言特性不支持。
  • 安全(获取锁的节点崩溃或失去连接、锁资源会释放)。
可用的存储组件选择

Redis、MySQL(乐观锁、或悲观锁)、ZooKeeper、Etcd、Memcache等存储组件都可以实现分布式锁。
ZooKeeper、Etcd是Java生态,PHP几乎不用。
Memcache很少用了,一般都会用redis。
MySQL性能比不了Redis,高并发过来容易被夯住,数据不会自动过期删除,需要逻辑判断。所以也不用。
分布式锁要求高性能,和自动过期的兜底特性,所以用Redis的set命令刚好。
Redis分布式锁,又称为Redis Distributed Lock,也叫RedLock。
用Redis手动实现分布式锁(示例)

这是花十分钟写出来的例子,不建议商用。
  1. class RedLock {
  2.     //声明redis
  3.     private $redis;
  4.     /**
  5.      * @function 构造方法初始化redis
  6.      * @other    void
  7.      */
  8.     public function __construct() {
  9.         $redis = new Redis();
  10.         $redis->connect('127.0.0.1', 6379);
  11.         $this->redis = $redis;
  12.     }
  13.     /**
  14.      * @function 非阻塞分布式锁
  15.      * @param    $key string 锁名称
  16.      * @param    $ttl int    key自动过期时间,单位毫秒
  17.      * @return   array|false 成功返回数组,失败返回false
  18.      * @other    void
  19.      */
  20.     public function addLock($lock_name, $ttl = 10000) {
  21.         $val = base64_encode(openssl_random_pseudo_bytes(32));
  22.         $set = $this->redis->set($lock_name, $val, ['NX', 'PX' => $ttl]);
  23.         if($set === false) {
  24.             return false;
  25.         }
  26.         return ['key' => $lock_name, 'val' => $val];
  27.     }
  28.     /**
  29.      * @function 阻塞式分布式锁
  30.      * @param    $key string 锁名称
  31.      * @param    $ttl int    key自动过期时间,单位毫秒
  32.      * @param    $ttl int    超时时间,单位毫秒
  33.      * @return   array|false 成功返回数组,失败返回false
  34.      * @other    void
  35.      */
  36.     public function addLockSpin($lock_name, $ttl = 10000, $timeout = 3000) {
  37.         $start = bcmul(microtime(true), 1000, 2);
  38.         $val = base64_encode(openssl_random_pseudo_bytes(32));
  39.         $set = $this->redis->set($lock_name, $val, ['NX', 'PX' => $ttl]);
  40.         if($set === false) {
  41.             while(true) {
  42.                 //超时
  43.                 $start_loop = bcmul(microtime(true), 1000, 2);
  44.                 if(bcadd($start, $timeout, 2) <= $start_loop) {
  45.                     return false;
  46.                 }
  47.                 //尝试获取锁
  48.                 $set_loop = $this->redis->set($lock_name, $val, ['NX', 'PX' => $ttl]);
  49.                 if($set_loop) {
  50.                     return ['key' => $lock_name, 'val' => $val];
  51.                 }
  52.                 usleep(50000);
  53.             }
  54.         }
  55.         return ['key' => $lock_name, 'val' => $val];
  56.     }
  57.     /**
  58.      * @function 释放锁资源
  59.      * @param    $key array|false 锁资源
  60.      * @return   bool
  61.      * @other    void
  62.      */
  63.     public function unLock($lock) {
  64.         if($lock === false) {
  65.             return false;
  66.         }
  67.         $script = '
  68.             if redis.call("GET", KEYS[1]) == ARGV[1] then
  69.                 return redis.call("DEL", KEYS[1])
  70.             else
  71.                 return 0
  72.             end
  73.         ';
  74.         if(! $this->redis->eval($script, [$lock['key'], $lock['val']], 1)) {
  75.             return false;
  76.         }
  77.         return true;
  78.     }
  79. }
  80. $redLock = new RedLock();
  81. if($lock = $redLock->addLockSpin('test_key')) {
  82.     echo '抢到锁了,处理一些业务逻辑';
  83.     $redLock->unLock($lock); //记得及时释放锁资源
  84. } else {
  85.     echo '锁没有抢到';
  86. }
复制代码
现有的解决方案

java实现分布式锁有redisson,PHP也有自己的包。
看过一些博主的用PHP实现分布式锁,好多没有使用Lua,这没办法保证多条Redis语句原子性的执行。
项目中能用到这种东西的,对于高可用、原子性、稳定性有很强的依赖,所以推荐使用成熟的扩展包。
  1. composer require signe/redlock-php
  2. 文档:https://packagist.org/packages/signe/redlock-php
  3. 执行之后看使用redis的monitor指令查看,发现用了Lua,说明这个包,兼顾了原子性的操作。
  4. 我这个是示例,记得无论最后执行成功还是失败,都记得及时释放锁资源。
  5. 非自旋写法
  6. $server = new \Redis;
  7. $server->connect('127.0.0.1', 6379);
  8. $servers = [$server,];
  9. $redLock = new \RedLock\RedLock($servers);
  10. $lock = $redLock->lock('my_resource_name', 10000);
  11. if($lock) {
  12.     echo '加锁成功';
  13.     $redLock->unlock($lock);
  14. } else {
  15.     echo '加锁失败';
  16. }
  17. 自旋写法
  18. $server = new \Redis;
  19. $server->connect('127.0.0.1', 6379);
  20. $servers = [$server,];
  21. $redLock = new \RedLock\RedLock($servers);
  22. $lock = $redLock->lock('my_resource_name', 10000);
  23. if($lock) {
  24.     echo '加锁成功';
  25. //    $redLock->unlock($lock);
  26. } else {
  27.     while(true) {
  28.         $lock2 = $redLock->lock('my_resource_name', 10000);
  29.         if($lock2) {
  30.             echo '加锁成功2';
  31.             //运行某些代码
  32.             $redLock->unlock($lock2);
  33.             return '';
  34.         }
  35.     }
  36. }
复制代码
如果需要:拿到锁后,释放锁前,业务逻辑代码块再对拿到锁的分布式锁续期。
因为redis的key与val值都不变,只变动过期时间,所以使用PEXPIRE指令,也可使用PSETEX指令。
又需要防止这个锁自动过期,已经被其它节点占用,已经改成了其它节点的数据,所以value值需要验证是不是当前锁的value值。
两个操作为了保证原子性,就用到了Lua。
  1. //$redLock = new \RedLock\RedLock($servers);
  2. //$lock = $redLock->lock('my_resource_name', 20000);
  3. $script = '
  4.             if redis.call("GET", KEYS[1]) == ARGV[1] then
  5.                     return redis.call("PEXPIRE", KEYS[1], KEYS[2])
  6.             else
  7.                 return 0
  8.             end
  9.         ';
  10. $server->eval($script, [$lock['resource'], '毫秒过期时间', $lock['token']], 2);
复制代码
PHP使用分布式锁的局限性问题


  • 重入性无法实现:PHP这门语言有局限性,不适合和redis结合做分布式锁,分布式锁的重入性无法实现,因为脚本能执行完内存就被回收了,无法像C/C++那样轻松操控进程和线程。
  • 超时问题没有监控机制:没有像redisson一样的watch dog看门狗的机制,去监控业务执行过长导致redis分布式锁自动释放,被其它锁占用的问题。
PHP使用分布式锁,有种东施效颦的感觉。
为什么加锁时set指令要加NX

set指令加nx表示,只有在key不存在的情况下才能设置键值对。
多个节点加锁,获取分布式锁资源,实质就是在redis中设置一条值。因为分布式锁的排它性,同一时间内只能有一个节点可以拿到该锁。
若用set,不加nx,就会产生覆盖,造成业务错乱。
客户端宕机导致锁资源无法释放的死锁问题

redis单线程通常不会发生死锁问题。
Redis在客户端挂掉的情况的情况,会导致分布式锁锁资源无法及时释放,这可能会导致其它节点无法加锁从而阻塞,类似死锁的效果。
添加过期时间做兜底即可。
对高可用:MySQL可以主从,Redis也可以,从而保证分布式锁存储的高可用性。
分布式锁redis操作的原子性问题

就算是redis事务(multi)也是弱事务,仍旧会出现并发安全问题,最好使用Lua+Redis的方式去实现原子性的分布式锁,这会把一些指令集当做一个任务队列去处理,保证原子性。
如何设置拿到锁资源后的超时时间

对于Java,redisson有watch dog的自动监控机制,但是PHP没有。
PHP也很难实现,原因有2:

  • 不知道自动续期的时机:业务流程没走完,分布式锁临近过期才续期,业务流程走完了还续什么期?这个时机,高并发场景下难以获取,净增加复杂度。
  • PHP语言本身缺少锁机制:就算知道了要续期,加锁与续期监控,缺少锁机制的强关联,加锁一个进程,监控又一个进程,进程间通信是一个问题,PHP进程间通信与Redis操作无法原子执行又是一个问题,也就是说就算被通知要续期了,再续期时,锁资源超时自动释放后,可能都被别的节点占用了。
PHP能做的只能是设置更多的超时时间,来防止锁资源自动释放被其它节点抢走。
缺点也很明显,一旦这个节点挂掉,锁资源需要很长时间才能释放,这个时间段的分布式锁无法被任意一个节点使用。
锁资源的错误释放问题

时序图:
步骤客户端1客户端2补充1获取锁成功//2执行中获取锁失败客户端1的锁阻塞了客户端23执行中获取锁失败客户端2自旋,不断尝试获取锁4锁资源到期自动释放获取锁成功由于客户端1的锁资源过期,才导致客户端2拿到的分布式锁5释放锁执行中这一步才是客户端1真正释放(删)锁的时刻,但是由于没做验证,这个释放(删)的过程,会把会话2创建的锁给释放(删)掉,造成误删除为了避免这个问题,val值可设置为节点标识。
所以redis在get值的时候,需要判断,val值是不是当前的节点标识。
为了保证原子性,查询和删除两个操作需要用Lua脚本。
其次要注意,不管节点程序执行成功或者失败,只要该走的流程走完了,都需要及时释放锁。
分布式锁的可重入问题

PHP解决不了。
假设同一个节点,递归或循环添加分布式锁,是否让同一节点重复加同一把锁,大部分场景不需要,但是也得看业务场景。
这种机制是为了避免第一层循环添加成功,之后失败的问题。
对于非PHP而言,重入问题,还需要再维持一个redis hash,key为锁名,field为节点的唯一标识,value为重入次数,重入1次次数加1。因为重入相当于重新获取锁,但是不会新增锁资源,如果这个时间被删掉,那么重入时会加锁成功,但锁资源被强制释放,此时重入后的业务逻辑还不一定执行完毕。所以删除时需要判断value值是否为0,如果不为0,说明有重入,这两步操作,也是需要再一个Lua脚本中。
分布式锁的自旋机制

自旋可以理解为内部死循环,内部不断重试,直到满足条件,直观感受就是被阻塞。
如果没有自旋,10个节点,只有1个能加锁成功,其余9个失败,如果这9个全部失败掉,看起来差点意思。
因此可以选择被阻塞,期间不断重试,所谓的自旋方案,其实很好理解,重试伪代码如下:
[code]while(加锁失败) {        usleep(10000);        重新尝试加锁代码        if(加锁成功) {                return '加锁成功';        }}此处也可以添加一个次数限制,防止永久死循环的兜底策略$retry_count = 0;while(true) {    $retry_count ++;    if('加锁成功') {        return '加锁成功';    }    if($retry_count > 20) {        echo 1;        return '重试次数过多';    }        usleep(30000);}也可以根据时间去做限制,防止永久死循环的兜底策略$start_time = microtime(true);while(true) {        if('加锁成功') {                return '加锁成功';        }        if($start_time + 5

举报 回复 使用道具