Laravel:单行实现乐观锁

Laravel locking

Laravel 原生支持悲观锁。也就是说,应用程序可以要求数据库 “锁定” 某些行,使其在对这些记录进行更新之前不发生任何改变。这很棒,直到你因为没有将其包装在事务中而遇到死锁问题。

有时,悲观锁确实能正常工作,但它并非万能的解决方案。有这么一个场景,有人试图编辑一篇文章,却发现文章的几十段内容都不见了,原因是在该用户访问编辑器的时候,主编也刚好决定更新这篇文章。

这个问题可以通过乐观锁来解决。

幸运的是,Laravel 的 Eloquent ORM 具备实现乐观锁所需的所有工具,无需下载第三方包,甚至无需对 Eloquent 模型进行过度修改,以免影响维护。

问题与解决方案

假设我们正在编辑一篇 “文章”,与此同时,主编也在编辑它。主编在 14:50 从服务器获取了这篇文章,而我们在 15:00 之后开始编辑,并在 15:40 完成。

正如你所想,如果主编在我们完成编辑后更改了一个字母并点击 “更新”,我们所有的更改都会被主编浏览器中的内容覆盖。

请不要对这个例子过度纠结,它只是为了便于说明。你应该分别保存更改,以便日后恢复,如果可能的话,还应展示更改与数据库中数据的差异。

你可能经常在网上看到乐观锁的示例使用版本号甚至哈希值,但在这里我们已经有了现成可用的东西。每次 Eloquent 模型在数据库中更新时,updated_at 列都会使用当前时间戳进行更新 —— 前提是你没有从模型声明中移除它。

echo $post->updated_at; // 2024 - 01 - 01 15:50:00

$post->update(['title' => '我修改了内容']);

echo $post->updated_at; // 2024 - 12 - 12 16:30:54

换句话说,如果 updated_at 时间戳发生了变化(变得比我们获取到的时间更新),那么我们处理的就是过时的数据。这就是关键所在,因为乐观锁需要一种方法来判断记录数据是否发生了变化。

在我们的应用程序中,我们希望使用路由模型绑定来检索模型,同时获取时间戳作为 Unix 时间戳(新纪元时间),以便稍后与模型进行比较。如果我们检索到的数据的时间戳与数据库中的列值相等,那么我们就可以安全地更新模型,因为在此之前没有人更新过它。

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::patch('post/{post}', function (Post $post, Request $request) {
    // 验证请求中包含 Unix 时间戳("U" 格式)
    $request->validate([
        'updated_at' =>'required|timestamp:U'
    ]);

    // 检查文章的时间戳是否相同。如果不同,则说明它已在其他地方被更新,数据可能已过时。
    if ($post->updated_at->notEqual($request->int('updated_at'))) {
        return back()->withErrors([
           'save' => '文章在更新前已发生变化。请刷新'
        ])->withInput($request->only(['body', 'text']));
    }

    // 验证请求数据以填充文章。
    $validated = $request->validate([
        //...
    ]);

    // 使用验证后的数据保存模型。
    $post->update($validated);

    return back();
});

这对于小型应用程序可能就足够了。虽然这种情况看似合理,但很难出现两个或更多用户同时尝试更新同一资源的情况。

经验丰富的开发人员会注意到,对于一个不断被多个应用程序实例修改的资源,在检查和更新之间存在一个微小的时间窗口,过时的数据可能会覆盖较新的数据。但这并非世界末日,我们可以解决这个问题。

通过多写一点代码,我们可以通过在一个查询中对数据库中的行进行检查并直接更新来实现乐观锁。我们只需要添加一个 WHERE 子句,使 updated_at 列与我们作为请求一部分接收到的值相匹配。

use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::patch('post/{post}', function (Post $post, Request $request) {
    // 验证请求中包含 Unix 时间戳
    $request->validate([
        'updated_at' =>'required|timestamp:s'
    ]);

    // 验证请求中的数据。
    $validated = $request->validate([
        //...
    ]);

    // 更新文章,并返回受影响的行数。
    $updated = Post::whereKey($post->id)
        ->where('updated_at', $request->date('updated_at'))
        ->update($validated);

    // 如果受影响的行数为 0,则说明文章之前已被更新。
    if (!$updated) {
        return back()->withErrors([
           'save' => '文章在更新前已发生变化。'
        ])->withInput($request->only(['body', 'text']));
    }

    return back();
});

上述示例不会触发任何 Eloquent 事件,这是一种权衡,除非你想手动触发。在同步更改的属性后,使用 event() 辅助函数可以轻松实现。

// 使用刚更新的文章触发 “updated” 事件。
event("eloquent.updated: ".Post::class, $post->fresh());

显然,这是在数据库事务中使用悲观锁的一个很好的替代方案,因为在接收到的数据方面,数据库行仍存在一个(微小的)时间窗口可能会发生变化。

Publish on 2024-12-25,Update on 2025-02-10