幂等设计:从接口到消息消费的防重体系
本文目录
"这个接口重复调用两次会怎样?"——这是评审任何写接口时应该脱口而出的第一问。分布式系统里,重复不是异常场景而是常态:网络超时让调用方不知道结果、客户端重试、网关重发、MQ 重投,都会让同一个业务动作执行不止一次。幂等设计的目标很朴素:同一个业务动作,无论被触发多少次,系统状态只产生一次变更。这篇文章把幂等从概念落到体系:方法语义、幂等键、数据库兜底、状态机、乐观锁、去重载体选型,最后给出下单/支付回调的完整示例和"超时未知结果"这个最容易被忽略的边界。
为什么幂等:重复是常态
先看重复从哪里来,每一类都真实存在:
| 重复来源 | 场景 | 不幂等的后果 |
|---|---|---|
| 用户重复操作 | 双击提交按钮、弱网下多点几次 | 同一订单创建多笔 |
| 客户端超时重试 | HTTP 客户端按超时自动重发 POST | 支付扣款两次 |
| 网关/负载均衡重发 | 超时后在另一实例重试 | 优惠券被重复核销 |
| MQ at-least-once | 消费者 ack 前崩溃导致重投 | 通知/积分重复发放 |
| 定时任务与补跑 | 上次任务实际成功但记录丢失,人工重跑 | 报表口径翻倍 |
关键认知:超时在分布式语义里是"未知",不是"失败"。请求可能已经完整执行,只是响应没回来。所以调用方一定会重试,服务端必须把重试当正常流量设计。反过来,幂等做好了,重试策略才可以放心地激进——这两个设计是配套的。
天然幂等 vs 需要设计
| HTTP 方法 | 语义 | 幂等性 | 说明 |
|---|---|---|---|
| GET | 读 | 幂等 | 前提是真的无副作用(GET 里夹带写库就不是了) |
| PUT | 全量替换 | 幂等 | 目标态确定,重复替换结果不变 |
| DELETE | 删除 | 幂等 | 第二次删返回 404 还是 204 是口味问题,语义上都算幂等 |
| POST | 创建/触发动作 | 不幂等 | 重复提交 = 重复创建,必须设计 |
两点纠偏:第一,"方法幂等"只是起点,真正的幂等要求状态收敛——重复调用不仅不报错,而且系统状态和首次调用的结果一致,最好连响应都一致(后面会讲响应缓存)。第二,别用"GET 幂等"当挡箭牌:把写操作塞进 GET(方便缓存、绕过 CSRF token)既破坏语义又制造不可防重的副作用,这类接口是幂等事故的高发区。
幂等键:谁来发号
幂等的前提是"系统能识别两次调用是同一个动作",靠的是幂等键。发号有两种模式:
| 模式 | 做法 | 适用 | 关键约束 |
|---|---|---|---|
| 客户端生成 | 提交时带 Idempotency-Key(uuid) | 用户触发的一次性动作(支付、下单) | 提交失败后键要稳定(不能用页面刷新就换的新 uuid),成功后换新键 |
| 服务端发号 | 预下单接口先领 token,提交时携带并核销 | 强防重放场景(支付凭证、表单提交) | token 必须一次性:核销后立即失效,绑定当前用户 |
// 客户端生成模式:入口中间件校验键格式与作用域
import { randomUUID } from 'node:crypto';
app.post('/api/orders', requireAuth, (req, res, next) => {
const key = req.header('Idempotency-Key');
if (!key || !/^[0-9a-f-]{36}$/.test(key)) {
return res.status(400).json({ code: 'INVALID_IDEMPOTENCY_KEY' });
}
// 键的作用域绑定用户:不同用户撞同一个 uuid 时不会误判为重复
res.locals.idempotencyKey = `order:${req.user.id}:${key}`;
next();
});
// 前端:进入收银台时生成一次,成功/明确失败前不变
let checkoutKey: string | null = null;
async function submitOrder() {
checkoutKey ??= randomUUID();
return fetch('/api/orders', {
method: 'POST',
headers: { 'Idempotency-Key': checkoutKey, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
客户端生成模式有天然的局限:页面刷新后内存里的键丢了,用户"提交一次、刷新一次"会带两个键。所以按钮防抖、提交后禁用按钮这些前端手段仍然要做,但它们只是优化用户体验,服务端幂等才是正确性的唯一保障。
服务端发号模式的重心在核销的原子性:领取的 token 存 Redis(SET token userId EX 600),提交时用 Lua 或 GETDEL 原子取出并校验归属,一步完成"校验 + 核销",杜绝并发双花同一个 token。
数据库唯一约束兜底
代码层的"先查再插"永远有竞态窗口(两个请求同时查到不存在再同时插入),唯一约束是唯一不可穿透的防线:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
order_no TEXT NOT NULL,
user_id BIGINT NOT NULL,
amount_cents INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 业务单号全局唯一:重复提交靠它硬挡
CREATE UNIQUE INDEX orders_order_no_uq ON orders (order_no);
async function insertOrder(orderNo: string, userId: bigint, amount: number) {
try {
return await db`INSERT INTO orders (order_no, user_id, amount_cents)
VALUES (${orderNo}, ${userId}, ${amount}) RETURNING *`;
} catch (error: any) {
if (error.code === '23505') { // PostgreSQL unique_violation
const [existing] = await db`SELECT * FROM orders WHERE order_no = ${orderNo}`;
return existing; // 已存在:返回首次结果,而不是报错
}
throw error;
}
}
注意唯一约束的语义前提:唯一键必须对应"同一个业务对象"。用 user_id + product_id 做唯一键,会阻止用户在不同时间买同一商品(正常业务),那就不是幂等而是限购——键的设计要跟业务对象定义对齐。
状态机幂等
很多重复不是"重复创建"而是"重复推进":支付回调重复到达、定时器重跑、MQ 重投,都会把同一个订单的状态变更请求送来两次。状态机幂等的做法是让非法流转本身不可达:
PENDING ──支付成功──> PAID ──发货──> SHIPPED ──确认──> DONE
│ │
└──取消──> CANCELLED ─┘(PAID 后取消走退款子流程)
async function applyPaymentCallback(orderNo: string, paidAmount: number) {
// 只允许 PENDING -> PAID 一次;已 PAID 的重复回调会在这里 affected=0
const result = await db`
UPDATE orders SET status = 'PAID', paid_at = now()
WHERE order_no = ${orderNo} AND status = 'PENDING' AND amount_cents = ${paidAmount}
RETURNING *`;
if (result.length === 0) {
const [current] = await db`SELECT status, amount_cents FROM orders WHERE order_no = ${orderNo}`;
if (!current) throw new Error('ORDER_NOT_FOUND');
if (current.status === 'PAID') return { duplicated: true }; // 重复回调:幂等成功返回
throw new Error(`ILLEGAL_TRANSITION:${current.status}`); // PENDING 但金额不符等异常:必须告警
}
return { duplicated: false, order: result[0] };
}
affected rows = 0 之后必须区分两种情况:当前状态已经是目标态(重复请求,返回成功);当前状态是其他态(非法流转或金额不符,抛错并告警)。不做这个区分,把所有 0 行影响都当"幂等成功",会把金额篡改攻击和正常重试混在一起,这是支付回调实现里最危险的偷懒。
乐观锁版本号
幂等键防的是"重复提交",乐观锁防的是"并发覆盖"——两个合法请求同时更新同一行,后写的会悄悄覆盖先写的。做法是给行加 version 列,更新时校验:
UPDATE user_profile
SET nickname = $2, version = version + 1
WHERE user_id = $1 AND version = $3;
-- affected=0 说明读到的是旧版本:客户端用 GET 拿最新数据重试(带退避)
async function updateNickname(userId: bigint, nickname: string, baseVersion: number) {
const rows = await db`
UPDATE user_profile SET nickname = ${nickname}, version = version + 1
WHERE user_id = ${userId} AND version = ${baseVersion} RETURNING version`;
if (rows.length === 0) {
const [latest] = await db`SELECT version, nickname FROM user_profile WHERE user_id = ${userId}`;
if (latest.version === baseVersion) throw new Error('CONCURRENT_UPDATE_LOST');
return { retryWithVersion: latest.version }; // 版本变了:让调用方基于最新版本重试
}
return { version: rows[0].version };
}
乐观锁与幂等键经常搭配:幂等键挡住重复提交(同一动作执行两次),乐观锁挡住并发冲突(两个不同动作同时改一行)。冲突重试要有次数上限与退避,防止高并发下活锁。
去重表 vs Redis 去重
去重载体选型按业务价值分档:
| 维度 | 去重表(同库唯一约束) | Redis(SETNX + TTL) |
|---|---|---|
| 可靠性 | 与业务库同生共死,事务内生效 | 宕机或淘汰后丢键,重复穿透 |
| 一致性语义 | 与业务写入同事务提交,强 | 先占位后执行:执行失败要删键(补偿),弱 |
| 性能 | 每次写入多一次同库操作 | 内存操作,快一个量级 |
| 保留期 | 持久,需归档策略 | 只能靠 TTL,键多了吃内存 |
结论可以很干脆:资金、订单、核销类操作用去重表(和业务事务绑在一起);高频低价值操作(埋点去重、通知防发、防抖类缓存)用 Redis,接受极小概率重复,性能和存储压力更合理。Redis 方案要处理的边界:执行失败后必须删掉占位键(否则真正的重试会被挡在外面),删键和业务失败的顺序要能自洽。
完整示例:下单与支付回调
把前面的零件拼成一条链路。下单接口(客户端幂等键):
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY, -- order:{userId}:{uuid}
biz_type TEXT NOT NULL,
request_hash TEXT NOT NULL, -- 同键不同请求体的检测
status TEXT NOT NULL, -- PROCESSING | DONE
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
app.post('/api/orders', requireAuth, async (req, res) => {
const key = res.locals.idempotencyKey;
const requestHash = sha256(JSON.stringify(req.body));
try {
// 1. 抢占幂等记录(唯一约束,PROCESSING 占位)
await db`INSERT INTO idempotency_records (key, biz_type, request_hash, status)
VALUES (${key}, 'create_order', ${requestHash}, 'PROCESSING')`;
} catch (error: any) {
if (error.code !== '23505') throw error;
const [record] = await db`SELECT * FROM idempotency_records WHERE key = ${key}`;
if (record.request_hash !== requestHash) {
return res.status(422).json({ code: 'KEY_REUSED_WITH_DIFFERENT_BODY' }); // 键复用滥用
}
if (record.status === 'PROCESSING') {
return res.status(409).json({ code: 'REQUEST_IN_PROGRESS' }); // 上一次还没完,稍后再查
}
return res.status(200).json(record.result); // 幂等命中:返回首次结果
}
try {
const order = await createOrderInTx(req.user.id, req.body); // 业务事务 + orders.order_no 唯一约束
await db`UPDATE idempotency_records SET status='DONE', result=${JSON.stringify(order)} WHERE key=${key}`;
res.status(201).json(order);
} catch (error: any) {
// 执行失败:删掉占位,允许真正的重试(成功结果才值得缓存)
await db`DELETE FROM idempotency_records WHERE key = ${key} AND status='PROCESSING'`;
throw error;
}
});
支付回调(第三方异步通知,天然会重发)走的是状态机幂等:回调处理里先验签、查订单,用上文的 applyPaymentCallback 做 PENDING -> PAID 的条件更新,重复回调返回 200(告诉第三方"我处理过了",停止重发),非法流转告警。两个接口用的幂等手段不同,这是有意的:用户交互接口靠幂等键,机器回调接口靠状态机。
整套链路的验收不是靠 code review,是靠可复现的请求回放——同键同体重放必须拿到首次结果:
KEY="0b2c1c6e-1f7b-4f0f-9d3a-8f2f6f0a1111"
# 第一次提交:期望 201,创建订单
curl -s -X POST http://127.0.0.1:3000/api/orders \
-H "Idempotency-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"skuId": 9001, "qty": 2}'
# 第二次重放:期望 200 + 同一个订单(不是新建一单)
curl -s -X POST http://127.0.0.1:3000/api/orders \
-H "Idempotency-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"skuId": 9001, "qty": 2}'
# 同键不同体:期望 422 KEY_REUSED_WITH_DIFFERENT_BODY
curl -s -X POST http://127.0.0.1:3000/api/orders \
-H "Idempotency-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"skuId": 9002, "qty": 2}'
这个回放脚本应该进 CI 的冒烟集:幂等行为是接口契约的一部分,回归一次都不能破。
边界:超时与未知结果
幂等体系最薄弱的一环是"未知态":创建接口超时了,客户端不知道单建没建成功。正确姿势不是重发一遍(可能造成重复请求体),而是查询补偿:
async function submitOrderWithRecovery() {
try {
return await submitOrder();
} catch (error) {
if (error.code !== 'ETIMEDOUT') throw error;
// 超时 = 未知:先查,再决定重试还是收下结果
const status = await fetch(`/api/orders/${checkoutKey}`, { method: 'GET' });
if (status.ok) return status.json(); // 上次其实成功了
if (status.status === 404) return submitOrder(); // 确认不存在:安全重试(同键)
throw error;
}
}
配套的三条纪律:业务对象要能按幂等键查回来(所以幂等键要落库,而不是只在 Redis 里过一夜);对账任务是最终兜底——定时对账发现"第三方成功、本地未标记"的订单走补偿修数,这是幂等体系的扫地僧;未知态要在状态模型里有名字(PENDING/PROCESSING),没有中间态的模型只能在"成功/失败"里二选一,超时永远处理不对。
注意事项与边界
- 幂等键的作用域要隔离(用户级),否则跨用户键碰撞会误伤;全局业务单号(
order_no)作为唯一约束时另算。 - 幂等记录的保留期要覆盖客户端最长重试窗口(建议至少 24–72 小时),过期清理用任务批量删,别靠人工。
- 只缓存成功结果:失败后要允许同键重试;
PROCESSING状态要挂超时清理,防止第一个请求崩溃后键被永久占死。 - MQ 消费里的去重优先用业务单号而不是 broker 的 messageId:重投的 messageId 一样,但生产者补发的新消息 messageId 变了,靠它去重会漏。
- 幂等不等于防刷:一个用户每秒提交一百个"不同幂等键"的请求,幂等管不了,那是限流与风控的职责(见本站《服务端限流:算法、层级与降级预案》)。
- 分布式事务/编排场景(Saga 各步骤补偿)里,每个正向与补偿动作都要带幂等键,否则补偿重试会重复扣减。
总结
幂等体系的骨架是三层递进:识别重复(幂等键,客户端发号或服务端发号)、挡住重复(数据库唯一约束与状态机条件更新,乐观锁防并发覆盖)、收敛未知(超时查询补偿 + 对账兜底)。选型上记住分档:资金与订单走去重表 + 同事务,高频低价值走 Redis。做完这三层,"接口重复调用两次会怎样"这个问题,答案才敢写成"和调用一次完全一样"。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。