Между нажатием кнопки и результатом у нас проходит от трёх до шестидесяти секунд. Внутри — очередь, воркер, внешний API, иногда таймаут и ретрай. А снаружи — пользователь, у которого на счету ровно одна генерация, и палец, который очень хочет нажать ещё раз.

Задача звучит просто: списать за работу один раз. Не ноль, не два.

Дальше — как мы к этому пришли, включая пару мест, где схема до сих пор острая. Проект под NDA, поэтому названия, лимиты и продуктовая часть обобщены; механика и код — настоящие, только переименованные.

Почему нельзя списать сразу и нельзя списать потом

Очевидных вариантов два, и оба плохие.

Списать при постановке в очередь. Пользователь платит, воркер падает на таймауте внешнего API, деньги остались у нас. Надо возвращать. Возврат — это ещё одна операция с балансом, которая тоже может не выполниться: воркер уже мёртв, а кто возвращает? Поверх этого надо помнить, из какого кармана списали, чтобы вернуть туда же. Дальше возврат нужно сделать идемпотентным, иначе ретрай джобы вернёт дважды. Мы прошли этот путь на полшага и развернулись.

Списать по факту успешного завершения. Тогда проверка баланса при постановке в очередь ничего не гарантирует: с одной генерацией на счету можно поставить в очередь пять, все пять отработают, спишется одна. Причём для этого не нужен злой умысел — достаточно нетерпеливого пальца.

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

Ключевое свойство: пока задача в работе, баланс пользователя не тронут, но и потратить его второй раз нельзя.

Жизненный цикл резерва: постановка в очередь, работа, три исхода
Жизненный цикл резерва: постановка в очередь, работа, три исхода

Резерв — это строка в таблице

Никаких отдельных полей «заморожено» в балансе. Резерв живёт отдельной строкой, которая появляется при постановке в очередь и исчезает при завершении:

Schema::create('generation_states', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('chat_id');
    $table->enum('type', ['text', 'image']);
    $table->enum('status', ['queued', 'in_progress'])->default('queued');

    $table->unsignedInteger('held_daily_messages')->default(0);
    $table->unsignedInteger('held_daily_images')->default(0);
    $table->unsignedInteger('held_balance')->default(0);

    $table->timestamps();

    // не даст создать две записи для одного пользователя
    $table->unique('chat_id');

    $table->foreign('chat_id')->references('id')->on('chats')->onDelete('cascade');

    // для чистильщика зависших резервов
    $table->index('created_at');
});

Три колонки held_* — это не дубль баланса, а слепок решения: из какого кармана и сколько мы собираемся взять, когда работа закончится. Почему карманов несколько — ниже.

Уникальный индекс вместо мьютекса

Самая приятная часть этой схемы: блокировка «один пользователь — одна задача» достаётся бесплатно. Её обеспечивает UNIQUE (chat_id).

Важно, как именно её ловить. Проверка «а нет ли уже записи» отдельным SELECT — классическая гонка: два апдейта от одного пользователя прилетают в соседние процессы, оба видят пусто, оба вставляют. Побеждает база, но только если ей дать это сделать:

try {
    GenerationState::create([
        'chat_id'             => $this->chat->id,
        'type'                => 'text',
        'held_daily_messages' => $billing->isPremium() ? 0 : 1,
    ]);
} catch (QueryException $e) {
    if ($e->getCode() === '23000') {   // нарушение уникальности
        throw new GenerationBusyException;
    }

    throw $e;
}

23000 — это SQLSTATE «integrity constraint violation». Ловить именно его, а не QueryException целиком: иначе упавшее соединение с базой превратится для пользователя в вежливое «вы уже в очереди», и вы об этом никогда не узнаете.

Быстрый SELECT перед вставкой у нас тоже есть, но он не про корректность, а про то, чтобы не гонять транзакцию ради заведомо отклонённого запроса. Гарантию даёт только индекс.

Проверка баланса живёт внутри транзакции

Вторая гонка — между проверкой баланса и резервом. Решается тем, что и то и другое происходит под блокировкой строки баланса:

DB::transaction(function () {
    $billing = $this->chat->billing()->lockForUpdate()->first();

    if (!$billing->isPremium() && $billing->daily_messages <= 0) {
        throw new InsufficientMessagesException;
    }

    // ... создание резерва
});

lockForUpdate() здесь не для красоты. Без него две параллельные проверки увидят один и тот же остаток и обе решат, что ресурс есть. Уникальный индекс спасёт от двух одновременных задач одного пользователя, но не спасёт от того же пользователя в двух ботах или от админской операции, идущей параллельно.

Порядок важен: сначала блокируем баланс, потом читаем, потом решаем, потом вставляем резерв. Не наоборот.

Два кармана и правило, из какого брать

Ресурс у пользователя живёт в двух местах: суточный лимит, который обновляется сам, и купленный баланс, который не сгорает.

Правило простое, и оно продуктовое, а не техническое: сначала тратим то, что сгорит, потом то, что куплено.

$heldDaily = 0;
$heldBalance = 0;

if ($billing->daily_images > 0) {
    $heldDaily = 1;
} elseif ($billing->balance > 0) {
    $heldBalance = 1;
} else {
    throw new InsufficientImagesException;
}

Если бы мы сначала списывали купленное, пользователь за свои же деньги терял бы бесплатный лимит, который через несколько часов всё равно обновится. Такое запоминают и пишут в поддержку.

Заодно становится понятно, зачем в резерве три колонки, а не одно число: к моменту списания нужно знать не «сколько», а «откуда».

Списываем ровно то, что держали

Самый важный метод во всей схеме — и самый скучный на вид:

DB::transaction(function () use ($state) {
    $billing = $this->chat->billing()->lockForUpdate()->first();

    $billing->update([
        'daily_messages' => $billing->daily_messages - $state->held_daily_messages,
        'daily_images'   => $billing->daily_images   - $state->held_daily_images,
        'balance'        => $billing->balance        - $state->held_balance,
    ]);

    $state->delete();
});

Обратите внимание, чего здесь нет: пересчёта цены. Мы не спрашиваем заново, премиум ли пользователь и какой у него карман. Мы вычитаем ровно то, что записали при постановке в очередь.

Соблазн пересчитать велик, и он ошибочный. Между резервом и списанием проходит до минуты, за которую успевает произойти многое: истечёт подписка, приедет платёж, сработает суточное обновление лимитов. Пересчёт означает, что пользователь заказал работу по одной цене, а заплатил по другой. Резерв — это в том числе зафиксированная цена.

Провал, отмена и почему отменить можно не всегда

Провал обрабатывается на удивление приятно:

public function failed(): bool
{
    return (bool) GenerationState::where('chat_id', $this->chat->id)->delete();
}

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

Отмена по кнопке сложнее, потому что у резерва есть статус:

if ($state->status === 'in_progress') {
    return false;   // поздно, работа уже идёт
}

$state->delete();

Пока задача ждёт в очереди — отменяем, ресурс не тронут. Как только воркер взял её в работу, отмена запрещена: мы уже потратили внешний вызов, за который заплатили деньгами, а не лимитами. Пользователю честнее дождаться результата, чем получить «отменено» после того, как счётчик у провайдера уже щёлкнул.

Зависшие резервы

У схемы есть обратная сторона, и это её главный недостаток. Резерв — одновременно и блокировка. Если воркер умер между «взял задачу» и «завершил», строка остаётся. Ресурс не списан, но пользователь заблокирован навсегда: каждая следующая попытка получает «вы уже в очереди».

Развалившийся воркер, перезапуск на деплое, OOM — это не гипотеза, это вторник.

Лечится чистильщиком по расписанию:

GenerationState::where('created_at', '<', now()->subMinutes($timeout))
    ->chunk(100, function ($stuck) {
        foreach ($stuck as $state) {
            $chat = $state->chat;

            $state->delete();   // деньги не списывались, возвращать нечего

            if ($chat) {
                $this->notify($chat);   // «что-то пошло не так, попробуйте ещё раз»
            }
        }
    });

Три вещи, которые стоили нам нервов:

Индекс по created_at нужен с первого дня. Таблица маленькая только на бумаге: в ней одна строка на активного пользователя, и чистильщик ходит по ней каждые несколько минут.

chunk(), а не get(). Если провайдер лёг на десять минут, зависших резервов будет не три штуки, а столько, сколько у вас было активных пользователей.

Уведомление отправляется после удаления строки и в своём try/catch. Пользователь, которому не дошло сообщение, но которого разблокировали, — это неприятно. Пользователь, которого не разблокировали, потому что не отправилось сообщение, — это тикет в поддержку.

Отдельно надо сказать про таймаут чистильщика. Он должен быть заметно больше самой долгой легальной работы, иначе чистильщик снимет блокировку с живой задачи, пользователь запустит вторую, а первая потом придёт и спишет за себя. Мы держим запас примерно втрое.

Очередь с приоритетом и сообщение «вы в очереди»

Раз уж резерв всё равно создаётся до постановки в очередь, туда же прицепился выбор очереди:

$queue = $this->chat->isPremium()
    ? sprintf('%s_generation_priority', $type)
    : sprintf('%s_generation', $type);

$job->onQueue($queue);

Разные очереди, разные воркеры, платящий пользователь не стоит за бесплатным. Приоритет внутри одной очереди тут работает хуже: он не даёт изоляции, и один провайдерский таймаут тормозит всех.

Рядом живёт мелочь, которая заметно улучшила ощущение от бота:

QueueBusyMessageJob::dispatch($this->chat)
    ->delay(5)
    ->onQueue('default');

Сообщение «вы в очереди, это займёт время» отправляется не сразу, а через пять секунд. Если генерация уложилась быстрее — пользователь его вообще не увидит, потому что джоба к тому моменту проверит, что резерва уже нет, и промолчит. Показывать «подождите» человеку, который уже получил ответ, — верный способ выглядеть глупо.

Суточные лимиты: скользящее окно вместо полуночи

Последний кусок — откуда берётся тот самый суточный лимит.

Самое простое решение — крон в полночь, который всем обновляет счётчики. Оно плохое сразу по двум причинам. Во-первых, полночь у пользователей разная, а у вас одна. Во-вторых, это UPDATE по всей таблице пользователей одним залпом, и происходит он ровно тогда, когда часть аудитории активна.

Мы сделали скользящее окно. У баланса есть отметка времени, и она означает не «когда обновить», а «когда начался текущий цикл»:

public function ensureRefill(Chat $chat): void
{
    $billing = $chat->billing;

    // null — предыдущий цикл закрыт, новый стартует с активности
    if ($billing->reset_at === null) {
        $billing->reset_at = now();
        $billing->save();

        return;
    }

    if ($billing->reset_at <= now()->subMinutes(self::REFILL_INTERVAL_MINUTES)) {
        $this->performRefill($chat);
    }
}

Вызывается это из middleware на каждом апдейте от пользователя. Кто пришёл — тому и обновили, размазано по суткам, никакого всплеска.

null как «цикл не начат» — единственное решение здесь, о котором я до сих пор спорю сам с собой. Оно компактное, но у колонки появляется два разных смысла, и каждый новый человек в проекте спотыкается об это ровно один раз.

Крон всё же остался, но в другой роли: он проходит по тем, кто не заходил дольше суток, обновляет им лимиты и присылает сообщение «лимиты обновились, заходи». Это уже не биллинг, а реактивация, и выборка там соответствующая — без заблокированных, без забаненных, без тех, кто так и не нажал «старт».

Где схема острая

Честно про то, что мне в ней не нравится.

Резерв и блокировка — одна сущность. Пока продукт разрешает пользователю ровно одну задачу за раз, это экономия. В день, когда захочется параллельных генераций, UNIQUE (chat_id) придётся снимать, и вместе с ним уедет вся защита от дублей. Разделять их надо будет заранее, не в тот же день.

Арифметика по unsigned-колонкам. balance - held_balance предполагает, что к моменту списания остаток не меньше зарезервированного. Обычно так и есть: пополнения только добавляют. Но обновление суточного лимита умеет и обнулять — например, когда подписка истекла между резервом и списанием. Тогда вычитание уходит в минус по беззнаковой колонке, и это либо ошибка базы, либо тихо испорченный баланс, в зависимости от режима. Правильно оборачивать в GREATEST(0, ...) и считать это не защитой от ошибки, а частью контракта.

Статус резерва меняет воркер, а читает его пользовательский поток. Между «проверили, что ещё queued» и «удалили» есть окно. Оно маленькое, но оно есть, и закрывается оно тем же lockForUpdate, до которого руки дошли не сразу.

Чеклист

Если у вас асинхронная работа, за которую надо списывать:

  1. Двухфазное списание: резерв при постановке в очередь, списание при успехе. Возврат средств — это операция, которой лучше не существовать.

  2. Резерв хранит не сумму, а решение: сколько и из какого кармана.

  3. Цена фиксируется в момент резерва. Никаких пересчётов при списании.

  4. Уникальность — в базе, а не в приложении. SELECT перед INSERT ничего не гарантирует.

  5. Ловите конкретный код ошибки, а не исключение целиком.

  6. Проверка остатка и создание резерва — одна транзакция с блокировкой строки баланса.

  7. Сначала тратится то, что сгорает, потом то, что куплено.

  8. Провал должен быть удалением строки, а не финансовой операцией.

  9. Отмена разрешена, только пока работа не началась.

  10. Чистильщик зависших резервов пишется в тот же день, что и резервы. Не позже.

  11. Таймаут чистильщика — кратно больше самой долгой легальной работы.

  12. Суточные лимиты — скользящим окном от активности, а не общим кроном в полночь.

Отдельно любопытно: как вы решаете пункт 9? Мы запрещаем отмену после старта, потому что внешний вызов уже оплачен, но подозреваю, что есть схемы поинтереснее.

Комментарии (1)


  1. stackshortlist
    16.10.2026 15:03

    Двухфазная схема с резервом отдельной строкой выглядит здраво, особенно то, что баланс не трогается до успешного завершения. Самое тонкое место, на мой взгляд, это резервы, которые зависли из-за упавшего воркера: по какому признаку вы их считаете протухшими и не получится ли так, что задача всё-таки завершится после того, как резерв уже сняли? Было бы интересно увидеть, как вы это обрабатываете на уровне уникальных ограничений в таблице.