在PHP中,如何实现互斥锁,避免同时大量请求查询同一个数据导致缓存失效(被击穿)

很多技术确实也只有用到了才能理解。

之前听到缓存失效的三种情况,缓存雪崩、缓存穿透、缓存击穿是很难理解的,因为那时候用的缓存确实比较少,没有那么多问题,也就理解不了这些理论。

最近用了一段时间的缓存,再看这个理论也就熟悉很多了。

首先说一下今天遇到的这个问题:

在 PHP 中开发接口中,需要为客户端提供三个接口,以显示商品相关的信息。

而这三个接口的数据,都来自于同一个外部接口。

为了避免三个后端接口调用三次外部接口数据,于是我就用了缓存,根据商品ID,相同的商品第一次调用后就缓存起来,后面其它的接口就可以直接用缓存数据,而无需重复调用外部接口。

听起来很合理对吧,以下是第一次的代码:

public function getGoodsData($goodsId, $pid = '', $relationId = '')
{
    $cacheName = 'goods_' . $goodsId;
    $responseArray = Cache::get($cacheName);
    if (empty($responseArray)) {
        $responseArray = $this->getGoodsDataFromApi($goodsId, $pid, $relationId);
        Cache::set($cacheName, $responseArray, 7200);
    }
    return $responseArray;
}

但查看日志就发现不对劲了,每次都还是发了三次外部请求,缓存压根就没用。

再一想也就理解了,因为这三个接口,客户端是同时请求的,完全没有串行啥的,也就意味着这个方法是同时被调用的,这个时候缓存里面根本就没有数据,所以就要发三次请求,缓存完全失效了。

这个时候互斥锁就可以发挥作用了。

Ethan_2024-05-14_16-07-03

互斥锁

互斥锁(Mutex,Mutual Exclusion Lock)是一种用于控制对共享资源的访问的同步机制。它确保在任何给定的时刻,只有一个线程或进程可以访问共享资源,而其他线程或进程必须等待。

在 PHP 中实现互斥锁可以使用 PHP 提供的扩展或者基于文件、数据库等方式来实现。

本期主要利用文件锁来实现,也就是方法二,不需要安装任何额外的扩展即可实现,比较简单。

如果愿意的话,也可以使用 PHP 扩展 Mutex 来实现,可以看方法一。

方法一、使用 PHP 扩展 Mutex

PHP 提供了一个名为 Mutex 的扩展来实现互斥锁,需要单独安装扩展。

可以使用 Mutex 扩展来确保在多个进程中对共享资源的访问是安全的。

使用示例:

方法二、使用文件锁

文件锁(file lock)实现互斥锁的原理主要利用了操作系统对文件锁定机制的支持。具体来说,它依赖于文件系统提供的文件锁定功能,确保同一时间只有一个进程可以对文件进行排他性访问。

在 PHP 中,可以使用 flock() 函数来实现文件锁。flock() 函数提供了一种简单的接口来使用操作系统的文件锁功能。我们首先尝试获取一个文件锁。如果能够获取到锁,则在临界区内进行操作:检查缓存是否存在,如果不存在则从 API 获取数据并缓存;然后释放锁。如果无法获取到锁,则等待一段时间后再次尝试获取锁。

通过这种方式,相同的 $goodsId 在同时请求时只会有一个请求进入临界区进行 API 访问,其他请求会等待该请求完成后再继续执行。

可以看下面优化后的代码:

public function getGoodsData($goodsId, $pid = '', $relationId = '')
{
    $cacheName = 'goods_' . $goodsId;
​
    // 尝试从缓存获取数据
    $responseArray = Cache::get($cacheName);
​
    // 如果缓存不存在,则尝试获取文件锁
    if (empty($responseArray)) {
        $lockFile = root_path() . "/runtime/temp/goods_lock_" . $this->safeFileName($goodsId) . ".lock";
        $fp = fopen($lockFile, "w+");
​
        // 尝试获取文件锁
        if (flock($fp, LOCK_EX)) {
            // 再次检查缓存,因为获取文件锁后可能其他请求已经获取了缓存数据
            $responseArray = Cache::get($cacheName);
​
            if (empty($responseArray)) {
                // 缓存仍然不存在,从 API 获取数据并缓存
                $responseArray = $this->getGoodsDataFromApi($goodsId, $pid, $relationId);
                Cache::set($cacheName, $responseArray, 7200);
            }
​
            // 释放锁
            flock($fp, LOCK_UN);
​
            // 临界区操作完成后,删除锁文件
            unlink($lockFile);
        } else {
            // 无法获取锁,等待一段时间再尝试
            usleep(10000); // 等待 10 毫秒
            fclose($fp);
            // 递归调用自身,等待其他请求完成后再次尝试获取数据
            return $this->getGoodsData($goodsId, $pid, $relationId);
        }
​
        fclose($fp);
    }
​
    return $responseArray;
}

现在通过数据库查看请求日子,就可以看到不会出现缓存失效的情况了,只有一个响应是请求了远程数据:

需要注意的是,无论使用 Mutex 扩展还是文件锁,都需要确保在临界区内操作完共享资源后及时释放锁,以避免死锁等问题。

总结

文件锁利用操作系统提供的文件锁定机制,通过文件描述符和原子性操作,确保同一时间只有一个进程可以获得排他性访问,从而实现互斥锁的功能。

这种机制简单且有效,如果不想增加扩展,可以考虑使用这种方法。


这是一个从 https://juejin.cn/post/7368767452614770723 下的原始话题分离的讨论话题