Що таке ідемпотентність?
Ідемпотентність (idempotence) - це властивість операції, при якій повторний виклик дає той самий кінцевий стан, що й перший.
Теорія
Коротко
- Фарбуємо стіну в червоний: перший шар - червона стіна, другий шар - все ще червона стіна. Ось ідемпотентність.
PUT /balance/1 {balance: 200}, викликаний три рази, завжди повертаєbalance: 200.POST /charge {amount: 100}, викликаний три рази, списує $300 загалом. Не ідемпотентно.- Правило вибору: GET, PUT, DELETE безпечні для повторних запитів. POST без додаткового захисту - ні.
- У розподілених системах retry неминучий. Ідемпотентні операції роблять його безпечним.
Швидкий приклад
// PUT - ідемпотентний: встановлює стан абсолютно
app.put('/balance/:id', (req, res) => {
const newBalance = req.body.balance; // {balance: 200}
userBalance = newBalance; // Перезаписує, результат однаковий кожен раз
res.json({ balance: userBalance });
});
// POST - не ідемпотентний: накопичує стан
app.post('/charge', (req, res) => {
total += req.body.amount; // +100 кожен раз: 100, 200, 300...
res.json({ total });
});
// Три однакових PUT → balance: 200, 200, 200
// Три однакових POST → total: 100, 200, 300PUT перезаписує. POST додає. У цьому вся різниця.
Що означає "той самий результат"
Ідемпотентність стосується фінального стану, а не HTTP-відповіді. DELETE на вже видалений ресурс поверне 404, але стан однаковий: ресурсу немає. Обидва виклики досягли одного результату.
RFC 9110 визначає ідемпотентні методи як ті, де "намірений ефект на сервері від кількох однакових запитів такий самий, як і від одного." Саме намірений ефект, не ідентична відповідь.
HTTP методи та ідемпотентність
| Метод | Ідемпотентний | Безпечний | Примітки |
|---|---|---|---|
| GET | Так | Так | Тільки читання, стан не змінюється |
| HEAD | Так | Так | Як GET, без тіла відповіді |
| PUT | Так | Ні | Повний перезапис, той самий payload = той самий стан |
| DELETE | Так | Ні | Ресурс відсутній після першого виклику |
| POST | Ні | Ні | Кожен раз створює новий запис |
| PATCH | Залежить | Ні | Операції-інкременти порушують ідемпотентність |
Безпечний означає відсутність будь-яких побічних ефектів. Ідемпотентний означає, що повторення не додає нових ефектів. Кожен безпечний метод є ідемпотентним, але не навпаки. PUT змінює стан, але повторення не змінює його далі.
Пастка PATCH
Саме тут більшість розробників помиляються. PATCH може бути ідемпотентним або ні - залежить від реалізації. Я бачив, як PATCH з операцією-інкрементом проходив code review в продакшн API, а потім спричиняв тихе пошкодження даних при retry.
// НЕПРАВИЛЬНО: цей PATCH не є ідемпотентним
app.patch('/user/:id', async (req, res) => {
// При повторному запиті: visits стає 2, потім 3, потім 4
await db.user.update({ where: { id }, data: { visits: { increment: 1 } } });
});
// Краще: використовувати PUT з абсолютним значенням
app.put('/user/:id', async (req, res) => {
const updates = req.body; // { visits: 5 } - абсолютне, не відносне
await db.user.update({ where: { id }, data: updates });
});Проблема increment: 1 - це відносна операція. Однаковий запит з однаковим payload змінює стан по-різному залежно від кількості викликів. Це робить retry небезпечним.
Idempotency key для POST
Іноді POST - правильний метод, але потрібен захист від повторних запитів. Для цього існують idempotency key.
// POST з idempotency key в стилі Stripe
app.post('/charge', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key']; // UUID від клієнта
const existing = await redis.get(`idem:${idempotencyKey}`);
if (existing) return res.json(JSON.parse(existing)); // Повертаємо збережений результат
const result = await chargeCustomer(req.body);
// Зберігаємо результат прив'язаним до цього ключа на 24 години
await redis.setex(`idem:${idempotencyKey}`, 86400, JSON.stringify(result));
res.json(result);
});Stripe саме так реалізує /v1/charges. Клієнт генерує UUID, додає його в заголовок, сервер дедуплікує. Перший виклик обробляє платіж. Кожен наступний retry отримує оригінальну відповідь. Без подвійного списання.
Типові помилки
Вважати, що PATCH завжди ідемпотентний. HTTP-специфікація каже, що PATCH може бути ідемпотентним, якщо реалізований відповідно, але нічого не гарантує. Операції-інкременти - не ідемпотентні за визначенням.
Думати, що 204 vs 404 порушує ідемпотентність DELETE. Другий DELETE, що повертає 404 замість 204, не порушує ідемпотентність. Ресурс відсутній в обох випадках. RFC 9110 про це однозначний: важливий намірений стан.
Вставка в базу без UNIQUE-обмеження:
// Неправильно: retry створює дублікати в таблиці платежів
db.query('INSERT INTO payments VALUES (?, ?)', [paymentId, amount]);
// Правильно: PostgreSQL пропускає дублікат без помилки
db.query(
'INSERT INTO payments VALUES (?, ?) ON CONFLICT (payment_id) DO NOTHING',
[paymentId, amount]
);Це класична проблема Kafka-консюмерів. Повідомлення оброблено, консюмер впав до коміту offset, при перезапуску обробляє те саме повідомлення ще раз. Без ON CONFLICT - отримаєш дублікати в таблиці.
Розраховувати на PUT в eventually-consistent системі. При replica lag два однакових PUT на різні ноди можуть тимчасово давати різний стан. Система зрештою синхронізується, але в цьому вікні ідемпотентність не гарантована. Тому CockroachDB і DynamoDB надають умовний запис (compare-and-swap).
Вважати GET завжди стабільним. GET ідемпотентний за дизайном, але /users?offset=10&limit=5 може повернути різні записи, якщо між запитами були вставки. Операція ідемпотентна, але результати не стабільні. Ключі кешу мають включати повний URL.
Де зустрічається в реальних проектах
- Stripe API: кожен
POST /v1/chargesприймає заголовокIdempotency-Key. Retry протягом 24 годин повертає оригінальну відповідь. - Kubernetes:
kubectl delete pod my-pod, викликаний двічі, залишає систему в одному стані. Другий виклик отримує 404, і це очікувано. - React Query:
useMutationзmutationKeyдозволяє бібліотеці дедуплікувати запити в польоті. - AWS DynamoDB:
PutItemперезаписує весь елемент. Однаковий payload - однаковий результат. - PostgreSQL:
INSERT ... ON CONFLICT DO NOTHINGіUPSERT- стандартні інструменти для ідемпотентних записів у навантажених системах.
Питання на співбесіді
Q: Які HTTP методи є ідемпотентними за RFC 9110?
A: GET, HEAD, PUT і DELETE. POST і PATCH не є ідемпотентними за замовчуванням, хоча PATCH можна реалізувати ідемпотентно, якщо використовувати абсолютні значення замість відносних операцій.
Q: У чому різниця між idempotent і safe в HTTP?
A: Safe означає відсутність будь-яких побічних ефектів (тільки читання). Idempotent означає, що повторні виклики не додають нових ефектів. GET - і те, і інше. PUT - ідемпотентний, але не safe. POST - ні те, ні інше.
Q: Як зробити POST безпечним для retry?
A: Додати idempotency key (UUID в заголовку), зберігати результат прив'язаним до цього UUID, при повторному запиті з тим самим ключем повертати збережений результат. Так роблять Stripe, Braintree та більшість платіжних сервісів.
Q: Чи може PUT порушити ідемпотентність у розподіленій системі?
A: Так. При eventual consistency replica lag може призвести до того, що два однакових PUT на різні ноди тимчасово дадуть різний стан. Умовний запис через ETag або compare-and-swap вирішує це ціною складності.
Q (senior): Спроектуй ідемпотентний лічильник для шардованої бази даних.
A: Зберігай поточне значення на клієнті і використовуй PUT з абсолютним цільовим значенням замість відносного інкременту. Якщо потрібен саме інкремент, обгортай у транзакцію з унікальним operation ID: перевіряй чи вже застосовано, пропускай якщо так. Це outbox pattern для лічильників.
Приклади
Базовий: PUT проти POST при повторному запиті
const express = require('express');
const app = express();
app.use(express.json());
let userBalance = 100;
const charges = [];
// Ідемпотентний: завжди встановлює задане значення
app.put('/balance/:id', (req, res) => {
userBalance = req.body.balance;
res.json({ balance: userBalance });
});
// Не ідемпотентний: записує новий платіж кожен раз
app.post('/charge', (req, res) => {
charges.push({ amount: req.body.amount, at: Date.now() });
res.json({ charges });
});
// Три PUT /balance/1 {balance: 200} → завжди {balance: 200}
// Три POST /charge {amount: 100} → [{...}, {...}, {...}], разом 300PUT встановлює цільовий стан. POST фіксує подію. Якщо мережа повторить PUT - нічого страшного. Якщо повторить POST - клієнт заплатить тричі.
Середній: Idempotency key для POST-запитів
const express = require('express');
const app = express();
app.use(express.json());
const processedKeys = new Map(); // У продакшені: Redis з TTL
app.post('/payment', async (req, res) => {
const idempotencyKey = req.headers['x-idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Заголовок x-idempotency-key обов\'язковий' });
}
// Повертаємо збережений результат, якщо вже бачили цей ключ
if (processedKeys.has(idempotencyKey)) {
return res.json(processedKeys.get(idempotencyKey));
}
const result = {
paymentId: `pay_${Date.now()}`,
amount: req.body.amount,
status: 'charged'
};
processedKeys.set(idempotencyKey, result);
res.json(result);
});
// Клієнт: POST /payment з заголовком x-idempotency-key: uuid-abc-123
// Retry з тим самим ключем → той самий paymentId, без подвійного списання
// Новий ключ → новий запис про платіжКлючова деталь: клієнт сам генерує idempotency key. Якщо клієнт не знає, чи дійшов перший запит до сервера, він надсилає той самий ключ ще раз. Дедуплікацією займається сервер.
Senior: Ідемпотентний консюмер у черзі повідомлень
// Kafka-консюмер - повідомлення може прийти більше одного разу (at-least-once)
async function processPaymentMessage(message) {
const { paymentId, amount, userId } = JSON.parse(message.value);
// Використовуємо paymentId як природний idempotency key
const existing = await db.payments.findUnique({ where: { paymentId } });
if (existing) {
console.log(`Пропускаємо дублікат: ${paymentId}`);
return; // Вже оброблено, комітимо offset і йдемо далі
}
await db.$transaction(async (tx) => {
await tx.payments.create({ data: { paymentId, amount, userId } });
await tx.users.update({
where: { id: userId },
data: { balance: { decrement: amount } }
});
});
}
// Без цієї перевірки: at-least-once delivery = потенційні подвійні платежі
// З цією перевіркою: at-least-once delivery стає effectively-once обробкоюЦей патерн зустрічається в кожній системі, що використовує Kafka, SQS або RabbitMQ. Гарантія at-least-once delivery означає, що консюмер рано чи пізно побачить те саме повідомлення двічі. Перевірка ідемпотентності - це те, що відокремлює систему, яка обробляє це коректно, від тієї, яка відправляє клієнту два чеки.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.