支付系统开发(五):幂等性与防重设计

支付系统里"幂等"这个词出现的频率极高。简单说就是:同一个请求执行一次和执行多次的效果必须完全一样。听起来简单,但要做好挺费心思的。

为什么支付必须幂等

网络是不可靠的。用户点了支付按钮,请求发出去了,但迟迟没收到响应。这时候可能有三种情况:

  1. 请求压根没到服务端——用户重试没问题
  2. 服务端处理成功了但响应丢了——用户重试会导致重复扣款
  3. 服务端处理失败了——用户重试没问题

情况 2 最危险。如果没有幂等机制,用户可能被扣两次钱。这在支付领域是绝对不能接受的。

幂等键设计

幂等的核心思路:用一个唯一标识来识别"同一个请求"。

方案一:客户端生成请求 ID

前端在发起请求时生成一个 UUID 作为 requestId,放在请求头或请求体里。后端用这个 requestId 来判断是否已经处理过。

type PayRequest struct {
    RequestID string `json:"request_id" binding:"required"`
    OrderNo   string `json:"order_no"`
    Amount    int64  `json:"amount"`
    Channel   string `json:"channel"`
}

优点是简单通用。缺点是客户端可能每次重试都生成新的 requestId(如果前端没做好的话),导致幂等失效。

方案二:业务流水号

用业务本身的唯一标识作为幂等键。比如支付场景用"订单号+支付渠道"作为幂等键,退款场景用"退款单号"。

idempotentKey := fmt.Sprintf("pay:%s:%s", req.OrderNo, req.Channel)

这个方案更可靠,因为业务流水号在整个流程中是不变的,不管重试多少次都是同一个 key。我最终选的是这个方案。

Redis + Lua 实现分布式幂等校验

幂等校验的逻辑:

  1. 用幂等键查 Redis,如果存在则说明已经处理过,直接返回之前的结果
  2. 如果不存在,设置幂等键(带过期时间),继续处理业务逻辑
  3. 处理完成后,更新幂等键的值为处理结果

这里有个并发问题:两个相同的请求同时到达,都查到 Redis 中不存在,然后都去处理。所以"查询+设置"必须是原子操作,用 Lua 脚本:

-- idempotent_check.lua
-- KEYS[1] = 幂等键
-- ARGV[1] = 过期时间(秒)
-- ARGV[2] = 初始状态值(processing)
-- 返回: 0=首次请求(已设置), 1=重复请求(返回已有值)

local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local init_val = ARGV[2]

local exists = redis.call('GET', key)
if exists then
    return {1, exists}
end

redis.call('SET', key, init_val, 'EX', ttl)
return {0, init_val}

Go 代码调用:

const idempotentScript = `
local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local init_val = ARGV[2]
local exists = redis.call('GET', key)
if exists then
    return exists
end
redis.call('SET', key, init_val, 'EX', ttl)
return 'NEW'
`

func CheckIdempotent(ctx context.Context, rdb *redis.Client, key string, ttl int) (bool, string, error) {
    result, err := rdb.Eval(ctx, idempotentScript, []string{key}, ttl, "PROCESSING").Result()
    if err != nil {
        return false, "", err
    }
    
    val := result.(string)
    if val == "NEW" {
        return true, "", nil // 首次请求
    }
    return false, val, nil // 重复请求,返回之前的结果
}

幂等键的过期时间

设多长合适?太短了可能请求还没处理完就过期了,太长了浪费内存。我的设置是 24 小时。支付请求不太可能隔一天还重试。

处理完成后更新结果

请求处理完后,需要把结果写回幂等键:

func UpdateIdempotentResult(ctx context.Context, rdb *redis.Client, key string, result string, ttl int) error {
    return rdb.Set(ctx, key, result, time.Duration(ttl)*time.Second).Err()
}

这样后续的重复请求可以直接返回上次的结果,不需要再走业务逻辑。

数据库唯一索引兜底

Redis 幂等是第一道防线,但 Redis 不是 100% 可靠的(比如 Redis 挂了、主从切换时 key 丢了)。所以需要数据库层面的兜底。

在支付流水表上加唯一索引:

CREATE TABLE payment_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL,
    channel VARCHAR(32) NOT NULL,
    amount BIGINT NOT NULL,
    status TINYINT NOT NULL,
    -- ...
    UNIQUE KEY uk_order_channel (order_no, channel)
);

即使 Redis 幂等校验失效,插入数据库时唯一索引也会阻止重复记录。代码里捕获 duplicate key 错误并优雅处理:

func CreatePaymentOrder(ctx context.Context, order *PaymentOrder) error {
    err := db.WithContext(ctx).Create(order).Error
    if err != nil {
        if isDuplicateKeyError(err) {
            // 重复请求,查询已有记录返回
            existing, _ := GetPaymentOrderByNo(ctx, order.OrderNo, order.Channel)
            return &DuplicateError{Existing: existing}
        }
        return err
    }
    return nil
}

前端防重提交

后端幂等是兜底,前端也要做防重:

  1. 按钮置灰:点击后立即 disable 按钮,防止用户手快多点
  2. Loading 状态:显示 loading 动画,给用户反馈"正在处理中"
  3. 请求去重:前端维护一个请求中的标记,相同请求还在等待响应时不允许再次发起
let paying = false;

async function handlePay() {
    if (paying) return;
    paying = true;
    
    try {
        const result = await api.createPayment(orderData);
        // 处理成功
    } catch (err) {
        // 处理失败
    } finally {
        paying = false;
    }
}

完整流程

把前端和后端的防重串起来:

1. 前端点击支付 → 按钮置灰 + paying=true
2. 请求到后端 → Redis Lua 脚本检查幂等键
3. 幂等键不存在 → 设置 PROCESSING → 继续处理
4. 调用支付渠道 → 获取结果
5. 更新幂等键为结果 → 更新数据库
6. 返回前端 → paying=false + 按钮恢复

重复请求路径:
2. 请求到后端 → Redis Lua 脚本检查幂等键
3. 幂等键存在 → 直接返回已有结果(不再执行业务逻辑)

一些细节

  1. 幂等键的 PROCESSING 状态:如果一个请求设置了 PROCESSING 但处理过程中崩溃了,后续请求会读到 PROCESSING 状态。我的处理是:如果读到 PROCESSING 且已经超过一定时间(比如 60 秒),就认为上次处理失败了,允许重新处理。

  2. 返回值一致性:幂等接口对于重复请求要返回和首次请求完全一致的结果(包括 HTTP 状态码)。不能首次返回 200,重复返回 409。

  3. GET 请求天然幂等:只有会改变状态的操作(POST、PUT、DELETE)需要做幂等处理。GET 请求读取数据,天然幂等。

  4. 幂等 vs 去重:幂等是"执行多次效果一样",去重是"只执行一次"。在支付场景这两个效果等价,但概念上不同。

幂等和防重是支付系统的生命线。做好了用户无感知,做不好就是资金事故。宁可多加一层校验,也不能漏掉一个重复请求。