理解 Node.js POST 接口中的幂等性键
解释了为何 POST 请求在重试时会出现不可预测的失败情况,以及客户端生成的幂等性键如何帮助 Node.js API 安全地处理重复请求。
“幂等性”是一个在支付 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接口没有设计为能够承受此类重试,那么同一笔购买行为就可能导致客户被重复收费——而双方都不存在明显过错。在客户端看来,请求确实失败了;而在服务器端看来,请求又确实成功了。
那么要如何让Node中的POST接口真正具备幂等性呢?
传统的解决方案是让客户端为每个逻辑操作生成一个唯一的键,将其作为请求头附加,这样服务器就可以利用该键识别出重试的请求其实与之前已处理的请求相同,而不会将其视为新的请求。
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操作按设计本就应该是可重试的——它不应改变任何内容,因此无需特殊处理即可安全地重复调用。可重试键模式专门用于那些会创建或修改状态的操作,因为粗心地重复调用会导致效果叠加。如果某个GET接口本身就不适合反复调用,那真正的问题在于它在GET语义下执行了本不应进行的副作用。
可重试键的有效期应设为多久?
理想情况下,其有效期应足以覆盖常见的重试场景,但又不能过长以至于存储的键无限堆积。许多支付平台的设定在24小时到几天之间。随后可以通过定时清理任务来删除过期的键条目:
await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");
如果时间窗口设置得过窄,那么因合理原因而延迟的重试——比如顾客在结账过程中手机信号中断了十分钟——就可能会错过该时间窗口,从而引发真正的重复记录。而如果让时间窗口永久保持开放状态,表格只会不断膨胀,却不会带来任何实际好处。
这种模式是否只对支付系统重要?
人们在支付环节往往最先学到这个教训,主要是因为重复扣费这类故障通常会在一小时之内就引发客户愤怒的邮件投诉。但根本问题在于——客户无法区分“我的请求失败了”和“我的请求成功了但我没有收到回复”——这种问题会出现在任何会产生副作用的操作中:发送邮件、触发 webhook、创建新账户、启动后台任务。凡是有可能需要重试,且重复执行会比不执行更糟糕的操作,都适合采用同样的处理方式。
相关阅读
- 分层式 Node.js API 设计:从臃肿的控制器到整洁架构 — 了解如何将 Node.js API 重构为控制器、服务层和数据访问层,从而解决复杂的业务逻辑、不一致的错误以及扩展难题。
- 防止生产环境服务器宕机的 20 种 Node.js 模式 — 学习 20 种实用的 Node.js 模式,涵盖错误处理、优雅关闭和连接池管理等内容,这些模式能在不得不重启之前避免系统崩溃。