Содержание

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

Разберём, что такое ACID, какие бывают аномалии, чем отличаются уровни изоляции и как защищаться от гонок.

Что такое транзакция и ACID?

Транзакция объединяет несколько операций в одну: например, списать деньги со счёта, создать заказ и уменьшить остаток. Свойства ACID описывают гарантии, которые даёт база данных.

Свойство Что означает Пример
Atomicity — атомарность Всё или ничего Нет заказа без списания и списания без заказа
Consistency — согласованность Данные соответствуют правилам Остаток не может стать отрицательным, если есть ограничение
Isolation — изоляция Параллельные транзакции не мешают друг другу Два покупателя не купят последний товар одновременно
Durability — долговечность Зафиксированное не пропадёт После подтверждения заказ переживёт перезапуск сервера

Какие аномалии возникают при параллельной работе?

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

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

Человек вводит код на клавиатуре банкомата Фото: Unsplash

Чем отличаются уровни изоляции?

Уровни изоляции — это компромисс между защитой от аномалий и производительностью. По умолчанию PostgreSQL использует Read Committed, MySQL с InnoDB — Repeatable Read. Ни один из них сам по себе не защищает от всех гонок в бизнес-логике.

Уровень Что видит транзакция От чего защищает
Read Committed Только зафиксированные изменения, на момент каждого запроса От чтения «грязных» данных
Repeatable Read Снимок данных на начало транзакции Плюс неповторяемое чтение
Serializable Результат как при последовательном выполнении От всех аномалий, ценой повторов

На строгом уровне Serializable база может прервать транзакцию с ошибкой сериализации — приложение должно уметь её повторить.

Как защититься от гонок на практике?

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

// 1. Атомарное обновление с условием: остаток не уйдёт в минус
$updated = DB::table('products')
    ->where('id', $productId)
    ->where('stock', '>=', $qty)
    ->decrement('stock', $qty);

if ($updated === 0) {
    throw new OutOfStock($productId);
}

// 2. Явная блокировка строки до конца транзакции
DB::transaction(function () use ($accountId, $amount) {
    $account = Account::whereKey($accountId)->lockForUpdate()->first();
    $account->balance -= $amount;
    $account->save();
});

Правила:

  1. Проверку и изменение делайте одним запросом или под блокировкой, а не «прочитал — подумал — записал».
  2. Держите транзакции короткими: никаких внешних HTTP-запросов внутри.
  3. Ставьте ограничения в базе — уникальные индексы и проверки — как последнюю линию обороны.
  4. Для повторных запросов используйте ключи идемпотентности — подробнее в статье идемпотентность платежей и вебхуков.

Как проверить, что гонок нет?

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

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

О безопасности финансовых операций подробнее — в статье разработка финтех-продукта, а о выборе СУБД — в материале SQL или NoSQL.

Коротко

  • Транзакция выполняет группу изменений целиком или не выполняет вовсе.
  • Без правильной изоляции появляются потерянные обновления, фантомы и перекосы записи.
  • Уровни изоляции — компромисс между защитой и производительностью; Serializable требует повторов.
  • От гонок защищают атомарные запросы, блокировка строк и оптимистическая блокировка.
  • Гонки проверяют параллельными тестами, а не обычными.

Частые вопросы

  • Почему не использовать Serializable везде?

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

  • Можно ли делать HTTP-запросы внутри транзакции?

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

  • Что такое взаимная блокировка?

    Ситуация, когда две транзакции ждут друг друга. База обнаруживает её и прерывает одну из транзакций. Чтобы снизить риск, блокируйте строки в одинаковом порядке и делайте транзакции короткими.