Между нажатием кнопки и результатом у нас проходит от трёх до шестидесяти секунд. Внутри — очередь, воркер, внешний 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, до которого руки дошли не сразу.
Чеклист
Если у вас асинхронная работа, за которую надо списывать:
Двухфазное списание: резерв при постановке в очередь, списание при успехе. Возврат средств — это операция, которой лучше не существовать.
Резерв хранит не сумму, а решение: сколько и из какого кармана.
Цена фиксируется в момент резерва. Никаких пересчётов при списании.
Уникальность — в базе, а не в приложении. SELECT перед INSERT ничего не гарантирует.
Ловите конкретный код ошибки, а не исключение целиком.
Проверка остатка и создание резерва — одна транзакция с блокировкой строки баланса.
Сначала тратится то, что сгорает, потом то, что куплено.
Провал должен быть удалением строки, а не финансовой операцией.
Отмена разрешена, только пока работа не началась.
Чистильщик зависших резервов пишется в тот же день, что и резервы. Не позже.
Таймаут чистильщика — кратно больше самой долгой легальной работы.
Суточные лимиты — скользящим окном от активности, а не общим кроном в полночь.
Отдельно любопытно: как вы решаете пункт 9? Мы запрещаем отмену после старта, потому что внешний вызов уже оплачен, но подозреваю, что есть схемы поинтереснее.
stackshortlist
Двухфазная схема с резервом отдельной строкой выглядит здраво, особенно то, что баланс не трогается до успешного завершения. Самое тонкое место, на мой взгляд, это резервы, которые зависли из-за упавшего воркера: по какому признаку вы их считаете протухшими и не получится ли так, что задача всё-таки завершится после того, как резерв уже сняли? Было бы интересно увидеть, как вы это обрабатываете на уровне уникальных ограничений в таблице.