为什么 Cache::lock() return 在 Laravel 7 中为假?

Why does Cache::lock() return false in Laravel 7?

我的框架是Laravel 7,缓存驱动是Memcached。我想执行原子缓存 get/edit/put。为此,我使用 Cache::lock() 但它似乎不起作用。 $lock->get() returns false(见下文)。我该如何解决这个问题?

堡垒测试,我重新加载Homestead,运行只有下面的代码。锁定永远不会发生。有没有可能Cache::has()打破锁机制?

if (Cache::store('memcached')->has('post_' . $post_id)) {
    $lock = Cache::lock('post_' . $post_id, 10);
    Log::info('checkpoint 1'); // comes here

    if ($lock->get()) {
        Log::info('checkpoint 2'); // but not here.
        $post_data = Cache::store('memcached')->get('post_' . $post_id);
        ... // updating $post_data..
        Cache::put('post_' . $post_id, $post_data, 5 * 60);
        $lock->release();
    }
} else {
        Cache::store('memcached')->put('post_' . $post_id, $initial, 5 * 60);
}

Cache::lock('post_' . $post_id, 10)->get() return false,因为'post_' . $post_id被锁了,还没有释放锁。

所以需要先释放锁:

Cache::lock('post_' . $post_id)->release()
// or release a lock without respecting its current owner
Cache::lock('post_' . $post_id)->forceRelease(); 

再试一次,会returntrue.

并推荐使用try catchblock设置指定时限,Laravel会等待这个时限。会抛出一个Illuminate\Contracts\Cache\LockTimeoutException,可以释放锁

use Illuminate\Contracts\Cache\LockTimeoutException;

$lock = Cache::lock('post_' . $post_id, 10);

try {
    $lock->block(5);
    ...
    Cache::put('post_' . $post_id, $post_data, 5 * 60);
    $lock->release();
    // Lock acquired after waiting maximum of 5 seconds...
} catch (LockTimeoutException $e) {
    // Unable to acquire lock...
} finally {
    optional($lock)->release();
}
Cache::lock('post_' . $post_id, 10)->block(5, function () use ($post_id, $post_data) {
    // Lock acquired after waiting maximum of 5 seconds...
    ...
    Cache::put('post_' . $post_id, $post_data, 5 * 60);
    $lock->release();
});

首先介绍一下背景。

一个mutual exclusion (mutex) lock as you correctly mentioned is meant to prevent race conditions by ensuring only one thread or process ever enters a critical section.

但首先什么是临界区?

考虑这段代码:

public function withdrawMoney(User $user, $amount) {
    if ($user->bankAccount->money >= $amount) {
        $user->bankAccount->money = $user->bankAccount->money - $amount;
        $user->bankAccount->save();
        return true; 
    }
    return false;

}

这里的问题是如果两个进程同时运行这个函数,他们会同时进入if检查,并且都退出成功,但是这可能会导致用户余额为负数或资金被双倍提取而余额未更新(取决于流程的不同程度)。

问题是操作需要多个步骤,并且可以在任何给定步骤中断。换句话说,操作不是原子的

这就是互斥锁解决的临界区问题。您可以修改以上内容以使其更安全:

public function withdrawMoney(User $user, $amount) {
    try {
        if (acquireLockForUser($user)) {
            if ($user->bankAccount->money >= $amount) {
                $user->bankAccount->money = $user->bankAccount->money - $amount;
                $user->bankAccount->save();
                return true; 
            }
            return false;
         }
    } finally {
       releaseLockForUser($user);
    }

}

要指出的有趣的事情是:

  1. Atomic(或线程安全)操作不需要这种保护
  2. 我们放在锁获取和释放之间的代码,可以认为已经“转换”为原子操作。
  3. 获取锁本身需要是线程安全或原子操作。

在操作系统级别,互斥锁通常使用为此特定目的构建的原子处理器指令来实现,例如原子 test-and-set 操作。这将检查是否设置了一个值,如果未设置,则设置它。如果您只是说锁本身就是值的存在,那么这就像一个互斥体。如果存在,则获取锁,如果不存在,则通过设置值获取锁。

Laravel 以类似的方式实现锁。它利用了某些缓存驱动程序提供的“如果尚未设置则设置”操作的原子性质,这就是为什么锁仅在那些特定的缓存驱动程序存在时才起作用的原因。

然而,最重要的是:

在test-and-set锁中,锁本身就是被测试是否存在的缓存key。如果设置了密钥,则将获取锁并且不能通常 重新获取。通常锁是通过“旁路”实现的,如果同一个进程多次尝试获取同一个锁,它就会成功。这称为 reentrant mutex 并允许在整个关键部分使用相同的锁定对象,而不必担心将自己锁定在外。当临界区变得复杂并且跨越多个函数时,这很有用。

现在你的逻辑有两个缺陷:

  1. 对锁和值使用相同的密钥是破坏您的锁的原因。在锁类比中,您试图将贵重物品存放在保险箱中,而保险箱本身就是您贵重物品的一部分。那是不可能的。
  2. 你有 if (Cache::store('memcached')->has('post_' . $post_id)) { 在你的关键部分之外,但它本身应该是关键部分的一部分。

要解决此问题,您需要使用与缓存条目不同的密钥来锁定,并将您的 has 检查移至关键部分:


$lock = Cache::lock('post_' . $post_id. '_lock', 10);
try {
    if ($lock->get()) { 
        //Critical section starts
        Log::info('checkpoint 1'); // if it comes here  

        if (Cache::store('memcached')->has('post_' . $post_id)) {          
            Log::info('checkpoint 2'); // it should also come here.
            $post_data = Cache::store('memcached')->get('post_' . $post_id);
            ... // updating $post_data..
            Cache::put('post_' . $post_id, $post_data, 5 * 60);
                    
        } else {
            Cache::store('memcached')->put('post_' . $post_id, $initial, 5 * 60);
        }
     }
     // Critical section ends
} finally {
   $lock->release();
}

finally 部分使用 $lock->release() 的原因是,万一出现异常,您仍然希望释放锁而不是保持“卡住”状态。

另一件需要注意的事情是,由于PHP的性质,您还需要设置锁在自动释放之前保持的时间。这是因为在某些情况下(例如,当 PHP 运行 内存不足时)进程突然终止,因此无法 运行 任何清理代码。锁的持续时间确保即使在这些情况下也能释放锁,并且持续时间应设置为合理持有锁的绝对最长时间。

就我而言,我的 Redis 配置导致 Cache:lock 总是 return 错误的问题。这是因为我在 Laravel 用来释放锁的配置文件上重命名了命令 DELFLUSHDB

我认为重命名命令会提高安全性,但会导致应用程序级别出现问题。所以,如果有人使用 Redis 作为 Driver 那么不要重命名 DELFLUSHDB。我需要一个小时来弄清楚,希望它能帮助到其他人。

Debian/etc/redis/redis.conf 的配置文件如下所示

rename-command FLUSHDB ""
rename-command DEL ""