按钮连点发了三笔订单?前端拦截加后端幂等一起堵
一、背景:连点一下多出三笔订单

下单页一个「提交订单」按钮,当初没做防护。有次用户在网络慢的时候连点了好几下,浏览器里同一个 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% 真正穿过去的。少一层都不踏实,尤其是下单、支付这类接口,幂等是底线中的底线。
- 点赞
- 收藏
- 关注作者
评论(0)