ESPNThe only MLB playoff preview you need: World Series odds, likely MVPs and how far all 12 teams will goThe Jerusalem PostHezbollah's Nasrallah commemoration exposes its lost standing in Lebanon, expert saysPunchAI should complement, not replace, judicial officers – A’Ibom CJESPN DeportesAl Rojas Vivo: Álvarez, Sánchez, Durán, Stewart y Espada, los latinos de 2026Inquirer‘Most unique’ birds documented in Apayao biosphereColliderNew James Bond Release Officially Adds a 'Game of Thrones' StarAnime News NetworkStalled Despera Anime Project Gets MangaPopular ScienceBald eagles Shadow and Kaydee share a peaceful morning, Jackie’s memorial service approachesVariety‘Stillwater’: Amazon Series Based on Graphic Novel Casts Ben Hardy in Lead RoleBBC عربي"محادثات مرتقبة غير مباشرة" بين واشنطن وطهران في نيويورك، وخامئني يقول إن "القوات الأمريكية ستُجبر على مغادرة بحر العرب"20 Minuten«Bin 100 Meter reingelaufen»: Bodensee-Pegel so tief wie noch nieBBC NewsBest thing we can offer young people is a job, not benefits, says chancellor
The Daily Newsstand · Free, Always
Monday, September 28, 2026

Сотни Telegram‑ботов в одном процессе Node.js: общий webhook, очередь в PostgreSQL и Mini App на 5 КБ

Translate

Я делаю конструктор, в котором люди без программирования собирают 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 App

2040 запросов/с, 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 сообщений за раз.

Что буду делать, когда упрусь, по порядку:

  1. Обрабатывать несколько апдейтов параллельно. Очередь к этому готова: аренда, счётчик попыток и повторы уже есть. 8 параллельных отправок должны дать порядка 60 сообщений в секунду.

  2. Разнести веб и воркер по разным процессам, чтобы рестарт кабинета не задерживал ответы ботов.

  3. Запустить несколько воркеров. SKIP LOCKED и аренда уже защищают от двойной обработки.

  4. Кэшировать манифест Mini App и раздавать статику через CDN, если витрина начнёт греть базу.

  5. Поставить пулер соединений перед PostgreSQL.

Есть и внешний потолок: у Telegram около 30 сообщений в секунду на одного бота и примерно одно сообщение в секунду в один чат. Мои 8 в секунду ниже этого, так что в пик я упираюсь в себя, а не в Telegram. Это хорошая новость: своё чинится проще.

Итого

  • Один маршрут для всех вебхуков, различие по идентификатору в пути, проверка secret_token.

  • Webhook только пишет в таблицу, ответы делает воркер.

  • Очередь в PostgreSQL на FOR UPDATE SKIP LOCKED с арендой, повторами и dead letter — без отдельного брокера.

  • Один общий рантайм Mini App на 5 КБ вместо сборки на каждый проект.

Такая схема держит сотни спящих ботов на одном процессе в 140 МБ памяти. Узкое место известно и расширяется без переписывания. Если кто‑то делал похожее и упирался в другое — расскажите в комментариях, мне интересно.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.