基于 Redis 实现分布式锁及其在 Laravel 底层的实现源码


分布式锁的概念

不同于 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:

-w1220

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

-w980

两个进程可以并行处理这个程序,由于没有引入锁机制,所以如果把 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,这一次的日志输出结果就变成先执行一个进程,再执行另一个进程了:

-w976

这是因为锁生效的缘故。

RedisLock 底层实现源码

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

-w731

它在底层会先调用 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,感兴趣的同学可以去一探究竟,这里就不一一介绍了。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 基于 Redis 实现 Laravel 广播功能(下):在私有频道和存在频道发布和接收消息

>> 下一篇: Redis 分布式锁在 Laravel 任务调度底层实现中的应用