Contents

API 幂等性设计实战:四种方案对比与代码实现

为什么幂等性值得你认真对待

你写了一个支付接口,用户点击「确认支付」后网络抖动了一下,前端自动重试。结果用户被扣了两次钱。这个场景在分布式系统中每天都在发生——网络超时、消息队列重投、用户双击按钮,任何一个都可能触发重复请求。

幂等性的核心思想很简单:同一个请求执行一次和执行多次,结果应该完全一致。听起来简单,但实现方式有多种,每种都有适用场景和坑。下面我用 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 拦截明显重复,业务层用唯一索引或状态机保证数据一致,兜底用分布式锁防并发。不要指望一个方案解决所有问题,理解每种方案的本质——都是在回答同一个问题:「如何判断这个操作已经执行过了?」

关键原则:幂等不是可选优化,而是分布式系统的基本要求。在设计接口的第一天就考虑幂等,远比事后修复重复数据要省心得多。