Skip to main content

Що таке ідемпотентність?

Ідемпотентність (idempotence) - це властивість операції, при якій повторний виклик дає той самий кінцевий стан, що й перший.

Теорія

Коротко

  • Фарбуємо стіну в червоний: перший шар - червона стіна, другий шар - все ще червона стіна. Ось ідемпотентність.
  • PUT /balance/1 {balance: 200}, викликаний три рази, завжди повертає balance: 200.
  • POST /charge {amount: 100}, викликаний три рази, списує $300 загалом. Не ідемпотентно.
  • Правило вибору: GET, PUT, DELETE безпечні для повторних запитів. POST без додаткового захисту - ні.
  • У розподілених системах retry неминучий. Ідемпотентні операції роблять його безпечним.

Швидкий приклад

javascript
// 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, 300

PUT перезаписує. POST додає. У цьому вся різниця.

Що означає "той самий результат"

Ідемпотентність стосується фінального стану, а не HTTP-відповіді. DELETE на вже видалений ресурс поверне 404, але стан однаковий: ресурсу немає. Обидва виклики досягли одного результату.

RFC 9110 визначає ідемпотентні методи як ті, де "намірений ефект на сервері від кількох однакових запитів такий самий, як і від одного." Саме намірений ефект, не ідентична відповідь.

HTTP методи та ідемпотентність

МетодІдемпотентнийБезпечнийПримітки
GETТакТакТільки читання, стан не змінюється
HEADТакТакЯк GET, без тіла відповіді
PUTТакНіПовний перезапис, той самий payload = той самий стан
DELETEТакНіРесурс відсутній після першого виклику
POSTНіНіКожен раз створює новий запис
PATCHЗалежитьНіОперації-інкременти порушують ідемпотентність

Безпечний означає відсутність будь-яких побічних ефектів. Ідемпотентний означає, що повторення не додає нових ефектів. Кожен безпечний метод є ідемпотентним, але не навпаки. PUT змінює стан, але повторення не змінює його далі.

Пастка PATCH

Саме тут більшість розробників помиляються. PATCH може бути ідемпотентним або ні - залежить від реалізації. Я бачив, як PATCH з операцією-інкрементом проходив code review в продакшн API, а потім спричиняв тихе пошкодження даних при retry.

javascript
// НЕПРАВИЛЬНО: цей 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.

javascript
// 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-обмеження:

javascript
// Неправильно: 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 при повторному запиті

javascript
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} → [{...}, {...}, {...}], разом 300

PUT встановлює цільовий стан. POST фіксує подію. Якщо мережа повторить PUT - нічого страшного. Якщо повторить POST - клієнт заплатить тричі.

Середній: Idempotency key для POST-запитів

javascript
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: Ідемпотентний консюмер у черзі повідомлень

javascript
// 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 означає, що консюмер рано чи пізно побачить те саме повідомлення двічі. Перевірка ідемпотентності - це те, що відокремлює систему, яка обробляє це коректно, від тієї, яка відправляє клієнту два чеки.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.