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

289 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [arch/idempotency, unique-index, token-pattern, optimistic-lock, state-machine, request-id]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# 幂等设计方案
## 概述
幂等是指"多次执行同一操作与执行一次的效果相同"。在分布式系统中,网络重试、消息重复投递、用户重复点击都会导致同一个业务请求被执行多次。本文对比六种主流幂等方案的实现原理、适用场景和性能特征。
## 核心原理
### 方案一:数据库唯一索引防重
利用数据库的唯一约束(UNIQUE INDEX)作为最底层的幂保证:
```mermaid
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. 查询该笔流水对应的结果直接返回,不执行业务逻辑。
```java
// 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 预获取模式
适用于所有不可重入的业务操作(支付、转账、发货):
```mermaid
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`。
```lua
-- 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)思想:
```sql
UPDATE account SET balance = balance - 100, version = version + 1
WHERE user_id = 123 AND version = 5;
```
如果另一个请求已经将 version 从 5 改为 6,此语句的 `affected_rows = 0`,表示冲突了。
```go
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 重新提交) | 无法阻止不同请求同时成功 |
### 方案四:状态机校验
适用于有明确生命周期管理的业务流程,如订单、工单、审批流:
```mermaid
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
```
**核心规则:** 每个业务动作只允许从特定前驱状态转换到特定的后继状态。
```java
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 网关统一处理,作为第一道防线:
```mermaid
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 中取出之前的响应直接返回。
- 这个方案的局限性在于:如果后端真正执行了但还没返回,网关已经缓存了中间状态。
```java
// 网关层幂等拦截器伪代码
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:
```go
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 做去重判断。
## 关联笔记
- [[Redis 分布式锁]]
- [[ZooKeeper 分布式锁]]