接口幂等设计:重复提交怎么防
用户双击按钮、网关重试、消息队列重投,都会让同一个请求到达多次。读接口天然幂等,写接口不处理就会重复扣款、重复下单。幂等设计要趁接口定义时就考虑,事后补很难。
方案一:唯一业务编号
最可靠的方式是让请求自带唯一标识,服务端用它做去重:
CREATE TABLE pay_order (
id BIGINT PRIMARY KEY,
biz_no VARCHAR(64) NOT NULL UNIQUE, -- 幂等键
amount DECIMAL(10,2),
status TINYINT,
created_at DATETIME
);
重复请求触发唯一键冲突,服务端查询已有记录返回原结果。前端生成订单号、客户端生成 UUID,或网关分配 requestId 都可以充当幂等键。
方案二:Token 机制
进入页面时先领取一次性 token,提交时带上并校验删除:
$redis->set('token:' . $t, 1, ['nx', 'ex' => 600]);
// 提交时
if (!$redis->eval("if redis.call('del',KEYS[1])==1 then return 1 else return 0 end",
['token:' . $t], 1)) {
fail('请勿重复提交');
}
DEL 的返回值保证了「校验 + 删除」的原子性。缺点是需要多一次交互,且 token 与业务动作没有绑定,拿到 token 可以提交任意内容。
方案三:状态机约束
业务流程本身只允许合法的状态迁移:
UPDATE orders SET status = 'paid'
WHERE id = 100 AND status = 'pending'; -- 已支付时影响行数为 0
重复支付请求第二次执行时状态已是 paid,更新不生效。这是最符合业务的幂等,几乎零成本。
组合使用
生产环境的典型组合:入口 token/前端防抖挡住明显重复 → 业务唯一键兜底 → 状态机约束迁移。三层各自便宜,缺一层都会在某个环节漏。
返回值语义
幂等接口对重复请求应返回第一次的结果,而不是报错。客户端收到 409 就会重试,形成循环。正确做法是 200 + 原订单数据,配合响应里的 idempotent_replayed: true 标记。
最后提醒:MQ 消费端同样要幂等。消息至少投递一次(at-least-once)是常态,用消息 ID 去重表是最通用的兜底。