Защита от дублей в автопостинге: ключ, ON CONFLICT и очередь
Дубли убирают на уровне базы, а не сценария: каждому предложению присваивают уникальный ключ и вставляют его через ON CONFLICT DO NOTHING. Очередь на отправку забирают с FOR UPDATE SKIP LOCKED, а статусы new, sending и sent позволяют безопасно повторять любой шаг.
Автопостинг и интеграции рано или поздно начинают слать повторы: сценарий запустили дважды, сервис повторил запрос, выгрузка пришла с теми же данными. Надёжнее всего защита, которая живёт в базе данных, а не в самом сценарии.
Откуда берутся дубли
- Повторный запуск по расписанию или вручную.
- Повторная доставка вебхука: отправитель не получил ответ и прислал запрос ещё раз.
- Пересечение источников: одно и то же предложение приходит из двух мест.
- Сбой посередине: сообщение ушло, а отметка об этом не сохранилась, поэтому при повторе оно уйдёт снова.
Шаг 1: уникальный ключ предложения
Придумайте стабильный ключ, по которому две записи считаются одним и тем же предложением. Для билетов это маршрут, дата и ценовой диапазон, для объявлений адрес и цена, для заявок идентификатор запроса.
CREATE TABLE offers (
id bigserial PRIMARY KEY,
dedup_key text NOT NULL UNIQUE,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'new', -- new, sending, sent, failed
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
Ограничение UNIQUE не даст вставить второй раз то же самое, даже если два сценария запущены одновременно.
Шаг 2: вставка без дублей
INSERT INTO offers (dedup_key, payload)
VALUES ($1, $2)
ON CONFLICT (dedup_key) DO NOTHING
RETURNING id;
Если запись новая, запрос вернёт её id. Если такой ключ уже есть, вернётся пустой результат, и вы просто пропускаете предложение. Это работает без отдельной проверки «есть ли уже такое», которая сама подвержена гонкам.
Шаг 3: очередь на отправку
Отправку делайте отдельным шагом, который берёт записи из очереди. Чтобы два обработчика не взяли одну и ту же запись, используйте блокировку строки с пропуском занятых:
UPDATE offers
SET status = 'sending'
WHERE id = (
SELECT id FROM offers
WHERE status = 'new'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, payload;
FOR UPDATE SKIP LOCKED блокирует выбранную строку и пропускает те, что уже взяты другим обработчиком. Каждый обработчик получает свою запись.
Шаг 4: статусы и повтор
После отправки отметьте результат:
UPDATE offers SET status = 'sent', published_at = now() WHERE id = $1;
-- при ошибке:
UPDATE offers SET status = 'failed' WHERE id = $1;
Теперь любой шаг можно безопасно повторить:
- записи со статусом
failedможно отправить снова; - записи, зависшие в
sendingдольше нескольких минут, возвращаются вnewотдельным заданием (так вы не теряете сообщения при падении обработчика).
Нюансы
- Сообщение могло уйти, а отметка нет. Если процесс упал между отправкой и обновлением статуса, при повторе сообщение уйдёт снова. Для критичных случаев храните идентификатор отправленного сообщения и проверяйте перед повтором. Для постов в канал обычно достаточно небольшой возможной повторной публикации.
- Ключ должен быть стабильным. Не включайте в него поля, которые меняются при каждой выгрузке (время запроса).
- Старые записи. Чистите или архивируйте таблицу по расписанию: ограничение уникальности работает, пока запись существует. Если нужно помнить ключ дольше, храните только ключи.
- Пауза между отправками защищает от лимитов Telegram.
Как это встроено в n8n
В сценарии n8n это узлы PostgreSQL: вставка с ON CONFLICT, выбор из очереди с блокировкой, отправка в Telegram и обновление статуса. Состояние лежит в базе, поэтому перезапуск сценария ничего не ломает. Общая схема конвейера описана в статье автопостинг в Telegram на n8n и PostgreSQL, а приём данных с сайта в защите webhook в n8n. Такой подход использован в проектах HotTrip и Da Nang House. Услуга: «Контент и соцсети».
Частые вопросы
Почему нельзя проверять дубли в сценарии?
Проверка «есть ли запись» и вставка выполняются не атомарно: два запуска могут одновременно решить, что записи нет. Ограничение уникальности в базе закрывает это без гонок.
Что делать, если у предложений нет естественного ключа?
Сформируйте его из набора полей (источник, идентификатор у источника, дата) и храните в виде хеша или строки. Главное, чтобы ключ не менялся при повторной выгрузке.
Как обработать «зависшие» записи?
Периодическим заданием возвращайте записи со статусом `sending` старше заданного времени обратно в `new` и пишите об этом в лог.
Подходит ли это для других баз?
Идея универсальна, но синтаксис отличается. `ON CONFLICT` и `SKIP LOCKED` есть в PostgreSQL, в других базах используют свои аналоги.
Версия для ИИ и чтения без оформления: Markdown.