Kirakirill1111 час назад
Сотни Telegram‑ботов в одном процессе Node.js: общий webhook, очередь в PostgreSQL и Mini App на 5 КБ
Уровень сложностиСреднийВремя на прочтение5 минОхват и читатели1.9KNode.JS*PostgreSQL*Мессенджеры*Высоконагруженные системы*КейсИз песочницыЯ делаю конструктор, в котором люди без программирования собирают Telegram‑ботов и мини‑приложения. Каждому пользователю нужен свой бот со своим токеном, и в какой‑то момент встаёт вопрос: как это всё держать, чтобы хостинг не стоил как самолёт. Расскажу, к чему я пришёл, с замерами, и где у этой схемы потолок.
Один бот — не один процесс
Самое очевидное решение — запускать на каждого бота свой процесс с long polling. Оно же самое плохое: сто ботов — сто процессов, каждый держит соединение и память, даже если боту никто не пишет неделями.
Поэтому у меня на каждого бота не создаётся ни сервера, ни процесса. Все боты получают обновления через webhook на один и тот же маршрут, а различаются по идентификатору в пути:
app.post("/v1/telegram/webhooks/:integrationPublicId", async (request, reply) => {
const header = request.headers["x-telegram-bot-api-secret-token"];
const presentedSecret = typeof header === "string" ? header : "";
const receipt = await service.receive(
request.params.integrationPublicId, presentedSecret, request.body);
return reply.code(200).send({ ok: true, duplicate: receipt.duplicate });
});При подключении бота платформа вызывает setWebhook с secret_token, и Telegram потом присылает его в заголовке X-Telegram-Bot-Api-Secret-Token. Чужой или неверный секрет — 401, битое тело — 400. В логах этот заголовок вырезается вместе с токенами и паролями.
В итоге спящий бот — это строка в таблице, а не работающий процесс. Это главное, что делает схему дешёвой.
Webhook только принимает, работу делает очередь
Обработчик вебхука ничего не отвечает пользователю. Он только проверяет секрет и кладёт апдейт в таблицу telegram_updates. Первичный ключ — пара (integration_id, update_id), поэтому если Telegram пришлёт тот же апдейт повторно (а он присылает, если не получил вовремя 200), вставка просто отметит его как дубликат, и сообщение не обработается дважды.
Дальше апдейты разбирает воркер. Отдельного брокера нет: очередь живёт в той же PostgreSQL. Задание забирается так (сокращённо):
SELECT u.integration_id, u.update_id, ...
FROM telegram_updates u
JOIN bot_integrations b ON b.id = u.integration_id
JOIN projects p ON p.id = b.project_id
WHERE u.processed_at IS NULL
AND u.dead_lettered_at IS NULL
AND u.next_attempt_at <= now()
AND u.attempts < $maxAttempts
AND (u.processing_started_at IS NULL
OR u.processing_started_at < now() - $leaseSeconds * interval '1 second')
AND b.status = 'active' AND p.status = 'active'
ORDER BY u.next_attempt_at, u.received_at, u.update_id
FOR UPDATE OF u SKIP LOCKED
LIMIT 1Что здесь важно:
• FOR UPDATE SKIP LOCKED — два воркера не возьмут одно и то же задание, и не будут ждать друг друга на блокировке.
• У задания есть аренда: processing_started_at и lease_id. Если воркер упал посреди обработки, через leaseSeconds задание снова станет доступным.
• attempts и next_attempt_at дают повторы с задержкой, а после лимита попыток апдейт уходит в dead_lettered_at и не крутится вечно.
• Для выборки есть частичный индекс по (next_attempt_at, received_at) только по необработанным строкам, так что таблица с историей не тормозит выборку.Почему не Redis или RabbitMQ: на старте это ещё один сервис, который надо держать живым и бэкапить. PostgreSQL уже есть, транзакции в ней уже есть, а нагрузка пока далека от того, где это станет узким местом. Redis я оставил на потом — для распределённого rate limit и кэша, когда замеры покажут, что он нужен.
Mini App на 5 КБ
Мини‑приложения пользователей — это не отдельные сборки. Все они работают на одном общем рантайме, а проект — это JSON‑описание страниц, которое строго проверяется схемой (Zod) на сервере. Рантайм написан на TypeScript без UI‑фреймворка, на голом DOM API, и весит около 5 КБ в gzip. В нём нет кода конкретного проекта и нет внешних зависимостей, поэтому приложение открывается даже на плохом мобильном интернете.
Тот же рантайм отдаёт и обычный сайт проекта: тот же релиз, другая раскладка. React используется только в кабинете, где человек собирает проект, и в критический путь клиента бота не попадает.
Сколько это держит: замеры
Нагрузочный прогон шёл на настоящей PostgreSQL, с живым HTTP и подменённым Telegram, который отвечает с задержкой 120 мс — примерно как настоящий Bot API. Стенд: 4 vCPU, 16 ГБ, Node 22, один процесс, в котором и кабинет, и вебхуки, и воркер.
Что меряли100 клиентов500 клиентовПриём вебхуков1530 запросов/с, p95 72 мс1610 запросов/с, p95 64 мсРазбор очереди воркером7,9 сообщения/с7,9 сообщения/сМанифест Mini App2040 запросов/с, p95 35 мс1750 запросов/с, p95 47 мсСтраница сайта1265 запросов/с, p95 51 мс1087 запросов/с, p95 56 мсПамять процесса139 МБ142 МБРазницы между 100 и 500 клиентами почти нет. Число ботов само по себе ничего не стоит, стоит только трафик.
Отдельно гонял мусор: 900 запросов в 100 потоков с чужими секретами, на несуществующих ботов и флудом на логин. Чужой секрет и несуществующий бот получают 401, флуд на логин — 429 после первого десятка попыток. После этого сервис отвечал нормально, очередь была пустой.
Где потолок
Он один, и я его вижу: разбор очереди, около 8 сообщений в секунду. Воркер берёт апдейты по одному, и почти всё время ждёт ответа Telegram. Без задержки Telegram тот же цикл даёт 166 сообщений в секунду, то есть мой код тратит примерно 6 мс на сообщение, остальное — сеть.
Много это или мало? 8 сообщений в секунду — это около 680 тысяч в сутки, если равномерно. Сто активных клиентов по 50 сообщений в день дают 5 000 в сутки, то есть 0,06 сообщения в секунду. Запас больше чем стократный.
Больно станет не от объёма, а от пика. Очередь общая и разбирается по порядку: 500 сообщений, прилетевших разом, разбираются около минуты, и последний в очереди ждёт ответа всё это время. Мой порог — пик больше примерно 500 сообщений за раз.
Что буду делать, когда упрусь, по порядку:
• Обрабатывать несколько апдейтов параллельно. Очередь к этому готова: аренда, счётчик попыток и повторы уже есть. 8 параллельных отправок должны дать порядка 60 сообщений в секунду.
• Разнести веб и воркер по разным процессам, чтобы рестарт кабинета не задерживал ответы ботов.
• Запустить несколько воркеров. SKIP LOCKED и аренда уже защищают от двойной обработки.
• Кэшировать манифест Mini App и раздавать статику через CDN, если витрина начнёт греть базу.
• Поставить пулер соединений перед PostgreSQL.Есть и внешний потолок: у Telegram около 30 сообщений в секунду на одного бота и примерно одно сообщение в секунду в один чат. Мои 8 в секунду ниже этого, так что в пик я упираюсь в себя, а не в Telegram. Это хорошая новость: своё чинится проще.
Итого
• Один маршрут для всех вебхуков, различие по идентификатору в пути, проверка secret_token.
• Webhook только пишет в таблицу, ответы делает воркер.
• Очередь в PostgreSQL на FOR UPDATE SKIP LOCKED с арендой, повторами и dead letter — без отдельного брокера.
• Один общий рантайм Mini App на 5 КБ вместо сборки на каждый проект.Такая схема держит сотни спящих ботов на одном процессе в 140 МБ памяти. Узкое место известно и расширяется без переписывания. Если кто‑то делал похожее и упирался в другое — расскажите в комментариях, мне интересно.Теги:• бот
• телеграм
• миниприложение
• webhook
• postgresql
• node.js
• очереди
• telegram bot apiХабы:• Node.JS
• PostgreSQL
• Мессенджеры
• Высоконагруженные системы
Получайте больше инсайтов о систематизации бизнеса
Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности
💬 Подписаться на канал→ Оригинальная статья