Что может пойти не так при одновременном изменении баланса пользователя с уровнем изоляции read committed
Что проверяют
Понимаете ли вы, от чего на самом деле защищает read committed, и видите ли потерянное обновление в схеме «прочитал баланс, посчитал в коде, записал». Для Go вопрос вдвойне рабочий: горутины и пул соединений делают такую гонку обычным поведением сервиса под нагрузкой.
Как отвечать
Read committed гарантирует только отсутствие грязного чтения: вы не увидите незакоммиченных изменений. Внутри транзакции каждый оператор видит свой снимок, поэтому два одинаковых SELECT могут вернуть разное. Классический сценарий: две транзакции читают баланс 100, каждая считает 100 минус 50 и пишет 50. Списали дважды, баланс уменьшился один раз. Это потерянное обновление, и уровень изоляции его не ловит.
Что делать:
- считать в самой базе, а не в приложении:
UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1, дальше проверятьRowsAffected. ВторойUPDATEтой же строки ждёт первого и перечитывает актуальную версию; SELECT ... FOR UPDATE, если решение всё же принимается в коде;- оптимистичная блокировка по колонке версии:
UPDATE ... WHERE version = $old, ноль строк означает повтор операции; serializableтам, где логика сложнее одной строки, с обязательным ретраем.
В Go стоит добавить про defer tx.Rollback(), повтор транзакции целиком, а не отдельного запроса, и одинаковый порядок захвата строк.
Где спотыкаются
Называют read committed защитой от гонок. Он исключает только грязное чтение, а неповторяемое чтение, фантомы и потерянное обновление остаются. Проверка остатка отдельным запросом и списание следующим небезопасны на любом уровне ниже serializable.
Ставят serializable и не пишут обработку ошибки сериализации. База откатывает транзакцию с кодом 40001, а сервис, не умеющий её повторить, начинает отдавать пользователю ошибки ровно тогда, когда нагрузка выросла.
Защищаются мьютексом в приложении. В одном процессе это работает, при двух репликах сервиса мьютекс уже не общий, нужна блокировка в базе или advisory lock. Рядом живёт вторая беда: при переводе между счетами транзакции берут строки в разном порядке и ловят взаимную блокировку, лечится захватом по возрастанию идентификатора.