按钮连点发了三笔订单?前端拦截加后端幂等一起堵

举报
海是岛思念的泪 发表于 2026/09/26 22:48:49 2026/09/26
【摘要】 下单按钮连点导致重复落单,前端用按钮 loading + axios 拦截器 pending 去重拦掉手滑,后端用 requestId + 数据库唯一索引做幂等兜住穿透;附拦截器漏删 key、requestId 刷新复用等真实踩坑。

一、背景:连点一下多出三笔订单

9f68419f-8f8d-4f7f-8ba8-6a7db6ed36c5.png

下单页一个「提交订单」按钮,当初没做防护。有次用户在网络慢的时候连点了好几下,浏览器里同一个 POST 发了 3 次,后端那接口又没防重,直接落了 3 笔订单,用户立马炸了。一开始只靠按钮 disabled,结果没顶住——因为 loading 状态在某些异步分支里被提前清掉了。那次事故靠运营手动退款才平掉,但从那以后所有写接口我都默认加防重。教训就是:前端那层只是体验,真正兜底必须在后端。这类问题后端日志看不出异常,因为请求全是合法的重复提交,只有靠幂等才能拦。

二、前端第一层:按钮 loading 加 disabled

最直观的,点击之后立刻 loading = true 并且 disabled,Promise 成功或失败回来再复位。这层拦掉绝大多数「手滑连点」,成本低、体验好,但定位是体验层不是安全层。

async function submit() {
  if (loading.value) return
  loading.value = true
  try {
    await api.post('/order', payload)
  } finally {
    loading.value = false
  }
}

但光这层不够:loading 状态可能在请求真正发出前就被某处提前清掉,或者用户用回车、用脚本绕过按钮。所以得有第二层。

补充一句,disabled 有个前提:点击和发请求之间不能有异步空挡让按钮先复位,所以 finally 里复位比在 then 里更稳,异常分支也不会漏。

三、前端第二层:axios 拦截器去重

用 axios 的请求拦截器,按 method + url + params 算一个 key,请求在飞的时候把 key 记进 pending 表,同一个 key 的并发请求直接拦掉;响应和报错都要从表里删 key,否则这个 key 会被永久拦住,后续正常请求也废了。另外 pending 表的 key 要包含 params 序列化,否则不同参数的同名请求会被误判成重复;GET 请求一般不用进这张表。拦截器去重对并发同参数有效,但两个请求参数里带了不同时间戳,key 就不同拦不住,所以它只是辅助。

const pending = new Map()
const keyOf = (cfg) => `${cfg.method}|${cfg.url}|${JSON.stringify(cfg.params || cfg.data || {})}`
axios.interceptors.request.use((cfg) => {
  const k = keyOf(cfg)
  if (pending.has(k)) return Promise.reject({ repeat: true })
  pending.set(k, true)
  return cfg
})
axios.interceptors.response.use(
  (res) => { pending.delete(keyOf(res.config)); return res },
  (err) => { if (err.config) pending.delete(keyOf(err.config)); return Promise.reject(err) },
)

四、后端第三层:幂等才是底线

前端两层再严也挡不住「用户刷新页面重新点」「两个设备同时点」这类情况。所以后端必须做幂等:前端每次下单带一个客户端生成的 requestId(UUID),数据库给 request_id 加唯一索引兜底,重复插入直接返回首次结果,不重复落单。唯一索引是数据库层的终极保证,比任何应用层判断都可靠,因为并发场景下应用层判断会有竞态,唯独数据库约束是原子的。

ALTER TABLE orders ADD UNIQUE KEY uk_request_id (request_id);
try {
  const order = await Orders.create({ requestId, ...payload })
  return ok(order)
} catch (e) {
  if (e.code === 'ER_DUP_ENTRY') {
    const exist = await Orders.findOne({ where: { requestId } })
    return ok(exist) // 返回首次结果
  }
  throw e
}

五、踩过的坑

第一,拦截器只在 request 里 set、忘了在 response 和 error 里 delete,导致该 key 永久被拦,后面正常请求全废——必须两处都删。第二,requestId 如果每进一次页面都重新生成,用户刷新后重复点会被当新请求,要么前端用 sessionStorage 在本次会话里复用同一个,要么后端再叠加业务维度幂等(同用户 + 同购物车 + 短时间内)。第三,幂等 key 要设过期(比如 24 小时),不然唯一索引无限涨,而且历史 requestId 还可能被恶意复用。第四,幂等主要护写操作,只读 GET 不需要。

六、小结

防重复提交是「前端体验 + 后端底线」的双重活:前端两层拦掉 99% 的手滑,后端唯一索引兜住那 1% 真正穿过去的。少一层都不踏实,尤其是下单、支付这类接口,幂等是底线中的底线。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。