Logo Craft Homelab Docs Нейросети Хостинг Обучение Контакты
Очередь видеозаданий на PostgreSQL: lease, токен владельца, checkpoint и receipt Трендовые github проекты в нашем телеграм канале. Подпишись →
9 ноября 2026 г.

Из прототипа на SQLite в шесть VPS: что пришлось дописать к работающему видеоботу

Исходная точка: Telegram-бот, собранный с помощью ИИ человеком без опыта программирования. Пользователь присылает ролик, выбирает одну, пять или десять версий, бот через FFmpeg делает слегка изменённые копии для повторной публикации в Reels, Shorts или TikTok. Внутри Python, FFmpeg и SQLite. Для проверки идеи этого хватало. Для сервиса с оплатой и параллельными пользователями — нет.

Диск кончается раньше CPU

Первый сбой случился, когда бот ещё жил на Mac разработчика: «На сервере недостаточно свободного места». Для одного заказа одновременно лежат исходник (до 150 МБ), временные файлы и готовые копии. Лечится двумя вещами: расчётом нужного места до старта и гарантированной очисткой после. В тесты добавили проверку, что заказ завершается полностью и временные файлы исчезают. Проверки «FFmpeg создал файл» для этого мало.

Почему SQLite уступила место PostgreSQL

Когда задания понадобилось раздавать между несколькими VPS, а подтверждение заказа и резервирование монет — делать одной транзакцией, общий SQLite-файл по сети стал плохой идеей: запись в него последовательная. Перешли на PostgreSQL ради конкурентных транзакций и блокировок строк. SQLite осталась в панели управления для локального инвентаря серверов.

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

Четыре механизма для зависшего worker

Сценарий: worker A взял заказ на пять копий, сохранил первую и завис. В базе фаза encode, состояние running, пользователь ждёт. В тестах такую ситуацию воспроизводили принудительным убийством обработчика.

  • Lease — ограниченный срок владения заданием. Пока worker жив, он его продлевает. Истёк — задание может забрать другой. Выдача идёт через SELECT ... FOR UPDATE SKIP LOCKED, чтобы не ждать занятые строки; истечение lease проверяется отдельно.
  • Токен владельца выдаётся при захвате. Каждое изменение состояния сверяет токен и срок lease. Если A очнулся после того, как заказ забрал B, записать результат он уже не сможет.
  • Checkpoint. План эффектов сохраняется заранее, готовые копии фиксируются. B видит первую готовую копию и делает оставшиеся четыре с теми же эффектами.
  • Receipt. Если B сообщил API о завершении, а ответ потерялся, он повторяет запрос с тем же идентификатором и получает сохранённый результат вместо повторного выполнения.

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

Одну гарантию дать нельзя: файл мог уйти в Telegram, а запись об отправке не успела сохраниться. После восстановления последняя копия придёт повторно. Внутри системы операции идемпотентны, exactly-once для внешнего API не получить.

Монеты как маленькая бухгалтерия

Одного поля balance хватает до первой отмены. Начисления разные: стартовые, реферальные, ручные, купленные, у каждого свой срок и условия. Поэтому каждое начисление хранится отдельной партией, а заказ создаёт резерв.

Пример: на счету 50 монет, копия стоит 4, заказ на пять копий резервирует 20. После двух отправленных копий пользователь отменяет: 8 монет списываются, 12 возвращаются из резерва в исходные партии с прежними сроками. Доступно 42, резерв 0. Если бы при отмене срок начинался заново, монеты можно было бы продлевать бесконечно.

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

Отправлять копии по мере готовности

Бот делал пять копий и отправлял их пачкой в конце. Кодирование и отправку разделили: worker сохраняет копию и checkpoint, шлюз сразу шлёт файл. В тесте копии пришли примерно на 40, 70, 105, 136 и 171 секунде — первая на 131 секунду раньше конца заказа. Кодирование не ускорилось, но пользователь получает результат сразу.

PgBouncer и блокировки под нагрузкой

Исходники переехали в S3, в PostgreSQL остались ключи объектов, размеры, SHA-256 и состояния. Результаты проверяются через ffprobe и полное декодирование.

Перед базой поставили PgBouncer в транзакционном режиме: на стенде до 2000 клиентских подключений обслуживаются 10 серверными соединениями. Сетевые вызовы вынесены за пределы SQL-транзакций, чтобы соединение не простаивало в ожидании Telegram. Попытка поднять пул приложения с 8 до 16 при лимите PgBouncer 10 ничего не ускорила — выросло ожидание у пулера.

Пустая очередь тоже грузит базу, если все worker постоянно её опрашивают. В Job API добавили long polling: HTTP-запрос ждёт работы, не держа SQL-соединение, а пробуждение означает только «попробуй захватить», сам захват по-прежнему транзакционный. Для продления lease и записи checkpoint выделен отдельный пул HTTP-потоков, чтобы ожидание новых заданий не мешало запущенным.

Волна из 1000 подтверждений заказов шла 5,9 секунды, при этом суммарное ожидание одного типа блокировки — около 40 секунд. Причина — общий эксклюзивный lock на заказы и настройки разных пользователей. После разделения по пользователям — 3,2 секунды. Заодно полный COUNT очереди при каждой загрузке заменили транзакционным учётом свободных мест, а индексы добавляли под конкретные запросы по планам выполнения.

Ещё одна деталь из нагрузки: ответ на нажатие кнопки иногда приходил так поздно, что Telegram его отбрасывал. Теперь answerCallbackQuery для основных кнопок отправляется до бизнес-проверок; он означает «нажатие получено», списание денег проходит все проверки позже.

Сколько worker нужно на 5 CPU

Тест: 20 роликов 720×1280, 3 секунды, 25 FPS, по одной копии, настоящие FFmpeg, PostgreSQL и S3, 5 CPU. Самый медленный вариант обработал волну за 78 секунд (15,4 задания в минуту, 644 МиБ RAM). Потолок около 29 заданий в минуту наступил на четырёх-пяти обработчиках. Двенадцать процессов дали 27 заданий в минуту при пике 3133 МиБ — больше памяти, меньше работы. Для длинных роликов в 1080p цифры будут другими, поэтому число worker стоит подбирать замером на своих роликах и своей машине.

Для 10 000 первых обращений с имитацией Telegram p95 готовности экрана оказался около 30 секунд. Дополнительный FFmpeg тут не помогает, поэтому появились единый ingress Telegram-событий и inbox в PostgreSQL: событие сначала сохраняется, потом его забирает один из core, порядок внутри пользователя сохраняется. Сопоставимого нагрузочного прогона после перехода на inbox пока нет.

Раскладка по ролям и выкатка

Итоговая схема: control (панель и мониторинг), core (API и меню), edge (Telegram и Local Bot API), worker (FFmpeg), db-01/db-02 (основной и резервный PostgreSQL), плюс S3. Edge вынесен отдельно, потому что основной регион может не иметь прямого доступа к Telegram. У каждой ноды своя API-идентичность, worker не получает методов для произвольного SQL или изменения баланса. Edge и worker пока единичные, это известная точка отказа.

Core обновляется через blue/green: новая версия поднимается рядом и проходит проверку /_info, установщик сначала проверяет свободную RAM и при нехватке не переключает. Старая версия удаляется только после проверки активных lease. Миграции идут с окном совместимости: в базе хранится допустимый уровень кода и контрольные суммы миграций, удаление колонок разносится на несколько релизов. Откат кода не трогает финансовые данные.

Пара защит, которые стоит забрать себе. Отсутствие рабочей базы не считается первым запуском: автоматика падает с ошибкой вместо создания пустой БД. Реплика не заменяет бэкап, потому что повторяет и ошибочные изменения, а восстановление проверяется вне рабочей базы.

Что отказались выкатывать

Общий rate limiter для Telegram API через Python HTTP-прокси давал 240–300 запросов в секунду против 1000 напрямую. Вариант без прокси на имитаторе снизил медианный RPS с 1142 до 1090, а массовых ответов 429 в реальности не наблюдалось. Издержки измерены, польза не подтверждена — эксперимент удалили.

Приёмка финального пути идёт через MTProto-клиент с двумя настоящими аккаунтами: загрузка файла больше 20 МБ, подтверждение, первая копия, отмена, скачивание, полное декодирование и сверка баланса и резервов до и после. Юнит-тест на вызов метода отправки не покажет, что пользователь получил исправный файл и заплатил правильную сумму; сквозной прогон через живой клиент показывает именно это.