🌐 Раздел 6 · Вопрос #5

Что такое идемпотентность (idempotency)

Простыми словами: если выполнить идемпотентную операцию 1 раз или 100 раз подряд — состояние системы на сервере останется неизменным.


🟢 Junior Level

Идемпотентность (Idempotency) в программировании и веб-протоколах — это свойство операции, при котором повторный вызов одного и того же запроса с теми же параметрами приводит к точно такому же состоянию сервера, как и после первого выполнения.

Простыми словами: если выполнить идемпотентную операцию 1 раз или 100 раз подряд — состояние системы на сервере останется неизменным.

Наглядная аналогия из жизни

  • Идемпотентное действие: Нажать кнопку «Выключить свет». Если свет включен — он погаснет. Если свет уже выключен — повторные нажатия ничего не изменят, свет так и останется выключенным.
  • Неидемпотентное действие: Нажать кнопку «Переключить свет» (toggle) или «Списать 100 рублей». Каждое повторное нажатие приводит к новому действию (свет зажигается/гаснет, деньги списываются снова и снова).

Идемпотентность в HTTP (RFC 9110)

По спецификации HTTP-методы делятся по свойствам идемпотентности:

  • GET /users/42 — идемпотентен (и безопасен). Сколько бы раз мы ни читали данные, состояние сервера не меняется.
  • PUT /users/42 — идемпотентен. Запрос целиком перезаписывает данные пользователя {"name": "Ivan"}. Повторение запроса перезаписывает те же поля теми же данными.
  • DELETE /users/42 — идемпотентен. Первый запрос удаляет пользователя. Повторные запросы видят, что пользователя уже нет. Состояние сервера (пользователь отсутствует) одинаково после первого и последующих запросов.
  • POST /users — неидемпотентен. Каждый повторный вызов создает нового пользователя с новым идентификатором.

Зачем нужна идемпотентность

Компьютерные сети принципиально ненадежны. Если при вызове API клиент получил сетевую ошибку Read timed out (таймаут ответа), клиент не знает:

  1. Запрос не дошел до сервера?
  2. Сервер упал во время обработки?
  3. Сервер успешно выполнил операцию, но ответ потерялся по дороге?

Если метод идемпотентен — клиент может безопасно повторить (retry) этот же запрос, не рискуя испортить данные или создать дубликаты.


🟡 Middle Level

Различие между Safe и Idempotent методами

В спецификации HTTP (RFC 9110) строго разделяются понятия безопасности (Safety) и идемпотентности (Idempotency):

Свойство Определение Допустимость изменения состояния HTTP-методы
Safe (Безопасный) Вызов метода семантически предназначен только для чтения и не имеет права менять состояние сервера. ❌ Нет изменений GET, HEAD, OPTIONS, TRACE
Idempotent (Идемпотентный) Многократный вызов приводит к тому же состоянию сервера, что и однократный. ✅ Может менять (один раз) GET, HEAD, OPTIONS, TRACE, PUT, DELETE
Non-idempotent Каждый вызов может производить новые побочные эффекты и мутации. ✅ Каждое выполнение меняет состояние POST, PATCH (по умолчанию)

Математическая формула: $f(f(x)) = f(x)$. Все безопасные (Safe) методы заведомо являются идемпотентными, но не все идемпотентные методы безопасны (PUT и DELETE меняют состояние БД!).

Важнейший парадокс: Идемпотентность состояния vs Код ответа

На собеседованиях часто задают вопрос-ловушку:

«Первый запрос DELETE /users/42 вернул 204 No Content, а второй вернул 404 Not Found. Одинаковы ли коды ответа? Является ли метод идемпотентным?»

Правильный ответ: Да, метод строго идемпотентен! Спецификация HTTP требует идентичности состояния целевого ресурса на сервере, а не HTTP-статуса ответа или заголовков. И после первого, и после десятого вызова ресурс users/42 в системе отсутствует.

Паттерн PRG (Post/Redirect/Get) в Web

При отправке веб-форм (например, оформление заказа) браузер выполняет неидемпотентный POST. Если пользователь обновит страницу (нажмет F5), браузер предупредит о повторной отправке данных формы.

Для решения этой проблемы используется паттерн PRG:

  1. Браузер отправляет POST /orders.
  2. Сервер сохраняет заказ и отвечает редиректом: HTTP/1.1 303 See Other с заголовком Location: /orders/123.
  3. Браузер автоматически делает идемпотентный GET /orders/123.
  4. Нажатие F5 теперь повторяет только безопасный GET.

🔴 Senior Level

Реализация идемпотентности для неидемпотентного POST (Паттерн Idempotency-Key)

В финансовых сервисах (Stripe, Adyen, Tinkoff), e-commerce и микросервисах повтор сетевого запроса POST /api/v1/payments без защиты приведет к повторному списанию средств.

Стандартом индустрии (IETF Draft draft-ietf-httpapi-idempotency-key-header) является передача клиентом уникального заголовка:

POST /api/v1/payments HTTP/1.1
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
Content-Type: application/json

{
  "accountId": 1001,
  "amount": 5000
}

Архитектура и состояния машины идемпотентности

Client                     API Gateway / Filter                      Redis Cache               Business Logic
  │                                  │                                    │                          │
  ├─ POST + Idempotency-Key ────────>│                                    │                          │
  │                                  ├─ SETNX key "PROCESSING" ─────────>│                          │
  │                                  │<─ 1 (Key created, lock acquired) ──┤                          │
  │                                  ├──────────────────────────────────────────────────────────────>│
  │                                  │                                    │             (Execute TX) │
  │                                  │<─ Response (201 Created + Body) ──────────────────────────────┤
  │                                  ├─ SET key "COMPLETED" + Body ──────>│                          │
  │<─ 201 Created + Body ────────────┤                                    │                          │
  │                                  │                                    │                          │
  │── Retry POST (Same Key) ────────>│                                    │                          │
  │                                  ├─ GET key ─────────────────────────>│                          │
  │                                  │<─ "COMPLETED" + Body ──────────────┤                          │
  │<─ 201 Created + Cached Body ─────┤ (Skip Business Logic!)             │                          │

Обработка краевых сценариев (Edge Cases):

  1. Состояние PROCESSING (Параллельный дубликат): Если поступает второй запрос с тем же ключом, пока первый еще выполняется, сервер не должен выполнять операцию параллельно. Возвращается статус 409 Conflict либо 425 Too Early.
  2. Несоответствие тела запроса (Payload Hash Mismatch): Если клиент прислал тот же Idempotency-Key, но с другим телом запроса (amount: 9999 вместо 5000), сервер обязан отклонить запрос статусом 422 Unprocessable Entity или 400 Bad Request. Для этого в хранилище сохраняется SHA-256 хеш исходного тела запроса.
  3. Время жизни ключа (TTL): Записи в Redis должны иметь строгий TTL (например, 24 часа), после которого ключ инвалидируется.

Production-ready реализация на Spring Boot 3 + Redis

Ниже представлена потокобезопасная реализация фильтра идемпотентности с распределенной блокировкой:

package com.example.idempotency;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

@Component
public class IdempotencyFilter extends OncePerRequestFilter {

    private static final String IDEMPOTENCY_HEADER = "Idempotency-Key";
    private static final Duration LOCK_TIMEOUT = Duration.ofMinutes(2);
    private static final Duration CACHE_TTL = Duration.ofHours(24);

    private final StringRedisTemplate redisTemplate;

    public IdempotencyFilter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        // Проверяем идемпотентность только для мутирующих методов с переданным заголовком
        String idempotencyKey = request.getHeader(IDEMPOTENCY_HEADER);
        if (idempotencyKey == null || !"POST".equalsIgnoreCase(request.getMethod())) {
            filterChain.doFilter(request, response);
            return;
        }

        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);

        // Читаем тело запроса
        byte[] requestBody = wrappedRequest.getInputStream().readAllBytes();
        String requestHash = DigestUtils.md5DigestAsHex(requestBody);

        String redisLockKey = "idemp:lock:" + idempotencyKey;
        String redisDataKey = "idemp:data:" + idempotencyKey;

        // Атомарный захват блокировки SETNX
        Boolean acquired = redisTemplate.opsForValue().setIfAbsent(redisLockKey, requestHash, LOCK_TIMEOUT);

        if (Boolean.FALSE.equals(acquired)) {
            // Ключ уже существует. Проверяем, завершилась ли предыдущая операция
            String cachedResponse = redisTemplate.opsForValue().get(redisDataKey);
            if (cachedResponse != null) {
                // Возвращаем сохраненный результат клиенту без повторного выполнения бизнес-логики
                response.setStatus(HttpStatus.OK.value());
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                response.setHeader("X-Cache-Lookup", "HIT-IDEMPOTENT");
                response.getWriter().write(cachedResponse);
                return;
            }

            // Запрос все еще обрабатывается другим потоком/нодой
            response.setStatus(HttpStatus.CONFLICT.value());
            response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
            response.getWriter().write("{\"title\":\"Conflict\",\"detail\":\"Concurrent request in progress\"}");
            return;
        }

        try {
            // Передаем управление сервисному слою
            filterChain.doFilter(wrappedRequest, wrappedResponse);

            // Сохраняем успешный ответ в Redis
            byte[] responseBody = wrappedResponse.getContentAsByteArray();
            String responseStr = new String(responseBody, StandardCharsets.UTF_8);

            if (wrappedResponse.getStatus() < 400) {
                redisTemplate.opsForValue().set(redisDataKey, responseStr, CACHE_TTL);
            }

            wrappedResponse.copyBodyToResponse();
        } finally {
            // Освобождаем lock
            redisTemplate.delete(redisLockKey);
        }
    }
}

Идемпотентность на уровне СУБД (Database Level Idempotency)

Даже если кэш или распределенная блокировка дали сбой, уровень базы данных должен выступать гарантированным барьером против дубликатов:

  1. Уникальные составные индексы: CREATE UNIQUE INDEX uq_order_idemp ON orders (idempotency_key);
  2. UPSERT-конструкции:
    INSERT INTO payments (idempotency_key, account_id, amount, status)
    VALUES (:key, :account, :amount, 'COMPLETED')
    ON CONFLICT (idempotency_key) DO NOTHING;
    
  3. Transactional Outbox & Inbox Pattern: Паттерн Idempotent Consumer при вычитке сообщений из Kafka / RabbitMQ с дедупликацией по message_id в таблице processed_messages.

🎯 Шпаргалка для интервью

30-секундный ответ

Идемпотентность (RFC 9110) — это свойство операции, гарантирующее, что многократное выполнение запроса с теми же аргументами оставляет сервер в том же состоянии, что и однократное.

По спецификации:

  • Идемпотентны: GET, HEAD, OPTIONS, PUT (полная замена), DELETE (удаление).
  • Неидемпотентны: POST (создание новых сущностей), PATCH (зависит от операции, по умолчанию не гарантируется).

Идемпотентность определяется состоянием сервера, а не кодом HTTP-ответа (повторный DELETE может вернуть 404, но метод остается идемпотентным). В распределенных системах для защиты POST применяется паттерн Idempotency-Key с дедупликацией через Redis и UNIQUE-констрейнты в СУБД.

4 каверзных вопроса с ответами

  1. Если первый вызов DELETE возвращает 200/204, а второй 404, почему метод считается идемпотентным? Ответ: Спецификация HTTP определяет идемпотентность через неизменность состояния ресурсов на сервере, а не идентичность заголовков и статус-кодов ответов. После первого вызова ресурс удален; после второго вызова состояние сервера то же самое — ресурса нет.

  2. Чем отличаются Safe и Idempotent методы? Ответ: Безопасные (Safe) методы (GET, HEAD) вообще не имеют права изменять состояние ресурсов (read-only). Идемпотентные методы (PUT, DELETE) могут изменять состояние сервера, но только при первом применении; повторные вызовы не производят дополнительных модификаций. Любой Safe-метод идемпотентен, но не наоборот.

  3. Что делать, если пришел запрос с тем же Idempotency-Key, но с другим телом (payload)? Ответ: Это нарушение протокола интеграции. Сервер обязан вычислить хеш (SHA-256) пришедшего тела, сравнить с хешем сохраненного ключа и при несовпадении вернуть 422 Unprocessable Entity или 400 Bad Request с сообщением Idempotency key was previously used with a different request payload.

  4. Что такое Retry Storm и как идемпотентность связана с этой проблемой? Ответ: При аварии на сервере клиенты начинают лавинообразно повторять запросы (retries). Благодаря идемпотентности повторы безопасны для данных, но создают колоссальную нагрузку на инфраструктуру. Решение: Exponential Backoff с Random Jitter со стороны клиента и Rate Limiting / Circuit Breaker со стороны сервера.

Красные флаги (чего категорически нельзя говорить)

  • ❌ «Идемпотентность означает, что сервер обязан всегда возвращать одинаковый HTTP-статус код» (грубейшая ошибка: идемпотентность — это про состояние данных на сервере).
  • ❌ «POST принципиально невозможно сделать идемпотентным» (на уровне приложения реализуется через паттерн Idempotency-Key).
  • ❌ «Safe и Idempotent — это синонимы» (Safe не меняет данные вовсе, Idempotent меняет, но стабилен к повторам).
  • ❌ «PATCH всегда идемпотентен, как и PUT» (PATCH с операциями add к списку или инкрементом счетчика неидемпотентен).

Связанные темы