引言
在 web 开发中,数据的完整性和准确性非常重要。因此,必须确保我们编写的代码能够以安全的方式存储、更新和删除数据库中的数据。
在本文中,我们将看看什么是数据库事务,为什么它们很重要,以及如何在 Laravel 开始使用它们。我们还将研究一个常见的 “问题”,涉及队列和数据库事务。
什么是数据库事务?
在我们开始研究 Laravel 的数据库事务之前,让我们先看看它们是什么以及它们如何有益。
对于什么是数据库事务,有许多听起来复杂的技术解释。但是,对于大多数 web 开发人员来说,我们只需要知道事务是完成数据库中整个工作单元的方式。
为了理解这实际上意味着什么,让我们来看一个基本的例子,它将给出一点上下文。
假设我们有一个允许用户注册的应用程序。每当用户注册时,我们都希望为他们创建一个新帐户,然后为他们分配一个默认角色 “general”。
我们的代码可能是这样的:
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
乍一看,这段代码似乎完全没问题。但是,当我们仔细观察的时候,我们可以发现实际上有一些事情可能会出错。我们可以创建用户,但是不能为他们分配角色。这可能是由许多不同的原因造成的,比如分配角色的代码中的错误,或者甚至是阻止我们到达数据库的硬件问题。
由于这种情况的发生,这将意味着系统中将有一个没有角色的用户。正如您可以想象的那样,这可能会在您的应用程序中的其他地方引起异常和 bug,因为您总是假设用户有一个角色(这是正确的)。
因此,为了解决这个问题,我们可以使用数据库事务。通过使用事务,它可以确保在执行代码时,如果出现任何错误,事务内部对数据库的任何更改都将回滚。例如,如果用户被插入到数据库中,但是由于任何原因分配角色的查询失败,那么事务将被回滚,用户行将被删除。通过这样做,它意味着我们不能创建没有分配角色的用户。
换句话说,它 “要么全有,要么全没有”。
在 Laravel 中使用数据库事务
现在我们对事务是什么以及它们实现了什么有了一个简单的概念,让我们来看看如何在 Laravel 中使用它们。
在 Laravel 中,由于我们可以在 DB
门面上访问 transaction()
方法,因此开始使用事务实际上是很容易的事。继续使用之前的示例代码,让我们看看在创建用户并为其分配角色时如何使用事务。
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
});
现在我们的代码被包裹在一个数据库事务中,如果在其中的任意一点抛出异常,对数据库的任何更改都将返回到事务开始之前的状态。
在 Laravel 中手动使用数据库事务
有时,您可能希望对事务进行更精细的控制。例如,假设您正在与第三方服务集成,比如 Mailchinp 或 Xero。我们会说,每当您创建一个新用户时,您还需要向他们的 API 发出 HTTP 请求,以将他们也创建为该系统中的用户。
我们可能想要更新我们的代码,以便如果我们无法在我们自己的系统 ** 且 ** 在第三方系统中创建用户,则两个系统都不创建用户。 如果您正在与第三方系统交互,那么您可能有一个可用于发出请求的类。 或者,可能有一个您可以使用的包。 有时,当某些请求无法完成时,发出请求的类可能会抛出异常。 然而,其中一些类可能会消除错误,而只是从您调用的方法中返回 false
,并将错误放置在类的字段中。
因此,我们假设我们有以下调用 API 的基本示例类:
class ThirdPartyService
{
private $errors;
public function createUser($userData)
{
$request = $this->makeRequest($userData);
if ($request->successful()) {
return $request->body();
}
$errors = $request->errors();
return false;
}
public function getErrors()
{
return $this->errors;
}
}
当然,上面的请求类代码是不完整的,我下面的代码示例也不是很清楚,但它应该能让您大致了解我要表达的观点。所以让我们使用这个请求类并将其添加到我们之前的代码示例中:
use Illuminate\Support\Facades\DB;
use App\Services\ThirdPartyService;
DB::beginTransaction();
$thirdPartyService = new ThirdPartyService();
$userData = [
'email' => $request->email,
];
$user = User::create($userData);
$user->roles()->attach(Role::where('name', 'general')->first());
if ($thirdPartyService->createUser($userData)) {
DB::commit();
return;
}
DB::rollBack();
report($thirdPartyService->getErrors());
查看上面的代码,我们可以看到我们启动了一个事务,创建了用户并为他们分配了一个角色,然后我们调用了第三方服务。如果在外部服务中成功创建了用户,知道所有内容都已正确创建,我们就可以安全地提交数据库更改。但是,如果没有在外部服务中创建用户,则回滚数据库中的更改 (删除用户及其角色分配),然后报告错误。
与第三方服务交互的技巧
作为一个额外的技巧,我通常建议将任何影响第三方系统、文件存储或缓存的代码放在数据库调用之后。
为了更深入地理解这一点,让我们以上面的代码示例为例。请注意,在向第三方服务发出请求之前,我们是如何首先对数据库进行所有更改的。这意味着,如果从第三方请求返回任何错误,将回滚我们自己数据库中的用户和角色分配。
然而, 如果我们反过来做,我们在修改数据库之前发出请求,那就不是这种情况了。出于任何原因,如果我们在数据库中创建用户时发生任何错误,我们会在第三方系统中创建一个新用户,但是在我们系统中却没有创建。如你所想, 这可能会导致更多问题。通过编写一个清理方法将用户从第三方系统中删除,可以降低这个问题的严重性。 但是,正如您可以想象的那样, 这可能会导致更多的问题,并导致编写、维护和测试更多的代码。
所以,我总是建议把数据库调用放在 API 调用之前。但并不总是这样,有时可能需要将第三方请求返回的值保存到数据库中。如果是这种情况,就需要 API 调用放到数据库调用之前了,只要您确保有一些代码可以处理任何失败,这是完全可以的。
使用自动或手动事务
同样值得注意的是,因为我们最初的示例使用 DB:transaction()
方法,在抛出异常时回滚事务,所以我们也可以使用这种方法向我们的第三方服务发出请求。相反,我们可以这样更新类:
use Illuminate\Support\Facades\DB;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
if (! $thirdPartyService->createUser($userData)) {
throw new \Exception('User could not be created');
}
});
这绝对是一个可行的解决方案,并将按照预期成功回滚事务。事实上,就我个人的偏好而言,我实际上更喜欢这种方式,而不是手动使用事务。我认为它看起来更容易阅读和理解。
然而,与手动提交或回滚事务时使用 ‘if’ 语句相比,异常处理在时间和性能方面可能会比较昂贵。
因此,举个例子,如果这段代码用于导入包含 10,000 个用户数据的 CSV 文件,您可能会发现抛出异常会大大减慢导入速度。
但是,如果它只是在一个用户可以注册的简单 web 请求中使用,那么抛出异常可能没有问题。当然,这取决于应用程序的大小,性能是关键因素;所以你需要根据具体情况来决定。
在数据库事务中调度队列
每当您在事务中处理队列时,您都需要注意一个 “陷阱”。
为了提供一些上下文,让我们继续使用之前的代码示例。我们可以想象,在我们创建了我们的用户之后,我们想要运行一个任务来提醒管理员通知他们新注册并向新用户发送欢迎电子邮件。我们将通过分派一个名为 AlertNewUser
的队列任务来做到这一点,如下所示:
use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
AlertNewUser::dispatch($user);
});
当您开始一个事务并对其中的任何数据进行更改时,这些更改仅对正在运行事务的请求 / 进程可用。对于任何其他访问您更改的数据的请求或进程,必须先提交事务。因此,这意味着如果我们从事务内部分派任何排队的队列、事件监听器、邮件,通知或广播事件。由于竞争条件,我们的数据更改可能在事务内部不可用。
如果队列在事务提交之前开始处理排队的代码,就会发生这种情况。因此,这可能导致您的排队代码可能试图访问不存在的数据,并可能导致错误。在我们的例子中,如果在事务提交之前运行队列 AlertNewUser
作业,那么我们的作业将尝试访问一个尚未实际存储在数据库中的用户。如您所料,这将导致作业失败。
为了防止这种竞争条件的发生,我们可以对我们的代码和 / 或我们的配置进行一些更改,以确保仅在事务成功提交后才调度队列。
我们可以更新 config/queue.php
并添加 after commit
字段。让我们想象一下,我们正在使用 redis
队列驱动程序,所以我们可以这样更新配置:
<?php
return [
// ...
'connections' => [
// ...
'redis' => [
'driver' => 'redis',
// ...
'after_commit' => true,
],
// ...
],
// ...
];
通过进行此更改,如果我们尝试在事务内调度队列,则队列将在实际调度队列之前等待事务提交。 方便的是,如果事务回滚,它也会阻止队列被调度。
然而,可能有一个原因,您不希望在配置中全局设置此选项。 如果是这种情况,Laravel 仍然提供了一些很好的助手方法,我们可以根据具体情况使用它们。
如果我们想更新事务中的代码,只在任务提交后才分派任务,可以使用 afterCommit()
方法,如下所示:
use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
AlertNewUser::dispatch($user)->afterCommit();
});
Laravel 还提供了另一个我们可以使用的方便的 beforeCommit()
方法。 如果我们在队列配置中设置了全局 after_commit => true
,但不关心等待事务被提交,就可以使用这个。 要做到这一点,我们可以简单地像这样更新我们的代码:
use Illuminate\Support\Facades\DB;
use App\Jobs\AlertNewUser;
use App\Services\ThirdPartyService;
DB::transaction(function () use ($user, $request): void {
$user = User::create([
'email' => $request->email,
]);
$user->roles()->attach(Role::where('name', 'general')->first());
AlertNewUser::dispatch($user)->beforeCommit();
});
总结
希望本文能让您大致了解什么是数据库事务以及如何在 Laravel 中使用它们。 它还向您展示了如何在从内部事务调度队列时避免 “陷阱”。