接口重复提交:用幂等键和唯一索引兜住重试

同一个表单提交了两次

去年底有个读者给我发消息,说他的站出了件怪事:后台订单里偶尔出现完全重复的两条记录,时间差不到一秒,金额、商品、收货地址一模一样。他一开始怀疑是用户手贱点了两次,于是在前端加了"提交中禁用按钮"。结果重复订单从每天几条降到每周一两条,但从来没彻底消失。

这个现象其实很有代表性。"按钮禁用"只能挡住正常用户的双击,挡不住网络层的重试、浏览器的后退重提交、以及移动端弱网下的自动重发。真正能兜住的是服务端的幂等设计——也就是"同一个操作执行一次和执行十次,结果一样"。

这篇就讲讲在小站的实际条件下,怎么用最少的改动做出靠谱的幂等。不用引入消息队列,不用上分布式锁,就用一张表、一个唯一索引、几个状态位。

先分清三种"重复"

很多人把幂等理解成"防重复提交",其实需要分别处理的是三种完全不同的场景,混在一起想就会设计出既复杂又漏的方案。

第一种,客户端重试导致的重复。用户点了提交,请求发出去了,但响应在路上丢了(弱网、超时、切后台)。前端不知道成没成,于是重试,服务端收到第二次请求。这类重复的特征是两个请求携带的业务数据完全一致。

第二种,用户主动重复操作。用户点了两次,或者在两个标签页里各提交一次。这类重复的数据也往往一致,但可能相隔几秒甚至几分钟。

第三种,浏览器行为导致的重复。用户在结果页按了后退,再按提交;或者支付回跳时浏览器重新 POST。这类重复最难缠,因为它是浏览器协议层的固有行为,前端代码完全无法干预。

三种场景的共同点是:服务端会收到两次内容相同的写请求,且第一次可能已经生效。所以幂等的本质是——服务端必须能识别出"这是一个已经处理过的操作"。识别靠什么?靠一个由客户端生成、在重试时保持不变、且全局唯一的标识。

幂等键:客户端生成,服务端认账

最通用的做法是引入一个幂等键(idempotency key)。规则很简单但每条都必须守住:

  • 由客户端生成,通常在用户打开表单(或点击提交)的那一刻用 UUID v4 生成一次,放进请求体或请求头。
  • 重试时必须复用同一个键。这一点是全部设计的命门——如果每次提交都重新生成键,幂等就退化成"永远不重复"的假象,实际上什么都没防住。
  • 服务端把它当唯一约束,不是当"查一下有没有"的提示。查询和插入之间有时间窗,必须靠数据库唯一索引来兜底。

服务端的处理逻辑,用伪代码表示是这样一个固定套路:

BEGIN;
  INSERT INTO idempotency (key, scope, status, response, created_at)
  VALUES (?, ?, 'processing', NULL, NOW());
  -- 如果这里抛唯一键冲突,说明是重复请求,走"读回"分支
  ... 执行业务写入 ...
  UPDATE idempotency SET status = 'done', response = ? WHERE key = ?;
COMMIT;

关键在"唯一键冲突"这个分支怎么处理。很多实现到这里就返回一个 500 或者直接抛出异常,用户看到报错但实际上第一次已经成功了,于是又试第三次。正确做法是分三种情况:

  1. 冲突且原记录的 status = 'done':直接返回原记录里存的响应。这才是真正的"幂等返回"——第二次请求得到的响应和第一次完全一样。
  2. 冲突且 status = 'processing':说明第一次还在跑。返回 409,附一个 "in progress" 的语义,让客户端稍后重试或者轮询结果。绝对不要在这里"顺手再执行一次"。
  3. 冲突但原记录已过期(比如超过 24 小时):可以认为这个键已作废,删掉旧记录重新插入。但要注意并发,用 DELETE ... WHERE key = ? AND created_at < ... 带条件删。

把"响应"也存进幂等表,是这套方案最实用的一点。它让"重复请求返回首次结果"成为可能,而不是简单返回一句"已处理"。对于下单、支付这类操作,用户重试时想知道的是"我的订单号是多少",不是"你重复了"。

表结构与索引:细节决定能不能扛住并发

这张表的实际建法,我一般写成这样(MySQL 8.0):

CREATE TABLE idempotency (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  idem_key CHAR(36) NOT NULL COMMENT '客户端生成的UUID',
  scope VARCHAR(32) NOT NULL COMMENT '业务域,如 order/comment/pay',
  status ENUM('processing','done','failed') NOT NULL DEFAULT 'processing',
  response MEDIUMTEXT NULL COMMENT '首次成功的响应体,重复请求直接回放',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_key_scope (idem_key, scope),
  KEY idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个设计选择值得解释:

唯一索引是 (idem_key, scope) 而不是只有 key。因为同一个页面可能同时触发多个业务动作(比如"下单"和"领取优惠券"),用 scope 隔离可以避免不同业务误撞键。当然如果你的键本来就是 UUID v4,撞的概率可以忽略——但明确隔离比依赖概率更靠谱。

status 用 ENUM 而不是 TINYINT。ENUM 在查询时直接可读,排查问题时省一次对照表。代价是加枚举值需要改表结构,但幂等表的状态就这三个,够用了。

response 用 MEDIUMTEXT 存 JSON。上限 16MB,实际响应一般几百字节到几 KB。注意不要存二进制(文件下载、PDF),那种场景应该在响应里存一个"结果地址",让客户端去取。

idx_created 索引是给清理任务用的。幂等键不能永久保留,否则表会无限膨胀。按 created_at 批量删除是最常见的清理方式。

如果不想为幂等单独建表,一个更轻的替代方案是在业务表上直接加唯一约束。比如订单表加 UNIQUE KEY uk_idem (idem_key),插入时捕获重复键错误,然后按 idem_key 查回原记录返回。这个方案少一张表、少一次事务,但缺点是无法表达"处理中"这个中间态——第一次请求还没提交,第二次请求进来时查不到,会走完整个业务逻辑再撞唯一键,把重复的工作做了一遍。对下单这种轻量业务可以接受,对"要扣库存、要调第三方支付"的业务就不合适。

状态机比唯一索引更难写对

有了唯一索引只是第一步。真正容易出问题的是"业务状态"和"幂等状态"的对应关系没理清。我见过最典型的一个 bug:订单表有 status(pending/paid/shipped),幂等表也有 status,两套状态各自流转,中间没有约束,结果出现了"幂等表说 done、订单表还是 pending"的不一致记录。

避免这个问题的方法是:幂等表的 status 只表达"这个键处理完了没有",业务状态一律以业务表为准。不要把业务进度塞进幂等表。幂等表的 done 只意味着"这个键对应的那次业务事务已经提交成功",具体业务处于什么阶段,去业务表查。

然后是并发下的状态迁移。这里有个反直觉的点:数据库行锁在幂等设计的核心位置,而不是应用层的锁。正确的写法是让第一次请求在事务里持有 idempotency 行的锁直到业务提交,第二次请求进来时会被阻塞在唯一索引的插入上,等第一次提交后拿到重复键错误,再去读原记录。这样天然串行化,不需要任何 Redis 锁。

-- 会话 A(第一次请求)
BEGIN;
INSERT INTO idempotency (idem_key, scope, status) VALUES ('k1','order','processing');
-- 拿到行锁,继续业务写入
UPDATE orders SET ...;
COMMIT;  -- 锁释放,status 已改为 done

-- 会话 B(重复请求)在 A 提交前会一直等 INSERT 返回
-- A 提交后 B 收到 Error 1062 Duplicate entry,走读回分支

注意这个方案的隐含前提:事务不能太长。如果第一个请求在事务里调用了第三方支付接口,一卡 30 秒,那么所有同键重复请求都会被阻塞 30 秒。所以正确顺序是"先把本地事务做完、再调外部接口",外部接口的结果用单独的状态字段记录并支持补偿,而不是把外部调用包在幂等事务里。

哪些操作根本不需要幂等

反过来说,也不是所有写操作都值得上这套机制。判断标准是"重复执行的代价":

  • 天然幂等的操作:UPDATE ... SET status = 'published' WHERE id = 1,执行一次和执行十次结果一样。这类不需要幂等键,只需要注意并发写不要互相覆盖(用 WHERE status = 'draft' 这样的条件更新来防)。
  • 天然非幂等但代价低:浏览计数、点赞数。偶尔多一次无所谓,加唯一约束反而影响性能(每次点赞都要查重)。可以用 Redis 的集合去重,或者干脆接受误差。
  • 天然非幂等且代价高:下单、支付、余额变动、发券、发邮件。这几类必须做,而且要做得严格。

还有个容易被忽略的场景:批量操作。一次提交 50 条数据,如果整批用同一个幂等键,那么重试时整批要么全回放、要么全跳过,无法只补失败的那几条。更好的做法是给每一行生成独立的键(批次键:行号),或者干脆把批量拆成单条提交。前者能精确补漏,后者实现最简单,我通常选后者。

前端那点不可省略的活

服务端做得再好,前端有一个错误会让整套机制归零:重试时重新生成了键。所以前端代码里,键的生成和生命周期必须写成明确的一处:

// 打开表单时生成一次,整个表单生命周期内不变
const idemKey = crypto.randomUUID();

async function submit(data, attempt = 0) {
  const resp = await fetch('/api/order', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idemKey },
    body: JSON.stringify(data)
  });
  if (resp.status === 409 && attempt < 3) {
    await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
    return submit(data, attempt + 1);   // 复用同一个 idemKey
  }
  if (!resp.ok) throw new Error('提交失败');
  return resp.json();
}

另外两件事也常被漏掉:一是成功后要清空并重新生成键,否则用户在同一页发起第二笔业务(新订单)会复用旧键,直接被当成重复请求回放上一个订单的响应;二是后退重提交的场景需要服务端兜住,前端无法干预,这就是后面要说的 PRG 模式。

还有个小细节:标头名 Idempotency-Key 是一个事实标准(Stripe 的推广让它被广泛接受),但它是自定义头,跨域请求时会触发预检。如果你的 API 走 CORS,记得在 Access-Control-Allow-Headers 里加上它,否则浏览器直接拦掉请求,报错信息还很不直观。

PRG:把浏览器行为也管住

第三种重复(后退再提交)用幂等键解决得不够优雅——它能防住重复写入,但用户看到的还是一个"要不要重新提交表单"的弹窗。更好的做法是从协议层消灭这种可能性,也就是 PRG(Post/Redirect/Get)模式:

  1. 表单 POST 到服务端。
  2. 服务端处理完成后,不直接输出结果页,而是返回 303 See Other,Location 指向结果页的 GET 地址。
  3. 浏览器自动 GET 结果页。此时用户按后退,回到的是表单页或者上一个 GET 页面,不存在"重新 POST"的选项。

用 303 而不是 302 是有讲究的:303 明确要求"用 GET 访问新地址",而 302 在历史上有浏览器把它实现成"用原来的方法访问",导致 POST 被重放。现在的浏览器基本都按 303 语义处理 302 了,但显式写 303 更清晰。

PRG 的代价是需要一个能承载结果的 GET 页面(通常是 /order/123 这种)。如果不想为此专门写页面,折中方案是在 POST 之后 302 回列表页,并用一次性 flash 消息(存在 session 里,读一次即删)展示"提交成功"。这个方案实现成本极低,是我在大多数小站上实际采用的。

清理与观测

幂等表会持续增长。一个日活几千的站,所有写操作都加幂等的话,一天可能积累几千到几万行。清理策略两条:

按时长清理。幂等键的有效期只需要覆盖"客户端可能重试的时间窗",一般 24 小时足够,保守一点给 7 天。清理任务写成一条带 LIMIT 的删除,避免长事务锁表:

DELETE FROM idempotency
WHERE created_at < NOW() - INTERVAL 7 DAY
ORDER BY created_at
LIMIT 5000;

这条语句循环跑,每轮 sleep 一秒,直到影响行数为 0。比一次性 DELETE ... WHERE created_at < ... 安全得多——后者可能一次锁住几十万行,期站内所有写请求全部卡住。

按命中率观测。更重要的是知道这套机制到底在起作用没有。加两个计数:一是唯一键冲突次数(说明真的挡住了重复),二是 processing 状态停留超过 60 秒的记录数(说明有事务卡死了,业务没提交完)。前者从 0 变成有值,说明你的站确实存在重试流量;后者持续不为 0,说明有 bug 需要查。

最后提醒一个容易忽略的对账点:幂等表里的 response 和业务表的真实状态可能不一致。比如幂等记录写着"订单已创建,订单号 1001",但那条订单后来被用户取消了。这时候重复请求回放的是过期数据。处理方式有两种:回放时不返回完整业务对象,只返回"操作已受理 + 资源 ID",让客户端自己再 GET 一次;或者在回放前校验业务状态,不一致就走"重新执行"。前者更简单,我一般选它。

一份最小可用的落地顺序

如果你的站现在一个重复提交防护都没有,不要一上来就设计完整的幂等框架。按这个顺序做,每一步都有独立价值:

第一步,把所有"提交后直接输出结果页"的表单改成 PRG。这一步不需要改表结构,只改路由,能消灭掉浏览器后退重提交这一大类问题,投入半天,收益立竿见影。

第二步,给最贵的三个接口加唯一约束。下单、支付回调、发券。用业务表上的唯一索引 + 捕获重复键错误,先止住"重复扣款"这类会造成实际损失的问题。

第三步,补幂等表。当业务开始需要"重复请求返回首次结果"时,再引入独立的幂等表,把响应也存进去。这一步是为了体验,不是为了正确性。

第四步,接监控。冲突次数和 processing 积压数接进每日巡检,出错能第一时间知道。

回到开头那位读者。最后查出来的根因不是双击,而是他用的支付回调接口在超时后会自动重发三次,而他这边每次收到回调都执行一遍"更新订单状态 + 给用户加积分"。加了幂等键之后,三次回调里有两次直接回放首次响应,积分重复发放的问题就彻底没了。幂等不是为了让代码优雅,而是为了让"重复"这件事在系统里变成一个无害的事件。

Last modification:October 26th, 2026 at 10:27 pm

Leave a Comment