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

289 lines
11 KiB
Markdown
Raw Normal View History

2026-08-08 19:01:04 +08:00
---
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 分布式锁]]