289 lines
11 KiB
Markdown
289 lines
11 KiB
Markdown
|
|
---
|
|||
|
|
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 分布式锁]]
|