Какие ключи использовать для идемпотентности
Что проверяют
Понимаете ли вы, что повтор запроса неизбежен (таймаут клиента, ретрай прокси, повторная доставка из очереди) и защищаться надо на стороне сервера. Следом обычно спрашивают, что делать, если второй запрос пришёл, пока первый ещё выполняется.
Как отвечать
Ключ должен обозначать намерение клиента, а не момент прихода запроса:
- ключ от клиента: клиент генерирует UUID на каждую операцию и шлёт его в заголовке
Idempotency-Key. Повтор той же операции идёт с тем же ключом; - естественный ключ: номер заказа, id платежа у провайдера, пара «пользователь и номер счёта». Хорош, когда такой идентификатор уже есть в предметной области;
- id события в очереди: консьюмер Kafka или NATS помечает идентификатор сообщения обработанным в той же транзакции, что и результат;
- версия ресурса: условие
UPDATE ... WHERE id = $1 AND version = $2по сути оптимистичная блокировка, но повтор тоже отсекает.
Реализация на Go обычно такая: таблица idempotency_keys с уникальным ограничением на ключ (часто вместе с user_id), в начале обработки INSERT ... ON CONFLICT DO NOTHING через pgx. Вставилось, значит выполняем операцию и сохраняем код и тело ответа. Не вставилось, значит отдаём сохранённый ответ, а если результата ещё нет, возвращаем 409. Храните и хеш тела запроса: тот же ключ с другим телом это ошибка клиента, обычно 422. Ключи чистят по TTL, обычно через сутки или несколько дней. Redis с SET key value NX EX 86400 годится как быстрый фильтр, но надёжнее, когда ключ и результат лежат в одной транзакции с бизнес-данными.
Где спотыкаются
Берут временную метку как ключ. Два разных запроса могут прийти в одну миллисекунду, а повтор одного запроса придёт с другой меткой.
Сначала выполняют операцию, потом записывают ключ. Между этими шагами процесс может упасть, и повтор выполнит операцию второй раз.
Проверяют через SELECT, а затем делают INSERT без уникального ограничения. Два параллельных запроса оба увидят пустоту, и от этой гонки спасает только ограничение в базе.