LaravelПул соединений и транзакции

Пул соединений и транзакции

Auth, session, request — с ними разобрались в прошлой главе: контекст плюс прокси, и фреймворк перестал путать запросы. С базой вроде бы ещё проще: девятая глава основной серии уже всё решила, PDO Pool сам выдаёт каждой корутине отдельное соединение и сам забирает его назад. Что здесь может пойти не так у Eloquent?

Может. Пул прозрачно чинит соединение. Он ничего не знает про то, что Laravel хранит рядом с этим соединением ещё один кусок состояния — счётчик вложенности транзакции.

Где живёт transactionLevel()

Внутри Illuminate\Database\Connection есть обычное свойство экземпляра:

php
protected $transactions = 0;

beginTransaction() увеличивает его, commit() и rollBack() уменьшают. Пока один Connection обслуживает один процесс — всё логично: свойство одно, транзакция одна. Но PDO Pool работает на уровень ниже Connection. Он подменяет физическое соединение под капотом объекта, а сам объект Connection, к которому привязан $this->transactions, как был один на весь DatabaseManager, так и остался.

Смоделируем ситуацию из предыдущей главы, только с базой:

php
$server->addHttpHandler(function ($request, $response) {
    DB::transaction(function () use ($request) {
        Order::create(['user_id' => $request->getQueryParam('u')]);
        delay(30); // корутина уснула прямо внутри транзакции
    });

    $response->json(['ok' => true]);
});

Два запроса конкурентно входят в DB::transaction(). Оба физических соединения честно выданы пулом, каждое своё. А вот $this->transactions у обоих — одно и то же число на одном и том же объекте Connection. Первая корутина увеличила счётчик до 1, заснула. Вторая тоже увеличила его, но уже до 2, хотя для неё это должна быть внешняя транзакция уровня 1. commit() первой корутины умножается на неверный уровень вложенности — и Laravel вместо честного COMMIT тихо создаёт SAVEPOINT там, где не должен, либо коммитит транзакцию раньше времени, которую всё ещё держит соседняя корутина.

Тот же рецепт: контекст, но не scope, а корутина

В прошлой главе состояние auth жило в контексте scope, потому что оно общее для всех корутин одного запроса. Счётчик транзакций устроен по-другому: физическое соединение PDO Pool выдаёт на корутину, а не на запрос целиком (параллельный TaskGroup внутри обработчика получит два разных соединения из пула). Значит, и счётчик должен жить в контексте корутины, а не в контексте scope:

php
trait CoroutineTransactions
{
    private const CTX_TRANSACTIONS = 'db.transactions';

    public function transactionLevel()
    {
        if ($this->isAsyncMode()) {
            return coroutine_context()->find(self::CTX_TRANSACTIONS) ?? 0;
        }

        return $this->transactions;
    }

    private function setTransactionLevel(int $level): void
    {
        if ($this->isAsyncMode()) {
            coroutine_context()->set(self::CTX_TRANSACTIONS, $level, replace: true);
        } else {
            $this->transactions = $level;
        }
    }

    // beginTransaction(), commit(), rollBack() и обработчики ошибок
    // переопределены так же: вместо $this->transactions читают и пишут
    // через setTransactionLevel()/transactionLevel().
}

coroutine_context() против current_context() — та самая граница, о которой шла речь ещё в главе про Context основной серии: первый приватен для одной корутины, второй общий для дерева корутин запроса. Здесь выбор не стилистический, а обязателен: перепутать их — и два параллельных похода в базу внутри одного запроса снова начнут делить чужой счётчик транзакций, просто дыра сдвинется на этаж выше.

Трейт не переписывает Connection целиком, он точечно перехватывает методы, которые трогают $this->transactions, и подключается к конкретному классу соединения:

php
class AsyncPgsqlConnection extends PostgresConnection
{
    use CoroutineTransactions;
}

Отдельный класс на каждую СУБДAsyncPgsqlConnection, AsyncMySqlConnection, AsyncMariaDbConnection, AsyncSqliteConnection, AsyncSqlServerConnection — потому что родительские классы у Laravel уже разные, а трейт с изоляцией счётчика один и тот же для всех.

Почему нельзя просто скоупить весь DatabaseManager

Соблазн есть: раз мы уже умеем прятать сервис за контекстом (ScopedServiceProxy из прошлой главы), почему бы не сделать то же самое с db? Причина, по которой этого не происходит, довольно неприятная. DatabaseServiceProvider::boot() один раз при старте записывает:

php
Model::setConnectionResolver($app['db']);

Это статическое свойство самого класса Model, общее для всех моделей и всех запросов. Если бы db резолвился по-разному для каждого scope, эта статическая ссылка указывала бы на DatabaseManager первого же запроса, который создал её. Когда scope этого запроса завершится и уберёт за собой, объект, на который ссылается Model::$resolver, будет уничтожен сборщиком мусора — а статическое свойство так и останется указывать на мёртвую память. Результат не «неправильные данные», а падение процесса целиком.

Поэтому db остаётся синглтоном, как и был. Физическая изоляция соединений — забота PDO Pool на уровне C, а не контейнера зависимостей. Изоляция счётчика транзакций — забота трейта на уровне одной корутины. Два узких, точных инструмента вместо одного большого рефакторинга, который бы вдобавок ронял сервер.

С состоянием запроса и с состоянием транзакции разобрались. Обычные HTTP-ответы Laravel умеет отдавать сам, буферизуя их целиком. А что если ответ не одноразовый, а поток: прогресс-бар в браузер или вызов от соседнего сервиса по gRPC? Туда и пойдём в следующей главе.