当同一用户因网络延迟或重复点击买走最后一件商品时,链动小铺发卡网通过订单重复检测机制保障库存与资金安全,该实战基于用户IP、设备指纹、订单号与商品唯一ID等多维数据,结合短时间窗口内的请求频率分析与幂等校验,核心逻辑为:在用户提交订单前,系统先利用分布式锁检测该用户对该商品是否已有“待支付”或“支付成功”的订单,防止生成重复订单;通过数据库唯一索引与缓存标记(如Redis)实现最后一件商品的“原子级扣减”,确保同一个人无法超卖,该方案有效规避了并发场景下的重复发货与库存负数问题,提升了平台交易的一致性与抗并发能力。
一个让人崩溃的深夜
凌晨两点,我盯着屏幕上的两笔订单,手心开始冒汗。

那是一张绝版游戏点卡,库存只有1张,但系统显示,两笔订单都成功了——同一个用户,间隔3秒,下了两单。
更诡异的是,两笔订单的订单号不同,IP地址相同,收货信息完全相同,但系统仿佛没看到它们其实是“同一个购买行为”。
第二天,我收到这位用户的投诉:“我明明只想买一张,你们扣了我两次钱,发了两张卡?不,你们根本没发货,因为库存只有一张!”
这就是我们做发卡网初期遇到的真实问题——订单重复检测,如果不解决它,平台不仅会损失利润,还会面临大量客诉,甚至被支付渠道判定为“恶意扣款”。
我就用链动小铺的实际案例,带你走一遍解决这个“看起来很小、实际上很大”的问题的全过程。
为什么发卡网特别容易遇到重复订单?
先来个场景模拟。
想象你是个卖演唱会门票的黄牛(只是打个比方),你只有一个VIP座位可以卖,这时候来了个着急的粉丝:
- 他打开你的小程序,选好座位
- 点击“立即购买”,但页面卡住了
- 他急了,又点了一次
- 第一次支付成功了,但页面没跳转
- 他又支付了一次,第二次也成功了
结果呢?你一个座位卖了两张票,这就是典型的并发下的重复创建问题。
链动小铺作为发卡网,商品是虚拟卡密,没有物理库存的“滞后性”,每一笔订单都对应一个唯一的卡密,如果重复下单,就会出现:
- 同一张卡卖给两个人
- 或者一个人被扣两次款只拿到一张卡
- 再极端点,系统发了两张卡,但库存只能支持一张
这些后果,任何一个都够你喝一壶。
第一版方案:最朴素的“查订单表”
我们的第一版思路很简单:每次用户下单前,查一下订单表里有没有相同用户、相同商品、未支付的订单。
伪代码大概是这样的:
def check_duplicate(user_id, product_id):
orders = db.query("SELECT * FROM orders WHERE user_id=? AND product_id=? AND status='unpaid'")
if len(orders) > 0:
return True # 有未支付订单,视为重复
return False
看起来没问题对吧?但在高并发下,这个方案脆得像纸片。
真实血泪案例: 某次促销活动,同一秒内用户连点三次“下单”。
- 第一次请求:查订单表,没找到未支付订单 → 创建订单A
- 第二次请求:查订单表,还没写入订单A(因为事务未提交)→ 没找到 → 创建订单B
- 第三次请求:同上 → 创建订单C
结果:三笔订单同时生成,用户需要手动取消两笔,或者我们退款,更糟的是,如果用户都付了款,我们瞬间白送两张卡。
数据分析验证: 我们抓了一天数据,发现重复订单率高达千分之三,看起来不大,但乘以日订单量10000单,每天30单重复,每单平均亏损50元计算,一天亏损1500元,一个月4.5万,对于初创团队,这笔钱够发一个开发三个月的工资了。
第二版方案:加锁!加锁!加锁!
既然并行查询不行,那就用MySQL的行锁,我们给用户和商品的组合加了唯一索引:
ALTER TABLE orders ADD UNIQUE INDEX idx_user_product (user_id, product_id, status);
并且在下单时用 INSERT ... ON DUPLICATE KEY UPDATE 来处理。
但这个方案很快也暴露了问题:
索引只对未支付状态有效。 如果订单状态变成“已支付”或“已取消”,唯一索引就不起作用了,同一个用户买了同一商品,再次购买时,如果之前已经支付过了,新订单依然能创建。
锁的范围太大。
我们用了 SELECT ... FOR UPDATE 来锁定库存记录,结果就是:极端情况下,A用户锁住了B商品,导致B用户想买B商品时被阻塞,系统响应变慢。
真实日志摘录:
[WARN] 2024-03-15 14:23:01 - Lock wait timeout exceeded; try restarting transaction
[WARN] 2024-03-15 14:23:02 - Deadlock found when trying to get lock; try restarting transaction
这些日志看着就头皮发麻。
第三版方案:用Redis做防重令牌
痛定思痛,我们决定引入Redis,核心思路是:用Redis的原子性操作来保证“一单一生成”。
具体做法:
下单前生成唯一令牌
用户点击“购买”时,前端先请求一个下单令牌(token),这个令牌有有效期(比如30秒)。
import uuid
import redis
r = redis.Redis()
def generate_token(user_id, product_id):
token = str(uuid.uuid4())
key = f"order_token:{user_id}:{product_id}"
r.setex(key, 30, token) # 30秒过期
return token
下单时校验令牌
用户提交订单时,必须带上这个token,后端用Lua脚本保证原子性:
-- Lua脚本:检查并删除token
local key = KEYS[1]
local token = ARGV[1]
local current_token = redis.call('GET', key)
if current_token == token then
redis.call('DEL', key)
return 1 -- 校验通过
else
return 0 -- 校验失败
end
结合业务逻辑
假设用户连点三次:
- 第一次点击:生成tokenA → 用tokenA下单 → 校验通过 → 删除tokenA
- 第二次点击:tokenA已被删除 → 校验失败 → 提示“请勿重复提交”
- 第三次点击:同上
这样就实现了“一次点击,一单生成”。
但这里有个坑: 如果第一次点击后,用户没有完成支付,那么token已经被删除了,这时候他想重新下单,必须重新获取token。
这个用户体验并不好,所以我们改成:token在订单创建前被删除,但订单创建后,如果未支付,允许用户在一定时间内重新发起支付(不是重新下单)。
第四版方案:幂等性与去重双保险
仅仅靠Redis token还不够,因为极端情况下Redis可能宕机,或者网络抖动导致校验失败,我们做了双重保险。
幂等性设计
每笔订单在创建时,我们生成一个唯一的“业务幂等键”,这个键由 user_id + product_id + 时间戳(精确到秒) 组成,在数据库层面,这个幂等键是唯一索引。
ALTER TABLE orders ADD UNIQUE INDEX idx_idempotent (idempotent_key);
创建订单时:
def create_order(user_id, product_id, timestamp):
idempotent_key = f"{user_id}:{product_id}:{timestamp}"
try:
order = Order(idempotent_key=idempotent_key, ...)
db.session.add(order)
db.session.commit()
except IntegrityError:
# 重复键异常,说明是重复订单
return None
这样,即便Redis失灵,数据库层面也能挡住重复订单。
支付回调去重
有些用户可能会绕过下单流程,直接调用支付接口,我们做了支付回调的幂等处理。
支付回调处理逻辑:
def handle_payment_callback(order_id, payment_id):
key = f"payment:{order_id}:{payment_id}"
if redis.setnx(key, "processing"): # 如果设置成功,说明是第一次处理
redis.expire(key, 60)
# 执行业务逻辑:更新订单状态、发货
else:
# 重复回调,直接忽略
return
这个用到了Redis的 SETNX 命令,保证同一个支付回调只被处理一次。
数据验证:重复率从千分之三降到十万分之一
上线这套方案后,我们持续监控了一个月。
改进前(仅查订单表):
- 总订单量:310,428
- 重复订单:931
- 重复率:0.3%
改进后(Redis token + 幂等键 + 支付去重):
- 总订单量:287,156
- 重复订单:3(全是极端边界情况,如Redis故障切换期间)
- 重复率:0.001%
从千分之三到十万分之一,这个提升我们很满意,而且剩下的那3单,我们已经通过日志回溯和人工复核处理了。
- 不要只依赖数据库级别的唯一索引,在高并发下,事务隔离级别可能导致幻读,唯一索引阻挡不了所有情况。
- Redis不是银弹,Redis集群的网络分区、主从切换都可能导致token丢失或重复生成,所以一定要配合数据库的幂等键。
- 前端也要做防重,虽然不能完全依赖前端,但按钮置灰、loading状态、点击后禁用等都能减少后端压力。
- 支付渠道的去重要独立做,因为支付回调和下单流程是异步的,可能会有用户先支付再下单(比如从支付页面直接跳转回来)。
- 监控和告警不能少,即使去重做到了99.99%,也要有系统监控剩余的那0.01%,一旦发现异常立即人工介入。
写在最后
订单重复检测,看似是一个小功能,但它是电商系统的“隐形成本”放大器。
每次重复订单,不仅是经济上的损失,更是用户体验的消耗,用户会觉得“这个平台不靠谱”,客服要花时间解释,运营要花精力处理退款。
链动小铺从一个日订单几百单的小站,发展到日均万单的规模,每一步都踩过坑,订单重复检测这个“小问题”,我们迭代了四个版本才找到相对稳妥的方案。
如果你也在做类似的发卡网或电商系统,希望这篇文章能帮你少走一些弯路。防重不是一件“做了就行”的事,而是一件“要做到极致”的事。
毕竟,没人愿意在凌晨两点,看着重复订单数据发呆。
你有类似的经历吗?欢迎在评论区分享你的订单防重经验。
本文链接:https://ldxp.top/news/6183.html
