Что такое идемпотентность (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 (таймаут ответа), клиент не знает:
- Запрос не дошел до сервера?
- Сервер упал во время обработки?
- Сервер успешно выполнил операцию, но ответ потерялся по дороге?
Если метод идемпотентен — клиент может безопасно повторить (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:
- Браузер отправляет
POST /orders. - Сервер сохраняет заказ и отвечает редиректом:
HTTP/1.1 303 See Otherс заголовкомLocation: /orders/123. - Браузер автоматически делает идемпотентный
GET /orders/123. - Нажатие 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):
- Состояние
PROCESSING(Параллельный дубликат): Если поступает второй запрос с тем же ключом, пока первый еще выполняется, сервер не должен выполнять операцию параллельно. Возвращается статус409 Conflictлибо425 Too Early. - Несоответствие тела запроса (Payload Hash Mismatch): Если клиент прислал тот же
Idempotency-Key, но с другим телом запроса (amount: 9999вместо5000), сервер обязан отклонить запрос статусом422 Unprocessable Entityили400 Bad Request. Для этого в хранилище сохраняется SHA-256 хеш исходного тела запроса. - Время жизни ключа (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)
Даже если кэш или распределенная блокировка дали сбой, уровень базы данных должен выступать гарантированным барьером против дубликатов:
- Уникальные составные индексы:
CREATE UNIQUE INDEX uq_order_idemp ON orders (idempotency_key); - UPSERT-конструкции:
INSERT INTO payments (idempotency_key, account_id, amount, status) VALUES (:key, :account, :amount, 'COMPLETED') ON CONFLICT (idempotency_key) DO NOTHING; - 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 каверзных вопроса с ответами
-
Если первый вызов
DELETEвозвращает 200/204, а второй 404, почему метод считается идемпотентным? Ответ: Спецификация HTTP определяет идемпотентность через неизменность состояния ресурсов на сервере, а не идентичность заголовков и статус-кодов ответов. После первого вызова ресурс удален; после второго вызова состояние сервера то же самое — ресурса нет. -
Чем отличаются Safe и Idempotent методы? Ответ: Безопасные (Safe) методы (
GET,HEAD) вообще не имеют права изменять состояние ресурсов (read-only). Идемпотентные методы (PUT,DELETE) могут изменять состояние сервера, но только при первом применении; повторные вызовы не производят дополнительных модификаций. Любой Safe-метод идемпотентен, но не наоборот. -
Что делать, если пришел запрос с тем же Idempotency-Key, но с другим телом (payload)? Ответ: Это нарушение протокола интеграции. Сервер обязан вычислить хеш (SHA-256) пришедшего тела, сравнить с хешем сохраненного ключа и при несовпадении вернуть
422 Unprocessable Entityили400 Bad Requestс сообщениемIdempotency key was previously used with a different request payload. -
Что такое Retry Storm и как идемпотентность связана с этой проблемой? Ответ: При аварии на сервере клиенты начинают лавинообразно повторять запросы (retries). Благодаря идемпотентности повторы безопасны для данных, но создают колоссальную нагрузку на инфраструктуру. Решение: Exponential Backoff с Random Jitter со стороны клиента и Rate Limiting / Circuit Breaker со стороны сервера.
Красные флаги (чего категорически нельзя говорить)
- ❌ «Идемпотентность означает, что сервер обязан всегда возвращать одинаковый HTTP-статус код» (грубейшая ошибка: идемпотентность — это про состояние данных на сервере).
- ❌ «POST принципиально невозможно сделать идемпотентным» (на уровне приложения реализуется через паттерн
Idempotency-Key). - ❌ «Safe и Idempotent — это синонимы» (Safe не меняет данные вовсе, Idempotent меняет, но стабилен к повторам).
- ❌ «
PATCHвсегда идемпотентен, как иPUT» (PATCHс операциямиaddк списку или инкрементом счетчика неидемпотентен).