编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 体系建设优化

  • 代码品质工坊

  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

    • README
    • 通用架构设计方案
    • 组件化方案的设计
    • SDK设计与发布方案
    • 缓存架构设计思想
    • 数据库SQL设计思想
    • 分库分表方案设计
    • 分布式ID生成方案
    • 消息队列方案选型
    • 09.长链接方案的设计
    • 认证授权方案设计
    • API网关设计方案
    • 路由库设计思想
    • 网络检测方案设计
    • 幂等性设计方案
      • 一、案例引入:3700 人扣两次款、赔 73 万的那一晚
        • 1.1 事故现场:11 秒的银行响应差点毁掉一款 App
        • 1.2 顺藤摸根因:不是"用户手快",是"接口没兜底"
        • 1.3 七个"为什么":疑惑清单
      • 二、架构决策三角:正确性 × 性能 × 简洁
      • 三、幂等的"存在本质":分布式系统里没有"恰好一次"
        • 3.1 网络通信的三种消息语义
        • 3.2 唯一的正解:At-least-once + 服务端幂等 = Effectively-once
        • 3.3 重复请求的六大来源(不是"用户手快"这么简单)
      • 四、HTTP 语义的幂等性:REST 规范和它的陷阱
        • 4.1 HTTP 方法幂等性表
        • 4.2 陷阱一:语义幂等 ≠ 实现幂等
        • 4.3 陷阱二:POST 天然不幂等,需要"业务幂等"
        • 4.4 陷阱三:GET 也不总是幂等
      • 五、六大幂等方案的谱系:怎么选不踩坑
        • 5.1 六大方案对比矩阵
        • 5.2 决策链:从业务类型出发
        • 5.3 方案一:唯一索引——DB 的最后一道防线
        • 5.4 方案二:状态机——最优雅的业务幂等
        • 5.5 方案三:Token 防重——防"用户手快"的专用武器
        • 5.6 方案四:乐观锁——扣库存 / 计数场景
        • 5.7 方案五:分布式锁——高竞争的最后手段
        • 5.8 方案六:幂等表——最通用的方案
      • 六、幂等键设计:微不足道却决定生死
        • 6.1 好的幂等键的三大特征
        • 6.2 幂等键场景速查
        • 6.3 幂等键的传递
        • 6.4 幂等键的时效
      • 七、副作用一起做幂等:本文事故的核心教训
        • 7.1 副作用是什么
        • 7.2 三种处理方式
        • 7.3 副作用漏掉一个都不行的心法
      • 八、反例合集:三种典型翻车
        • 8.1 反例一:先做后判(本文事故的第一个坑)
        • 8.2 反例二:时间戳当幂等键
        • 8.3 反例三:只做主业务、副作用漏掉(本文事故第 ⑤ 条)
        • 8.4 反例演进:V0 → V3
      • 九、幂等 AOP 中间件:一个注解搞定 80% 场景
        • 9.1 目标使用方式
        • 9.2 核心切面实现
        • 9.3 生产环境的坑
      • 十、综合案例串讲:把 7 问全部回扣
        • 10.1 逐条回答一开始的 7 问
        • 10.2 事故重演:如果当时用了本文方案
        • 10.3 一次支付的"幂等一生"完整时序
        • 10.4 四条幂等设计哲学
        • 10.5 幂等设计速查表
        • 10.6 上线检查清单(20 项)
      • 十一、写在最后
    • 分布式锁方案设计
    • 限流熔断方案设计
    • 移动端防抓包实践
    • 通用轮训方案设计
    • 状态机设计的思想
    • 20.实时通信设计原理
  • 性能优化实践

  • 真经
  • 方案设计思想
杨充
2026-05-21
目录

幂等性设计方案

# 14.幂等性设计方案

本篇定位:幂等性是分布式系统的"防重保险丝"——网络抖动、客户端重试、消息重投、定时任务重跑都可能让同一个操作跑多次。做对了用户扣款只扣一次、消息处理只处理一次;做差了就是重复扣款、订单跳号、积分翻倍、库存扣穿。本文从一次 "3700 人被扣两次款、赔 73 万" 的支付事故讲起,回答七个刺入骨髓的问题——为什么分布式系统必然产生重复请求?"恰好一次"到底存不存在?六大幂等方案怎么选?幂等键怎么设计不撞车?副作用(短信/积分/消息)如何一起纳入幂等?

# 一、案例引入:3700 人扣两次款、赔 73 万的那一晚

# 1.1 事故现场:11 秒的银行响应差点毁掉一款 App

某电商大促晚 22:00,用户 A 在 App 上点击"支付 ¥199"——11 秒后页面依然转圈。用户 A 心里嘀咕"是不是没点上?"再点了一次。

服务端视角:

  • 22:00:03 收到用户 A 的支付请求 #1,调用银行接口——银行响应正常但耗时 11 秒(后端偶发慢查询)。
  • 22:00:14 银行返回"扣款成功"——支付服务写入订单成功,返回 App。
  • 22:00:11 用户 A 已经点了第二次——请求 #2 到达支付服务。
  • 22:00:11 支付服务又发起一次银行扣款——因为没有幂等保护,接口层完全把两次当独立请求处理。
  • 22:00:22 第二次扣款成功——用户 A 的账户少了 ¥398,订单表里有两条 ¥199 的订单。

同一晚 3700 个用户被同样坑。事故第二天:

  • 客服中心接到 3700+ 投诉,3 天连轴转。
  • 财务紧急退款 + 补偿总计 ¥73 万。
  • App 评分从 4.5 星掉到 2.1 星(半个月才修复回来)。
  • 金融监管部门约谈——"重复扣款"属于金融事故。
  • 内部通报:全公司 P0 事故,责任团队被绩效扣分。

# 1.2 顺藤摸根因:不是"用户手快",是"接口没兜底"

复盘会锁定 7 处设计缺陷(每一处都是分布式系统的经典雷区):

# 缺陷 直接后果
① 支付接口没有 requestId / 幂等键 服务端根本分不清"两次请求"还是"两个请求"
② 客户端没有按钮防抖 / Loading 遮罩 用户可以疯狂点
③ 银行接口本身正常但慢,没有客户端超时 用户以为失败
④ 支付状态机不严 —— 已支付订单再次支付 UPDATE 不加 status='WAIT' 条件 重复支付通过
⑤ 副作用(发短信通知、扣积分、发红包)没纳入幂等 用户收到 2 条通知,积分扣 2 次
⑥ 幂等表 / 唯一索引一个都没做 DB 层根本挡不住
⑦ 事后没有对账机制发现异常 3700 人重复扣款直到用户投诉才知道

这 7 处联合作用——事故的规模指数放大。

# 1.3 七个"为什么":疑惑清单

复盘会上灵魂拷问:

  1. 分布式系统里为什么必然会有重复请求? 我不能设计成"客户端只发一次"吗?
  2. HTTP 语义里的 GET/POST/PUT/DELETE 幂等性是怎么定义的? 遵循了就没事吗?
  3. 业界都在说的"At-least-once"、"Exactly-once"、"At-most-once"到底啥区别? 恰好一次真的能实现吗?
  4. 六大幂等方案(唯一索引/Token/状态机/乐观锁/分布式锁/幂等表)在什么场景各自最合适?
  5. 幂等键(requestId / idempotent-key)该由谁生成? 用时间戳可以吗?
  6. 副作用(短信、积分、MQ 消息)怎么和主业务一起做幂等?
  7. 重复请求来了,是"直接拒绝"还是"返回首次结果"? 差别在哪?

这 7 问就是本文的骨架。走完第 10 章会全部回答。

# 二、架构决策三角:正确性 × 性能 × 简洁

幂等设计本质是在三个维度做联合最优化:

              正确性(绝不重复执行、零丢单)
                     ▲
                    /│\
                   / │ \
                  /  │  \
                 / 幂 │ 等\
                /   系  │ 统 \
               /   决  │ 策 \
              ────────┼──────
        性能(每请求增开销 <5ms)    简洁(业务代码不侵入)
  • 只求正确性 → 每个请求走分布式锁 + 双写幂等表——QPS 掉一半。
  • 只求性能 → 全用 Redis 缓存——极端场景(Redis 挂)依然会漏。
  • 只求简洁 → 全交给 DB 唯一索引——遇到"重复请求返回原结果"需求就不够。

架构选择的本质:"接受哪个代价"。金融接受性能损失换绝对正确、社交接受偶发重复换极致性能、B 端接受业务侵入换简洁明确。

# 三、幂等的"存在本质":分布式系统里没有"恰好一次"

要理解为什么必须做幂等,先要理解为什么分布式系统一定会有重复请求。

# 3.1 网络通信的三种消息语义

分布式系统里的每一次网络通信,都只能是这三种语义之一:

语义 定义 代价
At-most-once 至多一次——可能丢,不会重 发一次不管成没成
At-least-once 至少一次——可能重,不会丢 失败就重试
Exactly-once 恰好一次——不丢不重 理论上通信层做不到

为什么"恰好一次"是理论谎言:客户端发送请求后收不到响应,它无法区分:

  • 情况 A:请求根本没到服务端(该重试)。
  • 情况 B:请求到了但响应丢了(不该重试,会重复)。

任何单纯的通信层协议都不能区分这两种情况——所以"Exactly-once"在通信层做不到。

# 3.2 唯一的正解:At-least-once + 服务端幂等 = Effectively-once

客户端负责:at-least-once(不丢,可以重试)
服务端负责:幂等(重复请求不产生副作用)
──────────
组合效果:Effectively-once(业务上等价于恰好一次)

这就是分布式系统里"恰好一次"的唯一实现方式。Kafka Exactly-once、TCC 事务、支付重试——底层全是这个套路。

# 3.3 重复请求的六大来源(不是"用户手快"这么简单)

① 用户层
   ・多次点击按钮
   ・浏览器后退再前进
   ・刷新页面表单重发
   
② 客户端层
   ・HTTP 超时后重试
   ・断网重连补发
   ・App 后台进程唤起补发
   
③ 网络层
   ・LB 超时重试(后端可能已经处理成功)
   ・代理层重传
   
④ 服务层
   ・RPC Client 失败重试(Ribbon / Feign 默认 2 次)
   ・幂等 Ribbon 重试 GET 也会重试 POST(配错的话)
   
⑤ 消息队列层
   ・MQ 至少一次投递
   ・消费失败 NACK 重试
   ・Rebalance 时同一消息投多个消费者
   
⑥ 运维层
   ・定时任务重跑
   ・故障恢复后补偿
   ・人工重发脚本

每一层都可能产生重复——你堵得住用户,堵得住 LB 吗?堵得住 MQ 吗?结论:只能在服务端做幂等。

# 四、HTTP 语义的幂等性:REST 规范和它的陷阱

RESTful 规范里定义了 HTTP 方法的幂等性——但仅仅遵循 HTTP 语义远远不够。

# 4.1 HTTP 方法幂等性表

方法 幂等? 安全? 说明
GET ✅ ✅ 只读,天然幂等
HEAD ✅ ✅ 只读元信息
PUT ✅ ❌ 整体替换 —— 多次替换结果相同
DELETE ✅ ❌ 多次删同一资源,效果相同
POST ❌ ❌ 每次新建资源——天然不幂等
PATCH ⚠️ ❌ 视操作而定(age+1 就不幂等)

# 4.2 陷阱一:语义幂等 ≠ 实现幂等

HTTP 语义说 PUT /user/123 {name:"A"} 幂等——是设计上如此,不是实现上如此。你的实现可能是:

@PutMapping("/user/{id}")
void updateUser(@PathVariable Long id, @RequestBody User u) {
    userDao.update(id, u);          // 幂等 ✓
    auditLog.record("update user"); // ❌ 每次都写审计日志
    mqTemplate.send("user_changed"); // ❌ 每次都发消息
}

主操作幂等,副作用不幂等——重复请求的用户会看到审计日志双份、下游收到两条消息。这就是本文事故第 ⑤ 条。

# 4.3 陷阱二:POST 天然不幂等,需要"业务幂等"

创建订单、支付、注册——这些天然是 POST,但业务上必须幂等。HTTP 帮不了你,只能应用层设计。

# 4.4 陷阱三:GET 也不总是幂等

GET /user/123/click_ad?adId=456

这个 GET 其实记录了一次广告点击——读接口有副作用,重复请求就是重复计数。GET 的幂等语义是"约定",如果你违反了约定,重复就来了。

结论:RESTful 语义只是起点,不是终点。想真正幂等,看下面六大方案。

# 五、六大幂等方案的谱系:怎么选不踩坑

# 5.1 六大方案对比矩阵

方案 原理 实现难度 性能 支持"返回首次结果" 适合
唯一索引 DB 唯一约束 ⭐ ⭐⭐⭐⭐ ❌ 创建类(下单/注册)
状态机 UPDATE WHERE 条件 ⭐⭐ ⭐⭐⭐⭐ ⚠️ 查库返回状态 状态流转(支付/发货)
乐观锁 (version) UPDATE ... WHERE version=? ⭐⭐ ⭐⭐⭐⭐ ❌ 库存扣减、计数
Token 防重 提交前领 Token,消费一次即失效 ⭐⭐ ⭐⭐⭐⭐⭐ ❌ 表单提交、支付按钮
分布式锁 加锁串行化 ⭐⭐⭐ ⭐⭐ ❌ 高竞争(抢购/秒杀)
幂等表 请求键+结果一起存表 ⭐⭐⭐ ⭐⭐⭐⭐ ✅ 通用接口幂等

# 5.2 决策链:从业务类型出发

是查询接口? ── 是 ─→ 天然幂等,不用做
    │
    否
    ↓
是创建资源? ─── 是 ─→ 唯一索引(业务唯一键 + DB unique)
    │                    需要返回首次结果? 加"幂等表"
    否
    ↓
是状态流转? ─── 是 ─→ 状态机(UPDATE ... WHERE status=?)
    │
    否
    ↓
是计数/扣减? ─── 是 ─→ 乐观锁(version 字段 或 UPDATE 加条件)
    │
    否
    ↓
是表单提交? ─── 是 ─→ Token 防重(GETDEL 原子消费)
    │
    否
    ↓
高竞争场景? ── 是 ─→ 分布式锁(详见第 15 篇)
    │
    否
    ↓
通用接口需返回原结果? ─→ 幂等表(请求键+响应体)

# 5.3 方案一:唯一索引——DB 的最后一道防线

核心思想:业务唯一键 = DB 唯一索引——重复插入直接抛 DuplicateKey 异常。

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    request_id VARCHAR(64) NOT NULL,  -- 客户端生成的幂等键
    amount DECIMAL(10,2),
    status VARCHAR(20),
    created_at DATETIME,
    UNIQUE KEY uk_request_id (request_id)   -- 关键
);

代码:

public Order createOrder(OrderRequest req) {
    try {
        return orderRepo.insert(req.toEntity());
    } catch (DuplicateKeyException e) {
        // 说明重复请求 —— 查出首次记录返回
        return orderRepo.findByRequestId(req.getRequestId())
            .orElseThrow(() -> new IllegalStateException("data inconsistent"));
    }
}

为什么 DB 唯一索引最可靠:

  • DB 是数据的最后一道防线——缓存可以丢、应用可以重启,但 DB 唯一约束永远生效。
  • 零并发问题——DB 引擎的 unique 约束是原子的,不管多少个请求同时来。

局限:无法返回原结果(DuplicateKey 抛出时结果已经"发生"了,但拿不回原来的响应体)。想要"重复请求返回首次结果"必须叠加幂等表。

# 5.4 方案二:状态机——最优雅的业务幂等

核心思想:业务状态只能按规定路径流转——重复的状态转换会被 WHERE 条件挡掉。

订单状态机:
    ┌─→ WAIT_PAY ─→ PAID ─→ SHIPPED ─→ DELIVERED
    │                │
    └── CANCELLED    └─→ REFUNDING ─→ REFUNDED

代码:

@Transactional
public PayResult payOrder(String orderId) {
    // UPDATE 加 fromStatus 条件
    int rows = orderRepo.updateStatus(orderId, "WAIT_PAY", "PAID");
    
    if (rows == 1) {
        // 首次成功,触发副作用
        eventPublisher.publish(new OrderPaidEvent(orderId));
        return PayResult.SUCCESS;
    } else {
        // rows=0 —— 状态已经不是 WAIT_PAY,可能已支付
        Order current = orderRepo.findById(orderId);
        return switch (current.getStatus()) {
            case "PAID" -> PayResult.ALREADY_PAID;   // 幂等返回
            case "CANCELLED" -> PayResult.CANCELLED;
            default -> PayResult.STATUS_ERROR;
        };
    }
}

关键 SQL:

UPDATE orders 
SET status = 'PAID', pay_time = NOW()
WHERE order_id = ? 
  AND status = 'WAIT_PAY';   -- 状态机的核心:加原状态条件

影响行数是判断的关键:

  • rows == 1:首次执行成功。
  • rows == 0:已经执行过(幂等命中)或状态不允许。

优雅之处:零额外表 / 零额外锁——利用业务本身的状态约束就搞定了幂等。

# 5.5 方案三:Token 防重——防"用户手快"的专用武器

核心思想:用户操作前先领一个一次性 Token,提交时消费——同一 Token 只能消费一次。

流程:

① 用户打开下单页
   Client → Server: GET /pay/token
   Server: 生成 UUID → Redis SET token:xxx=UNUSED, TTL=30min
   Server → Client: token=xxx

② 用户点击支付
   Client → Server: POST /pay {..., token: xxx}
   Server: Redis GETDEL token:xxx     ← 关键:原子取出并删除
   ├── 返回 UNUSED → 首次提交,处理业务
   └── 返回 nil → 重复提交,拒绝

代码:

public PayResult pay(PayRequest req) {
    // GETDEL 是原子操作,一个 Token 只能成功消费一次
    String tokenStatus = redis.execute("GETDEL", "pay_token:" + req.getToken());
    if (tokenStatus == null) {
        throw new DuplicateSubmitException("please don't submit twice");
    }
    return doPayment(req);
}

Redis 版本要求 ≥ 6.2.0(GETDEL 命令自 6.2.0 引入)。旧版本用 Lua 脚本:

local val = redis.call('GET', KEYS[1])
if val then redis.call('DEL', KEYS[1]) end
return val

优缺点:

  • ✅ 用户端友好——一按到底直接拒绝,不涉及业务处理。
  • ✅ 性能极佳——只是一次 Redis 操作。
  • ❌ 只能防"提交前领了 Token 的场景"——API 直接调用(脚本、爬虫)绕过这个。

结论:Token 只是"用户体验层"防重,服务端还得叠加其他方案(唯一索引 / 状态机)兜底。

# 5.6 方案四:乐观锁——扣库存 / 计数场景

UPDATE stock 
SET count = count - 1, version = version + 1
WHERE sku_id = ? 
  AND version = ?     -- 乐观锁:拿到时的版本号
  AND count > 0;      -- 业务约束

影响行数 = 0 说明要么版本变了(有并发)、要么库存扣完了——业务层判断处理。

推广版:不用 version 字段,用业务字段自身做条件:

-- 转账扣款:只有余额 >= 金额才能扣
UPDATE account SET balance = balance - 100 WHERE user_id = ? AND balance >= 100;

# 5.7 方案五:分布式锁——高竞争的最后手段

思想:加分布式锁把并发操作串行化——详见第 15 篇分布式锁专题。

注意:分布式锁不是"幂等"的银弹——它防的是"并发冲突"而非"同一请求重复"。如果同一个 requestId 前后跨越了锁的过期时间,仍然会重复执行。分布式锁 + 幂等键才是完整方案。

# 5.8 方案六:幂等表——最通用的方案

核心思想:请求进来时,先把 (bizType, idempotentKey) 插到幂等表,作为"我要开始处理这个请求"的标记;处理完成后回写响应。

CREATE TABLE idempotent_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_type VARCHAR(50) NOT NULL,       -- 业务类型
    idempotent_key VARCHAR(128) NOT NULL, -- 幂等键
    status VARCHAR(20) NOT NULL,          -- PROCESSING / SUCCESS / FAIL
    request_body TEXT,                    -- 原始请求(可选,用于对账)
    response_body TEXT,                   -- 首次响应
    created_at DATETIME,
    completed_at DATETIME,
    expired_at DATETIME,
    UNIQUE KEY uk_biz_key (biz_type, idempotent_key)
);

流程:

public Response handle(Request req) {
    String key = req.getIdempotentKey();
    String bizType = "pay";
    
    // Step 1: 插入 PROCESSING 记录(DB 唯一索引保证原子)
    try {
        idempotentRepo.insert(bizType, key, "PROCESSING", req.toJson());
    } catch (DuplicateKeyException e) {
        // 说明是重复请求 —— 查已有记录
        IdempotentRecord existing = idempotentRepo.findByBizAndKey(bizType, key);
        return switch (existing.getStatus()) {
            case "SUCCESS" -> Response.fromJson(existing.getResponseBody());
            case "PROCESSING" -> Response.processing("in progress");  // 首次还在处理
            case "FAIL" -> Response.fail("previous failed");
        };
    }
    
    // Step 2: 处理业务
    Response resp;
    try {
        resp = doBusiness(req);
        idempotentRepo.updateResponse(bizType, key, "SUCCESS", resp.toJson());
    } catch (Exception e) {
        idempotentRepo.updateResponse(bizType, key, "FAIL", null);
        throw e;
    }
    return resp;
}

优点:

  • 重复请求返回首次结果——用户体验一致。
  • 通用——任何接口都能套用。

代价:

  • 每次请求多 1~2 次 DB IO。
  • 幂等表可能很快膨胀,需要定期清理过期记录(比如 7 天前的 PROCESSING/SUCCESS 归档)。

# 六、幂等键设计:微不足道却决定生死

幂等键(idempotent-key、request-id、trace-id) 是幂等的"锚点"——设计不好,方案再对也白搭。

# 6.1 好的幂等键的三大特征

特征一:客户端生成,客户端持久化。

必须客户端生成——服务端生成的话,客户端每次重试都会拿到新的 ID,幂等就废了。

必须持久化——App 重启、页面刷新后,同一个业务的重试要保持同一个键。

特征二:业务唯一性。

不同业务操作要不同的键(否则会互相干扰)。推荐组合:

requestId = ${bizType}:${businessKey}:${clientNonce}

例:
"pay:U123:O456:a3f8b2"   支付
"refund:U123:R789:c9d1e5" 退款
"send_msg:conv999:m444:xyz" 发消息

特征三:足够随机 + 长度控制。

UUID.v4() 是首选(128 位随机,撞概率 = 每秒生成 100 亿个,261 亿年才有 50% 撞率)。

避免以下作法:

  • ❌ 用时间戳:同一毫秒并发会撞。
  • ❌ 用自增 ID:客户端间独立,无法全局唯一。
  • ❌ 用随机数(Random.nextInt):Java 默认 Random 只有 48 位种子,撞概率不能忽视。
  • ❌ 用用户手机 MD5:不同请求撞键。

# 6.2 幂等键场景速查

业务场景 幂等键构造
下单 ${userId}:${clientRequestId}
支付 ${orderId}:${paymentChannel}
退款 ${refundId}
转账 ${srcAccount}:${dstAccount}:${clientSerialNo}
消息消费 ${consumerGroup}:${topic}:${partition}:${offset}
定时任务 ${jobName}:${executionDate}:${bucketNo}

# 6.3 幂等键的传递

关键要求:跨服务调用时,幂等键要沿链路透传:

Client (生成 reqId=abc) ─→ Gateway ─→ Service A ─→ Service B ─→ Service C
                             │           │           │           │
                             全部使用同一个 reqId=abc 做自己的幂等

做法:HTTP Header 里放 X-Request-Id(约定俗成):

  • 网关生成或校验(如果客户端没带就自动生成)。
  • 后端每层调用透传给下一层。
  • traceId 与 requestId 可以是同一个(就是"链路追踪 + 幂等"合一)。

# 6.4 幂等键的时效

幂等键不能无限保留(DB 会爆),也不能太短(重试还没到期就失效)。经验值:

业务 保留时长 理由
表单 Token 30 分钟 用户填写 + 提交时间
支付幂等 24 小时 常见重试周期
通用 API 1 小时 大多数客户端重试都会在此内
消息消费 7 天 MQ 最长重投窗口
定时任务 30 天 跨月对账场景

# 七、副作用一起做幂等:本文事故的核心教训

事故里的第 ⑤ 条——"副作用没纳入幂等"是新手最容易漏的坑。

# 7.1 副作用是什么

主业务:扣款、生成订单。 副作用:发短信、扣积分、增经验值、发 MQ 消息、写审计日志、发 Push、更新推荐画像……

副作用往往分散在多个服务、多个存储——如果只对主业务做了幂等,用户会看到:

  • 只扣一次钱(主业务对了)。
  • 但收到 2 条短信(副作用没保护)。
  • 积分扣 2 次(另一个服务的另一个副作用)。
  • 推荐画像更新 2 次(下游 MQ 消费两次)。

用户投诉根本讲不清"到底出了什么问题" —— 表面正常,细节全乱。

# 7.2 三种处理方式

方式一:全部一起放事务。

@Transactional
public void pay(PayRequest req) {
    // 主业务
    orderRepo.updateStatus(req.getOrderId(), "PAID");
    
    // 副作用(同库同事务)
    accountRepo.deduct(req.getUserId(), req.getAmount());
    pointRepo.deduct(req.getUserId(), req.getPointsUsed());
    auditRepo.insert(new AuditLog("PAY", req));
    
    // 消息通过 事务消息 或 本地消息表(详见第 8 篇)
    localMsgRepo.insert(new PendingMessage("SMS", req.getUserId(), "支付成功"));
}

同一事务内的操作要么都成、要么都失败——天然幂等(因为主业务的状态机会挡)。

方式二:主业务幂等键作为副作用的幂等键。

如果副作用跨库/跨服务,无法放同一事务:

public void pay(PayRequest req) {
    // 主业务(DB 幂等)
    if (orderRepo.updateStatus(...) == 0) return;   // 已支付
    
    // 副作用 - 每个都用主业务的幂等键
    smsService.send(req.getUserId(), "支付成功", req.getRequestId());   // 短信服务用同一 key 幂等
    pointService.deduct(req.getUserId(), 100, req.getRequestId());     // 积分服务同上
    mqService.publish("pay_success", req.toJson(), req.getRequestId()); // MQ 用同一 key 去重
}

关键:每个下游服务的接口都要接收幂等键并自己做幂等——这是"合约",业务代码里必须写。

方式三:事件驱动 + 消息最终一致。

主业务写好 → 发消息 → 下游订阅消费——每个消费者自己保证幂等(消息 ID 或业务键)。这是微服务时代的标准做法(详见第 8 篇 MQ 专题)。

# 7.3 副作用漏掉一个都不行的心法

心法一:副作用清单化。

每个接口开发前列一张表——这个操作会产生哪些副作用:

支付接口的副作用清单:
□ 扣余额(DB)
□ 扣积分(RPC)
□ 加经验(RPC)
□ 发支付短信(RPC)
□ 发 App Push(RPC)
□ 发 MQ pay_success(MQ)
□ 更新推荐画像(MQ)
□ 写审计日志(DB)

每一项都要标注"如何幂等"——填不出来的就是隐患点。

心法二:先做后判 → 变成 → 先判后做。

对每个副作用都问一句:"如果它发生了两次,用户能不能忍" ——不能忍的必须先判后做。

# 八、反例合集:三种典型翻车

# 8.1 反例一:先做后判(本文事故的第一个坑)

// ❌ 错误:先扣款再判重
public PayResult pay(PayRequest req) {
    // 立即调银行扣款
    BankResult r = bank.deduct(req.getUserId(), req.getAmount());
    
    // 事后判重
    if (idempotentRepo.exists(req.getRequestId())) {
        bank.refund(req.getUserId(), req.getAmount());  // 回滚
        return PayResult.DUPLICATE;
    }
    return PayResult.SUCCESS;
}

问题:

  • 重复请求瞬间资金已经动了——回滚可能失败(银行接口挂了)。
  • 多次调用银行接口浪费额度、增加成本。
  • 出问题时监管审计——"你自己承认知道重复了,为啥还扣了钱"。

修正:永远先判后做。判重通过再动业务。

# 8.2 反例二:时间戳当幂等键

// ❌ 错误:用时间戳
String key = req.getUserId() + ":" + System.currentTimeMillis();

问题:

  • 同毫秒并发必然撞键(客户端点两次 <1ms 完全正常)。
  • 跨机器时钟不同步——两台机器 T=100ms 差异,同一 key 出现 100ms 差。
  • 重试时时间戳变了——服务端根本识别不出是同一请求。

修正:客户端 UUID + 客户端持久化——重试用同一个。

# 8.3 反例三:只做主业务、副作用漏掉(本文事故第 ⑤ 条)

public void handle(Request req) {
    if (orderRepo.updateStatus(...) > 0) {
        smsService.send(...);       // ❌ 没传幂等键
        pointService.deduct(...);   // ❌ 没传幂等键
        mqTemplate.send(...);       // ❌ 消费者没做幂等
    }
}

表面看没问题——主业务的状态机确实挡住了重复。但如果客户端超时重试:

  • 请求 #1 主业务失败在 sms 前——重试
  • 请求 #2 主业务成功——发 SMS + 扣积分 + 发 MQ
  • 请求 #1 那次可能又被处理——再发一次 SMS + 扣积分

修正:把 requestId 传给每个下游,下游自己幂等。

# 8.4 反例演进:V0 → V3

V0(事故版,本文案例):
  ✗ 接口无 requestId
  ✗ 客户端无防抖
  ✗ 状态机 UPDATE 不带条件
  ✗ 副作用未纳入幂等
  ✗ 无对账

V1(补救版):
  ✓ 接口加 requestId(客户端 UUID)
  ✓ 状态机严格 UPDATE ... WHERE status='WAIT_PAY'
  ✓ 前端按钮防抖 + Loading 遮罩
  ✓ 银行接口客户端超时 8s

V2(工程化):
  ✓ AOP 注解幂等中间件 @Idempotent(key="#req.requestId")
  ✓ 幂等表存请求响应
  ✓ 副作用统一透传 requestId
  ✓ 每日对账任务

V3(全链路):
  ✓ 网关注入 X-Request-Id
  ✓ 链路追踪整合
  ✓ 全链路重复率监控
  ✓ 自动补偿 + 差错处理

# 九、幂等 AOP 中间件:一个注解搞定 80% 场景

生产实践里,把上面的方案统一封装成注解,业务代码零侵入。

# 9.1 目标使用方式

@Idempotent(key = "#req.requestId", bizType = "pay", 
           expireHours = 24, mode = IdempotentMode.RETURN_FIRST_RESULT)
public PayResult pay(PayRequest req) {
    return doPayment(req);
}

一行注解等价于本文所有方案组合——Token 校验 + 幂等表 + 状态机 + 返回首次结果。

# 9.2 核心切面实现

@Aspect
@Component
public class IdempotentAspect {
    
    @Around("@annotation(idem)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idem) throws Throwable {
        String key = parseSpEL(idem.key(), pjp);  // 解析注解里的 SpEL
        String bizType = idem.bizType();
        
        // Step 1: 尝试插入 PROCESSING
        boolean firstTime = idempotentRepo.tryInsertProcessing(
            bizType, key, idem.expireHours()
        );
        
        if (!firstTime) {
            // 重复请求
            IdempotentRecord r = idempotentRepo.find(bizType, key);
            return switch (r.getStatus()) {
                case "SUCCESS" -> deserialize(r.getResponseBody(), pjp.getSignature());
                case "PROCESSING" -> throw new DuplicateProcessingException();
                case "FAIL" -> throw new PreviousFailedException();
            };
        }
        
        // Step 2: 执行业务
        try {
            Object result = pjp.proceed();
            idempotentRepo.updateSuccess(bizType, key, serialize(result));
            return result;
        } catch (Throwable ex) {
            idempotentRepo.updateFail(bizType, key);
            throw ex;
        }
    }
}

# 9.3 生产环境的坑

坑一:response 序列化 / 反序列化。 返回值可能包含 LocalDateTime、BigDecimal——Jackson 需要配置好类型信息。

坑二:PROCESSING 卡死。 服务突然 kill,幂等表里留了 PROCESSING 记录——必须有兜底扫描任务把超过 N 分钟的 PROCESSING 认定为 FAIL。

坑三:热点 key。 同一 key 短时间大量重试——DB 会热点。加 Redis 一层预判减轻 DB 压力。

# 十、综合案例串讲:把 7 问全部回扣

# 10.1 逐条回答一开始的 7 问

# 疑问 答案(章节)
① 分布式系统为什么必有重复请求? §3:六大来源 + 通信层永远做不到 Exactly-once
② HTTP 幂等语义够吗? §4:只是约定 + 主业务,副作用要单独做
③ 三种消息语义? §3:At-least-once + 幂等 = Effectively-once
④ 六大方案怎么选? §5:决策链(创建/流转/表单/竞争/通用)
⑤ 幂等键怎么设计? §6:客户端 UUID + 业务前缀 + 链路透传
⑥ 副作用怎么处理? §7:清单化 + 透传 requestId + 每个下游自己做
⑦ 拒绝还是返回原结果? §5.8:幂等表模式 = 返回原结果

# 10.2 事故重演:如果当时用了本文方案

回到开头 3700 人重复扣款:

若当时用了完整方案:

  1. 客户端生成 UUID requestId、按钮防抖 → 用户点两次也是同一 requestId。
  2. 接口层 @Idempotent 注解 → 请求 #2 进入时幂等表检测到 PROCESSING → 直接返回"支付进行中"。
  3. 状态机 UPDATE 加 status 条件 → 就算 #2 到了 Bank 调用前,UPDATE 也会返回 0 行 → 走幂等分支。
  4. 副作用透传 requestId → SMS/积分/MQ 都不会发两遍。
  5. 每日对账 → 就算前几层全漏掉,对账任务 T+1 会发现"用户 A 有两条 Order 单号不同但一模一样",触发告警。

结果:3700 人变 0 人,¥73 万损失变 0,公司无需被监管约谈。

# 10.3 一次支付的"幂等一生"完整时序

── T0: 用户点"支付 ¥199" ─────────────────
Client:
  1. 生成 requestId = UUID.v4() = "a3f8b2c4-..."
  2. 持久化到本地 sqlite (防 App kill 后重发)
  3. 按钮进入 loading 状态

── T0+50ms: 请求到达服务端 ─────────────
Gateway:
  - X-Request-Id 校验/注入
  - 转发到 pay-service

── T0+55ms: 进入 @Idempotent 切面 ─────
1. 从入参提取 requestId
2. Redis SETNX pay:req:a3f8b2c4 = PROCESSING (TTL=24h)
   ├── 首次 = true → 继续
   └── 首次 = false → 查 Redis 已有结果,返回

── T0+60ms: 业务处理 ────────────────
UPDATE orders 
SET status='PAID' 
WHERE order_id=? AND status='WAIT_PAY';

Bank RPC (with X-Request-Id header)
  - Bank 内部也做幂等(银行侧的责任)

── T0+2s: 用户不耐烦,又点了一下 ─────
Client:
  1. 检测 sqlite 里有 pending requestId a3f8b2c4 → 复用!
  2. 重新发起 HTTP,request body 完全一样

── T0+2.05s: 服务端第二次收到 ─────
1. @Idempotent 切面:Redis GET pay:req:a3f8b2c4 = PROCESSING
2. 返回 "支付进行中,请稍候"
3. 客户端收到 → 显示"正在处理"
4. Bank 没被调用第二次,副作用零重复

── T0+8s: 首次 Bank 处理完,主流程回来 ─────
1. UPDATE 成功
2. 副作用:
   - SMS 服务(透传 requestId)
   - 积分服务(透传 requestId)
   - MQ 发消息 pay_success(transactionId=requestId)
3. Redis SET pay:req:a3f8b2c4 = 结果 JSON, TTL=24h

── T0+8.1s: 客户端收到成功响应 ─────
显示"支付成功"

── T+8s ~ 24h: 期间任何重试 ─────
只要 requestId 一致,一律返回相同的成功响应
副作用零重复触发

── T+24h: 幂等键过期 ────────────
Redis TTL 到期 → 数据消失
(此时同 requestId 再来,视为新请求,但业务上已完成)

── T+1 day 03:00: 对账任务 ─────
扫描 T-1 天所有支付
- 双重支付检测(同 userId 同金额同 skuId 5min 内多单)
- 异常告警(人工介入)

# 10.4 四条幂等设计哲学

哲学一:"恰好一次"是双方的合约。 客户端 at-least-once + 服务端幂等 = effectively-once。任何一方偷懒,合约就废。别指望"网络稳定就不用做幂等"。

哲学二:先判后做,别搞逆天回滚。 主业务发生了 = 副作用可能已经发出去了。事后回滚永远不如事前拦截。特别是涉及第三方接口(银行、支付、短信),回滚比不上钱难要回。

哲学三:幂等键是"业务的 DNA",不能随便造。 客户端生成 + 客户端持久化 + 业务前缀 + 链路透传——这四条缺一不可。用时间戳、随机数、自增 ID 的都是给未来事故埋雷。

哲学四:主业务幂等只是入门,副作用幂等才是修行。 SMS、积分、Push、MQ、审计日志、推荐画像——每一个都要有幂等键。事故不是因为主业务错了,而是因为副作用没管。

# 10.5 幂等设计速查表

决策点 首选 备选 禁忌
幂等键生成 客户端 UUID + 持久化 客户端 UUID 服务端生成 / 时间戳
幂等键长度 UUID (36 字符) Snowflake ID Random 短字符串
创建类操作 唯一索引 + 幂等表 唯一索引 无约束
状态流转 状态机 UPDATE WHERE 分布式锁 直接 UPDATE 无条件
表单提交 Token GETDEL + 状态机双重 Token 只用一层 无 Token 无状态机
通用接口 @Idempotent 注解 + 幂等表 Redis 存结果 每次业务代码里手写
副作用处理 透传 requestId + 每层幂等 事件驱动 + 消费者幂等 主业务外无保护
幂等键时效 Redis 24h + DB 7 天 与 MQ 重试周期对齐 无过期
兜底 每日对账 + 差错告警 人工审计 完全依赖前端

# 10.6 上线检查清单(20 项)

幂等键

  • [ ] 每个写接口都定义了幂等键字段
  • [ ] 客户端生成 UUID + 本地持久化
  • [ ] 链路透传(Gateway → Service → Service)
  • [ ] 明确的时效(24h / 7d)

主业务

  • [ ] 创建类:唯一索引
  • [ ] 状态类:UPDATE 加 WHERE status
  • [ ] 计数类:乐观锁或 UPDATE 加 balance>0

副作用

  • [ ] 副作用清单化列出
  • [ ] 每个副作用透传 requestId
  • [ ] SMS/Push/积分/MQ 消费者自己做幂等
  • [ ] 关键副作用与主业务同事务或走本地消息表

用户端

  • [ ] 按钮防抖 + Loading 遮罩
  • [ ] 显式重试提示(不是无限自动重试)
  • [ ] 客户端超时设置合理

服务端

  • [ ] @Idempotent 注解中间件覆盖率
  • [ ] 幂等表定期清理
  • [ ] PROCESSING 超时兜底扫描
  • [ ] Redis 热点 key 预判

运维

  • [ ] 每日对账 + 差错告警
  • [ ] 重复率、命中率监控
  • [ ] 演练"客户端疯狂重试"场景

# 十一、写在最后

幂等是"看不见的功课"——做对了没人看得见,做错了 3700 人被扣款、赔 73 万、监管约谈。分布式系统里,"恰好一次"是所有工程师追求的圣杯,实现路径只有一条——at-least-once + 服务端幂等。

幂等设计的三条底线:

  • 重复请求是分布式系统的常态——不要假设"客户端不会重发"、"MQ 不会重投"。
  • 主业务幂等只是起点,副作用幂等才是终点——用户看到的"重复"往往是副作用泄露。
  • 兜底是最后一道防线,对账是幂等的救命稻草——即使前面全漏,T+1 对账能救回来。

下次面对"这个接口做下幂等"的需求时,希望你脑子里冒出的不是"加个 Redis 判重",而是——"幂等键怎么设计、副作用怎么覆盖、返回策略怎么定、对账怎么兜底"。这,就是幂等设计的真正功力。

上次更新: 2026/07/02, 15:18:57
网络检测方案设计
分布式锁方案设计

← 网络检测方案设计 分布式锁方案设计→

最近更新
01
audit
07-27
02
C++入门教程全章思考题汇编
07-24
03
12.技术团队建设能力
07-21
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式