Що таке ідемпотентність (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), 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.
4 Tricky Questions
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 з боку сервера.
🎯 Interview Cheat Sheet
30-секундна відповідь
Ідемпотентність (RFC 9110) — це властивість операції, яка гарантує, що багаторазове виконання запиту з тими самими аргументами залишає сервер у тому самому стані, що й одноразове.
За специфікацією:
- Ідемпотентні:
GET,HEAD,OPTIONS,PUT(повна заміна),DELETE(видалення).- Неідемпотентні:
POST(створення нових сутностей),PATCH(залежить від операції, за замовчуванням не гарантується).Ідемпотентність визначається станом сервера, а не кодом HTTP-відповіді (повторний
DELETEможе повернути404, але метод залишається ідемпотентним). У розподілених системах для захистуPOSTзастосовується патерн Idempotency-Key з дедуплікацією через Redis таUNIQUE-констрейнти в СУБД.
Червоні прапорці (чого категорично не можна говорити)
- ❌ «Ідемпотентність означає, що сервер зобов’язаний завжди повертати однаковий HTTP-статус код» (груба помилка: ідемпотентність — це про стан даних на сервері).
- ❌ «POST принципово неможливо зробити ідемпотентним» (на рівні застосунку реалізується через патерн
Idempotency-Key). - ❌ «Safe та Idempotent — це синоніми» (Safe не змінює дані зовсім, Idempotent змінює, але стійкий до повторів).
- ❌ «
PATCHзавжди ідемпотентний, як іPUT» (PATCHз операціямиaddдо списку або інкрементом лічильника є неідемпотентним).