Два запроса бронируют последнее доступное место. Два воркера уменьшают один и тот же остаток товара. Два администратора обновляют один и тот же заказ.

Каждая операция может работать правильно сама по себе и всё равно выдавать некорректный результат при одновременном выполнении. В приложениях на Symfony с использованием Doctrine для решения этой задачи требуется координировать доступ к базе данных и следить за тем, чтобы ORM предоставляла актуальное состояние, которое действительно было заблокировано.

В этой статье в качестве примера рассматривается бронирование мест, но тот же подход применим к складским запасам, балансам и защищенным сменам статусов.

 Область применения: PostgreSQL в  READ COMMITTED . Примеры кода упрощены; ссылки ведут на реальные реализации в репозитории.

Вот с какой проблемой гонки данных мы разберемся в следующих разделах:

kU1qAAHzaawsbsR7mU5WVMLLc4AvF4OpqHjlHlKz.webp

Ожидание блокировки строки не повторяет предварительную проверку доступности в приложении.

Главное правило звучит так:

 Защищайте все решение целиком: читайте текущее состояние с использованием выбранного механизма параллелизма, проверяйте инвариант и фиксируйте соответствующие изменения вместе.

Для разных операций нужны разные механизмы. Прежде чем подробно рассматривать пессимистичную блокировку, полезно ознакомиться с общей моделью принятия решений:


Проблема Типичный механизм
| Прочитать текущее состояние, проверить несколько условий, затем изменить  | Пессимистичная блокировка с  SELECT ... FOR UPDATE 
| Выполнить простой защищенный переход статуса  | Условный  UPDATE 
| Обработать изменения, охватывающие несколько HTTP-запросов  | Оптимистичная блокировка
| Предотвратить одновременное создание одинаковой логической записи  | Ограничение  UNIQUE  или первичный ключ с  ON CONFLICT  

В остальной части статьи объясняется, почему эти механизмы решают разные проблемы параллелизма и что именно Doctrine добавляет поверх поведения PostgreSQL.

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

Рассмотрим типичную операцию чтения-проверки-записи:

 $seat = $seatRepository->find($seatId);

if (!$seat->isAvailable()) {
    throw new SeatNotAvailableException($seatId);
}

$seat->holdFor($reservationId);
$entityManager->flush();
 

Оба запроса могут прочитать  AVAILABLE  до того, как любой из них запишет свое изменение:


Шаг Запрос A Запрос B
| 1  | Читает AVAILABLE  | Читает AVAILABLE
| 2  | Проверка доступности пройдена  | Проверка доступности пройдена
| 3  | Обновляет владельца на A  | Ждет возможности обновить ту же строку
| 4  | Фиксирует транзакцию (commit)  | Обновляет владельца на B и фиксирует транзакцию

Оборачивание этого кода в транзакцию делает каждую операцию атомарной, но не мешает обеим проверкам доступности завершиться успешно.

PostgreSQL сериализует операции записи. Тем не менее, обновление только с условием  WHERE id = :id  все равно может перезаписать предыдущего владельца: его условие остается истинным после ожидания. Приложение приняло бизнес-решение на основе устаревшего состояния. Это соответствует поведению READ COMMITTED в PostgreSQL.

 Ключевое правило: Защищайте проверку доступности в связке с операцией записи.

Пессимистичная блокировка: защита всего решения целиком 

Для короткой операции, которая читает сущность, проверяет ее состояние и обновляет ее, Doctrine предоставляет  LockMode::PESSIMISTIC_WRITE .

В PostgreSQL блокирующий запрос использует  SELECT ... FOR UPDATE . Конкурирующая транзакция, запрашивающая ту же блокировку, ждет, пока текущий владелец зафиксирует (commit) или откатит (rollback) транзакцию.

Сервис: определение границ транзакции 

Транзакция должна охватывать  весь сценарий использования (use case). Упрощенный метод сервиса выглядит следующим образом:

 public function reserve(
    string $showId,
    string $seatId,
    string $reservationId,
): void {
    $this->entityManager->wrapInTransaction(function () use (
        $showId, $seatId, $reservationId,
    ): void {
        $seat = $this->seatRepository->findForUpdate($showId, $seatId);

        if ($seat === null || !$seat->isAvailable()) {
            throw new SeatNotAvailableException($seatId);
        }

        $seat->holdFor($reservationId);
    });
}
 

 wrapInTransaction()  выполняет сброс (flush) перед фиксацией. Транзакция уже должна быть активна в момент выполнения блокирующего запроса; добавление блокировки к обычному вызову репозитория вне транзакции неэффективно.

Контроллер лишь вызывает сервис и маппит результат в HTTP. Это позволяет использовать одни и те же границы транзакции как из HTTP, так и из CLI или обработчиков Messenger.

Выносите внешние HTTP-запросы и другие медленные операции за пределы заблокированной секции.

Репозиторий: получение блокировки 

Метод репозитория содержит блокирующий запрос:

 use Doctrine\DBAL\LockMode;
use Doctrine\ORM\Query;

public function findForUpdate(string $showId, string $seatId): ?Seat
{
    return $this->createQueryBuilder('s')
        ->andWhere('s.id = :seatId')
        ->andWhere('IDENTITY(s.show) = :showId')
        ->setParameter('seatId', $seatId)
        ->setParameter('showId', $showId)
        ->getQuery()
        ->setLockMode(LockMode::PESSIMISTIC_WRITE)
        ->setHint(Query::HINT_REFRESH, true)
        ->getOneOrNullResult();
}
 

Когда запрос A фиксирует бронь, запрос B получает блокировку и проверяет забронированное состояние. Если A делает откат (rollback), B может занять все еще доступное место.

 Примеры кода: блокирующий запрос и граница транзакции в сервисе.

Doctrine все еще может хранить устаревшую сущность 

Приведенный выше запрос не зря включает  Query::HINT_REFRESH .

Doctrine поддерживает  картy идентичности (identity map): в рамках одного EntityManager идентификатор сущности обычно соответствует одному и тому же PHP-объекту. Если этот объект был загружен ранее, выполнение другого запроса обычно не заменяет его поля значениями, возвращенными из базы данных.

Рассмотрим такую последовательность:

  1. Текущий EntityManager загружает место как  AVAILABLE .
  2. Другая транзакция изменяет его на  HELD  и фиксирует изменения.
  3. Текущий EntityManager выполняет блокирующий запрос для этого места.

База данных возвращает текущую заблокированную строку, в то время как Doctrine может сохранить ранее управляемый (managed) объект:

 PostgreSQL row:  HELD
PHP object:      AVAILABLE
 

Блокировка защищает строку, но проверка доступности все равно может использовать устаревшее состояние.

 HINT_REFRESH  заставляет Doctrine обновлять уже управляемую сущность данными из результата запроса. Это описано в документации по подсказкам (hints) запросов в Doctrine.

 Обновляйте перед изменением: обновление может затереть незафиксированные изменения. Если сущность загружается впервые, более раннего управляемого состояния для замены нет.Поэтому блокирующий запрос с обновлением следует рассматривать как  границу получения (acquisition boundary) для агрегата: получите блокировку и загрузите текущее состояние перед внесением изменений. Избегайте вызова таких запросов после изменения той же управляемой сущности, если только вы намеренно не хотите отбросить эти незафиксированные изменения. Получить блокировку → гидратировать текущее состояние → проверить → изменить → зафиксировать.Блокировка после предварительной проверки доступности оставляет возможность для гонки данных.

Несколько строк: согласованный порядок блокировки 

Когда одной операции требуется несколько строк,  получайте пессимистичные блокировки в согласованном порядке. Разный порядок получения может привести к дедлоку (deadlock):

 Transaction A: locks seat 1, waits for seat 2
Transaction B: locks seat 2, waits for seat 1
 
 Каждый конкурирующий путь выполнения кода должен следовать одному и тому же порядку. Сортировка ID на PHP помогает только в том случае, если репозиторий действительно получает блокировки в этой последовательности; порядок значений внутри SQL  IN (...)  этого не гарантирует.Согласованная упорядоченность снижает количество дедлоков для данного набора ресурсов, но не устраняет дедлоки на уровне транзакций, затрагивающие другие ресурсы. Пример кода: получение упорядоченных блокировок.

Ограничение времени ожидания блокировки запросом 

Выберите значение  lock_timeout в рамках бюджета задержки (latency budget) операции и примените его с помощью  SET LOCAL  внутри транзакции.Тайм-аут блокировки означает, что операция не смогла вовремя получить блокировку; это не доказывает, что место недоступно.Тайм-аут также применяется к блокировкам, получаемым неявно через  UPDATE  и  INSERT , включая альтернативные стратегии, описанные ниже.

Выбор стратегии параллелизма для операции 

Пессимистичная блокировка полезна, когда короткой транзакции необходимо изучить текущие сущности перед их изменением. Но это не единственный вариант.

Условный UPDATE: простые переходы статусов 

Для простого перехода статуса перенесите предварительное условие внутрь операции записи:

 UPDATE seats
SET state = 'HELD',
    held_by_reservation_id = :reservationId
WHERE id = :seatId
  AND state = 'AVAILABLE';
 
В Doctrine DBAL проверяйте количество затронутых строк (affected rows). Одна измененная строка означает, что переход удался; ноль означает, что место отсутствовало или было недоступно.Для операции с несколькими строками сравните количество с числом уникальных запрошенных ID и откатите частичные изменения перед фиксацией.Условные обновления все равно захватывают блокировки и могут ожидать. Прямой SQL также обходит управляемое состояние Doctrine, поэтому избегайте принятия дальнейших решений на основе сущностей, которые стали устаревшими.

Оптимистичная блокировка: изменения, охватывающие HTTP-запросы 

Оптимистичная блокировка — еще один выбор, когда конфликты происходят редко, особенно для правок, растянутых во времени из-за взаимодействия с пользователем.Используя поле  #[ORM\Version] , Doctrine проверяет версию сущности в базе данных при ее обновлении во время сброса (flush) и выбрасывает  OptimisticLockException  в случае несовпадения.В этом случае приложение должно вернуть явный ответ о конфликте или применить политику повторных попыток (retry policy). Не держите транзакцию базы данных открытой, пока пользователь редактирует форму.См. поддержку блокировок в Doctrine.Для правок, охватывающих HTTP-запросы,  сохраняйте версию, изначально показанную пользователю, и проверяйте эту ожидаемую версию при обработке отправки формы, например так:

 $entityManager->lock(
    $entity,
    LockMode::OPTIMISTIC,
    $expectedVersion,
);
 
Загрузка последней версии сущности при отправке и опора только на проверку во время flush все равно могут привести к перезаписи изменений, сделанных пока форма была открыта.См. примчания по реализации оптимистичной блокировки в Doctrine.

Конкурентный INSERT: пусть решение принимает уникальный ключ 

Что делать, если строка еще не существует?В  READ COMMITTED  оператор  SELECT ... FOR UPDATE , не обнаруживший строку, не резервирует этот ключ. Два запроса могут одновременно пройти проверку существования и попытаться выполнить вставку.Определите, что делает запись уникальной, с помощью  ограничения UNIQUE или PRIMARY KEY, а затем обработайте ожидаемый дубликат с помощью  ON CONFLICT :

 INSERT INTO inbox_messages (
    consumer_name,
    message_id,
    message_type,
    processed_at
)
VALUES (
    :consumerName,
    :messageId,
    :messageType,
    :processedAt
)
ON CONFLICT (consumer_name, message_id) DO NOTHING;
 
Здесь  (consumer_name, message_id)  является первичным ключом.С помощью метода  executeStatement()  в DBAL проверьте количество затронутых строк:
  •   1: эта транзакция вставила заявку (claim) и может обработать сообщение.
  •   0: заявка уже существует; пропустите дублирующую обработку.
Если другая транзакция вставляет тот же ключ, PostgreSQL может дождаться ее результата. Если она фиксируется, ожидающая вставка пропускает дубликат; если откатывается, ожидающая вставка может успешно выполниться.
 Одна транзакция: фиксируйте заявку и изменения базы данных обработчика вместе, используя одно и то же соединение. В противном случае зафиксированная заявка с последующим сбоем обработки может привести к тому, что повторные попытки пропустят незавершенную работу.
 DO NOTHING  обрабатывает этот ожидаемый конфликт без возникновения ошибки нарушения уникальности (unique-violation error).Используйте  DO UPDATE , если предполагаемым поведением является обновление существующей записи.Ни один из вариантов автоматически не проверяет, содержат ли дублирующиеся запросы одинаковые данные (payload).См. документацию PostgreSQL по ON CONFLICT. Пример кода: InboxMessageStore::claim() возвращает информацию об успешности вставки; посредник (middleware) вызывает обработчик только для успешно созданной заявки.

Обработка конкуренции и сбоев транзакций 

 Не каждый конфликт требует повторной попытки. Место, которое уже забронировано, — это валидный бизнес-результат; тайм-аут блокировки или дедлок — это транзиентный (временный) инфраструктурный сбой.
При временных сбоях повторяйте  всю транзакцию целиком, а не только упавший оператор. После отката начните с чистого  EntityManager  в Doctrine и перезагрузите текущее состояние.Ограничивайте количество повторных попыток в рамках бюджета задержки запроса. Частые немедленные повторы могут усилить конкуренцию, поэтому используйте небольшой лимит повторов с экспоненциальной задержкой (backoff) и отслеживайте ожидание блокировок, дедлоки и исчерпание лимита попыток.

Итоговое правило 

Транзакции, блокировки строк, поля версий и уникальные ограничения решают разные части проблемы параллелизма.Важно не выбрать самый мощный механизм. Важно выбрать тот механизм, который защищает инвариант именно в той точке, где принимается решение.
 Защищайте все решение целиком: читайте текущее состояние с использованием выбранного механизма параллелизма, проверяйте инвариант и фиксируйте соответствующие изменения вместе.