Пять разных идемпотентностей в одном проекте
«Идемпотентность» на собесе редко спрашивают одним вопросом — обычно она всплывает в разборе конкретной ручки («а если этот запрос продублируется?»). В «Avito.Кухне» пять разных точек входа решают одну и ту же проблему пятью разными, не взаимозаменяемыми механизмами. Разложить их по полочкам — сильный ответ на любой вопрос про дублирование запросов.
1. Создание заказа: клиентский Idempotency-Key
Проблема: клиент делает POST /orders, не получает ответ вовремя (таймаут сети), повторяет запрос. Без защиты — два заказа, двойное списание остатка.
Механизм: опциональный заголовок Idempotency-Key, колонка orders.client_idempotency_key, уникальность не глобальная, а UNIQUE(user_id, client_idempotency_key):
ALTER TABLE orders ADD COLUMN client_idempotency_key text;
CREATE UNIQUE INDEX ON orders(user_id, client_idempotency_key)
WHERE client_idempotency_key IS NOT NULL;
Если ключ присутствует и заказ с таким ключом у этого user_id уже есть — возвращается существующий заказ (200), новый не создаётся. Почему не глобальная уникальность: глобальный UNIQUE(client_idempotency_key) позволил бы одному клиенту (случайно или специально) занять ключ навсегда для всех остальных пользователей — чужой повторный запрос с тем же ключом молча получил бы не свой заказ.
2. Callback от заведения: серверный Idempotency-Key + аудит-таблица
Проблема: Kafka-коллбэк статуса может продублироваться (at-least-once доставка).
Механизм: таблица order_status_events (аудит + идемпотентность), частичный уникальный индекс:
CREATE UNIQUE INDEX ON order_status_events(order_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
Establishment-service формирует детерминированный ключ "{kitchen_order_id}:{status}" — не случайный. Разница видна сразу на двух подходах к генерации ключа:
func idempotencyKey() string {
return uuid.New().String()
}
// каждый вызов /advance с тем же action
// получает НОВЫЙ ключ - уникальный индекс
// не увидит связи между попытками, дубль
// события возможен при повторе после 502func idempotencyKey(orderID, status string) string {
return fmt.Sprintf("%s:%s", orderID, status)
}
// повторный /advance {"action":"accept"} на тот
// же заказ всегда возвращает тот же ключ -
// уникальный индекс сам не даёт вставить дубльПовторный вызов /advance с тем же действием на том же заказе всегда даёт тот же ключ, значит повторная вставка события в order_status_events с тем же (order_id, idempotency_key) конфликтует с уникальным индексом и не создаёт дубль.
3. Смена статуса: самоидемпотентный переход (без ключа вообще)
Механизмы 1 и 2 защищают от последовательных повторов через ключ. Они не защищают от параллельных запросов без ключа — два одновременных rejected-коллбэка на один заказ без Idempotency-Key оба успевают прочитать текущий статус до того, как любой из них зафиксирует изменение, и оба проходят валидацию перехода. Результат — остаток возвращается дважды.
Механизм: SELECT status FROM orders WHERE id = $1 FOR UPDATE перед любой проверкой допустимости перехода:
order, _ := repo.GetForUpdate(ctx, orderID) // держит блокировку строки
if order.Status == newStatus {
return order, nil // повтор того же статуса — no-op, без побочных эффектов
}
if !domain.CanTransition(order.Status, newStatus) {
return domain.Order{}, domain.ErrInvalidTransition
}
// применяем переход + событие + компенсация остатка — всё внутри той же
// транзакции, что держит блокировкуЭто идемпотентность по построению, не по ключу: если запрошенный статус совпадает с текущим — no-op, вне зависимости от того, был ли передан Idempotency-Key. Именно этот механизм защищает и от параллельных коллбэков, и от параллельных POST /orders/{id}/cancel одного и того же заказа — оба случая сводятся к «два одновременных запроса пытаются применить один и тот же переход».
Механизм 2 (ключ + уникальный индекс) и механизм 3 (блокировка + сравнение статуса) решают разные срезы одной проблемы: ключ отличает «два разных легитимных события» от «дубль одного и того же события», блокировка защищает от гонки за применение одного и того же перехода. Оба нужны одновременно, ни один не заменяет другой.
4. Kafka consumer в kitchen-service: at-least-once через commit-after-success
Механизм: offset коммитится после успешной обработки сообщения, не до:
isDomainErr, err := handler(ctx, msg.Key, msg.Value)
if err != nil {
if isDomainErr {
toDLQ(ctx, msg, err)
// только после успешной публикации в DLQ — коммит offset
}
// транзиентная ошибка — retry с backoff, offset не коммитится
return
}
consumer.CommitMessages(ctx, msg) // offset двигается только тутЕсли процесс падает после того, как переход статуса применился в БД, но до commit offset — при рестарте сообщение придёт снова. Это at-least-once, не at-most-once (сообщение может обработаться больше одного раза, но никогда не потеряется молча). Повторная обработка безопасна — она попадает ровно в механизм 3 (сравнение order.Status == newStatus → no-op).
5. /advance в establishment-service: идемпотентный повтор как путь восстановления после 502
Если PublishStatusUpdate падает, локальный статус в establishment-service уже применён (не откатывается), но HTTP-ответ — 502 publish_failed, не 200. Человек, увидевший 502 в демо-интерфейсе, просто жмёт кнопку ещё раз. Повторный /advance с тем же action:
- не меняет локальный статус повторно (уже равен целевому — гейт из пункта, аналогичного механизму 3, только на стороне establishment-service);
- пробует публикацию заново с тем же детерминированным idempotency_key из пункта 2 — то есть даже если первая попытка успела дойти до Kafka, но ответ потерялся, повторная публикация не создаёт дублирующее событие на стороне kitchen.
Итоговая таблица
| Точка входа | От чего защищает | Механизм |
|---|---|---|
POST /orders |
Дубль заказа при повторном клиентском запросе | Idempotency-Key + UNIQUE(user_id, key) |
| Kafka-коллбэк статуса | Дубль события при повторной доставке | Детерминированный Idempotency-Key + UNIQUE(order_id, key) |
| Любая смена статуса | Гонка параллельных запросов без ключа | SELECT ... FOR UPDATE + сравнение с текущим статусом |
| Kafka consumer (kitchen) | Потеря сообщения при падении процесса | Commit offset после успешной обработки (at-least-once) |
/advance (establishment) |
502 оставляет систему в непонятном состоянии |
Повтор того же action безопасен по построению (детерминированный ключ + гейт по локальному статусу) |
Готовый ответ на «а как ты вообще думал про идемпотентность в проекте» — не «добавил Idempotency-Key», а именно эта таблица: разные точки входа, разные угрозы, разные (и при этом совместимые) механизмы.