Что может пойти не так при одновременном изменении баланса пользователя с уровнем изоляции read committed
Что проверяют
Понимаете ли вы, от чего защищает уровень изоляции, а от чего нет. Read committed убирает чтение незакоммиченных данных и ничего не обещает про гонку двух списаний. Вопрос показывает, писали ли вы денежную логику или пересказываете таблицу аномалий.
Как отвечать
Назовите конкретную аномалию: потерянное обновление при схеме «прочитал, посчитал в приложении, записал».
row = await conn.fetchrow("select balance from accounts where id = $1", uid)
new_balance = row["balance"] - amount
await conn.execute("update accounts set balance = $1 where id = $2", new_balance, uid)Две транзакции читают сто, одна пишет сто пятьдесят, вторая семьдесят, и одно из изменений исчезает. Транзакция не помогает: атомарность есть, защиты от гонки нет. Рядом вторая беда: проверка баланса в питоне устаревает к моменту записи, и счёт уходит в минус.
Решения, от дешёвого к дорогому:
- считать в базе одним запросом:
update accounts set balance = balance - $1 where id = $2 and balance >= $1, потом смотреть число затронутых строк. Конкурирующий запрос дождётся блокировки и применится к свежему значению; select ... for updateв начале операции, с единым порядком захвата строк, иначе встречные переводы блокируют друг друга;- версия строки в условии обновления и повтор при несовпадении;
- уровень
serializableс обязательным циклом повторов по ошибке сериализации.
Добавьте ограничение на неотрицательный баланс в таблице и ключ идемпотентности, чтобы повтор запроса не списал дважды.
Где спотыкаются
Называют происходящее грязным чтением. На read committed его уже нет, а здесь потерянное обновление и неповторяющееся чтение. Фантомы тоже ни при чём, речь про одну строку.
Считают транзакцию достаточной защитой. ORM подталкивает к опасной схеме: изменение поля объекта и коммит превращаются ровно в тот самый цикл чтения и записи, поэтому арифметику переносят на сторону базы.
Предлагают serializable и на этом заканчивают. Postgres в этом режиме отменяет одну из транзакций с ошибкой сериализации, и без повторов сервис начнёт сыпать ошибками под нагрузкой. То же с блокировками: взяли их в разном порядке, получили взаимные блокировки вместо гонок.