必须用第三方交易号唯一索引+状态机更新实现幂等:先通过INSERT payment_transactions(trade_no唯一键)拦截重复,再WHERE status='unpaid'更新orders,最后校验ROW_COUNT()。

回调接口直接UPDATE orders表会重复扣款
微信或支付宝的支付成功回调,本质是“通知”,不是“指令”。很多项目在回调里直接写UPDATE orders SET status = 'paid',没加条件校验,一旦平台重发(比如网络超时后补发),就会把已支付订单再更新一遍——状态虽不变,但paid_at被覆盖、ROW_COUNT()返回0却无感知,更糟的是,如果回调里还混着扣余额、发券等逻辑,就真会重复执行。
必须用WHERE status = 'unpaid'做状态机约束
更新订单状态不是“设值”,而是“状态跃迁”。只允许从unpaid→paid,不允许paid→paid。否则就不是幂等,是裸奔。
面向 CRM 系统的 MySQL 8 数据库模式设计。当用户需要为客户关系管理(CRM)系统设计、评审、优化或生成数据库模式时,应使用此项技能。
UPDATE orders SET status = 'paid', paid_at = NOW() WHERE order_no = ? AND status = 'unpaid'- 执行后立刻检查
ROW_COUNT():等于1才说明真正更新了;等于0说明已被处理,直接返回成功,不走后续逻辑 - 不要用
SELECT status FROM orders WHERE order_no = ?再判断——这中间有并发窗口,查完是unpaid,下一毫秒别人就改成了paid
第三方交易号唯一索引是硬性兜底
光靠订单号防不住回调重复,因为同一笔订单可能对应多个支付渠道请求(比如用户先点微信失败,又点支付宝成功)。真正能唯一标识一次支付动作的,是第三方返回的trade_no(如微信的transaction_id、支付宝的trade_no)。
- 在
payment_transactions表上建UNIQUE KEY uk_trade_no (trade_no) - 回调收到
trade_no后,先INSERT INTO payment_transactions,带trade_no主键或唯一键 - 若插入失败且错误码是
1062(Duplicate entry),说明这笔支付已处理过,直接跳过整个业务逻辑 - 这个
INSERT必须和后续UPDATE orders在同一个事务里,否则INSERT成功但UPDATE失败,会导致资金流水有、订单状态没变
别信前端传来的order_no,必须用回调里的out_trade_no
回调参数里的out_trade_no(商户订单号)是第三方原样回传你发起预支付时给的值,可信;而前端JS拼接或篡改的order_no不可信,尤其当用户F12改请求时。
- 回调入口第一件事:从
$_POST['out_trade_no']或JSON body里取out_trade_no,而不是从URL query或header里读 - 验证签名前就该用它查库,确认订单存在且未过期——避免无效回调浪费资源
- 如果
out_trade_no查不到对应订单,直接返回失败,不进事务,也不记日志(防刷)
UPDATE加了WHERE status = 'unpaid',只要没前置用trade_no唯一索引挡掉重复,就仍有极小概率因事务提交顺序导致两次插入都成功(比如两个并发事务同时查到trade_no不存在,然后都插入)。所以UNIQUE KEY uk_trade_no不是可选项,是必须项。

















