接口幂等性设计:令牌、去重表与状态机
接口幂等性设计:令牌、去重表与状态机
在现代分布式系统和高并发场景下,保证接口的幂等性至关重要。一次网络超时、用户重复点击或 MQ 消息重投都可能导致同一请求被多次执行,若接口不具备幂等性,就会产生重复扣款、重复下单、数据错乱等严重问题。本教程将带你深入理解幂等性设计的本质,并详解三种最经典、最实用的实现方案:令牌机制、去重表和状态机。
1. 什么是接口幂等性?
幂等性原本是数学和函数式编程中的概念,指一次或多次执行同一个操作,产生的结果完全相同,且不会引发额外的副作用。
在 HTTP/API 的语境下,幂等性可以这样理解:
客户端对同一接口发起的一次或多次相同的请求,服务端最终的处理结果与只执行一次的效果一致。
需要特别注意的是:幂等性并不要求接口返回的数据每次都完全一样(比如时间戳可能不同),但强调业务状态和数据状态不受重复执行的影响。
为什么需要幂等性?
- 网络重试:客户端因为超时未收到响应,发起重试。
- 用户误操作:抢购按钮连点、表单重复提交。
- 消息队列重投:消费者处理成功但 ACK 丢失,导致消息再次消费。
- 服务内部重试:微服务间 RPC 调用因熔断、降级自动重试。
2. 三种经典幂等性实现方案
根据不同的业务场景,我们可以选择相应的幂等性设计方案。下面依次介绍令牌机制、去重表和状态机。
2.1 令牌机制 (Token)
令牌机制的核心思想是:在执行业务操作前必须先获取一次性凭证,操作时校验凭证是否有效,用完即失效。它适用于“先占位后执行”的场景,比如防止表单重复提交、防止重复点击。
实现原理
- 获取令牌:客户端在进入业务页面时(或提交前),先调用服务端接口申请一个全局唯一的 Token,服务端将该 Token 存入 Redis(设置合理过期时间)。
- 提交请求:客户端提交业务请求时,将 Token 放入请求头或参数中。
- 服务端校验:服务端使用 Lua 脚本或 Redis 原子命令检查 Token 是否存在,若存在则删除并继续执行业务逻辑;若不存在则直接返回“请勿重复提交”的错误。
- 删除即一次性: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. 总结
接口幂等性是分布式系统中的必备防御手段。本教程详细剖析了令牌、去重表和状态机这三种核心实现:
- 令牌适用于“先申请、后提交”的防重场景,简单高效;
- 去重表利用唯一索引,提供最严格的业务去重,适合资金交易;
- 状态机融合业务流转,优雅解决有状态操作的幂等,充分体现领域建模价值。
理解它们的设计哲学与适用边界,你将能够从容应对绝大多数的幂等性需求,构建出更健壮、更可靠的系统。