Понимание ключей идемпотентности в концах POST Node.js
Объясняется, почему запросы типа POST непредсказуемо терпят неудачу при повторной отправке, и как ключи идемпотентности, генерируемые клиентом, позволяют API Node.js безопасно обрабатывать дублирующиеся запросы.
Идемпотентность — это термин, который встречается повсюду в документации к API платежей, обычно сопровождаемый словарным определением, которое все просматривают поверхностно, не вникая в суть. Ниже приведена попытка объяснить его через вопросы, которые на самом деле задают разработчики, сталкиваясь с ним в производственном коде, а не через абстрактную версию, приведенную в учебнике.
Что на самом деле означает «идемпотентный», когда вы пишете код, а не читаете глоссарий?
Операция считается идемпотентной, когда её выполнение один раз приводит к тому же конечному состоянию, что и её выполнение пять раз подряд с абсолютно такими же входными данными. Возьмём операцию PUT /users/8/name с телом данных { "name": "Jane" }: независимо от того, вызовете ли её один раз или пять раз, имя пользователя будет «Jane», и никаких изменений не накопится. Сравните это с операцией POST /orders, предназначенной для создания нового заказа — если вызвать её пять раз, скорее всего, получится пять отдельных заказов, а не один, поскольку в самой операции нет ничего, что мешало бы их накоплению.
Почему это становится такой серьёзной проблемой именно в связи с запросами типа POST?
Метод POST обычно используется для создания новых данных, и сети имеют свою специфическую причину сбоев, которая делает это опасным: запрос может успешно обработаться на стороне сервера, в то время как клиент ничего об этом не узнает, поскольку сам ответ теряется по пути обратно. С точки зрения клиента виден только сигнал о истечении времени. У него нет возможности узнать, действительно ли запрос был выполнен, поэтому он делает единственное разумное действие — пытается снова.
// the client's perspective, roughly
async function submitOrder(payload) {
try {
return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
} catch {
return submitOrder(payload); // did the first one actually fail, or just the response?
}
}
Если конечная точка /orders не разработана с учетом таких повторных попыток, клиенту может быть выставлен двойной счет за одну покупку — причем ни одна из сторон явно не виновата. С точки зрения клиента запрос действительно сбился, но с точки зрения сервера он действительно был успешным.
Так что же необходимо, чтобы конечная точка POST в Node была действительно идемпотентной?
Традиционное решение заключается в том, что клиент генерирует уникальный ключ для каждой логической операции, присоединяет его в качестве заголовка и позволяет серверу использовать этот ключ для распознавания повторно отправленной заявки как той же самой, которую он уже обработал, вместо того чтобы рассматривать её как новую.
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
if (existing) {
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
const order = await createOrder(req.body);
await db.query(
"INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
[idempotencyKey, JSON.stringify(order), 201]
);
res.status(201).json(order);
});
Задача клиента — повторно использовать тот же ключ при повторных попытках выполнения одной и той же логической заявки; обычно это UUID, сгенерированный один раз непосредственно перед первой попыткой. Задача сервера проще: он распознаёт ключ, который уже видел ранее, и возвращает сохраненный результат вместо того, чтобы выполнять работу заново.
Кто должен генерировать ключ идемпотентности — клиент или сервер?
Это должен быть клиент, и это удивляет многих людей, потому что их инстинкт подсказывает обратное. Если бы ключ генерировался сервером, каждая попытка отправки принесла бы новый ключ, что сделало бы весь механизм бесполезным — у сервера не было бы оснований отличать повторную попытку от нового запроса. Ключ должен существовать ещё до того, как будет отправлена первая попытка, именно чтобы ту же самую ценность можно было отправить снова, если потребуется повторить попытку.
Что, если два идентичных запроса придут буквально в один и тот же момент, а не по очереди?
Именно здесь почти всегда происходят ошибки при первой попытке реализовать эту схему. Простой подход «проверить, затем вставить», описанный ранее, содержит в себе проблему конкурентного доступа: два запроса с одинаковым ключом могут выполнить свои операции SELECT, получить пустые результаты и продолжить создание заказа — тем самым полностью сводя на нет смысл использования ключа.
// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"]; try {
await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
} catch (err) {
if (err.code === "23505") { // unique constraint violation
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
throw err;
}
const order = await createOrder(req.body);
await db.query(
"UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
[JSON.stringify(order), 201, idempotencyKey]
);
res.status(201).json(order);
});
Установка уникального ограничения для столбца key переносит принятие решений с логики приложения на саму базу данных: когда сталкиваются два одновременных запроса, база данных решает, какой из них будет принят, а другой получает четкую ошибку, которую можно обработать, вместо того чтобы незаметно быть проигнорированным. Одного лишь оператора if в обработчике маршрута недостаточно для устранения этой проблемы — вопросы конкурентности такого рода необходимо решать на том уровне, который фактически сериализует доступ, а этим уровнем является база данных, а не условная проверка в коде.
Влияет ли это также на запросы типа GET?
Не таким образом, и это часто вводит людей в заблуждение. GET по своей концепции уже должен быть идемпотентным — он не должен ничего изменять, поэтому его свободное повторное выполнение по своей природе безопасно и не требует особого обработки. Паттерн idempotency-key существует именно для операций, которые создают или изменяют состояние, где небрежное повторное выполнение может удвоить эффект. Если конечная точка GET не является безопасной для многократных вызовов, настоящая проблема заключается в том, что она выполняет побочные эффекты, которые вообще не должны происходить согласно семантике GET.
Как долго должен оставаться действительным ключ идемпотентности?
В идеале достаточно долго, чтобы покрыть реалистичные сценарии повторных попыток, но не настолько долго, чтобы хранимые ключи накапливались бесконечно. Многие платежные платформы выбирают срок от 24 часов до нескольких дней. Затем запланированная задача по очистке может удалить истекшие записи:
await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");
Если окно слишком узкое, попытка повторной обработки, отложенная по уважительной причине — например, телефон клиента потерял сигнал на десять минут во время оформления покупки — может не укладываться в это окно и привести к появлению настоящего дубликата. Если же окно оставить открытым вечно, таблица будет продолжать расти без какой-либо реальной пользы.
Стоит ли беспокоиться об этом подходе только в системах оплаты?
Именно с оплатами люди обычно впервые усваивают этот урок, причем чаще всего из-за дублирующихся списаний, которые приводят к тому, что клиент уже через час отправляет разгневанное письмо. Однако коренная проблема — невозможность клиента отличить ситуацию «моя заявка провалилась» от ситуации «моя заявка была принята, но я так и не получил ответа» — проявляется в любой ситуации, где есть побочные эффекты: отправка письма, запуск webhook-запроса, создание новой учетной записи, начало выполнения фоновой задачи. Любая операция, при которой возможен повторный запуск и при котором её выполнение дважды приведет к худшим результатам, чем её полное отсутствие, является хорошим кандидатом на применение этого подхода.
Связанные материалы
- Проектирование API на Node.js с использованием слоев: от громоздких контроллеров к чистой архитектуре — Узнайте, как переписать API на Node.js с использованием слоев контроллеров, сервисов и доступа к данным для устранения запутанной бизнес-логики, неоднородных ошибок и проблем с масштабированием.
- 20 шаблонов Node.js, предотвращающих простои сервера в производстве — Ознакомьтесь с 20 практическими шаблонами Node.js — от обработки ошибок до плавного выключения и пуллинга соединений — которые предотвращают сбои до того, как станет необходимо перезагрузка сервера.