13. Idempotency ключи
Ключи идемпотентности: защита от дублирования
«В распределенных системах нельзя считать сообщение полученным лишь потому, что вы его отправили. Но что куда опаснее: нельзя считать его необработанным только потому, что в ответ пришла ошибка».
— Принцип неопределенности распределенных сетей
Проблема слепых зон: Когда сеть подводит
В статье 5. HTTP методы и идемпотентность мы сформулировали базовое правило сетевых протоколов: безопасные и идемпотентные методы (GET, PUT, DELETE) можно безопасно повторять при сбоях. Если соединение разорвалось посреди передачи, балансировщик нагрузки или HTTP-клиент в Go могут автоматически отправить повторный запрос (Retry).
Но что делать с методами, которые мутируют состояние и фундаментально неидемпотентны? Классический пример — финансовые транзакции (POST /transactions) или создание заказа в интернет-магазине (POST /orders).
Представьте сценарий классической сетевой «слепой зоны»:
- Мобильное приложение отправляет запрос:
POST /transactionsсо списанием 100$. - Ваш Go-бэкенд получает сетевой пакет, открывает транзакцию в PostgreSQL, успешно списывает деньги со счета и фиксирует изменения (
COMMIT). - Бэкенд формирует успешный ответ
200 OKи отправляет его в TCP-сокет. - В этот момент на физическом уровне происходит сбой (Network Partition / Packet Loss): базовая станция сотовой связи теряет сигнал, Wi-Fi переключается, или промежуточный роутер сбрасывает TCP-сессию.
- Ответ сервера испаряется в эфире. Мобильный клиент ожидает ответа, пока не срабатывает контекстный таймаут:
context.DeadlineExceeded. - С точки зрения клиента операция не состоялась. Приложение показывает пользователю красный баннер: «Ошибка сети. Повторить попытку?».
- Пользователь нажимает кнопку, приложение добросовестно повторяет запрос — и ваш сервер списывает еще 100$!
Для любого бизнеса, связанного с деньгами, заказами или бронированием, подобный сценарий недопустим. Чтобы превратить опасный неидемпотентный метод POST в безопасную операцию, применяется паттерн Idempotency Key (Ключ идемпотентности).
Механика Idempotency Key: Контракт надежности
Идея ключей идемпотентности зародилась в платежном шлюзе Stripe и сегодня оформлена в виде проекта стандарта IETF (The Idempotency-Key HTTP Header Field).
Алгоритм взаимодействия предельно строг:
- Перед отправкой критического
POST-запроса клиент генерирует уникальный случайный идентификатор — UUIDv4 (например,123e4567-e89b-12d3-a456-426614174000). -
Клиент передает этот идентификатор в стандартном HTTP-заголовке:
HTTP Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000 -
Сервер перед выполнением операции атомарно проверяет: встречался ли этот ключ ранее.
- Если ключ новый — сервер регистрирует его, выполняет бизнес-логику и сохраняет финальный HTTP-ответ (статус, заголовки, тело) в постоянное хранилище.
- Если клиент повторяет запрос с тем же самым ключом, сервер полностью пропускает бизнес-логику (не трогает балансы в базе данных!), мгновенно извлекает сохраненный ответ первой попытки и отдает его клиенту со статусом 6. Статусы HTTP.
flowchart TD
Client["Клиент (App / Web)"] -->|"POST /pay<br>Idempotency-Key: 12345"| API["API Gateway / Go Server"]
API -->|"Проверка ключа"| Cache{"Ключ найден?"}
Cache -->|"Да"| CheckStatus{"Статус операции"}
CheckStatus -->|"Успешно завершена"| ReturnCache["Возврат сохраненного HTTP 200"]
CheckStatus -->|"В процессе"| Return409["Возврат HTTP 409 Conflict"]
ReturnCache --> Client
Return409 --> Client
Cache -->|"Нет"| Lock["Блокировка ключа<br>Статус: In Progress"]
Lock --> Logic["Бизнес-логика<br>Транзакция БД"]
Logic --> Save["Сохранение ответа<br>Статус: Completed"]
Save --> ReturnFresh["Возврат свежего HTTP 200"]
ReturnFresh --> Client
classDef proc fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
classDef fast fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
classDef slow fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#e2e8f0;
class Client,API,Cache,CheckStatus proc;
class ReturnCache,Save,ReturnFresh fast;
class Lock,Logic,Return409 slow;
Где хранить состояние: Redis vs PostgreSQL
В промышленном кластере Kubernetes работают десятки реплик вашего Go-сервиса. Хранить ключи в локальной оперативной памяти процесса (в структуре sync.Map) невозможно: повторный запрос с вероятностью 90% прилетит на соседний под. Требуется централизованное хранилище.
Подход 1: Redis (Высокая производительность)
Ключи фиксируются в Redis атомарной командой SETNX (Set if Not eXists) или через Lua-скрипты.
- Плюсы: Сверхнизкие задержки (< 1 мс). Идеально сочетается с Rate Limiting (11. Rate limiting в API), не создает нагрузку на диск реляционной БД. Автоматический TTL избавляет от необходимости писать фоновые чистильщики.
- Минусы: Риск рассинхронизации двух разных систем. Если транзакция в PostgreSQL зафиксировалась, но сеть до Redis моргнула и ответ не записался — повторный запрос приведет к повторному списанию.
Подход 2: Основная реляционная БД (PostgreSQL) — Идеально для транзакций
Таблица idempotency_keys размещается в той же базе данных, где живут счета пользователей и проводки.
- Плюсы: Абсолютная ACID-надежность. Обновление статуса ключа идемпотентности происходит в рамках той же самой транзакции базы данных, что и перевод денег. Либо все изменения фиксируются атомарно, либо откатываются вместе.
- Минусы: Дополнительные дисковые операции ввода-вывода (IOPS). Очистка старых записей требует партиционирования таблиц или периодических фоновых задач.
Race Conditions: Параллельные ретраи
Самая опасная уязвимость при реализации идемпотентности — конкурентные запросы.
Что произойдет, если пользователь в панике нажмет кнопку «Оплатить» пять раз подряд, или клиентская библиотека запустит параллельные попытки? Два запроса с абсолютно одинаковым ключом придут на бэкенд в одну и ту же миллисекунду.
Оба запроса будут приняты разными горутинами Go. Если проверка ключа построена по принципу «сначала прочитай, потом запиши» (SELECT $\to$ INSERT), обе горутины увидят, что ключа нет, и параллельно спишут средства! Это классическое состояние гонки (Race Condition / Lost Update).
Защита строится на жестких механизмах уникальности:
- На колонку
keyнакладывается первичный ключ (PRIMARY KEY) или ограничениеUNIQUE. - Вставка записи в состоянии
'in_progress'выполняется атомарно. - Вторая горутина мгновенно получает ошибку нарушения уникальности (Unique Constraint Violation).
Что сервер обязан вернуть второму запросу?
Худшая ошибка — заблокировать вторую горутину через time.Sleep или sync.Cond в надежде дождаться, пока первый запрос завершится. Это связывает системные ресурсы и забивает пул горутин.
Идиоматичный подход: Немедленно завершить обработку и вернуть клиенту статус 409 Conflict (или 425 Too Early), сигнализируя: «Предыдущий запрос с этим ключом прямо сейчас находится в обработке. Не дублируйте вызовы, подождите завершения».
Ловушки и корнер-кейсы (Gotchas)
Жизненный цикл ключа в Go (Архитектура)
Реализация идемпотентности не должна размазываться по бизнес-логике. В идиоматичном Go-коде это выносится либо в Middleware (если используется Redis), либо в паттерн Decorator для сервисного слоя (если транзакции завязаны на БД).
Пример схемы таблицы в PostgreSQL:
CREATE TABLE idempotency_keys (
idempotency_key UUID PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
http_status INT, -- Заполняется после успеха
response_body JSONB, -- Заполняется после успеха
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
TTL (Time To Live) — обязательное условие. Ключи идемпотентности не должны храниться вечно. Для большинства API срок жизни ключа составляет 24 часа. В PostgreSQL для этого можно использовать партиционирование таблиц по датам (с drop старых партиций) или фоновый процесс (Daemon) на Go, который раз в час выполняет DELETE FROM idempotency_keys WHERE expires_at < NOW().
Итог
- Идемпотентные ключи спасают бизнес-критичные методы (
POST,PATCH) от дублирования при нестабильной сети. - Имплементация требует атомарной блокировки (через Redis SETNX или Constraints в реляционных БД), чтобы избежать двойного исполнения при конкурентных ретраях.
- Обязательно защищайтесь от Payload Mismatch, хэшируя тело запроса.
- Никогда не кэшируйте технические ошибки (5xx) — это "отравит" ключ и заблокирует клиента.
Проектируя такие сложные и неявные контракты, мы должны как-то донести до потребителей нашего API (клиентов, партнеров), какие заголовки они обязаны передавать, какие статусы ожидать и как формировать тела запросов. Хранить это в Wiki — путь к рассинхрону. Индустрия решила эту проблему через строгие схемы описания. О том, как документировать контракты так, чтобы код генерировался сам, мы поговорим в следующей статье: 14. OpenAPI и Swagger.