锁php_基于 Redis 实现分布式锁及对应的 PHP 实现源码

12cf78d11a391fac5b479c5f71719798.png

分布式锁的概念

不同于 Java、Golang 这些语言,PHP 本身并不支持并发编程,因为对于 PHP 的主战场 Web 应用而言,每次用户请求都是通过独立的 PHP-FPM 进程处理的,PHP 为了保持语言的简单性,并不支持在这个进程内开启多进程/线程,也就不存在什么基于锁的并发安全问题。

这也是很多 PHP 程序员刚开始迈入 Java/Golang 门槛时最容易犯错的地方,作为静态编译型语言,它们都是支持并发编程的,并且支持通过锁/通道处理并发安全问题。

我们今天要介绍的分布式锁也是为了解决并发安全问题所引入的一种锁机制。

要了解什么是分布式锁,先要了解并发和锁的概念。这一点,你可以参考学院君之前编写的 Go 并发编程或者 MySQL 并发事务了解详细细节,这里我们简单介绍下大致原理。

当两个并发运行的进程/线程要同时处理某个资源的时候,同时只能让一个进程/线程获取到这个资源,待其处理完成后,才能让另一进程/线程开始处理这个资源,否则就会导致这个资源的状态管理出现混乱,而要保证并发运行的程序同时只有一个进程/线程处理这个资源,就需要引入锁机制 —— 某个进程/线程获取到资源锁后,才能对其进行操作,当其他进程/线程试图获取这个资源进行处理时,发现对应的资源锁已经被占用了,就会进入阻塞状态,直到持有这个资源锁的进程/线程处理资源完毕,将锁释放。

注:你可以类比数据库事务的并发操作来理解为什么并发处理资源的进程/线程会导致资源状态出现混乱,比如对于更新用户账户余额的程序,一个线程将用户余额更新还未保存,另一个线程就进来将其更新,最终会导致处理结果与我们预期不一致。我们通过锁机制让并发运行的程序同时只有一个线程才能处理账户更新,则不会出现这样的问题。

所谓分布式锁,指的是这个锁可以被多个分布式部署的服务/应用/进程共享,而不仅仅局限于某个服务/应用/进程内部。

另外,对于所有锁而言,不同进程/线程在竞争获取锁时,要确保获取锁的操作是原子性的,否则依然存在并发安全问题,即同时有多个进程/线程获取并处理同一个资源。

最后,这个锁还支持在上锁的同时设置过期时间,否则万一某个进程/线程获取到锁之后,处理资源时异常退出,导致锁没有释放,那么其他进程/线程就永远处于阻塞状态,不能再处理这个资源了。

通过 Redis 实现分布式锁

Redis 作为分布式存储中间件,天然适合实现分布式锁,因为它同时满足上面这三个条件:

  • 以单进程模式运行的 Redis 服务可以同时被分布式部署和运行的多个服务/应用/进程共享;

  • Redis 的 SET 指令支持在设置键值的同时设置过期时间,并且整个操作是原子性的,所以完全可以基于这个操作来实现分布式锁,待资源处理完成后,再通过 DEL 指令删除键值来释放锁。

为了直观地给大家展示这个分布式锁的效果,我们在 Laravel 中编写一个 Artisan 命令来模拟并发运行的应用:

php artisan make:command ScheduleJob

先看看不使用分布式锁的运行情况:

<?php namespace App\Console\Commands;use Illuminate\Console\Command;use Illuminate\Support\Facades\Storage;class ScheduleJob extends Command{protected $signature = 'schedule:job {process}';protected $description = 'Mock Schedule Jobs';public function __construct(){parent::__construct();
    }public function handle(){
        $processNo = $this->argument('process');for ($i = 1; $i <= 10; $i++) {
            $log = "Running Job #{$i} In Process #{$processNo}";// 将运行日志记录到本地文件存储(storage/app/schedule_job_logs)
            Storage::disk('local')->append('schedule_job_logs', $log);
            sleep(1);  // 模拟长时间运行的任务
        }
    }
}

我们通过 Artisan 命令参数传入模拟的进程 ID,然后将运行日志记录到本地存储的 storage/app/schedule_job_logs 日志文件。打开两个终端窗口同时运行这个 Artisan 命令,并传入不同的进程 ID:

95d2cc404b6bb13e6b6ed147749b35ea.png

打开日志文件,可以看到运行记录呈犬牙交错状:

a13b38618f4c4cb37ec27cb74436d596.png

两个进程可以并行处理这个程序,由于没有引入锁机制,所以如果把 for 循环看作一个资源处理,那么两个进程可以同时获取这个资源进行处理,进而导致并发安全问题,要解决这个问题,我们可以通过 Redis 实现一个锁,Laravel 底层已经实现了基于 Redis 的锁 Illuminate\Cache\RedisLock,所以不需要重复造轮子了,直接拿来用就好了:

<?php namespace App\Console\Commands;use Illuminate\Cache\Lock;use Illuminate\Cache\RedisLock;use Illuminate\Console\Command;use Illuminate\Contracts\Cache\LockTimeoutException;use \Illuminate\Redis\Connections\Connection as RedisConnection;use Illuminate\Support\Facades\Storage;class ScheduleJob extends Command{protected $signature = 'schedule:job {process}';protected $description = 'Mock Schedule Jobs';protected Lock $lock;public function __construct(RedisConnection $redis){parent::__construct();// 基于 Redis 实现锁,过期时间 60s$this->lock = new RedisLock($redis, 'schedule_job', 60);
    }public function handle(){// 如果没有获取到锁,阻塞 5s,否则执行回调函数$this->lock->block(5, function () {
            $processNo = $this->argument('process');for ($i = 1; $i <= 10; $i++) {
                $log = "Running Job #{$i} In Process #{$processNo}";
                Storage::disk('local')->append('schedule_job_logs', $log);
            }
        });
    }
}

删除上次生成的 schedule_job_logs,再次同时运行这两个 Artisan 命令 schedule:job,这一次的日志输出结果就变成先执行一个进程,再执行另一个进程了:

3d3a1decda6fc9c3269ad3822b8df7a3.png

这是因为锁生效的缘故。

RedisLock 底层实现源码

这个 RedisLock 底层正是使用了 Redis SET 指令实现锁的设置,我们查看 block 函数底层源码:

f44b18305c00e596b4383f37b759412f.png

它在底层会先调用 acquire 函数试图获取锁:

public function acquire(){
    if ($this->seconds > 0) {
        return $this->redis->set($this->name, $this->owner, 'EX', $this->seconds, 'NX') == true;
    } else {
        return $this->redis->setnx($this->name, $this->owner) === 1;
    }
}

这里我们设置了锁的过期时间,所以会调用第一个 if 里面的代码,即通过 Redis 的 SET key value EX expire NX 指令设置锁,该指令只会在锁不存在的情况下设置,如果已经存在,则返回 false,这是一个原子操作;如果初始化 RedisLock 时未指定过期时间,则调用 SETNX 指令设置锁,这也是一个只有锁不存在的情况下操作才会成功的原子操作。

回到 block 函数,如果获取锁失败,则当前进程会阻塞一段时间(通过 usleep 函数模拟)后尝试重新获取锁,如果阻塞时间过长,超出锁的过期时间设置,则抛出锁超时异常。

如果成功获取到锁,则执行回调函数中的代码(真正的业务代码),最后调用 release 函数释放锁:

public function release(){
    return (bool) $this->redis->eval(LuaScripts::releaseLock(), 1, $this->name, $this->owner);
}

如果你进一步追溯底层源码,会发现其实调用的是 Redis 的 DEL 指令删除对应的键实现锁释放。

由于这把锁是基于 Redis 实现的,所以它既可以作为 Laravel 应用中普通进程之间的锁,也可以作为分布式锁,不过对于 PHP 应用而言,主要的多进程场景在于控制台应用,比如消息队列这种多进程处理,或者任务调度中的多进程处理。限于篇幅,学院君将在下篇教程给大家详细介绍分布式锁在任务调度底层的应用。

RedisLock 外,Laravel 底层还基于其他驱动实现了类似的分布式锁,比如 CacheLockDatabaseLockDynamoDbLock,感兴趣的同学可以去一探究竟,这里就不一一介绍了。

本系列教程首发在学院君网站(xueyuanjun.com),你可以点击页面左下角阅读原文链接查看最新更新的教程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
基于 Redis 实现分布式锁可以利用 Redis 的原子性操作和超时特性来实现。下面是一个基本的实现思路: 1. 获取:使用 Redis 的 SETNX 命令,如果指定的 key 不存在,则设置该 key 的值为当前时间戳加上的超时时间,并返回成功;否则,返回失败。 2. 释放:使用 Redis 的 EVAL 命令,通过 Lua 脚本来实现原子性的删除。脚本的内容是先判断是否存在且超时,如果是则删除并返回成功;否则,返回失败。 下面是一个简单的 Python 代码示例: ```python import redis import time class RedisLock: def __init__(self, redis_client, lock_key, expire_time): self.redis = redis_client self.lock_key = lock_key self.expire_time = expire_time def acquire(self): while True: timestamp = int(time.time() * 1000) + self.expire_time acquired = self.redis.set(self.lock_key, timestamp, nx=True, px=self.expire_time) if acquired: return True time.sleep(0.001) def release(self): lua_script = """ if redis.call("exists", KEYS[1]) == 1 then local current_value = tonumber(redis.call("get", KEYS[1])) if current_value and current_value <= tonumber(ARGV[1]) then return redis.call("del", KEYS[1]) end end return 0 """ self.redis.eval(lua_script, 1, self.lock_key, int(time.time() * 1000) + self.expire_time) # 使用示例 redis_client = redis.Redis(host='localhost', port=6379, db=0) lock = RedisLock(redis_client, 'my_lock', 1000) # 的超时时间为 1000 毫秒 if lock.acquire(): try: # 执行需要加代码 pass finally: lock.release() ``` 需要注意的是,以上代码仅是一个简单的实现示例,实际使用中还需要考虑异常处理、的可重入性、的可拥有时间等问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值