外观
支付重复提交避坑实录:三层幂等防重实战
问题背景:一次支付请求为什么会执行多次
在支付系统里,“请求只发送一次”通常只是客户端的美好愿望。用户连续点击支付按钮、App 超时后自动重试、网关重放请求、消息队列至少一次投递,都可能让同一个业务请求被服务端执行多次。只靠前端按钮置灰或 Redis 加锁,往往无法覆盖进程崩溃、锁过期、数据库主从切换等异常路径。
接口幂等性的目标不是禁止重复请求,而是让同一请求执行一次和执行多次的业务结果一致。支付场景建议采用三层防线:
- 第一层:幂等令牌,识别客户端的一次操作;
- 第二层:唯一索引,阻止同一业务键插入或扣款多次;
- 第三层:状态机,限制订单只能沿合法状态迁移。
下面用 Go、Gin、GORM 和 MySQL 实现一套可运行的最小闭环。
方案对比:只加分布式锁为什么不够
常见方案各有边界,不能相互替代。
| 方案 | 解决的问题 | 主要风险 |
|---|---|---|
| 前端禁用按钮 | 减少明显重复点击 | 抓包、重试、服务端重放均无效 |
| Redis SETNX 锁 | 拦截短时间并发请求 | 锁过期、误删、Redis 故障后语义不完整 |
| 幂等令牌 | 建立“一次操作”的唯一身份 | 令牌申请、消费、异常返回需设计完整 |
| 数据库唯一索引 | 提供最终落库强约束 | 只能拦截唯一键冲突,不能表达状态流转 |
| 状态机 | 防止已支付订单再次扣款 | 必须配合条件更新,避免读旧值后覆盖 |
核心原则是:缓存和锁用于降低冲突、改善体验;数据库唯一约束和条件更新才是最终一致性的裁决者。
数据库设计:唯一索引是支付防重的底线
先建立订单表、支付流水表和幂等请求表。支付流水必须以业务请求号建立唯一索引;订单状态使用整数枚举,状态迁移通过 WHERE 条件完成。
sql
CREATE TABLE pay_orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL COMMENT '单位:分',
status TINYINT NOT NULL COMMENT '0待支付 1处理中 2已支付 3失败',
version INT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_no (order_no)
);
CREATE TABLE pay_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
request_no VARCHAR(64) NOT NULL COMMENT '一次支付请求的唯一业务号',
amount BIGINT NOT NULL,
status TINYINT NOT NULL,
UNIQUE KEY uk_request_no (request_no),
KEY idx_order_no (order_no)
);
CREATE TABLE idempotent_requests (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
token VARCHAR(80) NOT NULL,
request_hash CHAR(64) NOT NULL,
status TINYINT NOT NULL COMMENT '0处理中 1成功 2失败',
response_body TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_token (token)
);注意,幂等令牌只能证明“客户端拿到了一次操作凭证”,不能证明订单不会被其他入口重复支付。因此 pay_records.uk_request_no 和订单状态条件更新仍然不能省略。
Go 实现:令牌申请、原子消费与状态机
下面示例使用 Gin + GORM。为了便于运行,先给出模型和核心服务。
go
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PayOrder struct {
ID int64 `gorm:"primaryKey"`
OrderNo string `gorm:"uniqueIndex:uk_order_no;size:64"`
Amount int64
Status int
Version int
}
type PayRecord struct {
ID int64 `gorm:"primaryKey"`
OrderNo string `gorm:"index:idx_order_no;size:64"`
RequestNo string `gorm:"uniqueIndex:uk_request_no;size:64"`
Amount int64
Status int
}
type IdempotentRequest struct {
ID int64 `gorm:"primaryKey"`
Token string `gorm:"uniqueIndex:uk_token;size:80"`
RequestHash string `gorm:"size:64"`
Status int
ResponseBody string `gorm:"type:text"`
}
const (
OrderPending = 0
OrderPaying = 1
OrderPaid = 2
OrderFailed = 3
)
const (
IdemProcessing = 0
IdemSuccess = 1
IdemFailed = 2
)
type PayService struct {
db *gorm.DB
// 单机演示用;多实例部署时应替换为 Redis 等待通知或短轮询
localWaits sync.Map
}
type PayRequest struct {
Token string `json:"token" binding:"required"`
OrderNo string `json:"order_no" binding:"required"`
RequestNo string `json:"request_no" binding:"required"`
Amount int64 `json:"amount"`
}
func hashRequest(r PayRequest) string {
// Token 是一次性凭证,不参与请求体指纹;业务参数参与哈希,防止令牌被挪用到其他订单
r.Token = ""
b, _ := json.Marshal(r)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}申请令牌时只插入一条未消费记录。真正关键的是支付时通过数据库行锁或唯一插入完成“原子消费”。
go
func (s *PayService) CreateToken(c *gin.Context) {
token := uuid.NewString()
if err := s.db.Create(&IdempotentRequest{Token: token, Status: IdemProcessing}).Error; err != nil {
c.JSON(500, gin.H{"message": "创建令牌失败"})
return
}
c.JSON(200, gin.H{"token": token})
}
func (s *PayService) Pay(c *gin.Context) {
var req PayRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"message": "参数错误"})
return
}
hash := hashRequest(req)
var idem IdempotentRequest
// 第一次请求:锁定令牌行;重复请求:读取首请求结果
err := s.db.Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("token = ?", req.Token).First(&idem).Error
if err != nil {
return errors.New("令牌不存在")
}
if idem.Status != IdemProcessing {
return nil
}
if idem.RequestHash == "" {
idem.RequestHash = hash
return tx.Model(&idem).Update("request_hash", hash).Error
}
if idem.RequestHash != hash {
return errors.New("令牌与请求参数不匹配")
}
return nil
})
if err != nil {
c.JSON(409, gin.H{"message": err.Error()})
return
}
// 已有成功结果,直接返回,不再次执行业务
if idem.Status == IdemSuccess {
c.Data(200, "application/json; charset=utf-8", []byte(idem.ResponseBody))
return
}
if idem.Status == IdemFailed {
c.JSON(409, gin.H{"message": "首次请求失败,请使用新令牌重试"})
return
}
resp, bizErr := s.doPay(req)
finalStatus := IdemSuccess
if bizErr != nil {
finalStatus = IdemFailed
}
// 保存首请求的最终响应。响应体应保持稳定,避免重复请求看到不同单号
s.db.Model(&IdempotentRequest{}).Where("token = ?", req.Token).
Updates(map[string]interface{}{"status": finalStatus, "response_body": resp})
if bizErr != nil {
c.JSON(409, gin.H{"message": bizErr.Error()})
return
}
c.Data(200, "application/json; charset=utf-8", []byte(resp))
}doPay 同时使用支付流水唯一索引和订单状态机。即使令牌服务异常、内部定时任务或 MQ 消费者绕过 HTTP 入口调用支付,也不会重复扣款。
go
func (s *PayService) doPay(req PayRequest) (string, error) {
err := s.db.Transaction(func(tx *gorm.DB) error {
var order PayOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_no = ?", req.OrderNo).First(&order).Error; err != nil {
return errors.New("订单不存在")
}
if order.Amount != req.Amount {
return errors.New("支付金额与订单金额不一致")
}
record := PayRecord{
OrderNo: req.OrderNo, RequestNo: req.RequestNo,
Amount: req.Amount, Status: OrderPaying,
}
// 唯一索引兜底:同一 request_no 并发插入时只有一个成功
if err := tx.Create(&record).Error; err != nil {
return errors.New("请求正在处理或已处理")
}
// 状态机:只有“待支付”可以进入“处理中”
res := tx.Model(&PayOrder{}).
Where("order_no = ? AND status = ? AND version = ?",
req.OrderNo, OrderPending, order.Version).
Updates(map[string]interface{}{"status": OrderPaying, "version": order.Version + 1})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("订单状态不允许支付")
}
return nil
})
if err != nil {
return "", err
}
// 此处调用三方支付渠道。渠道调用最好也携带 request_no 作为商户唯一单号
time.Sleep(100 * time.Millisecond)
// 渠道返回成功后,只允许“处理中”变为“已支付”
res := s.db.Model(&PayOrder{}).
Where("order_no = ? AND status = ?", req.OrderNo, OrderPaying).
Update("status", OrderPaid)
if res.Error != nil || res.RowsAffected == 0 {
return "", errors.New("支付确认失败,请进入对账流程")
}
resp := fmt.Sprintf(`{"order_no":"%s","request_no":"%s","status":"PAID"}`,
req.OrderNo, req.RequestNo)
return resp, nil
}
func main() {
dsn := "root:root@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic(err)
}
db.AutoMigrate(&PayOrder{}, &PayRecord{}, &IdempotentRequest{})
svc := &PayService{db: db}
r := gin.Default()
r.POST("/tokens", svc.CreateToken)
r.POST("/pay", svc.Pay)
http.ListenAndServe(":8080", r)
}并发请求下的正确语义
假设用户快速点击两次,两个请求携带同一个令牌和同一个 request_no:第一个请求拿到令牌并进入支付,第二个请求不能简单返回“处理中”后结束,否则客户端可能拿不到最终支付结果。更稳妥的做法是让重复请求短暂等待或轮询首请求状态,然后返回首请求保存的响应。
如果服务端在调用第三方渠道前崩溃,令牌可能停留在“处理中”,流水状态也是“处理中”。此时不能直接把订单改回待支付后重新扣款,而应先查询渠道或通过对账任务确认该 request_no 是否已经成功。支付系统的恢复原则是:不确定结果时,用同一业务单号查询或对账,不能生成新单号再次发起扣款。
避坑总结:生产环境至少注意这 5 点
只在 Redis 里
SETNX,数据库却没有唯一索引:常见现象是 Redis 锁过期或服务切换后出现两条支付流水。修正方法是为request_no、商户订单号等业务唯一键建数据库唯一索引,并把唯一键冲突视为重复请求,而不是系统异常。先查状态再无条件更新:代码先
SELECT status,判断不是已支付后执行UPDATE status=2,高并发下两个事务可能同时读到旧状态。修正方法是使用条件更新WHERE order_no=? AND status=?,检查RowsAffected,必要时加行锁或版本号。幂等令牌不校验请求指纹:令牌被前端缓存后,可能被用于另一个订单或另一个金额。修正方法是首次消费时记录订单号、金额等参数的哈希,重复请求必须比对,不一致直接拒绝。
失败响应也被永久缓存:参数错误、限流、数据库超时等临时失败如果写入成功幂等结果,会导致用户换网络后仍拿到旧错误。修正方法是区分业务终态失败与可重试失败;只有业务明确终态时才固化结果,临时失败应允许配合新令牌或同一业务单号重试。
第三方支付调用没有透传唯一单号:本系统防住了重复落库,但渠道侧可能生成两笔交易。修正方法是把
request_no作为渠道的商户请求号;超时后使用同一单号查询,禁止新建单号扣款,并通过对账任务补偿处理中订单。
最终落地时,可以把三层能力沉淀成统一中间件:入口校验幂等令牌,写库依赖唯一索引,订单服务维护状态机。这样既能覆盖用户重复提交,也能覆盖 MQ 重复投递、任务重跑和跨服务重试,支付接口才具备真正可验证的幂等性。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。