支付系统里"幂等"这个词出现的频率极高。简单说就是:同一个请求执行一次和执行多次的效果必须完全一样。听起来简单,但要做好挺费心思的。
为什么支付必须幂等
网络是不可靠的。用户点了支付按钮,请求发出去了,但迟迟没收到响应。这时候可能有三种情况:
- 请求压根没到服务端——用户重试没问题
- 服务端处理成功了但响应丢了——用户重试会导致重复扣款
- 服务端处理失败了——用户重试没问题
情况 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 实现分布式幂等校验
幂等校验的逻辑:
- 用幂等键查 Redis,如果存在则说明已经处理过,直接返回之前的结果
- 如果不存在,设置幂等键(带过期时间),继续处理业务逻辑
- 处理完成后,更新幂等键的值为处理结果
这里有个并发问题:两个相同的请求同时到达,都查到 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
}
前端防重提交
后端幂等是兜底,前端也要做防重:
- 按钮置灰:点击后立即 disable 按钮,防止用户手快多点
- Loading 状态:显示 loading 动画,给用户反馈"正在处理中"
- 请求去重:前端维护一个请求中的标记,相同请求还在等待响应时不允许再次发起
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. 幂等键存在 → 直接返回已有结果(不再执行业务逻辑)
一些细节
-
幂等键的 PROCESSING 状态:如果一个请求设置了 PROCESSING 但处理过程中崩溃了,后续请求会读到 PROCESSING 状态。我的处理是:如果读到 PROCESSING 且已经超过一定时间(比如 60 秒),就认为上次处理失败了,允许重新处理。
-
返回值一致性:幂等接口对于重复请求要返回和首次请求完全一致的结果(包括 HTTP 状态码)。不能首次返回 200,重复返回 409。
-
GET 请求天然幂等:只有会改变状态的操作(POST、PUT、DELETE)需要做幂等处理。GET 请求读取数据,天然幂等。
-
幂等 vs 去重:幂等是"执行多次效果一样",去重是"只执行一次"。在支付场景这两个效果等价,但概念上不同。
幂等和防重是支付系统的生命线。做好了用户无感知,做不好就是资金事故。宁可多加一层校验,也不能漏掉一个重复请求。