Кратко — проблема в том, что вы читаете значение до того, как «закрепили» за собой строку. Правильные варианты — либо делать изменение атомарно на сервере, либо сначала захватывать блокировку и уже потом читать/обновлять, либо использовать оптимистичную конкуренцию (версионирование + повтор). Уровень изоляции здесь не решит задачу сам по себе.
Варианты решений (корректные, распространённые)
1) Делайте операцию на стороне СУБД одним запросом (атомарно)
- Самый простой и надёжный путь для арифметических обновлений:
UPDATE users
SET balance = balance + :delta
WHERE id = :id;
— либо с защитой от отрицательного баланса:
UPDATE users
SET balance = balance - :amount
WHERE id = :id AND balance >= :amount;
Затем проверяете affected_rows: если 0 — откат/повтор/ошибка.
- В PostgreSQL можно вернуть новое значение:
UPDATE users SET balance = balance + $1 WHERE id = $2 RETURNING balance;
Преимущество: никакой «старой» копии значения у транзакции не бывает, весь расчёт делается атомарно.
2) Захватить блокировку и только потом читать (pessimistic locking)
- BEGIN;
SELECT balance FROM users WHERE id = :id FOR UPDATE; -- блокирует строку и возвращает актуальное значение
-- теперь безопасно вычислять и UPDATE
UPDATE users SET balance = :new_balance WHERE id = :id;
COMMIT;
- Важно: не делайте сначала plain SELECT (без FOR UPDATE), потом ждите блок и полагаетесь на старое прочитанное значение. Если вы хотите быть уверены, что видите актуальное после ожидания, — чтение должно быть выполнено уже после получения блокировки.
3) Оптимистичная блокировка (version / compare-and-swap)
- Добавляете поле version или updated_at, при обновлении используете WHERE version = :old_version и увеличиваете version:
UPDATE users
SET balance = :new_balance, version = version + 1
WHERE id = :id AND version = :old_version;
— если affected_rows == 0 — значит кто-то изменил запись; читаем снова и повторяем транзакцию.
- Подходит при редких конфликтах, уменьшает время блокировок.
Почему трюк «прочитать, ждать блок, потом продолжать с ранее прочитанным значением» плох:
- Если вы сначала сделали простое SELECT, получили snapshot, а затем только пытались захватить блок/обновить, вы можете оперировать устаревшими данными. Либо надо всегда re-read под блокировкой, либо не читать заранее.
Нюансы по СУБД
- В большинстве СУБД SELECT ... FOR UPDATE возвращает актуальные данные и блокирует строку. Но если вы используете REPEATABLE READ / snapshot-исполения (и читали без FOR UPDATE раньше), то вы можете иметь старую копию. Поведение немного отличается между PostgreSQL и MySQL/InnoDB — но общая рекомендация остаётся: либо атомарный UPDATE, либо lock-then-read, либо versioning.
Резюме
- Если возможно — делайте изменение как один UPDATE (с проверкой условий в WHERE). Это самое простое и надёжное.
- Если нужно предварительно прочитать текущее значение — сначала захватите блокировку SELECT ... FOR UPDATE, затем читайте и обновляйте.
- При высоком contention используйте оптимистичную стратегию с version и повтором.
Если хотите, могу показать конкретный пример для вашей СУБД (Postgres / MySQL) под вашу задачу (например перевод денег между пользователями с проверкой баланса).