
В 2023-м я написал бота, который публикует посты в Telegram-каналы по расписанию. Планировщик до сих пор лежит на гитхабе, в telegrammanager, и вот он целиком:
public void distribution() { List<Note> notes = dataManager.load(Note.class) .query("select n from telegrambot_Note n where n.status = :status") .parameter("status", StatusEnum.DELAYED.getId()) .list(); for (Note note : notes) { Date targetDate = note.getDateScheduled(); Date currentDate = new Date(); long delayInMillis = targetDate.getTime() - currentDate.getTime(); if (delayInMillis < 0) { telegramBotService.send(note.getId()); delayInMillis = 0; } } }
Забрать всё, чему пора, и отправить в цикле. Работало. Публиковало мне в каналы несколько лет, и я о нём не вспоминал.
Потом было пару лет на Telegram-платформе, работавшей на ~300K MAU, ~75 RPS в пике и ~1,5 миллиона событий в день, — и там выяснилось, что в этих пятнадцати строках неверно вообще всё.
Стек дальше будет важен, поэтому сразу: Laravel-бэкенд на MySQL, фоновая работа — в родной Laravel-очереди (jobs / failed_jobs): генерации, ретраи, всё, что падает и подхватывается заново. Redis стоял рядом и занимался другим: исходящей отправкой и бюджетом рейт-лимита. Это две очереди с двумя разными задачами, и путать их — первое, чего делать не надо. Про MySQL-половину этой системы я уже писал в статье про аналитику — путь отправки это та половина, которая никогда не становится строкой, и вот её история. Не «неоптимально» — неверно. Неверен цикл. Неверно отсутствие задержки. Неверна сама идея, что «отправить» — это операция, которую данный процесс имеет право выполнить самостоятельно.
Фраза, после которой всё встало на места:
Рейт-лимит — это не ограничение скорости. Это бюджет, и тратишь его не ты один.
Ограничение скорости — свойство твоего цикла, и закрывается оно sleep-ом. Бюджет — свойство токена, общее для всех процессов с этим токеном, и изнутри одного процесса его закрыть нельзя. Почти каждый баг с лимитами, который я видел, — это схлопывание вот этой разницы.
Дисклеймер: платформа под NDA. Названия, точные объёмы и часть деталей изменены. Порядки величин, конфиги и сами инциденты — настоящие. Код 2023 года выше — публичный и не тронут.
Что на самом деле ограничивает Telegram
Тут никакого секрета, это просто редко читают. Из официального FAQ, бот получает примерно:
~30 сообщений в секунду суммарно, на всё вместе;
~1 сообщение в секунду в один чат, короткие всплески прощаются;
~20 сообщений в минуту в одну группу.
Три лимита с тремя разными знаменателями — глобальный, на чат, на группу, — и вот это первым ломает наивную модель в голове. Нет одного числа, под которое надо подлезть. Бот, отправляющий 25 сообщений в секунду, спокойно влезает в глобальный лимит и при этом может словить бан — потому что 24 из них ушли в один чат.
И ни одна из этих цифр не является контрактом. Контракт — вот:
{ "ok": false, "error_code": 429, "description": "Too Many Requests: retry after 32", "parameters": { "retry_after": 32 } }
Документированные числа приблизительны по формулировке самого Telegram — «не более чем примерно». А 429 точен. Отсюда первая ошибка, и она же самая частая из всех, что я видел в чужих репозиториях.
Ошибка 1: бэкофф вместо чтения ответа
Вот ретрай, который пишут все, на любом языке, обычно скопировав из статьи про HTTP-клиенты:
int attempt = 0; while (attempt < MAX_ATTEMPTS) { Response r = send(message); if (r.code() != 429) return r; Thread.sleep((long) Math.pow(2, attempt++) * 1000); // 1с, 2с, 4с, 8с… }
Экспоненциальный бэкофф — правильный дефолт, когда ты не знаешь, сколько ждать. Здесь ты знаешь. Сервер положил число в тело ответа. retry_after: 32 — это тридцать две секунды, а сон на 1, 2, 4 и 8 означает четыре лишних запроса внутри окна, про которое тебе уже сказали, что ни один из них принят не будет.
И это не просто потраченные впустую запросы. В Telegram обращения во время лимита этот лимит продлевают. Бэкофф превращает паузу в 32 секунды в паузу на несколько минут, а несколько минут — в то, что будит дежурного в три часа ночи. Цикл, который писался ради защиты сервиса, и есть то, что сервис деградирует.
Response r = send(message); if (r.code() == 429) { long wait = r.parameters().retryAfter(); // число сервера, не твоё reschedule(message, now().plusSeconds(wait + 1)); return; }
Обрати внимание на второе изменение, оно важнее первого: тут нет сна. Тут перепланирование. К тому, почему это принципиально, вернусь ниже — там отдельный раздел.
Оговорка: числа может не быть, и оно может врать
Telegram ведёт себя прилично. retry_after есть, он честный, и соблюдать его достаточно. Большинство API — не Telegram, и всё остальное я узнал на market API Steam, где цифры лежат в публичном репозитории (caseforge), а не под NDA:
лимит — примерно 20 запросов в минуту; дальше 429 и несколько минут бана;
поэтому запросы сериализованы с интервалом 3,5 секунды, поиск кешируется на час, цены — на полчаса;
429 укладывает весь сервис спать на пять минут;
и отрицательные ответы кешируются тоже — иначе предмет, у которого нет лотов, уходит в Steam на каждой синхронизации.
Последнее — самое неочевидное. Пустой ответ это тоже ответ, и если его не кешировать, ты тратишь бюджет на перезадавание вопроса, ответ на который уже знаешь.
Ещё четыре вещи, стоившие мне времени. Ни одной из них Telegram не научит, потому что Telegram делает всё правильно.
Retry-After отсутствует или врёт чаще, чем хотелось бы. Полно серверов отдают 429 вообще без заголовка. Некоторые шлют константу независимо от реального состояния. Спека разрешает HTTP-date наравне с delta-seconds, так что нужны оба парсера плюс экспоненциальный фолбэк с джиттером и вменяемым потолком — на случай, когда парсить нечего.
Джиттер — не украшение. Без него все воркеры, получившие одинаковый retry_after, просыпаются в одну и ту же миллисекунду и воспроизводят ровно тот всплеск, за который их и залимитили. Самая дешёвая правка во всей статье и чаще всего пропускаемая.
Размораживать надо рампой, а не рубильником. Если на время бана ты заморозил очередь, а потом отпустил её в момент, когда проба вернула 200, всё накопившееся выстреливает разом — и этот всплеск неотличим от того, за который тебя забанили. Второй бан обычно длиннее, потому что многие сервисы эскалируют. Возобновляйся на доле прежней скорости, наращивай постепенно и роняй мультипликативно на следующем 429. Стоит немного пропускной способности и убирает раскачку.
Ключ блокировки — часто не тот, который ты выбрал. Лимиты вешают на префикс пути, на API-ключ или на аккаунт не реже, чем на хост. Заморозка целого домена, когда залимичен один эндпоинт, выбрасывает пропускную способность везде; а ключ по хосту, когда лимит на самом деле по токену, даёт блокировке одного токена застопорить все остальные.
И то, что на практике стоит дороже всего, — отдельным предложением:
Огромная часть рейт-лимитинга вообще не приходит как 429. Приходит 200 с капчей, пустая выборка или список, тихо обрезанный до первых 20 элементов.
Всё, что смотрит только на коды статуса, проезжает мимо и пишет правдоподобный мусор к себе в базу, о котором ты узнаёшь недели спустя. Это тот же отказ, что у скрапера, возвращающего валидные, правдоподобные и неверные данные, и защита та же: проверять форму ответа, а не только его статус.
Ошибка 2: лимитер лежит не в том процессе
Вот эта — главная. Всё остальное в статье — следствие.
Ты чинишь бэкофф. Добавляешь нормальный token bucket: 30 токенов, доливается 30 в секунду, перед каждой отправкой берём один. Корректно, покрыто тестами, на стенде безупречно. Потом ты масштабируешь отправку более чем в одном процессе — и получаешь лимит, причём жёстче прежнего, без единой правки кода и без роста трафика.
Потому что у каждого процесса своё ведро. Три воркера с ведром по 30 попытаются отправить 90 сообщений в секунду при лимите 30.
что предполагает код что видит Telegram ┌─────────────────────────┐ ┌─────────────────────────┐ │ под A · bucket(30/s) │───┐ │ │ │ под B · bucket(30/s) │───┼───▶│ один токен · 30/s │ │ под C · bucket(30/s) │───┘ │ │ └─────────────────────────┘ └─────────────────────────┘ три независимых лимита один общий бюджет
Лимит — свойство бот-токена, а токен не ограничен твоим процессом. Ему нет дела до числа реплик, до автоскейлера и до разового скрипта-бэкфилла, который коллега запустил с ноутбука по проду, — этот скрипт тратит тот же самый бюджет. Если бэкфилл может задушить живой трафик (а он может), значит живой трафик и бэкфилл лежат в одной очереди, независимо от того, моделировал ты это или нет.
Значит, лимитер не может жить в процессе. Он должен жить там, где всех потребителей видно, — ровно поэтому Redis в том стеке и стоял, рядом с совершенно нормальной Laravel-очередью, которая эту задачу решить не может. Очередь в твоей базе упорядочивает работу. У неё нет мнения о бюджете, который принадлежит чужому API. Как только такое мнение понадобилось, штука, которую ты строишь, перестаёт быть рейт-лимитером и становится очередью с воротами на входе.
Форма ровно та же, что у пула соединений к базе: пул соединений — это не то, чем кажется, и maximumPoolSize почти никогда не ответ именно потому, что пул — локальное представление о бюджете, который принадлежит кому-то другому.
Ошибка 3: INCR + EXPIRE — это не рейт-лимитер
Сниппет, который лежит в каждом туториале:
INCR rate:{token}:{second} EXPIRE rate:{token}:{second} 2
Две проблемы, и вторая хуже первой.
Это не атомарно. Две команды, два round-trip. Процесс может умереть между ними и оставить ключ без TTL — он навсегда застрянет со своим счётчиком и намертво заклинит эту секунду. Авария, нанесённая себе самому и переживающая рестарты. У SET key 1 EX 2 NX плюс INCR ровно та же форма. Всё, что должно быть атомарным больше чем по одному ключу или больше чем в одну команду, в Redis живёт в Lua-скрипте, потому что скрипт выполняется целиком:
-- KEYS[1] = ключ ведра -- ARGV[1] = ёмкость ARGV[2] = доливка в секунду -- ARGV[3] = сейчас(мс) ARGV[4] = сколько токенов просим local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(b[1]) or tonumber(ARGV[1]) local ts = tonumber(b[2]) or tonumber(ARGV[3]) local elapsed = math.max(0, tonumber(ARGV[3]) - ts) / 1000.0 tokens = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2])) if tokens < tonumber(ARGV[4]) then -- через сколько мс токенов хватит; вызывающий планирует, а не крутится в цикле local deficit = tonumber(ARGV[4]) - tokens return {0, math.ceil(deficit / tonumber(ARGV[2]) * 1000)} end redis.call('HSET', KEYS[1], 'tokens', tokens - tonumber(ARGV[4]), 'ts', ARGV[3]) redis.call('PEXPIRE', KEYS[1], 60000) return {1, 0}
И это неправильная форма. Счётчик с ключом по текущей секунде — это фиксированное окно, а фиксированное окно пропускает двойной лимит на своей границе: 30 сообщений в 12:00:00.999 и ещё 30 в 12:00:01.001 — это 60 сообщений за две миллисекунды, и все они проходят проверку. Telegram не примет аргумент, что формально ты уложился в оба окна.
У token bucket нет границ. У него есть уровень, и уровень непрерывен. В этом вся разница, и поэтому в скрипте выше лежит метка времени, а не ключ окна.
Ошибка 4: ретрай — это второй продюсер
Вот эта дала худший инцидент, и увидеть её заранее почти невозможно.
Сообщение получило 429. Очевидное действие — вернуть его в очередь. Оно возвращается в очередь — и теперь конкурирует за тот же бюджет с новыми сообщениями. Очередь разгребается медленнее, чем наполняется. Больше сообщений ловят 429. Они тоже возвращаются. Каждый ретрай занимает слот, в котором могло уехать новое сообщение, значит новые задерживаются, значит их тоже ретраят.
Ничего не упало. Каждый компонент ведёт себя ровно так, как спроектирован. Пропускная способность при этом схлопывается и сама не восстановится, потому что большая часть нагрузки системы — теперь её собственные ретраи. Разгребается это тогда, когда перестаёшь принимать новую работу, а не само.
Лечение — то самое изменение из сниппета выше: 429 — это не ошибка, это расписание.
ZADD send:{chat_id} <сейчас + retry_after + 1> <message_id>
Sorted set с ключом по времени доставки. Переотправляемое сообщение не стоит в голове очереди, получая отказ за отказом; его в очереди нет вообще до момента, когда его реально можно отправить. Ретрай перестаёт быть нагрузкой. Это и есть разница между retry policy и планировщиком, и под лимитом тебе нужен планировщик.
Заодно: ограничь число переносов и сделай это ограничение бизнес-решением, а не константой. Уведомление, опоздавшее на четыре часа, — не опоздавшее уведомление, а неверное. Выкинуть его правильно, и выбирать это число должен не ты.
Ошибка 5: одна очередь — значит один медленный чат стопорит всё
При одной глобальной очереди порядок — FIFO, и в голове стоит то, что пришло первым. Если сообщение в голове адресовано чату, который сейчас под лимитом, ждёт всё, что за ним, — включая сообщения во все остальные чаты, совершенно свободные.
Классический head-of-line blocking, и не замечают его потому, что метрики выглядят прекрасно. Пропускная способность на лимите. Бюджет выбран полностью. Выбран он одним чатом.
Дорожки по чатам:
send:ready → множество chat_id, у которых есть что отправить send:{chat_id} → личный FIFO этого чата bucket:{token} → единственный общий бюджет

Воркер берёт chat_id из send:ready, берёт один токен из общего ведра, отправляет одно сообщение из головы списка этого чата и возвращает чат в хвост send:ready, если там ещё что-то осталось. Порядок внутри чата сохранён — а он не опционален, бот, отвечающий на второй вопрос раньше первого, сломан, — и ни один чат не может захватить общий бюджет.
Лимит на чат (~1/сек) при этом живёт на дорожке, а не на воркере, в виде readyAt у чата. Чат, только что отправивший сообщение, просто ещё не попал в send:ready.
Что не обходится инженерно: мёртвые чаты стоят столько же
Какая-то доля аудитории заблокировала бота. Эта доля только растёт. Telegram сообщает об этом предельно внятно:
{ "ok": false, "error_code": 403, "description": "Forbidden: bot was blocked by the user" }
403 стоит ровно столько же бюджета, сколько доставленное сообщение. Это одно из твоих ~30 в секунду. При рассылке на большую аудиторию мёртвые чаты — не погрешность, а прямой процент от ёмкости, срезаемый сверху до того, как достучались хоть до одного живого человека, и так при каждой рассылке.
Значит, 403 обязан писать обратно. Помечать чат неактивным на первом же и переставать слать; возвращать в работу, когда пользователь сам напишет боту, — люди разблокируют. Уродливый вариант этого бага — когда никто не чистит, и рассылка каждый месяц становится медленнее по причинам, похожим на инфраструктурные. Это не инфраструктура. Это уходящая аудитория, отрендеренная как латентность.
Форма отказа та же, что у скрапера, который тихо возвращает пустоту: система рапортует об успехе, а то, ради чего всё затевалось, перестало быть правдой.
Что вешать на дашборд
Метрика, которую строят первой, — сообщений в секунду. Она почти бесполезна. Она говорит, сколько ты потратил, но не сколько тебе было можно, и выглядит одинаково и при 60% бюджета в тихий день, и когда ты прибит к 100% и режешь нагрузку.
Четыре, которые своё место окупают.
Утилизация бюджета — взятые токены к доступным, на токен, по минутам. Единственное число, которое говорит, насколько ты близко к стене. Всё остальное — его следствие.
Доля 429 с разбивкой по тому, какой лимит словили. Глобальные и чатовые 429 имеют совершенно разные причины и совершенно разные лечения, а усреднение прячет обе. Глобальные — ты вышел за ёмкость. По чату — один диалог слишком болтлив, и это почти всегда баг в логике диалога, а не в отправке.
Разница «запланировано / отправлено» по p99. Среднее тут врёт. Под лимитом почти всё уходит мгновенно, а маленький хвост опаздывает на минуты — и этот хвост и есть весь пользовательский опыт фичи. Если p99 ползёт вверх при плоской пропускной способности, ты насыщен и сбрасываешь нагрузку в будущее.
Глубина очереди по чатам — по максимуму. Не сумма, а максимум. Один чат с десятью тысячами сообщений в очереди — это где-то зациклилось, и в агрегате, где рядом лежат сотни тысяч здоровых чатов с одним сообщением, это не видно вообще.
Что бы я сказал себе в 2023-м
Тот планировщик на пятнадцать строк — не плохой код. Это корректный код с невысказанным допущением: что данный процесс — единственный, кто тратит бюджет. Для одного канала и одного бота допущение держалось годами.
Всё дорогое, что я узнал потом, — это одно и то же допущение, ломающееся на большем масштабе. Его сломало число подов. Его сломал скрипт-бэкфилл. Его сломал цикл ретраев, став продюсером. Его сломали заблокировавшие бота — они тратят бюджет, не будучи никем.
Общая формулировка, далеко за пределами Telegram:
Если лимит наложен на ресурс, который ты с кем-то делишь, контроль обязан жить там, где это «делишь» видно. Любой лимитер внутри процесса — это лимитер на представление процесса о мире.
Поэтому починенная версия — не более умный цикл с более удачным sleep. Это очередь, общее ведро и планировщик: три вещи, которых у оригинала не было и которые ему не были нужны — ровно до момента, когда стали нужны.
Я тут утверждаю, что на 429 надо перепланировать, а не ретраить, и что это задача планировщика, а не политики ретраев. Есть честный контраргумент: для бота с низким объёмом планировщик — это строго больше движущихся частей, чем sleep, а больше частей значит больше мест, где можно ошибиться.
Где проходит граница? На каком объёме общее ведро перестаёт быть оверинжинирингом и становится единственным работающим вариантом — и вы нашли эту границу лёгким способом или тем же, что и я?