Клиент прислал скриншот: в чате два одинаковых сообщения «Заказ №1842 принят». Совпадают текст, время с точностью до секунды и даже эмодзи. Первое желание — немедленно что-то переключить: webhook на polling, polling на webhook или просто добавить sleep. Не спешите. Два одинаковых сообщения — это симптом, а родиться он мог в трёх совершенно разных местах: один и тот же update пришёл дважды, две копии воркера взяли одну задачу, либо задача одна, но результат её отправки оказался неизвестен, и система отправила снова. Лечение у каждой причины своё, а неправильное лечение маскирует болезнь.

Начнём с самого дешёвого: разберёмся, чем два дубля отличаются друг от друга. Снаружи они близнецы, а внутри у них разные отпечатки.

Три близнеца с разными паспортами

Чтобы сравнивать дубли, у каждой отправки должна остаться запись в журнале: что пришло на вход, какая задача это обработала и что вернул Telegram. Минимальный набор полей: update_id входящего события, идентификатор вашей бизнес-задачи (например, номер заказа и тип уведомления), идентификатор попытки или воркера, время и ответ Bot API с message_id. Если этих полей нет, сначала добавьте их в лог и дождитесь следующего дубля: гадать по тексту сообщения бесполезно.

Теперь представим три условных случая. Все три выглядят для клиента одинаково — «пришло дважды».

Три одинаковых дубля и что в них различается
Что видно в журналеupdate_idID бизнес-задачиЗаписи отправкиВероятная причина
Случай АОдин и тот же, две обработкиОдна задача, создана дважды из одного событияДве записи, разные попыткиПовтор входа: webhook или polling доставил update повторно
Случай БОдин update, одна обработкаОдна задача, взята двумя воркерамиДве записи с одним ID задачи и разными воркерами в одно времяКонкурирующая обработка
Случай ВОдин update, одна задачаОдна задачаПервая попытка завершилась таймаутом без ответа, вторая — успехомНеизвестный результат отправки и повтор исходящего вызова

Обратите внимание, чего в таблице нет: номера телефона и текста сообщения. Это сознательно. Текст «Заказ принят» легко совпадёт у двух разных заказов, а один заказ может законно породить два похожих сообщения — «принят» и «передан в доставку». Телефон принадлежит человеку, а не событию: один клиент вполне может сделать два заказа за минуту. Ключ уникальности должен описывать событие или действие, а не получателя и не содержание.

Дальше идём по трём случаям в порядке подозрения: сначала вход, потом конкурентность, потом исходящий вызов.

Случай А: вход пришёл дважды, потому что Telegram не услышал «принято»

Если бот работает через webhook, помните одно правило: при неуспешном HTTP-ответе Telegram повторяет доставку (описание setWebhook). Успехом считается ваш быстрый ответ со статусом 2xx. Всё остальное — 500, 502 от прокси, таймаут, обрыв соединения — для Telegram означает «не доставлено, пришлю ещё раз».

Отсюда типичная ловушка. Обработчик получил update, создал задачу, отправил сообщение клиенту, потом упал на последнем шаге — скажем, на записи в журнал или на тяжёлом пересчёте. Клиент сообщение уже получил, а Telegram увидел ошибку и принёс тот же update снова. Бот честно обрабатывает его вторично и шлёт второе подтверждение. Логика бота при этом ни в чём не виновата: повтор — штатное поведение протокола.

Как это распознать по журналу:

  • в двух записях совпадает update_id;
  • между ними есть запись входящего запроса, который завершился не 2xx или не уложился во время ожидания (посмотрите логи обратного прокси и веб-сервера, а не только приложения);
  • разрыв по времени соответствует не мгновенности гонки, а паузе между попытками доставки.

Типичные причины неуспешного ответа лежат не в самом боте. Обработчик делает синхронно всё подряд — ходит во внешнюю CRM, считает, отправляет — и не успевает вернуть ответ. Прокси отдаёт 502 во время перезапуска приложения. Исключение вылетает после того, как побочный эффект уже случился.

Разберём на условном примере, как именно обычная медлительность превращается в дубль. Обработчик принимает update, синхронно вызывает CRM, ждёт ответа несколько секунд, затем отправляет клиенту «Заказ №1842 принят» и только после этого возвращает 200. Если прокси или клиент Telegram оборвут ожидание раньше, чем обработчик дойдёт до конца, сообщение уже уйдёт, а для Telegram доставка останется неуспешной. Тот же update придёт снова (setWebhook), и вторая обработка начнётся с чистого листа, если нигде не записано, что первая уже была.

Поэтому в журнале стоит различать три момента: когда запрос принят прокси, когда приложение вернуло статус и когда ушёл вызов sendMessage. Если вызов отправки зафиксирован позже, чем прокси записал обрыв, причина найдена: порядок действий в обработчике устроен так, что побочный эффект происходит раньше ответа. Перенос тяжёлых шагов в фоновую задачу сокращает окно, в котором повтор вообще возможен, а регистрация update_id делает безвредным тот повтор, который всё же случится.

Исправление состоит из двух независимых частей, и обе нужны.

  1. Отвечать быстро. Обработчик webhook должен только принять update, надёжно записать его и вернуть 2xx. Тяжёлая работа уходит в очередь или фоновую задачу.
  2. Не терять защиту от повторов. Даже при быстром ответе повтор возможен — сеть не гарантирует, что ваш 200 дошёл до Telegram. Поэтому первая же операция обработчика — зарегистрировать update_id как обработанный и отбросить повторы.

Про безопасность входа заодно вспомните secret_token: он приходит в специальном заголовке webhook, и по нему можно отсеять запросы не от Telegram. Но это защита от посторонних, а не от дублей: повтор настоящего update тоже придёт с правильным токеном.

Polling: дубли из-за забытого offset

В режиме long polling повтор выглядит иначе. Бот сам спрашивает обновления методом getUpdates, и подтверждением служит параметр offset: он сообщает, что обновления до указанного номера получены, а неподтверждённые могут поступать снова (FAQ Telegram о повторных updates). Если offset не передаётся или вы продвигаете его неверно, один и тот же update возвращается на каждом цикле.

Рабочая логика проста: взяли пачку, обработали, следующий запрос отправили с offset, который больше максимального update_id из пачки. Но тонкость в порядке действий. Продвинуть offset до обработки значит рискнуть потерять update при падении посреди пачки. Продвинуть после обработки значит получить повтор при падении между обработкой и следующим запросом. Универсального «правильного» порядка нет: выбирается компромисс, а повтор закрывается регистрацией update_id — тем же приёмом, что и в webhook.

Признаки в журнале: одинаковые update_id появляются в нескольких последовательных ответах getUpdates, а offset в запросах не растёт. Частая причина — исключение в цикле до строки с обновлением offset, после чего цикл перезапускается с тем же значением и снова получает ту же пачку. Одно «ядовитое» сообщение, на котором обработчик стабильно падает, способно породить лавину повторов: вся пачка, включая уже успешно обработанные update, приходит заново.

Отсюда практическое следствие: обработку каждого update нужно оборачивать так, чтобы сбой одного не мешал сдвинуть offset. Проблемное событие откладывается в отдельное хранилище ошибок, а не блокирует поток.

Полезно представить это в виде короткого расчёта на условных числах. Бот получил пачку из пяти updates с номерами от 101 до 105. Обработчик успешно разобрал 101–103, на 104 упал с исключением, а следующий запрос ушёл всё с тем же offset. Telegram не получил подтверждения, и неподтверждённые обновления могут прийти снова (FAQ о повторных updates). В итоге 101–103 обрабатываются повторно, а на 104 цикл снова падает. Клиенты, чьи события шли первыми, получают по второму сообщению на каждом круге, хотя виновато одно событие.

Видно это по двум признакам: в последовательных ответах getUpdates повторяется один и тот же набор номеров, а число повторных сообщений растёт с каждым циклом перезапуска. Если же offset двигается нормально, а дубль всё равно есть, polling можно исключить из подозреваемых и переходить к конкурентности. Так журнал избавляет от лишнего переключения режима.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Случай Б: два работника на одну задачу

Теперь представим, что вход чист: update_id уникален и обработан один раз, задача создана одна. Но сообщений всё равно два, и в журнале видно, что два воркера работали с одной задачей почти одновременно. Так бывает, когда бота запустили в двух экземплярах — например, после деплоя старый процесс не остановился, или в оркестраторе указано две реплики, — либо когда очередь отдаёт задачу повторно, потому что воркер не успел подтвердить выполнение до истечения времени блокировки.

Интересный и часто забываемый вариант: бот одновременно работает и через webhook, и через скрипт с polling у соседа по команде. Тогда два независимых потребителя стоят в разных режимах, а журналы у каждого выглядят нормально по отдельности. Сопоставление по update_id и идентификатору воркера в общей таблице это показывает сразу.

Лечение здесь — не «ещё одна проверка в коде», а уникальность, которую обеспечивает хранилище. Проверка «есть ли уже такая запись, если нет — создаём» в два шага ломается ровно в окно между шагами: оба воркера успевают увидеть «нет» и оба идут создавать. Нужна одна атомарная операция.

Атомарная регистрация события

В PostgreSQL это ограничения целостности: PRIMARY KEY задаёт уникальность и запрещает NULL, а UNIQUE может охватывать несколько столбцов (документация по ограничениям). Смысл в том, что база сама отклонит вторую вставку, как бы быстро ни гонялись воркеры.

CREATE TABLE processed_updates (
  bot_id     bigint NOT NULL,
  update_id  bigint NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (bot_id, update_id)
);

INSERT INTO processed_updates (bot_id, update_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING;

Если вставка затронула одну строку — вы первый, продолжаем. Если ноль строк — событие уже занято, выходим молча. Обратите внимание на составной ключ: у разных ботов нумерация update_id своя, поэтому в общей таблице уникальность нужна на паре «бот + update», а не на одном числе.

Тот же приём применим не только ко входу, но и к бизнес-действию. Для уведомлений удобен ключ вида «сущность + тип уведомления»:

CREATE TABLE notifications (
  order_id   bigint NOT NULL,
  kind       text   NOT NULL,
  state      text   NOT NULL DEFAULT 'pending',
  telegram_message_id bigint,
  UNIQUE (order_id, kind)
);

Одна строка на пару «заказ №1842 + подтверждение». Попытка создать вторую упрётся в ограничение. При этом «принят» и «передан в доставку» — разные значения kind, и законные последовательные сообщения не блокируют друг друга. Это как раз то, чего не умеет ключ по тексту или телефону.

Одна мелочь про NULL и пустые строки, которая портит уникальность незаметно. Документация PostgreSQL отдельно отмечает, что пустая строка не тождественна NULL. Если ключ по ошибке собирается из необязательного поля и иногда там пустая строка, а иногда NULL, ограничение будет вести себя по-разному в двух случаях, и дубль проскочит. Столбцы ключа делайте NOT NULL и проверяйте значения до вставки.

Теперь сопоставим три ключа на условных данных, чтобы увидеть, где каждый ошибается. Допустим, клиент с одним номером телефона оформил два заказа подряд, №1842 и №1843, и получил по сообщению «принят». Ключ по телефону примет первое сообщение и молча отбросит второе, то есть вы получите потерю вместо дубля. Ключ по тексту поведёт себя так же, поскольку тексты совпадают. Ключ (order_id, kind) пропустит оба, потому что заказы разные, и при этом заблокирует вторую попытку подтвердить тот же заказ №1842.

Как разные ключи ведут себя на одних и тех же событиях
СитуацияКлюч: телефонКлюч: текстКлюч: заказ + тип
Два разных заказа одного клиентаБлокирует второй, сообщение потеряноБлокирует второй, сообщение потеряноПропускает оба
Один заказ, «принят» и «передан в доставку»Блокирует второеПропускаетПропускает оба
Повторная обработка того же подтвержденияБлокируетБлокируетБлокирует

Только последний вариант ошибается в нужную сторону во всех трёх строках. Такой ключ описывает само действие, а не получателя и не содержание, поэтому ложных блокировок у него нет.

Случай В: вход один, задача одна, а сообщений всё равно два

Самый коварный вариант: update приходил один раз, воркер был один, строка в notifications существует в единственном экземпляре. А клиент получил два сообщения. Значит, дубль родился уже на выходе — при вызове sendMessage.

Механика такая. Воркер отправляет запрос в Bot API и ждёт ответ. Сообщение дошло до клиента, но ответ потерялся: истёк таймаут, оборвалось соединение, рестартовал процесс. Для воркера результат неизвестен. Был ли запрос принят? Он не знает. Если политика повторов гласит «не получили ответ — отправь ещё раз», получается дубль. Если политика «не получили ответ — считаем, что не отправлено», возникает обратная проблема: клиент вообще остался без подтверждения. Выбор между «возможный дубль» и «возможная потеря» — это решение бизнеса, а не технический трюк.

Здесь легко поддаться соблазну: сделать идемпотентным вызов с помощью «ключа». Но в Bot API для sendMessage нет универсального ключа идемпотентности, который гарантировал бы однократную отправку, поэтому защиту придётся строить вокруг вызова. Нельзя и положиться на то, что транзакция вашей БД охватит внешний вызов: коммит в базе и отправка в Telegram — два разных мира, атомарно их не связать. Новый идентификатор попытки сам по себе ничего не решает: если каждая попытка рождает свой ключ, повтор просто выглядит как новая операция.

Поэтому разумная схема — честно хранить состояние отправки и различать его фазы:

  1. pending — задача создана, отправка не начиналась;
  2. sending — воркер захватил задачу (условным обновлением, атомарно) и вот-вот вызовет API;
  3. sent — получен ответ с message_id, он записан;
  4. unknown — вызов начат, а результат не получен: таймаут, обрыв, падение после sending.

Захват задачи делается одним оператором: UPDATE notifications SET state='sending' WHERE order_id=$1 AND kind=$2 AND state='pending'. Если изменилась одна строка, задача ваша; если ноль — её уже кто-то держит. Это закрывает случай Б, но главное здесь — состояние unknown. Оно прямо признаёт: мы не знаем, что произошло, и автоматически отправлять ещё раз без решения нельзя.

Что делать с unknown, зависит от цены ошибки. Для подтверждения заказа лишнее сообщение почти безвредно, и можно повторять, предварительно записав факт повтора. Для сообщения с одноразовым кодом или денежной суммой дубль неприятнее — там лучше пометить событие для проверки или отправить новое осознанно, с пояснением. Такие правила стоит записать заранее по типам уведомлений, а не принимать на ходу посреди инцидента.

Важно и то, что уникальность входа не заменяет защиту выхода. Вы можете идеально отсечь повторные update — и всё равно слать дубли из-за неизвестного результата. Эти слои закрывают разные дыры: ключ входа не пускает повтор события, ключ задачи не пускает повторное создание действия, а состояние отправки управляет повтором исходящего вызова.

Как разобрать конкретный инцидент за вечер

Порядок расследования лучше держать в голове как последовательность вопросов, каждый из которых отсекает целый класс причин.

  1. Совпадают ли update_id у двух обработок? Да — смотрите вход (случай А). Нет — идём дальше.
  2. Если совпадают: что ответил обработчик на первую доставку? Не-2xx или таймаут в webhook-режиме — неуспешный ответ спровоцировал повтор. В polling — проверьте, растёт ли offset.
  3. Одна ли это бизнес-задача? Если разные задачи из одного события — дублируется создание. Нужен уникальный ключ на действии.
  4. Работали ли два воркера одновременно? Сравните время и идентификаторы процессов, посчитайте реплики и забытые старые экземпляры.
  5. Были ли в журнале попытки отправки с таймаутом перед успешной? Тогда причина — неизвестный результат вызова.

Вернёмся к нашему клиенту с двумя подтверждениями. Допустим, журнал показывает: один update_id, одна запись в notifications, два вызова sendMessage с разницей в несколько секунд, первый завершился ошибкой чтения ответа. Тогда менять протокол с webhook на polling бессмысленно: вход здесь чист, дубль родился на выходе. Правильное исправление — ввести состояния sending и unknown в таблице уведомлений и решить, как повторять именно этот тип сообщения. А если бы журнал показал два одинаковых update_id и 502 от прокси между ними, то лечить пришлось бы быстрый ответ webhook и регистрацию update, а не воркеры.

Где всё это живёт: немного о ресурсах

Для такой схемы не нужна громоздкая инфраструктура: бот, база с уникальными ограничениями, очередь или фоновый воркер и обратный прокси. На VPS MAATRIX (NVMe, AMD EPYC, root-доступ) всё это обычно помещается на одной машине, а root позволяет настроить прокси, процессы воркеров и PostgreSQL под себя. Перечень тарифов начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и доходит до Djed (16 vCPU, 32 ГБ, 320 ГБ), но это описание ресурсов, а не обещание конкретной ёмкости: сколько обновлений в секунду потянет ваш бот, покажет только нагрузочная проверка на ваших данных. Если вы собираете поддержку клиентов вокруг Telegram, пригодится и разбор объединения сообщений с сайта и Telegram в одном окне — там входящий поток тоже требует чёткой идентификации событий.

Полезная мелочь про эксплуатацию: после деплоя убеждайтесь, что старый процесс остановлен, а число реплик воркеров соответствует задуманному. Половина «таинственных» дублей рождается из забытого второго экземпляра, который тихо продолжает читать очередь.

Один дубль — одна причина

Возьмите последние два одинаковых сообщения из вашего чата и найдите их в журнале по номеру заказа. Выпишите три значения: update_id, идентификатор задачи и состояние попыток отправки. Если совпал первый идентификатор — вы нашли повтор входа и чините быстрый ответ с регистрацией update или продвижение offset. Если совпала задача, а работали два процесса — вы нашли гонку и закрываете её уникальным ограничением и условным захватом. Если всё совпало, но между отправками была ошибка ожидания ответа — вы нашли неизвестный результат и вводите состояние unknown с понятным для каждого типа сообщения правилом повтора. Выбирайте одно исправление под одну найденную причину, а переключение между webhook и polling оставьте тем случаям, когда журнал действительно показывает проблему именно в способе получения обновлений.