Очень распространённое заблуждение: если запросы выполняются внутри транзакции — значит данные уже защищены от гонок, но это не так.
Одна из классических проблем конкурентного доступа —
lost update. Например, есть таблица:accounts(id, balance)
Текущий баланс:
id | balance
1 | 1000
Два запроса одновременно читают баланс:
SELECT balance
FROM accounts
WHERE id = 1;
Оба получают: 1000. Дальше: первый процесс хочет списать 100; второй — 200. Первый считает: 1000 - 100 = 900. Второй: 1000 - 200 = 800.
После этого выполняются:
UPDATE accounts
SET balance = 900
WHERE id = 1;
а также:
UPDATE accounts
SET balance = 800
WHERE id = 1;
В итоге финальный баланс: 800, хотя математически должен быть: 700, одно обновление потерялось. Это и есть
lost update.И самое неприятное — такое может происходить даже внутри транзакций. Всё зависит от уровня изоляции, паттерна обновления и механики блокировок конкретной СУБД.
Очень частая ошибка выглядит так:
BEGIN;
SELECT balance
FROM accounts
WHERE id = 1;
-- вычисления в приложении
UPDATE accounts
SET balance = :new_balance
WHERE id = 1;
COMMIT;
Проблема в том, что между
SELECT и UPDATE другая транзакция может изменить строку.Один из самых надёжных вариантов — атомарное обновление:
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
Теперь вычисление происходит внутри самого
UPDATE.СУБД выполняет обновление на основе актуального значения строки и использует необходимые механизмы блокировок для корректной синхронизации конкурентных изменений. Это намного безопаснее.
Ещё один вариант — pessimistic locking:
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;
FOR UPDATE ставит row-level lock. Пока транзакция не завершится, другая транзакция не сможет изменить эту строку или получить несовместимую блокировку на неё.Но здесь есть trade-off, чем больше блокировок: тем выше contention; тем ниже concurrency; тем выше риск deadlock.
Ещё один подход — optimistic locking. Например:
accounts(id, balance, version)
Чтение:
SELECT balance, version
FROM accounts
WHERE id = 1;
Обновление:
UPDATE accounts
SET
balance = :new_balance,
version = version + 1
WHERE
id = 1
AND version = 5;
Если другая транзакция уже изменила строку —
UPDATE затронет 0 строк. Приложение понимает: данные устарели, нужно перечитать и повторить операцию. Ещё важно понимать: READ COMMITTED, REPEATABLE READ, SERIALIZABLE ведут себя по-разному в разных СУБД.Например, PostgreSQL и MySQL (InnoDB) используют разные механизмы MVCC и имеют различия в поведении блокировок и уровней изоляции.
🔥 Главное правило, если логика выглядит как: прочитал значение — изменил в приложении — записал обратно, то всегда стоит проверять, не появляется ли
lost update при параллельных запросах.➡️ SQL Ready | #практика