Race conditions в page-based subscription billing
Как три одновременных запроса загоняют баланс в минус — и почему очевидный фикс неправильный.
BookahTranslate списывает страницы у юзеров. Новым даём 5 бонусных. Логика списания выглядит безобидно:
user = User.query.get(user_id)
if user.bonus_pages >= pages_needed:
# ... запускаем перевод ...
user.bonus_pages -= pages_needed
db.session.commit()
Чтение bonus_pages и запись разделены тем, сколько идёт перевод — иногда минутами на 200-страничном PDF. Три параллельных запроса с bonus_pages=5 и pages_needed=5: все три проходят проверку, все три стартуют перевод, все три коммитят -5. Баланс становится -10. Юзер получил 15 страниц бесплатно.
Очевидный фикс, который не работает
Первая мысль — обернуть в транзакцию. Но дефолтная изоляция в Postgres — READ COMMITTED, параллельные транзакции всё равно видят одинаковое стартовое значение. Более высокая изоляция (SERIALIZABLE) работает, но платишь на каждой биллинг-операции и получаешь retryable serialization errors, которые всё равно надо обрабатывать в коде.
Что реально работает: блокируем строку
sub = (
db.session.query(UserSubscription)
.with_for_update() # SELECT ... FOR UPDATE
.filter_by(id=subscription_id)
.first()
)
if sub.pages_remaining < pages_count:
db.session.rollback()
return False
sub.pages_remaining -= pages_count
db.session.commit() # снимает lock
with_for_update() заставляет SELECT держать row-level lock до конца транзакции. Второй параллельный запрос блокируется на SELECT, ждёт коммита первого, потом читает обновлённое значение (pages_remaining=0) и проваливает проверку. Без retry, без оверхеда SERIALIZABLE, без неконсистентности.
Урок
Если апдейт баланса зависит от текущего баланса, чтение и запись должны быть под одним lock. Не в одной транзакции — под одним lock. Это верно и для Postgres, и для Redis, и для Mongo.