Два запроса бронируют последнее доступное место. Два воркера уменьшают один и тот же остаток товара. Два администратора обновляют один и тот же заказ.
Каждая операция может работать правильно сама по себе и всё равно выдавать некорректный результат при одновременном выполнении. В приложениях на Symfony с использованием Doctrine для решения этой задачи требуется координировать доступ к базе данных и следить за тем, чтобы ORM предоставляла актуальное состояние, которое действительно было заблокировано.
В этой статье в качестве примера рассматривается бронирование мест, но тот же подход применим к складским запасам, балансам и защищенным сменам статусов.
Область применения: PostgreSQL в READ COMMITTED . Примеры кода упрощены; ссылки ведут на реальные реализации в репозитории.
Вот с какой проблемой гонки данных мы разберемся в следующих разделах:
Ожидание блокировки строки не повторяет предварительную проверку доступности в приложении.
Главное правило звучит так:
Защищайте все решение целиком: читайте текущее состояние с использованием выбранного механизма параллелизма, проверяйте инвариант и фиксируйте соответствующие изменения вместе.
Для разных операций нужны разные механизмы. Прежде чем подробно рассматривать пессимистичную блокировку, полезно ознакомиться с общей моделью принятия решений:
Проблема Типичный механизм
| Прочитать текущее состояние, проверить несколько условий, затем изменить | Пессимистичная блокировка с 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-объекту. Если этот объект был загружен ранее, выполнение другого запроса обычно не заменяет его поля значениями, возвращенными из базы данных.
Рассмотрим такую последовательность:
- Текущий EntityManager загружает место как AVAILABLE .
- Другая транзакция изменяет его на HELD и фиксирует изменения.
- Текущий 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 проверьте количество затронутых строк:Если другая транзакция вставляет тот же ключ, PostgreSQL может дождаться ее результата. Если она фиксируется, ожидающая вставка пропускает дубликат; если откатывается, ожидающая вставка может успешно выполниться.
- 1: эта транзакция вставила заявку (claim) и может обработать сообщение.
- 0: заявка уже существует; пропустите дублирующую обработку.
Одна транзакция: фиксируйте заявку и изменения базы данных обработчика вместе, используя одно и то же соединение. В противном случае зафиксированная заявка с последующим сбоем обработки может привести к тому, что повторные попытки пропустят незавершенную работу.DO NOTHING обрабатывает этот ожидаемый конфликт без возникновения ошибки нарушения уникальности (unique-violation error).Используйте DO UPDATE , если предполагаемым поведением является обновление существующей записи.Ни один из вариантов автоматически не проверяет, содержат ли дублирующиеся запросы одинаковые данные (payload).См. документацию PostgreSQL по ON CONFLICT. Пример кода: InboxMessageStore::claim() возвращает информацию об успешности вставки; посредник (middleware) вызывает обработчик только для успешно созданной заявки.Обработка конкуренции и сбоев транзакций
Не каждый конфликт требует повторной попытки. Место, которое уже забронировано, — это валидный бизнес-результат; тайм-аут блокировки или дедлок — это транзиентный (временный) инфраструктурный сбой.При временных сбоях повторяйте всю транзакцию целиком, а не только упавший оператор. После отката начните с чистого EntityManager в Doctrine и перезагрузите текущее состояние.Ограничивайте количество повторных попыток в рамках бюджета задержки запроса. Частые немедленные повторы могут усилить конкуренцию, поэтому используйте небольшой лимит повторов с экспоненциальной задержкой (backoff) и отслеживайте ожидание блокировок, дедлоки и исчерпание лимита попыток.Итоговое правило
Транзакции, блокировки строк, поля версий и уникальные ограничения решают разные части проблемы параллелизма.Важно не выбрать самый мощный механизм. Важно выбрать тот механизм, который защищает инвариант именно в той точке, где принимается решение.Защищайте все решение целиком: читайте текущее состояние с использованием выбранного механизма параллелизма, проверяйте инвариант и фиксируйте соответствующие изменения вместе.

Комментарии (0)
Пока нет комментариев — будьте первым.