Files
autumn-recruitment/05.架构/idempotency/幂等设计方案.md
T

11 KiB
Raw Blame History

tags, create time, update time
tags create time update time
arch/idempotency
unique-index
token-pattern
optimistic-lock
state-machine
request-id
2026-08-08 18:00 2026-08-08 18:00

幂等设计方案

概述

幂等是指"多次执行同一操作与执行一次的效果相同"。在分布式系统中,网络重试、消息重复投递、用户重复点击都会导致同一个业务请求被执行多次。本文对比六种主流幂等方案的实现原理、适用场景和性能特征。

核心原理

方案一:数据库唯一索引防重

利用数据库的唯一约束(UNIQUE INDEX)作为最底层的幂保证:

flowchart LR
    R[请求到达] --> Q{SELECT count(1) FROM order_idemp WHERE biz_no='xxx'}
    Q -- 已存在 --> F[返回已有结果]
    Q -- 不存在 --> U[INSERT INTO ...]
    U -- 成功 --> S[执行业务逻辑]
    U -- Duplicate key error --> F

实现步骤:

  1. 业务流水号(biz_no / trade_no)作为唯一约束字段。
  2. INSERT 时如果发生 Duplicate entry 错误,说明已被处理过。
  3. 查询该笔流水对应的结果直接返回,不执行业务逻辑。
// MyBatis 插入幂等记录
@Insert("INSERT INTO idempotent_record (biz_no, status, result) VALUES (#{bizNo}, 'PROCESSING', null)")
int insertRecord(@Param("bizNo") String bizNo);

try {
    int rows = mapper.insertRecord(bizNo);
    // 首次执行 — 执行业务逻辑
    processOrder(order);
    mapper.updateStatus(bizNo, "SUCCESS", resultId);
} catch (DuplicateKeyException e) {
    // 重复请求 — 查询已有结果返回
    IdempotentRecord record = mapper.selectByBizNo(bizNo);
    return new Result(record.getResultId(), record.getStatus());
}
优点 缺点
零代码侵入(只需要一个唯一索引) 需要额外的幂等表/列占用存储
强一致性(数据库事务保证) 高并发下 INSERT 可能成为瓶颈
实现简单直观 无法区分"处理中"和"已完成"

Tip

面试常考点:唯一索引的幂等方案只能防止重复写入,不能防止"先查后写"中的 ToCToU 竞态(Time-of-check to Time-of-use)。必须把判断和写入打包成一个原子操作,要么用 INSERT IGNORE,要么用悲观锁 SELECT FOR UPDATE。

方案二:Token 预获取模式

适用于所有不可重入的业务操作(支付、转账、发货):

stateDiagram-v2
    [*] --> UNUSABLE: Token 创建 → 标记为不可用
    UNUSABLE --> USED: 业务提交成功 → 设为已使用
    UNUSABLE --> EXPIRED: 超过 TTL 自动过期
    USED --> [*]: 完成
    EXPIRED --> [*]: 回收

流程:

  1. 客户端先调用 /token/generate 接口获取一个一次性 Token。
  2. 服务端将 Token 存入 Redis,状态为 UNUSABLE,设置过期时间(如 5 分钟)。
  3. 客户端携带 Token 发起实际业务请求。
  4. 服务端用 Lua 脚本原子性地检查并消费 Token:if redis.call('get', key) == val then redis.call('set', key, 'USED') end。
-- Redis Lua 脚本:原子性检查 + 消费 Token
local key = KEYS[1]
local expected = ARGV[1]

if redis.call("GET", key) == expected then
    redis.call("SET", key, "USED", "EX", 0)
    return 1 -- 允许执行
end
return 0 -- 拒绝:Token 不存在或已被消费
优点 缺点
强幂等(一次消耗,永不复用) 多一次 API 调用(先领 token 再提交)
可防止 CSRF(token 绑定会话) Token 丢失 = 请求失效
自带时效控制(TTL) 需要额外管理 Token 的生命周期

Warning

Token 模式不适合短延迟批处理。例如批量导入数据,每次操作都去领一个 Token 会严重拖慢用户体验。此时更适合用唯一索引方案。

方案三:乐观锁版本号校验

适用于更新类操作的幂等——基于 CAS(Compare-and-Swap)思想:

UPDATE account SET balance = balance - 100, version = version + 1
WHERE user_id = 123 AND version = 5;

如果另一个请求已经将 version 从 5 改为 6,此语句的 affected_rows = 0,表示冲突了。

func UpdateWithOptimisticLock(ctx context.Context, sqlxDB *sqlx.DB, userID, amount int, expectedVersion int) error {
    query := `UPDATE orders SET status = ?, version = version + 1 WHERE id = ? AND version = ?`
    result, err := sqlxDB.ExecContext(ctx, query, "PAID", userID, expectedVersion)
    affected, _ := result.RowsAffected()
    if err != nil || affected == 0 {
        return fmt.Errorf("optimistic lock conflict: expected version %d but got stale data", expectedVersion)
    }
    return nil
}
优点 缺点
不需要额外的表或列 仅适用于更新操作,不适用于插入
读多写少时冲突率低 高并发下频繁回退,用户体验差
天然支持重试(换 version 重新提交) 无法阻止不同请求同时成功

方案四:状态机校验

适用于有明确生命周期管理的业务流程,如订单、工单、审批流:

flowchart LR
    A["CREATED"] -->|"支付"| B["PAID"]
    B -->|"发货"| C["SHIPPED"]
    C -->|"签收"| D["COMPLETED"]
    B -->|"退款"| E["REFUNDING"]
    E -->|"退款成功"| F["REFUNDED"]
    
    classDef invalid fill:#f99,color:#fff
    A -.->|"不允许直接到 COMPLETED"| G["COMPLETED"]: invalid
    G ::: invalid

核心规则: 每个业务动作只允许从特定前驱状态转换到特定的后继状态。

public enum OrderStatus {
    CREATED, PAID, SHIPPED, COMPLETED, REFUNDED;

    public static boolean canTransitionFrom(OrderStatus from, OrderStatus to) {
        return switch (to) {
            case PAID     -> from == CREATED;
            case SHIPPED  -> from == PAID;
            case COMPLETED-> from == SHIPPED;
            case REFUNDED -> from == REFUNDING;
            default       -> false;
        };
    }
}

// 幂等判断
public void payOrder(String orderId) {
    Order order = orderRepo.findById(orderId);
    if (!OrderStatus.canTransitionFrom(order.getStatus(), OrderStatus.PAID)) {
        throw new BusinessException("INVALID_TRANSITION: cannot PAY from " + order.getStatus());
    }
    // ... 执行业务逻辑
}
优点 缺点
语义清晰,天然防重 + 业务合规 需要在每个接口处做状态检查
能识别非法请求(不只是重复) 状态空间爆炸时难以维护
不需要额外数据结构

Note

状态机是最常被忽略的幂等手段。它既是约束也是保护——不仅拦截重复请求,还能拦截非法的请求序列(如未创建就付款)。

方案五:网关层 Request ID 去重

适合 API 网关统一处理,作为第一道防线:

sequenceDiagram
    participant Client as 客户端
    participant GW as API Gateway
    participant Redis as 去重缓存
    participant Backend as 后端服务

    Client->>GW: POST /api/pay {RequestId: uuid-a, Body:{...}}
    GW->>Redis: SETNX reqid:uuid-a 1 EX 300s
    Redis-->>GW: 1 (首次)
    GW->>Backend: 转发请求
    Backend-->>GW: 响应
    GW-->>Client: 响应

后续同一个 RequestId 到达时:

  • SETNX 返回 0(key 已存在)→ 直接从本地缓存或 DB 中取出之前的响应直接返回。
  • 这个方案的局限性在于:如果后端真正执行了但还没返回,网关已经缓存了中间状态。
// 网关层幂等拦截器伪代码
public Response handle(Request req) {
    String requestId = req.getHeader("X-Request-ID");
    String cacheKey = "reqid:" + requestId;

    if (redis.exists(cacheKey)) {
        return cachedResponse.get(requestId); // 直接返回之前结果
    }

    Response resp = forwardToBackend(req);

    // 缓存响应,避免重复请求再次走后端
    redis.setex(cacheKey, 300, serialize(resp));
    return resp;
}
优点 缺点
无业务侵入,网关层统一处理 只保护单次请求,跨重试窗口外的重复无法覆盖
可以记录请求全链路审计日志 缓存响应的大小限制了适用场景
对后端完全透明 RequestId 需要客户端配合生成

各方案综合对比

方案 一致性级别 实现复杂度 适用场景 性能影响
唯一索引防重 强一致 低 所有写操作兜底 低(DB 唯一约束开销很小)
Token 预获取 强一致 中 支付/转账等高风险操作 中(多一次 Redis 交互)
乐观锁版本 最终一致 低 计数器更新、库存扣减 低(单行 SELECT FOR UPDATE 替代)
状态机校验 最终一致 中 订单、工单等生命周期明确的业务 低(内存判断)
网关 Request ID 弱一致 中 API 入口通用防护 低(缓存命中率决定)

代码示例

Go 中使用唯一索引 + 事务的完整幂等 handler:

type IdempotentHandler struct {
    db *sql.DB
}

func (h *IdempotentHandler) Handle(bizID string, payload interface{}) (*Result, error) {
    tx, err := h.db.BeginTx(context.Background(), nil)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()

    var existing string
    tx.QueryRow("SELECT status FROM idempotent WHERE biz_id=?", bizID).Scan(&existing)
    if existing == "done" {
        return h.getCachedResult(bizID), nil // 幂等返回
    }

    if _, err := tx.Exec("INSERT INTO idempotent(biz_id,status) VALUES($1,'processing')", bizID); err != nil {
        if isDuplicate(err) {
            return h.getCachedResult(bizID), nil
        }
        return nil, err
    }

    result := executeBusiness(payload)
    tx.Exec("UPDATE idempotent SET status='done',result=$1 WHERE biz_id=$2", result, bizID)
    tx.Commit()
    return result, nil
}

实践场景

秋招高频问题:

  • "幂等和去重有什么区别?" — 幂等强调语义:多次执行结果相同;去重强调数据:相同的请求只出现一次。去重是手段,幂等是目标。用唯一索引去重是为了达到幂等效果。
  • "为什么不建议只用 Redis SETNX 做业务幂等?" — Redis 非持久化,宕机可能丢数据(即使有 AOF fsync=1 也有窗口期)。而且 SETNX 只能保证互斥,不能保证"之前是否已经成功执行过"——除非你在 Redis 里也存一份状态。这本质上是把数据库该做的事搬到了 Redis,增加了复杂度和不一致风险。
  • "最佳实践组合是什么?" — 推荐三层防御:① 网关层 Request ID 拦截重复流量 ② Token 模式控制高风险操作 ③ 数据库唯一索引做最后兜底。三道防线互为补充,任何一层被突破都有下一层兜住。

Tip

面试加分回答:幂等的根本原因是分布式系统的不可靠性(网络重试、消息队列至少一次投递)。解决思路不是"消灭重复"(不可能),而是"让重复变得无害"。所有方案的核心设计原则都是:用一个确定的 key(biz_no / token / request_id)来标识一次业务意图,然后用这个 key 做去重判断。

关联笔记