Блокировки транзакций PostgreSQL: почему вторая транзакция видит старый баланс

    При параллельном выполнении двух транзакций, обновляющих баланс пользователя, часто возникает проблема: вторая транзакция читает устаревшее значение (100 вместо 200). Это не связано с уровнем изоляции - дело в типе блокировки и способе получения данных. Разберём корректное решение.

    Почему возникает проблема

    Когда первая транзакция выполняет UPDATE users SET balance = 200 WHERE id = 1, она блокирует строку. Вторая транзакция ждёт снятия блокировки, но если она предварительно прочитала balance = 100 (например, через SELECT без блокировки), то после снятия блокировки UPDATE использует это старое значение. В результате баланс не увеличивается, а перезаписывается.

    Решение: SELECT ... FOR UPDATE

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

    BEGIN; SELECT balance FROM users WHERE id = 1 FOR UPDATE; -- читаем и блокируем строку -- теперь баланс точен и строка залочена UPDATE users SET balance = balance + 100 WHERE id = 1; COMMIT;

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

    Почему это лучше костылей

    Варианты с блокировкой другой записи или повторной проверкой - это обходные пути, которые усложняют код и снижают производительность. SELECT FOR UPDATE - стандартный механизм PostgreSQL для пессимистической блокировки, гарантирующий консистентность данных.

    Когда уровень изоляции не помогает

    Уровень изоляции Repeatable Read или Serializable не решает проблему, так как они предотвращают фантомное чтение, но не влияют на чтение устаревших данных внутри одной транзакции, если блокировка строки не установлена. Только явная блокировка строки обеспечивает актуальность.

    Дополнительные рекомендации

    • Всегда используйте SELECT ... FOR UPDATE перед обновлением критичных полей (баланс, запас товара).
    • Избегайте длинных транзакций - блокировка строки может вызвать дедлоки.
    • Рассмотрите SELECT ... FOR UPDATE NOWAIT или SKIP LOCKED для обработки конфликтов без ожидания.

    Альтернативный подход: атомарное обновление

    Если нужно просто увеличить баланс, можно обойтись одной командой без предварительного чтения: UPDATE users SET balance = balance + 100 WHERE id = 1. Это атомарно и не требует блокировок. Однако если логика зависит от текущего значения (например, проверка лимита), SELECT FOR UPDATE обязателен.

    Часто задаваемые вопросы