跳到主内容

支付重复提交避坑实录:三层幂等防重实战 ​

问题背景:一次支付请求为什么会执行多次 ​

在支付系统里,“请求只发送一次”通常只是客户端的美好愿望。用户连续点击支付按钮、App 超时后自动重试、网关重放请求、消息队列至少一次投递,都可能让同一个业务请求被服务端执行多次。只靠前端按钮置灰或 Redis 加锁,往往无法覆盖进程崩溃、锁过期、数据库主从切换等异常路径。

接口幂等性的目标不是禁止重复请求,而是让同一请求执行一次和执行多次的业务结果一致。支付场景建议采用三层防线:

  • 第一层:幂等令牌,识别客户端的一次操作;
  • 第二层:唯一索引,阻止同一业务键插入或扣款多次;
  • 第三层:状态机,限制订单只能沿合法状态迁移。

下面用 Go、Gin、GORM 和 MySQL 实现一套可运行的最小闭环。

方案对比:只加分布式锁为什么不够 ​

常见方案各有边界,不能相互替代。

方案解决的问题主要风险
前端禁用按钮减少明显重复点击抓包、重试、服务端重放均无效
Redis SETNX 锁拦截短时间并发请求锁过期、误删、Redis 故障后语义不完整
幂等令牌建立“一次操作”的唯一身份令牌申请、消费、异常返回需设计完整
数据库唯一索引提供最终落库强约束只能拦截唯一键冲突,不能表达状态流转
状态机防止已支付订单再次扣款必须配合条件更新,避免读旧值后覆盖

核心原则是:缓存和锁用于降低冲突、改善体验;数据库唯一约束和条件更新才是最终一致性的裁决者。

重复支付请求点击/超时/MQ 重试第一层:幂等令牌原子消费并记录结果第二层:唯一索引业务单号不可重复落库第三层:状态机条件更新保证只扣一次

数据库设计:唯一索引是支付防重的底线 ​

先建立订单表、支付流水表和幂等请求表。支付流水必须以业务请求号建立唯一索引;订单状态使用整数枚举,状态迁移通过 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 点 ​

  1. 只在 Redis 里 SETNX,数据库却没有唯一索引:常见现象是 Redis 锁过期或服务切换后出现两条支付流水。修正方法是为 request_no、商户订单号等业务唯一键建数据库唯一索引,并把唯一键冲突视为重复请求,而不是系统异常。

  2. 先查状态再无条件更新:代码先 SELECT status,判断不是已支付后执行 UPDATE status=2,高并发下两个事务可能同时读到旧状态。修正方法是使用条件更新 WHERE order_no=? AND status=?,检查 RowsAffected,必要时加行锁或版本号。

  3. 幂等令牌不校验请求指纹:令牌被前端缓存后,可能被用于另一个订单或另一个金额。修正方法是首次消费时记录订单号、金额等参数的哈希,重复请求必须比对,不一致直接拒绝。

  4. 失败响应也被永久缓存:参数错误、限流、数据库超时等临时失败如果写入成功幂等结果,会导致用户换网络后仍拿到旧错误。修正方法是区分业务终态失败与可重试失败;只有业务明确终态时才固化结果,临时失败应允许配合新令牌或同一业务单号重试。

  5. 第三方支付调用没有透传唯一单号:本系统防住了重复落库,但渠道侧可能生成两笔交易。修正方法是把 request_no 作为渠道的商户请求号;超时后使用同一单号查询,禁止新建单号扣款,并通过对账任务补偿处理中订单。

最终落地时,可以把三层能力沉淀成统一中间件:入口校验幂等令牌,写库依赖唯一索引,订单服务维护状态机。这样既能覆盖用户重复提交,也能覆盖 MQ 重复投递、任务重跑和跨服务重试,支付接口才具备真正可验证的幂等性。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。