← все заметки
2026-04-12

Race conditions в page-based subscription billing

Как три одновременных запроса загоняют баланс в минус — и почему очевидный фикс неправильный.

postgresconcurrencybillingsaas

BookahTranslate списывает страницы у юзеров. Новым даём 5 бонусных. Логика списания выглядит безобидно:

python
Наивное списание — есть race
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, которые всё равно надо обрабатывать в коде.

Что реально работает: блокируем строку

python
Атомарное списание с row-level lock
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.