После конкурентного конфликта СУБД прервала транзакцию: почему безопасно повторять всю транзакцию, а не только последний оператор?
Повторять нужно всю транзакцию, потому что конфликт мог сделать недействительными не только последние данные, но и результаты всех предыдущих чтений и проверок. После отката повторный запуск заново получает согласованное состояние и повторно принимает решения на его основе; повтор одного оператора может использовать устаревшие промежуточные результаты.
Это правило относится к ошибкам сериализации и обычно к жертвам взаимной блокировки, но не ко всем ошибкам. Повтор допустим только после полного отката транзакции и с учётом побочных эффектов за пределами базы данных.
Конкурентное выполнение позволяет нескольким транзакциям работать одновременно, но некоторые сочетания операций невозможно корректно завершить без ожидания или отката одной из транзакций. СУБД используют блокировки, версии строк и проверку сериализуемости, чтобы обнаруживать такие конфликты.
Откат конфликтующей транзакции — компромисс между корректностью и производительностью: вместо глобальной блокировки всех операций СУБД прерывает только одну работу. Ответственность за повтор после временного конфликта обычно возлагается на приложение.
Транзакция часто сначала читает данные, затем вычисляет решение и только после этого изменяет несколько строк. Например, она может проверить лимит, выбрать доступный ресурс и создать связанные записи. Конфликт, обнаруженный в конце, означает, что исходные чтения могли быть несовместимы с действиями другой транзакции.
Если повторить только последний оператор, приложение сохранит старое решение, хотя основание для него уже изменилось. Это может привести к нарушению ограничений, неверному списанию, выбору уже занятого ресурса или частично выполненной бизнес-операции.
При конфликте сериализации СУБД обычно откатывает изменения транзакции или переводит её в состояние, в котором дальнейшие операторы нельзя безопасно выполнять. Поэтому приложение должно начать новую транзакцию: повторить все чтения, проверки, вычисления и изменения, а затем снова попытаться зафиксировать результат.
Причина — зависимость между операциями внутри транзакции. Если первая проверка прочитала значение, на основе которого были выполнены последующие действия, то после конфликта нельзя считать это значение актуальным. Повтор только записи не пересматривает принятую логику.
Для взаимной блокировки принцип аналогичен: СУБД выбирает одну транзакцию жертвой, откатывает её и освобождает блокировки. Оставшаяся транзакция продолжает работу, а приложение может повторить отклонённую транзакцию целиком.
Автоматический повтор должен иметь ограничения: число попыток, задержку или экспоненциальную паузу и фильтрацию ошибок. Повторять следует только временные ошибки конкуренции, например конфликт сериализации или взаимную блокировку, а не ошибки проверки данных, нарушения прав, синтаксические ошибки или постоянные нарушения ограничений.
Особую опасность представляют внешние эффекты. Отправка письма, вызов платёжного сервиса или публикация сообщения не откатываются вместе с транзакцией базы данных. Для таких случаев применяют идемпотентный ключ операции, таблицу намерений и шаблон outbox, чтобы повтор базы данных не создавал дубликаты внешнего действия.
Есть и неоднозначный случай: соединение может оборваться сразу после фиксации. Клиент не знает, завершилась ли транзакция. Без ключа идемпотентности повтор может создать дубликат, поэтому для критичных операций нужно отдельно проектировать проверку результата и дедупликацию.
Сервис бронирования одновременно обрабатывает несколько запросов на последний доступный билет. Транзакции проверяют остаток, создают бронирование и уменьшают доступное количество. При сериализуемом конфликте одна из них откатывается.
Вариант «повторить только уменьшение остатка» опасен: первоначальная проверка могла увидеть билет, который уже забрала другая транзакция. Вариант «взять блокировку на всю таблицу» упрощает рассуждение, но резко снижает конкурентность и увеличивает время ожидания.
Выбранное решение — повторять всю короткую транзакцию при ошибке сериализации, ограничивать количество попыток и использовать уникальный идентификатор запроса бронирования. Уникальность предотвращает дубли при повторе, а короткая транзакция уменьшает вероятность конфликтов; в результате корректность сохраняется без глобальной блокировки.
1. Нужно ли повторять транзакцию после любой ошибки блокировки?
Нет. Ожидание блокировки само по себе не является ошибкой: транзакция может продолжить работу после освобождения ресурса. Повтор нужен, когда СУБД действительно прервала транзакцию из-за взаимной блокировки или конфликта сериализации; точные коды и состояние транзакции зависят от конкретной СУБД.
2. Почему нельзя продолжить выполнение в той же транзакции после конфликта?
Потому что СУБД могла откатить изменения и признать текущий набор наблюдений несогласованным. Даже если отдельные операторы технически разрешены, приложение уже не может полагаться на прежние чтения и промежуточные вычисления. Новая транзакция создаёт новый согласованный контекст и заново выполняет всю зависимую логику.
3. Как сделать повтор безопасным при внешних побочных эффектах?
Повтор должен иметь устойчивый идентификатор логической операции, например ключ платежа или заказа. База данных может обеспечить уникальность этого ключа, а внешняя система — обработать его идемпотентно; для публикации событий часто используют outbox, где событие сначала фиксируется в той же транзакции, а затем доставляется отдельным процессом. Без этого повтор может привести к нескольким платежам, письмам или сообщениям, даже если состояние базы данных осталось корректным.