为什么幂等性值得你认真对待
你写了一个支付接口,用户点击「确认支付」后网络抖动了一下,前端自动重试。结果用户被扣了两次钱。这个场景在分布式系统中每天都在发生——网络超时、消息队列重投、用户双击按钮,任何一个都可能触发重复请求。
幂等性的核心思想很简单:同一个请求执行一次和执行多次,结果应该完全一致。听起来简单,但实现方式有多种,每种都有适用场景和坑。下面我用 Go 代码演示四种主流方案。
方案一:数据库唯一索引——最简单直接
利用数据库的唯一约束来防止重复处理。每笔业务请求携带唯一 ID(如订单号),写入时如果唯一索引冲突,说明已处理过。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
func ProcessPayment(db *sql.DB, reqID string, amount int64) error {
// 先插入请求记录,唯一索引兜底
_, err := db.Exec(
"INSERT INTO payment_requests (request_id, amount, status) VALUES (?, ?, 'processing')",
reqID, amount,
)
if err != nil {
// 唯一索引冲突 = 已处理过,直接返回成功
if isDuplicateKeyError(err) {
return nil // 幂等:已处理,不重复扣款
}
return err
}
// 执行扣款逻辑
err = chargeCard(amount)
if err != nil {
db.Exec("UPDATE payment_requests SET status='failed' WHERE request_id=?", reqID)
return err
}
db.Exec("UPDATE payment_requests SET status='success' WHERE request_id=?", reqID)
return nil
}
|
优点:实现简单,数据库层面强一致。
缺点:依赖数据库,高并发下可能成为瓶颈。注意失败请求会占用唯一索引,需要设计补偿机制(如定时清理 failed 记录或允许重试覆盖)。
方案二:Token 机制——防前端重复提交
服务端先发放一次性 Token,前端提交时携带,消费后即失效。适合表单提交场景。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
var tokenStore = sync.Map{} // 生产环境用 Redis
// 第一步:前端先请求获取 Token
func IssueToken() string {
token := uuid.New().String()
tokenStore.Store(token, true)
return token
}
// 第二步:提交时验证并消费 Token
func SubmitOrder(token string, orderData Order) error {
_, loaded := tokenStore.LoadAndDelete(token)
if !loaded {
return errors.New("token 已失效或重复提交")
}
return createOrder(orderData)
}
|
优点:在请求入口就拦截重复,不消耗下游资源。
缺点:需要前端配合改造,增加一次网络请求。Token 存储需要设过期时间,否则内存泄漏。
方案三:乐观锁状态机——适合状态流转
通过版本号或状态条件更新,确保只在预期状态下才执行操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
func CancelOrder(db *sql.DB, orderID string) error {
// 只有 pending 状态的订单才能取消
result, err := db.Exec(
`UPDATE orders
SET status = 'cancelled', updated_at = NOW()
WHERE id = ? AND status = 'pending'`,
orderID,
)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
// 要么订单不存在,要么状态已变(已被取消/已完成)
return nil // 幂等:已经是非 pending 状态,无需再取消
}
return nil
}
|
优点:无需额外存储,利用业务状态本身做幂等。
缺点:只适用于有明确状态流转的业务。WHERE 条件必须精心设计,确保状态转换是单向的。
方案四:分布式锁——通用兜底方案
用 Redis 分布式锁保证同一业务 key 同一时刻只有一个请求在处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func ProcessWithLock(rdb *redis.Client, bizKey string, fn func() error) error {
// 加锁,设置过期时间防止死锁
lockKey := "lock:" + bizKey
token := uuid.New().String()
ok, err := rdb.SetNX(ctx, lockKey, token, 30*time.Second).Result()
if err != nil || !ok {
return nil // 已有请求在处理,幂等返回
}
defer func() {
// 用 Lua 脚本安全释放锁,避免误删别人的锁
script := `if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else return 0 end`
rdb.Eval(ctx, script, []string{lockKey}, token)
}()
return fn()
}
|
优点:通用性强,对业务逻辑侵入小。
缺点:引入 Redis 依赖,锁过期时间不好定(太短业务没完成锁就释放,太长影响并发)。生产环境建议用 Redisson 或 etcd 等成熟方案。
四种方案怎么选
| 方案 |
适用场景 |
一致性保证 |
复杂度 |
| 唯一索引 |
支付、创建订单 |
强(DB 保证) |
低 |
| Token 机制 |
表单提交 |
中(Token 有效期内) |
中 |
| 乐观锁状态机 |
状态流转(取消、确认) |
强(DB 保证) |
低 |
| 分布式锁 |
通用兜底 |
中(锁有效期内) |
高 |
实践中往往是组合使用:入口用 Token 拦截明显重复,业务层用唯一索引或状态机保证数据一致,兜底用分布式锁防并发。不要指望一个方案解决所有问题,理解每种方案的本质——都是在回答同一个问题:「如何判断这个操作已经执行过了?」
关键原则:幂等不是可选优化,而是分布式系统的基本要求。在设计接口的第一天就考虑幂等,远比事后修复重复数据要省心得多。