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

Почему повторные попытки создают дубликаты (и почему идемпотентность важна)
Повторы происходят даже когда ничто явно не «сломано». Клиент получает таймаут, пока сервер всё ещё работает. Мобильное соединение прерывается — приложение повторяет запрос. Процесс-раннер получает 502 и автоматически отправляет тот же запрос снова. При доставке «как минимум один раз» (обычно для очередей и вебхуков) дубликаты — нормальное явление.
Именно поэтому идемпотентность важна: повторные запросы должны приводить к тому же конечному результату, что и одиночный запрос.
Несколько терминов легко перепутать:
- Safe: вызов не меняет состояние (например, чтение).
- Идемпотентный: многократные вызовы дают тот же эффект, что и один вызов.
- At-least-once: отправитель повторяет попытки, пока запрос «не сработает», поэтому приёмник должен уметь обрабатывать дубликаты.
Без идемпотентности повторы могут нанести реальный вред. Платёжный эндпоинт может списать деньги дважды, если первый платёж прошёл, но ответ не дошёл до клиента. Эндпоинт импорта может создать дубли строк при повторной попытке после таймаута. Вебхук-хендлер может обработать одно и то же событие дважды и отправить два письма.
Главная мысль: идемпотентность — это контракт API, а не приватная деталь реализации. Клиентам нужно знать, что можно повторять, какой ключ отправлять и какой ответ ожидать при обнаружении дубликата. Если вы молча меняете поведение, вы ломаете логику повторов и создаёте новые сценарии отказа.
Идемпотентность не заменяет мониторинг и сверку данных. Отслеживайте долю дубликатов, логируйте решения о «воспроизведении» и периодически сверяйте внешние системы (например, платёжного провайдера) с вашей базой.
Выберите область действия идемпотентности и правила для каждого эндпоинта
Прежде чем добавлять таблицы или middleware, решите, что значит «тот же запрос» и какое обещание даёт сервер при повторе.
Большинство проблем возникает на POST, потому что он часто что-то создаёт или вызывает сайд-эффект (списание, отправка сообщения, запуск импорта). PATCH тоже может требовать идемпотентности, если он вызывает сайд-эффекты, а не просто обновляет поле. GET не должен менять состояние.
Определите область: где ключ уникален
Выберите область, которая соответствует вашим бизнес-правилам. Слишком широкая область блокирует легитимные действия. Слишком узкая позволяет дубликаты.
Распространённые области:
- По эндпоинту + по клиенту/покупателю
- По эндпоинту + внешнему объекту (например, invoice_id или order_id)
- По эндпоинту + по арендаторам (tenant) в мультиарендных системах
- По эндпоинту + способу оплаты + сумме (только если бизнес-правила это допускают)
Пример: для эндпоинта «Создать платёж» сделайте ключ уникальным на уровне клиента. Для «Принять событие вебхука» ограничьте область ID события провайдера (глобальная уникальность от провайдера).
Решите, что возвращать при дубликатах
Когда приходит дубликат, верните тот же исход, что и при первой успешной попытке. На практике это значит воспроизвести тот же HTTP-статус и то же тело ответа (или по крайней мере тот же ID ресурса и состояние).
Клиенты зависят от этого. Если первый запрос прошёл, но с сетью что-то случилось, повторный запрос не должен создавать второй платёж или вторую задачу импорта.
Выберите окно хранения ключей
Ключи должны истекать. Храните их достаточно долго, чтобы покрыть реалистичные повторы и отложенные задания.
- Платежи: обычно 24–72 часа.
- Импорты: неделя может быть разумной, если пользователи повторяют позже.
- Вебхуки: подстраивайтесь под политику повтора провайдера.
Определите «тот же запрос»: явный ключ vs хеш тела
Явный ключ идемпотентности (в заголовке или поле) обычно самый чистый вариант.
Хеш тела может служить подстраховкой, но ломается от безобидных изменений (порядок полей, пробельные символы, метки времени). Если используете хеширование, нормализуйте вход и строго укажите, какие поля учитываются.
Ключи идемпотентности: как это работает на практике
Ключ идемпотентности — это простой контракт между клиентом и сервером: «Если увидите этот ключ снова — считайте это тем же запросом». Это один из самых практичных инструментов для устойчивых к повторам API.
Ключ может быть сгенерирован на любой стороне, но чаще его генерирует клиент. Клиент знает, когда он повторяет одно и то же действие, и может переиспользовать ключ между попытками. Серверные ключи полезны, когда вы сначала создаёте «черновик» ресурса (например, задачу импорта) и затем позволяете клиентам повторять, указывая ID этой задачи, но они не помогают с самым первым запросом.
Используйте случайную, непредсказуемую строку. Стремитесь к минимуму 128 бит энтропии (например, 32 hex-символа или UUID). Не собирайте ключи из временных меток или ID пользователя.
На сервере храните ключ с контекстом, чтобы выявлять неправильное использование и воспроизводить исходный результат:
- Кто сделал вызов (account или user ID)
- К какому эндпоинту или операции он относится
- Хеш важных полей запроса
- Текущий статус (в процессе, успешно, с ошибкой)
- Ответ, который нужно воспроизвести (код и тело)
Ключ обычно имеет область, как правило per-user (или per API token) + endpoint. Если тот же ключ приходит с другим payload, отвергайте его с понятной ошибкой. Это предотвращает случайные коллизии, когда багнутый клиент отправляет новую сумму платежа с уже использованным ключом.
При воспроизведении возвращайте тот же результат, что и при первой успешной попытке: тот же HTTP-код и то же тело ответа, а не свежее чтение, которое могло измениться.
Таблицы дедупликации в PostgreSQL: простой и надёжный паттерн
Выделенная таблица дедупликации — один из самых простых способов реализовать идемпотентность. Первый запрос создаёт запись с ключом. Каждая повторная попытка читает ту же запись и возвращает сохранённый результат.
Что хранить
Держите таблицу небольшой и фокусированной. Частая структура:
key: ключ идемпотентности (text)owner: кто владеет ключом (user_id, account_id или ID API клиента)request_hash: хеш важных полей запросаresponse: итоговое тело ответа (обычно JSON) или ссылка на сохранённый результатcreated_at: когда ключ впервые увидели
Уникальное ограничение — ядро паттерна. Обеспечьте уникальность по (owner, key), чтобы один клиент не мог создавать дубликаты, а два разных клиента не пересекались.
Также храните request_hash, чтобы обнаруживать неправильное использование ключа. Если повтор приходит с тем же ключом, но другим хешем, возвращайте ошибку вместо смешивания двух разных операций.
Хранение и индексация
Строки дедупликации не должны жить вечно. Храните их достаточно долго, затем очищайте.
Для скорости при высокой нагрузке:
- Уникальный индекс по
(owner, key)для быстрого вставления или поиска - Дополнительный индекс по
created_at, чтобы очистка была дешёвой
Если ответ большой, храните указатель (например, result ID), а полный payload держите отдельно. Это уменьшит раздувание таблицы, сохранив поведение при повторах.
Пошаговый поток обработки устойчивого к повторам хендлера в Go
Хендлер, устойчивый к повторным попыткам, нуждается в двух вещах: стабильном способе идентифицировать «тот же запрос снова» и надёжном месте, где хранить первый результат, чтобы его можно было воспроизвести.
Практичный поток для платежей, импортов и приёма вебхуков:
-
Валидируйте запрос, затем получите три значения: ключ идемпотентности (из заголовка или поля), владельца (tenant или user ID) и хеш запроса (хеш важных полей).
-
Начните транзакцию в базе и попытайтесь создать запись в таблице дедупликации. Сделайте уникальность по
(owner, key). Сохранитеrequest_hash, статус (started, completed) и заглушки для ответа. -
Если вставка конфликтует, загрузите существующую строку. Если она завершена — верните сохранённый ответ. Если она в процессе — либо подождите короткое время (простое опрашивание), либо верните 409/202, чтобы клиент попытался позже.
-
Только когда вы успешно «захватили» строку дедупликации, выполните бизнес-логику один раз. Пишите сайд-эффекты внутри той же транзакции, когда это возможно. Сохраните результат бизнес-работы и HTTP-ответ (код и тело).
-
Коммит, и логируйте ключ идемпотентности и владельца, чтобы служба поддержки могла отследить дубликаты.
Минимальный шаблон таблицы:
create table idempotency_keys (
owner_id text not null,
idem_key text not null,
request_hash text not null,
status text not null,
response_code int,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (owner_id, idem_key)
);
Пример: эндпоинт «Создать выплату» таймаутит после списания. Клиент повторяет с тем же ключом. Ваш хендлер ловит конфликт, видит завершённую запись и возвращает исходный payout ID без повторного списания.
Платежи: списывайте ровно один раз, даже при таймаутах
Платежи — область, где идемпотентность обязательна. Сети падают, мобильные приложения повторяют, а шлюзы иногда таймаутят, хотя платёж уже создан.
Практическое правило: ключ идемпотентности защищает создание списания, а ID провайдера (charge/intent ID) становится источником истины после этого. Как только вы сохранили provider ID, не создавайте нового списания для того же запроса.
Паттерн, который учитывает повторы и неопределённость шлюза:
- Прочитайте и проверьте ключ идемпотентности.
- В транзакции базы создайте или получите строку платежа, ключём которой служит
(merchant_id, idempotency_key). Если там уже естьprovider_id, верните сохранённый результат. - Если
provider_idотсутствует, вызовите шлюз для создания PaymentIntent/Charge. - Если шлюз вернул успех — сохраните
provider_idи пометьте платёж как «succeeded» (или «requires_action»). - Если шлюз таймаутит или возвращает неопределённый результат — сохраните статус «pending» и верните клиенту предсказуемый ответ, который позволяет безопасно повторять попытку.
Ключевой момент — как вы относитесь к таймаутам: не считайте их автоматически ошибкой. Пометьте платёж как pending, затем подтвердите состояние у шлюза позже (или через вебхук), используя provider ID, как только он станет доступен.
Ошибки возвращайте предсказуемо. Клиенты строят логику повторов вокруг того, что вы возвращаете, поэтому держите коды статусов и форму ошибок стабильными.
Импорты и батчевые эндпоинты: дедуп без потери прогресса
Импорты — зона, где дубликаты особенно вредны. Пользователь загружает CSV, сервер таймаутит на 95%, и он повторяет. Без плана вы либо создадите дубли, либо заставите пользователя начинать заново.
Для батчевой работы думайте в двух слоях: задание импорта и элементы внутри него. Идемпотентность на уровне задания не даст создать несколько одинаковых заданий. Идемпотентность на уровне строки не даст применить одну и ту же строку дважды.
Паттерн на уровне задания: требуйте ключ идемпотентности для каждого запроса импорта (или выводите его из стабильного хеша + user ID). Сохраняйте его вместе с записью import_job и возвращайте тот же job ID при повторах. Хендлер должен уметь сказать: «я уже видел это задание, вот его текущее состояние», а не «начать заново».
Для дедупа строк полагайтесь на естественный ключ, уже присутствующий в данных. Например, каждая строка может содержать external_id из источника, или стабильную комбинацию (account_id, email). Принудите это уникальным ограничением в PostgreSQL и используйте UPSERT, чтобы повторы не создавали дубликаты.
Перед релизом решите, что делает воспроизведение, если строка уже существует. Будьте явными: пропустить, обновить конкретные поля или вернуть ошибку. Избегайте «слияния» (merge), если у вас нет очень чётких правил.
Частичный успех — нормален. Вместо единого «ок» или «ошибка» храните результат по строкам, привязанный к заданию: номер строки, естественный ключ, статус (created, updated, skipped, error) и сообщение об ошибке. При повторах вы можете безопасно повторить обработку, сохраняя результаты для уже завершённых строк.
Чтобы сделать импорт возобновляемым, добавьте контрольные точки. Обрабатывайте страницами (например, по 500 строк), сохраняйте курсор последней обработанной страницы (индекс строки или исходный курсор) и обновляйте его после коммита каждой страницы. Если процесс упадёт, следующая попытка возобновит работу с последней контрольной точки.
Приём вебхуков: дедуп, валидация, затем безопасная обработка
Отправители вебхуков повторяют отправки. Они также присылают события не по порядку. Если ваш хендлер обновляет состояние при каждой доставке, рано или поздно вы создадите дубли записей, отправите два письма или дважды спишете деньги.
Начните с выбора лучшего ключа дедупа. Если провайдер даёт уникальный event ID — используйте его. Только если ID нет —fallback к хешу payload.
Безопасность прежде всего: проверьте подпись до того, как принимать что-либо. Если подпись не проходит, отвергните запрос и не пишите запись в таблицу дедупа. Иначе злоумышленник может «зарезервировать» ID события и блокировать реальные события позже.
Безопасный поток при повторах:
- Проверьте подпись и базовую форму (обязательные заголовки, event ID).
- Вставьте event ID в таблицу дедупа с уникальным ограничением.
- Если вставка падает из‑за дубля — верните 200 сразу.
- Сохраняйте сырое тело (и заголовки), если это полезно для аудита и отладки.
- Поставьте задачу на обработку асинхронно и верните 200 быстро.
Быстрое подтверждение важно, потому что у многих провайдеров короткие таймауты. Выполняйте минимально необходимую работу в запросе: валидация, дедуп, сохранение. Затем обрабатывайте асинхронно (воркер, очередь, фоновая задача). Если не можете делать асинхронно, держите обработку идемпотентной, привязывая внутренние сайд-эффекты к тому же event ID.
Доставки вне порядка — обычное дело. Не предполагая, что «created» приходит раньше «updated», используйте UPSERT по внешнему ID объекта и отслеживайте последнее обработанное событие по таймстампу или версии.
Хранение сырых payload помогает, когда клиент говорит «мы не получили обновление». Вы сможете повторно проиграть обработку из сохранённого тела после исправления бага, без запроса к провайдеру о повторной отправке.
Параллелизм: как оставаться корректным при одновременных запросах
Повторы усложняются, когда два запроса с одним и тем же ключом приходят одновременно. Если оба хендлера доходят до шага «выполнить работу» до того, как кто‑то сохранил результат, вы всё ещё можете получить двойное списание, двойной импорт или двойную постановку в очередь.
Самая простая точка координации — транзакция базы данных. Сделайте первым шагом «захват ключа», и пусть СУБД решает, кто выиграл. Распространённые варианты:
- Уникальная вставка в таблицу дедупа (СУБД обеспечивает одного победителя)
SELECT ... FOR UPDATEпосле создания (или поиска) строки дедупа- Транзакционные advisory locks, ключённые по хешу ключа идемпотентности
- Уникальные ограничения на бизнес-таблице в качестве последнего барьера
Для длительной работы избегайте удержания блокировок строк, пока вы зовёте внешние системы или выполняете минутные импорты. Вместо этого храните маленькую машину состояний в строке дедупа, чтобы другие запросы могли быстро завершиться.
Практический набор состояний:
in_progressсstarted_atcompletedс закешированным ответомfailedс кодом ошибки (опционально, в зависимости от политики повторов)expires_at(для очистки)
Пример: два инстанса приложения получают один и тот же платёжный запрос. Экземпляр A вставляет ключ и ставит in_progress, затем вызывает провайдера. Экземпляр B попадает на конфликт, читает запись дедупа, видит in_progress и возвращает быстрый ответ «всё ещё обрабатывается» (или ждёт короткое время и проверяет снова). Когда A завершит, он обновит строку в completed и сохранит тело ответа, чтобы последующие повторы получили точно тот же результат.
Распространённые ошибки, которые ломают идемпотентность
Большинство багов с идемпотентностью — это не про хитрые блокировки, а про «почти правильные» решения, которые проваливаются при повторах, таймаутах или действиях двух пользователей.
Одна частая ловушка — считать ключ глобально уникальным. Если вы не ограничиваете его областью (по пользователю, аккаунту или эндпоинту), два разных клиента могут столкнуться и получить чужой результат.
Ещё одна проблема — принимать тот же ключ с другим телом. Если первый вызов был на $10, а повтор — на $100, вы не должны молча возвращать первый результат. Храните хеш запроса (или ключевые поля), сравнивайте при повторе и возвращайте понятную ошибку конфликта.
Клиенты путаются, когда воспроизведение возвращает другую форму ответа или код статуса. Если первый вызов вернул 201 с JSON, повтор должен вернуть то же тело и тот же код. Изменение поведения при воспроизведении заставляет клиентов гадать.
Ошибки, которые часто приводят к дубликатам:
- Полагаться только на in-memory map или кэш, теряя состояние при перезапуске.
- Использовать ключ без области (коллизии между пользователями/эндпоинтами).
- Не валидировать несоответствие payload для одного ключа.
- Делать сайд-эффект первым (списать, вставить, опубликовать), а запись дедупа — после.
- Возвращать новый сгенерированный ID при каждой повторной попытке вместо воспроизведения исходного результата.
Кэш ускоряет чтения, но источником истины должна быть долговременная хранилище (обычно PostgreSQL). Иначе повторы после деплоя могут создать дубликаты.
Также планируйте очистку. Если хранить все ключи навсегда, таблицы растут, индексы замедляются. Задайте окно хранения по реальному поведению повторов, удаляйте старые записи и держите уникальный индекс компактным.
Быстрый чеклист и следующие шаги
Рассматривайте идемпотентность как часть контракта API. Каждый эндпоинт, который может быть повторён клиентом, очередью или шлюзом, нуждается в чётком правиле, что значит «тот же запрос» и как выглядит «тот же результат».
Чеклист перед релизом:
- Для каждого эндпоинта, подверженного повторам, определена ли область (per user, per account, per order, per external event) и задокументирована?
- Осуществляется ли дедуп через базу данных (уникальное ограничение на ключ и область), а не только «проверяется в коде»?
- При воспроизведении возвращаете ли вы тот же код и тело ответа (или документированное стабильное подмножество), а не свежий объект с новым таймстампом?
- Для платежей: корректно ли вы обрабатываете неопределённые исходы (таймаут при отправке, шлюз отвечает «processing»), не создавая двойных списаний?
- Делаю ли ялоги и метрики очевидным, когда запрос впервые увиден, а когда — воспроизведён?
Если хоть один пункт «возможно», исправьте это сейчас. Большинство проблем проявляются под нагрузкой: параллельные повторы, медленные сети и частичные отказы.
Если вы строите внутренние инструменты или клиентские приложения на AppMaster (appmaster.io), имеет смысл заложить ключи идемпотентности и таблицу дедупликации PostgreSQL на раннем этапе. Тогда даже если платформа регенерирует Go‑бэкенд при изменении требований, поведение при повторах останется согласованным.
Вопросы и ответы
Повторы — это нормальная часть работы сетей и клиентов. Запрос может успешно выполниться на сервере, но ответ не дошёл до клиента, и тогда клиент повторяет запрос — если сервер не умеет распознавать и воспроизводить исходный результат, работа выполнится дважды.
Отправляйте один и тот же ключ при каждой повторной попытке одного и того же действия. Лучше, если ключ генерирует клиент: случайная, непредсказуемая строка (например, UUID). Не используйте один и тот же ключ для разных действий.
Сделайте область действия ключа совпадающей с бизнес-правилом — обычно: endpoint + идентичность вызвавшего (user, account, tenant или API-токен). Это предотвращает коллизии между разными клиентами.
Возвращайте тот же результат, что и при первом успешном вызове. На практике это значит воспроизведение того же HTTP-кода и тела ответа, или как минимум того же ID ресурса и состояния, чтобы клиент мог безопасно повторять запросы.
Отклоняйте такие случаи с понятной ошибкой конфликта. Сравнивайте хеш значимых полей запроса: если ключ совпадает, а полезная нагрузка — нет, прервите и сообщите об ошибке, чтобы не смешивать разные операции под одним ключом.
Храните ключи достаточно долго, чтобы покрыть реалистичные повторные попытки, затем удаляйте. Часто используют 24–72 часа для платежей, неделю для импортов; для вебхуков можно ориентироваться на политику повторных отправок поставщика.
Выделенная таблица дедупликации удобна: СУБД гарантирует уникальность, данные выживают после перезапуска. Храните область (owner), ключ, хеш запроса, статус и ответ для воспроизведения; делайте уникальность по (owner, key).
Сначала «захватите» ключ внутри транзакции, а затем выполняйте сайд-эффект только если вы успешно его получили. При параллельном запросе другой экземпляр увидит in_progress или completed и вернёт ответ ожидания/воспроизведения вместо повторного выполнения логики.
Таймаут от шлюза — это «неизвестный» результат, а не автоматический провал. Отмечайте платёж как pending и, если у вас есть provider ID, используйте его как источник истины: проверяйте состояние позже или обрабатывайте вебхуком, чтобы не создавать дубликатов.
Делайте дедупликацию на двух уровнях: уровень задания и уровень строки. Возвращайте тот же ID задания при повторах; для строк используйте естественный ключ (external_id или комбинацию (account_id, email)) с уникальным ограничением или UPSERT, чтобы повторная обработка не создавала дубликаты.


