Как решить проблему, когда две одновременно выполняющиеся транзакции изменяют одну и ту же строку, и одна из них видит старые данные вместо обновлённых?

Есть таблица пользователей, у одного пользователя баланс равен 100. <br/> <br/> Две транзакции одновременно выбирают одну и ту же запись для UPDATE: одна транзакция блокирует запись, вторая ждёт её освобождения. Первая транзакция меняет баланс с 100 на 200 и успешно завершается. После этого вторая транзакция продолжает работу, но оперирует устаревшим значением баланса (100), а не уже обновлённым (200). <br/> <br/> Как сделать так, чтобы вторая транзакция видела обновлённый баланс? Я гуглил — уровень изоляции тут вряд ли поможет, похоже дело в типе блокировки записи. Вижу «костыли»: блокировать не саму изменяемую запись, а какую‑то другую (например вышестоящую), или после разблокировки заново читать данные и перепроверять баланс; оба подхода кажутся нежелательными. Какое более корректное решение?
  • 27 марта 2026 г. 22:00
Ответы на вопрос 3
Кратко — проблема в том, что вы читаете значение до того, как «закрепили» за собой строку. Правильные варианты — либо делать изменение атомарно на сервере, либо сначала захватывать блокировку и уже потом читать/обновлять, либо использовать оптимистичную конкуренцию (версионирование + повтор). Уровень изоляции здесь не решит задачу сам по себе.

Варианты решений (корректные, распространённые)

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) под вашу задачу (например перевод денег между пользователями с проверкой баланса).
уровни изоляции тут как раз при чём, это классический lost update)) ты SELECT-ом читаешь баланс, потом UPDATE-ом пишешь то что прочитал, вторая транзакция конечно работает со старым значением. Самый простой фикс: <pre><code>UPDATE users SET balance = balance + 100 WHERE id = 1</code></pre> , без промежуточного SELECT. А если между чтением и записью нужна логика, используй <code>SELECT ... FOR UPDATE</code> — он заблокирует строку и вторая транзакция при разблокировке перечитает актуальное.
Если ты просто увеличиваешь баланс, можно обойтись без чтения: <br/> <pre><code class="sql">UPDATE users SET balance = balance + 50 WHERE id = 1;</code></pre> <br/> Это атомарно, не требует блокировок в приложении, и не страдает от lost update. <br/> <br/> Но если логику select'а не обойти <br/> <pre><code class="sql">SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM users WHERE id = 1 FOR UPDATE;
-- вернёт актуальное значение после ожидания
UPDATE users SET balance = ...;
COMMIT;</code></pre>
Похожие вопросы