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

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

Дочитали статтю?