# Защита от дублей в автопостинге: ключ, ON CONFLICT и очередь

> Дубли убирают на уровне базы, а не сценария: каждому предложению присваивают уникальный ключ и вставляют его через ON CONFLICT DO NOTHING. Очередь на отправку забирают с FOR UPDATE SKIP LOCKED, а статусы new, sending и sent позволяют безопасно повторять любой шаг.

Дата: 2026-10-30 · Источник: https://oneswitch.ru/blog/dedup-v-avtoposting-n8n-postgresql/

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

## Откуда берутся дубли

- **Повторный запуск** по расписанию или вручную.
- **Повторная доставка** вебхука: отправитель не получил ответ и прислал запрос ещё раз.
- **Пересечение источников:** одно и то же предложение приходит из двух мест.
- **Сбой посередине:** сообщение ушло, а отметка об этом не сохранилась, поэтому при повторе оно уйдёт снова.

## Шаг 1: уникальный ключ предложения

Придумайте стабильный ключ, по которому две записи считаются одним и тем же предложением. Для билетов это маршрут, дата и ценовой диапазон, для объявлений адрес и цена, для заявок идентификатор запроса.

```sql
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: вставка без дублей

```sql
INSERT INTO offers (dedup_key, payload)
VALUES ($1, $2)
ON CONFLICT (dedup_key) DO NOTHING
RETURNING id;
```

Если запись новая, запрос вернёт её `id`. Если такой ключ уже есть, вернётся пустой результат, и вы просто пропускаете предложение. Это работает без отдельной проверки «есть ли уже такое», которая сама подвержена гонкам.

## Шаг 3: очередь на отправку

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

```sql
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: статусы и повтор

После отправки отметьте результат:

```sql
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](/blog/avtoposting-v-telegram-n8n-postgresql/), а приём данных с сайта в [защите webhook в n8n](/blog/n8n-webhook-zashchita-avtorizaciya/). Такой подход использован в проектах [HotTrip](/raboty/hottrip/) и [Da Nang House](/raboty/danang-house/). Услуга: [«Контент и соцсети»](/uslugi/kontent-i-soczseti/).

## Частые вопросы

### Почему нельзя проверять дубли в сценарии?

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

### Что делать, если у предложений нет естественного ключа?

Сформируйте его из набора полей (источник, идентификатор у источника, дата) и храните в виде хеша или строки. Главное, чтобы ключ не менялся при повторной выгрузке.

### Как обработать «зависшие» записи?

Периодическим заданием возвращайте записи со статусом `sending` старше заданного времени обратно в `new` и пишите об этом в лог.

### Подходит ли это для других баз?

Идея универсальна, но синтаксис отличается. `ON CONFLICT` и `SKIP LOCKED` есть в PostgreSQL, в других базах используют свои аналоги.
