接口幂等性设计:令牌、去重表与状态机

FreeGuideOnline 最新 2026-06-16

接口幂等性设计:令牌、去重表与状态机

在现代分布式系统和高并发场景下,保证接口的幂等性至关重要。一次网络超时、用户重复点击或 MQ 消息重投都可能导致同一请求被多次执行,若接口不具备幂等性,就会产生重复扣款、重复下单、数据错乱等严重问题。本教程将带你深入理解幂等性设计的本质,并详解三种最经典、最实用的实现方案:令牌机制、去重表和状态机。


1. 什么是接口幂等性?

幂等性原本是数学和函数式编程中的概念,指一次或多次执行同一个操作,产生的结果完全相同,且不会引发额外的副作用。
在 HTTP/API 的语境下,幂等性可以这样理解:

客户端对同一接口发起的一次或多次相同的请求,服务端最终的处理结果与只执行一次的效果一致。

需要特别注意的是:幂等性并不要求接口返回的数据每次都完全一样(比如时间戳可能不同),但强调业务状态和数据状态不受重复执行的影响

为什么需要幂等性?

  • 网络重试:客户端因为超时未收到响应,发起重试。
  • 用户误操作:抢购按钮连点、表单重复提交。
  • 消息队列重投:消费者处理成功但 ACK 丢失,导致消息再次消费。
  • 服务内部重试:微服务间 RPC 调用因熔断、降级自动重试。

2. 三种经典幂等性实现方案

根据不同的业务场景,我们可以选择相应的幂等性设计方案。下面依次介绍令牌机制去重表状态机

2.1 令牌机制 (Token)

令牌机制的核心思想是:在执行业务操作前必须先获取一次性凭证,操作时校验凭证是否有效,用完即失效。它适用于“先占位后执行”的场景,比如防止表单重复提交、防止重复点击。

实现原理

  1. 获取令牌:客户端在进入业务页面时(或提交前),先调用服务端接口申请一个全局唯一的 Token,服务端将该 Token 存入 Redis(设置合理过期时间)。
  2. 提交请求:客户端提交业务请求时,将 Token 放入请求头或参数中。
  3. 服务端校验:服务端使用 Lua 脚本或 Redis 原子命令检查 Token 是否存在,若存在则删除并继续执行业务逻辑;若不存在则直接返回“请勿重复提交”的错误。
  4. 删除即一次性:Token 被删除后不可重复使用,从而保证核心业务逻辑只会被执行一次。

流程示例(订单提交)

客户端                          服务端(Redis)
  |                                 |
  |--- 1. GET /api/token ---------->|
  |<-- 2. 返回 token: "uuid-123"   |
  |                                 |
  |--- 3. POST /api/order          |
  |      header: X-Token: uuid-123 |
  |                                 |-- 4. 检查并删除 token
  |                                 |      成功 -> 执行创建订单
  |                                 |      失败 -> 返回重复提交
  |<-- 5. 响应订单结果 -----------|

代码演示 (Go pseudo)

// 获取 token
func GetToken(ctx context.Context, userID string) (string, error) {
    token := uuid.New().String()
    err := redis.Set(ctx, "token:"+token, userID, 5*time.Minute).Err()
    return token, err
}

// 实际业务接口
func SubmitOrder(ctx context.Context, token string, req OrderReq) error {
    // 使用 Lua 保证原子性:存在则删除,并返回成功
    script := `
        if redis.call("exists", KEYS[1]) == 1 then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `
    result, err := redis.Eval(ctx, script, []string{"token:" + token}).Result()
    if err != nil || result.(int64) == 0 {
        return errors.New("重复请求或token已失效")
    }
    // 执行业务...
    return createOrder(req)
}

优缺点分析

优点 缺点
实现简单,只需依赖 Redis 需要额外的获取 Token 步骤
天然防止重复提交,实时性高 若业务未执行但 Token 被误删,会导致请求失败(可通过重试+新Token补偿)
可灵活设置有效期 对于无需先申请 Token 的场景(如回调)不适用

2.2 去重表 (唯一索引去重)

去重表方案借用数据库的唯一约束特性,将“请求唯一标识”作为数据表的一个唯一字段,插入时由数据库保证唯一性,业务处理前先尝试插入,插入成功则继续,失败则视为重复请求

实现原理

  • 为每个需要幂等的请求定义一个全局唯一 ID(可由客户端生成并传入,如订单 ID 或业务序列号;也可由服务端根据请求参数哈希生成)。
  • 在数据库中建一张去重表(或称为幂等表),核心字段包含 unique_id,并设置唯一索引。
  • 开启一个事务:先向去重表插入一条记录(包含 unique_id、业务状态、创建时间等),若插入成功则执行后续业务操作,并更新去重表状态为“已处理”;若插入失败(唯一约束冲突),则判断该记录的状态:
    • 处理中:返回“操作处理中,请稍后查询结果”。
    • 已成功:直接返回上次处理的结果(需存储结果快照)。
    • 已失败:则根据业务决定是否重试,通常返回明确失败原因。
  • 最终更新去重表状态。

表结构设计

CREATE TABLE idempotent_record (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    unique_id VARCHAR(128) NOT NULL COMMENT '业务唯一标识',
    status TINYINT NOT NULL COMMENT '处理状态:1-处理中,2-成功,3-失败',
    result TEXT COMMENT '处理结果(JSON)',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_unique_id (unique_id)
) ENGINE=InnoDB;

业务执行流程

func ProcessBiz(tx *sql.Tx, uniqueID string, bizData BizReq) error {
    // 1. 插入幂等记录
    _, err := tx.Exec("INSERT INTO idempotent_record (unique_id, status) VALUES (?, 1)", uniqueID)
    if err != nil {
        // 重复键冲突
        if isDuplicateKeyError(err) {
            return handleDuplicate(tx, uniqueID) // 查询已有状态并返回对应结果
        }
        return err
    }
    // 2. 执行核心业务
    result, bizErr := doBizLogic(bizData)
    newStatus := 2
    if bizErr != nil {
        newStatus = 3
    }
    // 3. 更新幂等记录
    _, updErr := tx.Exec("UPDATE idempotent_record SET status=?, result=? WHERE unique_id=?", newStatus, result, uniqueID)
    if updErr != nil {
        return updErr
    }
    return bizErr
}

优缺点分析

优点 缺点
强一致性,依赖关系型数据库的 ACID 特性 高并发下,唯一索引冲突会影响性能,可结合缓存
可以持久化保存操作历史与结果,方便查问题 需要额外的数据库表和维护逻辑
适用于任何需要绝对保证不重复的金融级场景 unique_id 的生成需保证全局唯一,客户端 id 不可信时需服务端规则校验

2.3 状态机 (乐观锁)

状态机方案通过规范业务对象的状态定义和状态转换规则,利用数据库行锁或乐观锁(CAS)来保证状态流转的原子性,从而间接实现幂等。

核心思想

  • 定义明确的状态集合允许的转移路径,例如订单状态:待支付 → 已支付 → 已发货 → 已完成。
  • 每次操作都基于当前状态进行前置判断:只有当对象处于特定前置状态时,才允许执行操作。
  • 数据更新时带上版本号或旧状态作为条件,UPDATE table SET status = 'new_status', version = version + 1 WHERE id = ? AND status = 'expected_status' AND version = old_version
  • 如果影响行数为 0,说明状态已被并发修改,或请求重复执行,直接返回幂等成功或冲突提示。

实例:订单支付

假设订单初始状态为 PENDING,支付接口检查订单状态并尝试更新:

UPDATE orders
SET status = 'PAID', pay_time = NOW(), version = version + 1
WHERE order_id = ? AND status = 'PENDING' AND version = ?;

在应用层实现:

func PayOrder(orderID string, currentVersion int) error {
    res, err := db.Exec(
        "UPDATE orders SET status='PAID', version=version+1 WHERE order_id=? AND status='PENDING' AND version=?",
        orderID, currentVersion,
    )
    if rowsAffected, _ := res.RowsAffected(); rowsAffected == 0 {
        // 查询当前状态
        var status string
        db.QueryRow("SELECT status FROM orders WHERE order_id=?", orderID).Scan(&status)
        if status == "PAID" {
            return nil // 幂等成功
        }
        return errors.New("订单状态异常,无法支付")
    }
    return nil
}

状态机+乐观锁的幂等保障

即使支付接口被重复调用 100 次,也只有第一次 UPDATE 会成功,后续尝试均因状态不再是 PENDING 或版本不匹配而失败,服务器只需检查当前状态即可返回幂等成功。

优缺点分析

优点 缺点
利用业务状态自然防重,无需额外去重表 只适用于状态驱动型业务
结合乐观锁可处理并发安全问题 状态复杂时需要维护状态机图,编码复杂度上升
可读性高,符合领域驱动设计 对无状态操作(如统计数据加一)不适用

3. 三种方案对比与选型

方案 应用场景 一致性 复杂度 性能
令牌机制 前端防重复提交、抢购点击、创建型操作 弱(依赖 Redis)
去重表 金融交易、订单回调、重度幂等要求 强(数据库 ACID) 中(可优化)
状态机 订单流转、工单状态、审批流 强(本地事务+乐观锁) 中高 高(无额外写入)

选型建议

  • 若只是防止用户短时间内重复点击,选用令牌机制足够,实现成本最低。
  • 若对接外部回调、或业务不允许出现任何重复(如扣款),请使用去重表
  • 若业务已经有清晰的状态流转,则直接利用状态机+乐观锁,一举两得。

在实践中,这几种方案可以组合使用,例如:前端用 Token 防重,后端服务间调用同时采用去重表,核心资产操作再用状态机兜底。


4. 实战注意事项

  • 唯一标识的生成策略:去重表的 unique_id 可以由“业务场景 + 业务主键 + 操作类型”组成,如 trade_pay_ORDER12345,避免全局主键碰撞。
  • 去重表的过期清理:随着时间推移,去重表会持续增长,需按日期分区或定期归档。
  • Redis 令牌的原子性:务必使用 Lua 或其他原子操作完成“校验 + 删除”,防止并发下的竞态条件。
  • 状态机的版本号传递:如果是微服务调用,需要下游服务传递版本号或旧状态,以便上游组装更新条件。
  • 返回码约定:对于幂等但非首次的请求,建议返回 200 OK 并带上同一个业务结果,而非错误码,避免上游当成异常重试。
  • 监控与告警:对重复请求量进行监控,若短时间内大量重复提交可能是上游重试风暴或脚本攻击。

5. 总结

接口幂等性是分布式系统中的必备防御手段。本教程详细剖析了令牌、去重表和状态机这三种核心实现:

  • 令牌适用于“先申请、后提交”的防重场景,简单高效;
  • 去重表利用唯一索引,提供最严格的业务去重,适合资金交易;
  • 状态机融合业务流转,优雅解决有状态操作的幂等,充分体现领域建模价值。

理解它们的设计哲学与适用边界,你将能够从容应对绝大多数的幂等性需求,构建出更健壮、更可靠的系统。