导读
商城系统里,重复下单是最隐蔽也最容易引发纠纷的问题之一:用户网络卡了多点了几下、前端请求超时自动重试、消息队列重复投递,都可能让同一笔订单被创建两次。很多系统只在按钮上做个置灰就以为解决了,结果重复单依然层出不穷。这篇文章从一套电商商城的真实治理出发,把重复下单防护拆成前端防抖、接口幂等、数据库唯一约束三层,给出可直接落地的代码和取舍分析。
一、先看清:重复下单到底是怎么发生的
只靠"用户手快"解释重复单是远远不够的,重复请求的来源贯穿了整条链路,不同来源要靠不同层来挡:
| 重复来源 | 触发场景 | 前端能否挡住 | 真正防线 |
|---|---|---|---|
| 用户连点 | 卡顿后连续点击提交按钮 | 能(部分) | 前端层 |
| 前端重试 | 请求超时,框架自动重发 | 不能 | 接口幂等层 |
| 用户重放 | 刷新重复提交、回退后再提交 | 不能 | 接口幂等层 |
| 消息重复 | MQ至少一次投递、消费者重试 | 不能 | 接口幂等层 |
| 并发插入 | 多端同时提交同一请求 | 不能 | 数据库约束层 |
关键结论先放在前面:前端防抖只能改善体验、挡掉误触,绝不能作为安全防线;真正的幂等必须在服务端,而数据库唯一约束是最后一道不会失效的兜底。 三层是纵深关系,缺一层就有漏网的重复单。
二、第一层 前端:防抖与提交状态锁
前端层的目标是"不让正常用户因为等待焦虑而重复点",核心是提交后立刻让按钮进入不可重复提交的状态,并对短时间重复调用做拦截:
// 提交状态锁:一次提交流程未结束前,忽略后续点击
function createSubmitLock(submitFn) {
let loading = false;
return async function (...args) {
if (loading) return; // 已有提交在途,直接忽略
loading = true;
try {
return await submitFn.apply(this, args);
} finally {
loading = false; // 无论成功失败都要释放,否则按钮永久卡死
}
};
}
// 用法:const onSubmit = createSubmitLock(async data => api.createOrder(data));
同时配合几个细节:提交成功后跳转订单结果页而不是停留在表单页(避免刷新重复提交);按钮点击后立即置灰并显示"提交中";对弱网给出明确反馈,降低用户反复点击的冲动。
必须清醒认识到前端层的边界:所有前端限制都能被绕过——直接调接口、脚本重放、网络层重试都不经过按钮。所以前端做得再完善,也只是体验优化,不能假设它能保证不重复。
三、第二层 接口幂等:服务端才是主战场
3.1 用幂等键标识"同一次业务请求"
幂等的核心思路:给每一次下单动作分配一个全局唯一的幂等键(Idempotency Key),服务端对同一个幂等键只处理一次,重复请求直接返回第一次的结果。幂等键在进入下单页时由服务端签发,提交时带上,消费后失效:
import uuid, json
def create_order(client_token, user_id, order_data, redis, order_repo):
# client_token 即幂等键,进入结算页时预先签发
idem_key = f"idem:order:{user_id}:{client_token}"
# setnx:只有第一个请求能设置成功,重复请求拿不到锁
first = redis.set(idem_key, "PROCESSING", nx=True, ex=300)
if not first:
cached = redis.get(f"idem:result:{user_id}:{client_token}")
if cached: # 已处理完,返回首次结果,不重复建单
return {
"duplicate": True, "order": json.loads(cached)}
return {
"duplicate": True, "msg": "请求处理中,请勿重复提交"}
order = order_repo.insert(user_id, order_data) # 真正创建订单
redis.set(f"idem:result:{user_id}:{client_token}",
json.dumps(order), ex=86400)
return {
"duplicate": False, "order": order}
3.2 幂等键怎么选,决定了防护粒度
- 服务端预签发token:进入结算页发一个一次性token,最严谨,能区分"用户主动下两单"和"同一单被重复提交",是下单场景的首选。
- 业务指纹:用"用户+商品+关键参数"算哈希做幂等键,实现简单,但要小心误伤——用户确实可能短时间内对同一商品下两单,需要加上时间窗口或购物车批次号来区分。
取舍建议:涉及资金和库存的下单链路用预签发token,查询类、低风险写入可用业务指纹。无论哪种,幂等记录都要有合理过期时间,既挡住瞬时重复,又不会无限占用存储。
3.3 处理"处理中"的中间态
第一个请求还没处理完、重复请求就到了,这时不能简单放行(会重复建单),也不能直接报错(用户体验差)。返回"处理中"并让前端短暂轮询首次结果,是兼顾正确性和体验的做法。
四、第三层 数据库唯一约束:永不失效的兜底
即使幂等逻辑有bug、Redis故障、或者多实例并发导致锁失效,数据库层依然要能拦住重复数据——这是最后一道不依赖应用状态的防线。
4.1 给业务唯一键建唯一索引
把幂等键或业务唯一编号落到订单表并加唯一约束,重复插入会被数据库直接拒绝:
-- 幂等键落库并建唯一索引,重复插入在数据库层报错
ALTER TABLE orders ADD COLUMN idem_key VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE orders ADD UNIQUE KEY uk_idem_key (idem_key);
-- 插入时捕获唯一键冲突,转为查询首次订单,而不是抛错给用户
-- INSERT INTO orders(idem_key, ...) VALUES(...); -- 冲突时 Duplicate entry
4.2 状态机约束,防"重复操作"而非只防"重复插入"
重复下单之外,还有一类是"同一订单被重复执行某个动作"(如重复确认、重复取消)。用订单状态机做条件更新,只有处于预期前置状态才允许流转:
-- 只有待确认状态才能确认,重复确认影响行数为0,天然幂等
UPDATE orders SET status='CONFIRMED'
WHERE order_no = ? AND status='PENDING';
我们在搭建商城下单链路时,用乔拓云商城后台的订单设置先配置了下单防重复、超时自动关单这类基础规则,减少了通用逻辑的开发量;但幂等键签发、Redis去重和数据库唯一索引这些和自身业务强绑定的保障,仍然在自己的服务端独立实现——平台提供通用开关,核心幂等链路必须自己握在手里并压测验证。
五、踩坑清单
坑1:只做按钮置灰就以为防住了重复单
问题:接口重试、脚本重放、MQ重复投递全部绕过前端。
解决:前端只做体验,幂等判定必须下沉到服务端。
坑2:幂等只挡"完全相同的请求",漏掉业务等价请求
问题:参数顺序、空格不同导致指纹不一致,重复单漏过。
解决:业务指纹先做参数归一化和排序,关键场景用预签发token。
坑3:只在Redis去重,没有数据库兜底
问题:Redis宕机、key过期或锁失效时重复单直接落库。
解决:幂等键落库加唯一索引,让数据库做最终兜底。
坑4:重复请求直接抛异常给用户
问题:用户看到"系统错误"以为没下单,反而再点一次。
解决:识别为重复时返回首次订单结果或"处理中",引导而非报错。
坑5:幂等键不过期或粒度设计错误
问题:要么存储无限膨胀,要么误伤用户的合法二次下单。
解决:设合理TTL,用token区分"重复提交"与"主动再下一单"。
六、总结
重复下单防护是一道纵深三层的体系:前端防抖与状态锁改善体验、挡掉误触;服务端用幂等键加去重逻辑保证"同一请求只处理一次",是真正的主战场;数据库唯一约束和状态机条件更新作为不依赖应用状态的最后兜底。三者层层递进,任何单点都无法独立保证正确。
落地时建议倒排优先级:先给订单表补上唯一索引(成本最低、永不失效),再实现服务端幂等键去重,最后打磨前端提交体验。补完后用自动化脚本模拟连点、超时重试、消息重复、并发插入四种场景压测,确认每种重复来源都只产生一张订单,这条防线才算真正立住。