Laravel: Использование транзакций

Источник: «Using Database Transactions to Write Safer Laravel Code»
В веб-разработке важны целостность и точность данных. Поэтому необходимо быть уверенным, что мы пишем код, который безопасно хранит, обновляет и удаляет данные в наших базах данных. В этой статье мы рассмотрим, что такое транзакции базы данных, почему они важны и как начать их использовать в Laravel. Мы так же рассмотрим типичные проблемы связанные с заданиями в очереди и транзакциями баз данных.

Что такое транзакции базы данных

Прежде чем начать разбираться с транзакциями в Laravel, давайте рассмотрим, что это такое и чем они полезны.

Существует множество технических, сложно звучащих объяснений того, что такое транзакция базы данных. Но для подавляющего большинства из нас, веб-разработчиков, нужно знать, что транзакция — способ завершить единицу работы в целом в базе данных.

Давайте рассмотрим базовый пример, который всё пояснит.

Представим, что у нас есть приложение регистрирующее пользователей. Каждый раз, когда пользователь регистрируется, мы хотим создать для него новую учётную запись, а затем назначить ему роль по умолчанию — general.

Наш код может выглядеть как-то так:

$user = User::create([
'email' => $request->email,
]);

$user->roles()->attach(Role::where('name', 'general')->first());

На первый взгляд может показаться, что с этим кодом всё в порядке. Но если мы присмотримся, то увидим, что на самом деле что-то может пойти не так. Есть вероятность, что мы могли бы создать пользователя, но не назначить ему роль. Это может быть вызвано множеством причин, например ошибкой в коде назначающим роль, или аппаратной проблемой, которая не позволяет нам получить доступ к базе данных.

В результате этого у нас в системе будет пользователь, у которого нет роли. Как вы понимаете, скорее всего это вызовет исключения и ошибки в других местах вашего приложения. Потому что вы всегда будете предполагать, что у пользователя есть роль (и это правильно).

Что бы решить эту проблему, мы можем использовать транзакции базы данных. Транзакция гарантирует, что если что-то пойдёт не так во время выполнения кода, любые изменения в базе данных внутри этой транзакции будут отменены. Например, если пользователь был вставлен в базу данных, но запрос на назначение роли не удался — транзакция будет отменена, а строка пользователя удалена. Это означает, что мы не сможем создать пользователя без роли.

Другими словами, это «всё или ничего».

Использование транзакций базы данных в Laravel

Теперь, когда мы имеем представление о транзакциях, давайте рассмотрим как ими пользоваться в Laravel.

В Laravel очень легко начать работу с транзакциями благодаря методу transaction(), с которым мы можем получить доступ к фасаду DB. Основываясь на предыдущем примере кода, давайте рассмотрим как использовать транзакции при создании пользователя и назначении ему роли в Laravel.

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

Бывают случаи, когда вы хотите более детально контролировать свои транзакции. Представим, что вы интегрируете сторонний сервис, например Mailchimp или Xero. Когда вы создаёте пользователя, вы также хотите сделать HTTP-запрос к их API, что бы создать его как пользователя в той системе тоже.

Вы наверное захотите обновить код, что бы если мы не можем создать пользователя в нашей или сторонней системе, ни один из пользователей не создавался. Возможно, если вы взаимодействуете со сторонней системой, у вас есть класс, который вы используете для выполнения запросов. Или возможно, есть пакет который вы используете. Иногда классы, отправляющие запрос, могут генерировать исключение, когда какой-либо запрос не может выполниться. Однако некоторые из них могут подавлять вывод ошибок и вместо этого вернуть 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. Однако это не всегда возможно. Бывают случаи, когда вам нужно сохранить в базе данных значение возвращаемое сторонним сервисом. В данном случае, это нормально, но убедитесь, что у вас есть код для обработки сбоев.

Использование автоматических или ручных транзакций

Поскольку наш исходный пример с использованием метода 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, когда мы вручную выполняем или отменяем транзакции.

Если этот код использовать для импорта 10000 пользователей из CSV файла, вы можете заметить, что обработка исключений значительно замедляет процесс.

Если бы он использовался в простом веб-запросе с регистрацией пользователя, то против обработки исключений нечего было бы возразить. Всё сильно зависит от размера вашего приложения и насколько всё зависит от производительности. Это, что нужно решать в каждом конкретном случае.

Диспетчеризация заданий внутри транзакций базы данных

Всякий раз когда вы работаете с заданиями внутри транзакций, вам нужно знать о «подводном камне»

Что бы дать немного контекста, давайте придерживаться нашего предыдущего примера кода. Представим, что после того, как мы создали нашего пользователя, мы хотим запустить задание, которое предупреждает администратора о новой регистрации и отправляет приветственное письмо новому пользователю. Мы сделаем это, отправив задание в очередь с названием 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);
});

Когда вы начнёте транзакцию и внесёте изменения в любые данные внутри неё, эти изменения доступны только для запроса/процесса в котором выполняется транзакция. Для других запросов или процессов для доступа к данным, которые вы изменили, транзакция должна быть завершена. Следовательно, если мы отправляем какие-либо задания в очередь, обработчикам событий, почтовым сообщениям, уведомлениям или широковещательным событиям изнутри нашей транзакции, наши изменения данных могут быть недоступны внутри них из-за состояния гонки (race condition).

Это может произойти, если обработчик очереди начинает процесс обработки кода из очереди до того как, транзакция была завершена. Это может привести к тому, что ваш поставленный в очередь код потенциально пытается получить доступ к ещё не существующим данным, и может вызвать ошибку. В нашем случае, если задание очереди 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(). Мы можем использовать его, если установили в файле конфигурации (config/queue.php) параметр 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. А так же поможет избежать ошибок связанных с отправкой заданий внутри транзакции.

Дополнительные материалы

Предыдущая Статья

PHP: Интерфейсы vs Абстрактные классы

Следующая Статья

Как обновить опубликован­ный пакет npm