一、幂等性的概念
1.1 什么是幂等性
幂等性(Idempotence)最早源自数学领域,指的是一个操作执行一次和执行多次所产生的影响是完全相同的。用数学语言表达,若函数 f(x) 满足 f(f(x)) = f(x),则称该函数是幂等的。
在编程中,幂等性意味着:一个操作执行一次和执行多次的影响效果相同。这里强调的是"对系统状态的副作用"一致,而非接口返回数据完全一致。例如,第一次创建订单返回 201 Created,重复提交返回 200 OK,但订单数据只创建了一次,这仍然是幂等的。
1.2 为什么需要幂等性
在分布式系统中,幂等性之所以必不可少,是因为系统存在诸多固有的"不确定性":
- 网络不可靠:超时重试是常态,TCP 层面成功不代表业务层面只执行了一次
- 用户操作不可控:双击、重复刷新、页面回退后重复提交
- 中间件机制:MQ 的"至少一次"投递(At Least Once)保证消息一定会到达,但可能重复
- 微服务调用重试:RPC 框架默认具有重试机制
典型场景示例:
| 场景 | 描述 |
|---|---|
| 场景 A:重复下单 | 用户网速慢,狂点击"提交订单"按钮,后台生成了 3 个一模一样的订单 |
| 场景 B:重复支付 | 支付回调接口,银行由于网络抖动连续推送了 2 次"支付成功"通知,用户账户被加了两次钱 |
| 场景 C:重复扣库存 | MQ 消费者处理消息失败,重试机制导致同一条消息被消费多次,扣减库存变为扣减两次 |
1.3 不幂等的代价
如果不做幂等性设计,可能导致以下严重后果:
- 资损:多发红包、重复扣款、重复转账
- 数据错乱:超卖、库存负数、订单重复
1.4 HTTP 协议中的幂等性
| HTTP 方法 | 幂等性 | 说明 |
|---|---|---|
| GET | 幂等 | 仅获取资源,不会修改服务器状态 |
| PUT | 幂等 | 全量替换资源,多次执行结果一致 |
| DELETE | 幂等 | 删除资源,多次删除同一资源结果相同 |
| POST | 非幂等 | 创建新资源,多次提交会创建多个资源 |
| PATCH | 非幂等 | 部分更新资源,多次执行可能产生不同结果 |
1.5 幂等 ≠ 去重
很多人把幂等和去重混为一谈,两者有关联,但不是一回事。
| 维度 | 去重 | 幂等 |
|---|---|---|
| 关注点 | 识别出重复请求,不要重复处理 | 即使重复处理了,系统状态也要保持一致 |
| 本质 | 是一种实现手段 | 是一种设计目标 |
关键理解:
- 赋值操作
SET balance = 100不需要任何去重机制,它本身就是幂等的 - 累加操作
balance = balance + 100必须借助去重机制才能实现幂等
“去重是实现幂等的一种手段,但不是唯一手段。”
二、幂等性核心设计模式
2.1 模式一:唯一索引/唯一约束
这是最根本、最可靠的防线,利用数据库的唯一约束字段(如订单号、流水号),重复插入时直接报错,从物理层面保证幂等。
实现方式:
-- 创建唯一索引
ALTER TABLE t_order ADD UNIQUE INDEX uk_biz_id (biz_id);
public Order createOrder(OrderDTO dto) {
try {
Order order = new Order();
order.setBizId(dto.getBizId());
orderMapper.insert(order);
return order;
} catch (DuplicateKeyException e) {
// 重复请求,查询已有记录返回
return orderMapper.selectByBizId(dto.getBizId());
}
}
关键要点:
- 全局唯一 ID 的生成:雪花算法、业务规则拼接
- 去重表(De-Duplication Table)设计:独立维护一张防重表
- insert 失败后的处理策略:直接返回已有结果 or 报错
优缺点分析:
| 维度 | 说明 |
|---|---|
| 优点 | 强保障,实现简单,可靠性高 |
| 缺点 | 有数据库性能瓶颈,仅适用于插入场景 |
2.2 模式二:状态机控制
状态机是更新场景的首选方案。业务流转必须严格按照状态图跳转,例如订单状态:待支付 → 已支付 → 已发货。
实现方式:
-- SQL 写法:利用状态条件更新
UPDATE t_order SET status = '已支付'
WHERE id = 123 AND status = '待支付';
// Java 实现:检查受影响行数
int affected = orderMapper.updateStatus(orderId, "PENDING", "PAID");
if (affected == 0) {
// 状态已变化,查询当前状态
Order order = orderMapper.selectById(orderId);
if ("PAID".equals(order.getStatus())) {
return order; // 已支付,直接返回
}
throw new BizException("订单状态异常");
}
乐观锁的广义形式:
乐观锁(Version 字段)可视作状态机的一种广义形式:
-- 带版本号的更新
UPDATE t_account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;
-- 如果受影响行数为 0,说明版本已变化,需重试
优缺点分析:
| 维度 | 说明 |
|---|---|
| 优点 | 业务语义清晰,自然贴合业务逻辑 |
| 缺点 | 强依赖数据库,复杂状态维护成本高 |
2.3 模式三:Token(令牌)机制
Token 机制是防止页面重复提交的经典方案。操作前先申请一个 Token(存在 Redis 中),业务操作时检查并删除 Token。
实现方式:
// 1. 获取 Token
@GetMapping("/token")
public String getToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"idempotent:" + token, "1", 10, TimeUnit.MINUTES);
return token;
}
// 2. 提交时验证 Token(使用 Lua 脚本保证原子性)
String luaScript = "if redis.call('get', KEYS[1]) then " +
"return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList("idempotent:" + token));
if (result == 0) {
throw new BizException("请勿重复提交");
}
全流程:
加载页面(获取 Token)→ 点击提交(携带 Token)→ 后端校验(Redis Lua 脚本保证 get+del 原子性)→ 若删除成功则放行,失败则拦截。
避坑指南:
- Redis 主从切换可能导致 Token 丢失,需要评估业务对小概率失败的接受度
- 如果业务要求绝对不可丢失,可考虑 RedLock 方案
优缺点分析:
| 维度 | 说明 |
|---|---|
| 优点 | 对插入和更新都有效,通用性强 |
| 缺点 | 需要前后端配合,多了次 Redis 请求 |
2.4 模式四:分布式锁
分布式锁是一种重量级方案,以业务唯一标识(如订单号)为 Key 加锁,谁拿到锁谁执行,执行完释放。
实现方式:
public PaymentResult processPayment(PaymentRequest request) {
String lockKey = "payment:" + request.getBizId();
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,等待 5 秒,锁过期 30 秒
if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
throw new BizException("系统繁忙,请稍后重试");
}
// 双重检查
PaymentRecord record = paymentMapper.selectByBizId(request.getBizId());
if (record != null) {
return PaymentResult.alreadyProcessed(record);
}
// 执行业务操作
return doPayment(request);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
与 Token 机制的对比:
相比 Token 机制,分布式锁的粒度更粗,耗时更长,一般不作为幂等首选,多用于极端并发下的串行化控制。
优缺点分析:
| 维度 | 说明 |
|---|---|
| 优点 | 可以精确控制并发执行顺序 |
| 缺点 | 锁的管理复杂,存在死锁风险 |
2.5 模式五:天然幂等分析
| 操作类型 | 幂等性 | 说明 |
|---|---|---|
| 查询(SELECT) | 天然幂等 | 无需处理 |
| 物理删除(DELETE) | 天然幂等 | 第一次删除后,后续删除返回影响行数为 0 |
| 插入(INSERT) | 重点防护 | 需要唯一索引等机制保障 |
| 更新(UPDATE) | 重点防护 | 需要状态机/乐观锁等机制保障 |
三、关键陷阱与最佳实践
3.1 竞态条件:先查后写的陷阱
幂等判断的过程通常是「先检查是否处理过,没有的话再处理」。如果检查和处理不是原子操作,就存在竞态条件。
// ❌ 错误:先SELECT再INSERT,不是原子操作
if (!exists(idempotentKey)) { // 请求A和B同时查到不存在
insert(idempotentKey); // A和B都插入成功
doBusiness(); // 业务逻辑执行两次!
}
// ✅ 正确:直接INSERT,依靠唯一约束
try {
insert(idempotentKey); // 原子操作
doBusiness();
} catch (DuplicateKeyException e) {
return; // 重复请求,直接返回
}
核心原则:不要用「先SELECT再INSERT」或「先GET再SET」的方式,这些都不是原子操作。数据库方案直接 INSERT,Redis 方案用 SETNX 命令。
3.2 异常处理策略
幂等组件碰到异常时需要区分两类异常:
| 异常类型 | 示例 | 处理策略 |
|---|---|---|
| 业务异常 | 参数校验失败、用户不存在、余额不足 | 保留幂等号 - 重试结果不变,避免无意义重试 |
| 系统异常 | 数据库连接超时、下游服务不可用 | 删除幂等号 - 系统恢复后允许重试 |
try {
return joinPoint.proceed(); // 执行业务逻辑
} catch (Exception e) {
if (isSystemException(e)) {
redisTemplate.delete(idempotentKey); // 系统异常,删除幂等号
}
throw e; // 业务异常保留幂等号
}
3.3 幂等性分层防护架构
┌─────────────────────────────────────┐
│ 接入层防护 │
│ Token机制 | 限流熔断 | 参数校验 │
├─────────────────────────────────────┤
│ 业务层防护 │
│ 状态机控制 | 业务规则校验 | 分布式锁 │
├─────────────────────────────────────┤
│ 数据层防护 │
│ 唯一索引 | 乐观锁 | 事务控制 │
└─────────────────────────────────────┘
设计理念:多层防护、逐级兜底。上层快速拦截大部分重复请求,下层确保最终数据一致性。
四、实战案例:高并发支付回调处理
4.1 需求背景
第三方支付回调,保证一笔订单只处理一次。如果重复处理,可能导致用户账户被重复加钱。
4.2 方案组合(多层防护)
这是一个典型的多层防护场景,需要结合多种幂等性模式:
第一层:前置快速拒绝
使用 Redis 的 setnx 指令,以 pay_callback_订单号 为 Key,过期时间设为 5 分钟。成功则进入业务逻辑,失败则直接返回 “SUCCESS” 以应付回调方。
String key = "pay_callback:" + orderId;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(success)) {
// 已有并发请求在处理,直接返回
return "SUCCESS";
}
第二层:业务校验
查询订单状态,若已为"支付成功"则直接返回。
Order order = orderMapper.selectById(orderId);
if ("PAID".equals(order.getStatus())) {
return "SUCCESS"; // 已处理,直接返回
}
第三层:数据库兜底
处理流水表有支付流水的唯一索引。
try {
PaymentFlow flow = new PaymentFlow();
flow.setFlowId(bankFlowId); // 银行流水号
flow.setOrderId(orderId);
flowMapper.insert(flow);
} catch (DuplicateKeyException e) {
// 重复回调,查询已有记录返回
return "SUCCESS";
}
第四层:并发更新
执行账户加钱等更新操作时,使用乐观锁(版本号)或状态机。
int affected = accountMapper.addBalance(
userId, amount, currentVersion);
if (affected == 0) {
throw new BizException("账户更新冲突,请重试");
}
4.3 完整代码示例
@Transactional
public String handlePaymentCallback(PaymentCallbackDTO dto) {
String orderId = dto.getOrderId();
// 1. 前置快速拒绝
String lockKey = "pay_callback:" + orderId;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(locked)) {
return "SUCCESS";
}
// 2. 业务校验
Order order = orderMapper.selectById(orderId);
if ("PAID".equals(order.getStatus())) {
return "SUCCESS";
}
// 3. 数据库兜底
try {
PaymentFlow flow = new PaymentFlow();
flow.setFlowId(dto.getBankFlowId());
flow.setOrderId(orderId);
flowMapper.insert(flow);
} catch (DuplicateKeyException e) {
return "SUCCESS";
}
// 4. 执行业务逻辑
accountService.addBalance(order.getUserId(), order.getAmount());
orderMapper.updateStatus(orderId, "PAID");
return "SUCCESS";
}
4.4 避坑指南
- Redis 故障降级:Redis 挂了怎么办?可暂时依赖数据库唯一索引兜底,允许短期不一致,但要监控告警
- 超时处理:如果流水插入成功但调银行超时,需通过后续查询或定时任务反查渠道方状态
五、跨系统幂等设计
5.1 调用链上的"多米诺骨牌"效应
一个业务请求在内部往往是多个系统的接力。任何一个环节的不幂等,都会导致全链路的重试产生灾难。
以支付为例,一个典型的调用链:客户端 → 网关 → 订单服务 → 支付服务 → 银行网关
如果支付服务调用银行网关超时,第一次调用其实已经到达银行端并扣款成功,第二次重试就会导致重复扣款。
5.2 全局唯一交易凭证
要串联起多个异构系统,唯一的纽带就是一个全局唯一、全链路透传的业务 ID。
错误设计:订单服务用 order_id_1,到了支付服务生成 payment_id_2,调用银行用 bank_trade_id_3。当银行回调说"我扣款成功了,ID 是 3",支付服务很难快速映射回订单。
正确设计:支付服务收到请求,不直接生成新 ID,而是用"订单号 + 业务场景(Pay)+ 固定后缀"作为整个支付链路的唯一凭证,并把它原封不动传给银行。这样,任何一环都能用同一个 ID 做防重。
5.3 “空转"查询模式
这是一个很好用的跨系统幂等模式,核心是:下游服务不为同一个业务凭证做两次副作用操作。
实现方式:下游服务(如支付服务)维护一张支付请求防重表,以上游业务唯一 ID 做主键。收到请求先 Insert,成功则执行扣款;冲突则直接查询已有结果返回。
5.4 回调与消息队列的幂等
回调的幂等:
外部系统(如微信支付)会以固定频率向你发送支付成功的通知。设计原则:
- 入口防重:收到通知,以"渠道 + 渠道流水号"做唯一索引防重
- 内部链路防重:触发内部"发货"逻辑时,使用状态机驱动的业务动作
-- 状态机驱动的业务动作
UPDATE t_order SET status = '支付成功'
WHERE id = 123 AND status = '待支付';
消息队列的幂等:
MQ 保证了消息一定会投递成功,所以消费者必然会收到重复消息。设计原则:
- 消费者去重表:维护一张以"消息 ID"为主键的去重表
- 业务状态反查:消费逻辑执行前,先去业务方反查状态
5.5 分布式一致性下的幂等终局
如果跨系统的下一个服务刚好宕机了呢?这时的幂等要和"最终一致性"结合。
思路:放弃传统的同步请求-响应模式,转而采用"异步回调 + 轮询查询”。
- 上游服务调用下游,若超时,不重试,而是创建一个任务:“查询下游凭证 ID 为 X 的结果”
- 这个查询任务是天然幂等的(查一次和查一万次结果一样),它会一直重试查询,直到下游最终处理完
六、业界大厂幂等实践
| 公司 | 幂等号传递 | 格式 | 有效期 | 重复请求处理 |
|---|---|---|---|---|
| Stripe | HTTP Header | V4 UUID | 24小时 | 返回缓存的首次响应 |
| PayPal | HTTP Header | UUID ≤38字符 | 按API不同 | 拒绝第二个并发请求 |
| 请求体字段 | UUID4 | 按API不同 | 返回之前成功的响应 | |
| 美团 | 特征计算 | MD5 | 可配置 | SETNX原子拒绝 |
Stripe 的设计亮点
- 缓存包括错误响应,保证相同 Key 永远相同结果
- 参数校验:Key 相同但参数不同直接报错
- 超时重试能拿到和第一次一样的响应
美团 GTIS 的特点
- 幂等号根据请求特征自动计算(MD5)
- 对调用方完全透明,无需改造
- 使用 Tair 做 SETNX 原子防重
七、常见设计谬误与反模式
7.1 谬误一:混淆"业务拒绝"与"系统异常"
当 B 系统收到重复请求时,如果订单已存在,这是一个确定的业务成功状态,不应该返回错误。
// 正确:返回成功
{ "code": 0, "message": "订单已存在,创建成功" }
// 错误:返回异常
{ "code": 500, "message": "DUPLICATE_ORDER" }
7.2 谬误二:用异常控制业务流程
重复订单不是一个 Exception,它是一个 DuplicateOrderSuccess。永远不要用异常来控制业务流程,尤其是幂等这种预期内的分支。
7.3 谬误三:未站在调用方角度设计接口
好的幂等设计会让调用方更简单。调用方的逻辑应该是:
if (result.isSuccess()) {
next(); // 继续后续流程
} else {
handleError(); // 处理错误
}
无需关心是否重复,重复请求的结果成功了,那么响应就该是成功。
八、幂等性设计哲学
8.1 幂等性的本质
幂等性的本质是对"不确定性"的一种回应。在分布式系统中,网络不可靠、节点故障等问题是不可避免的。我们无法消除这些不确定性,但可以通过幂等性设计来"容忍"它们。
8.2 选型金字塔
幂等性方案的选型应遵循分层防护原则:
▲ 贯穿始终:全局唯一的业务 ID
╱ ╲
╱ ╲ 接入层(按需做):Token 机制 / Redis 锁
╱─────╲
╱ ╲ 中间层(推荐做):业务状态机 / 乐观锁
╱─────────╲
╱ ╲ 底座(必须做):数据库唯一索引
╱─────────────╲
8.3 幂等黄金法则
- 宁可让请求失败,也不让数据变脏
- 任何包含 insert/update 的外部接口,都必须考虑幂等
- 永远不要信任上游的"我只发一次"承诺
8.4 跨系统幂等的金句
“单系统的幂等是技术(唯一索引、锁),跨系统的幂等是协议(全局 ID、状态机)。”
- 设计层面:画架构图时,每画一条服务间的连线,都要标注出此处的幂等协议是什么
- 技术层面:每个服务入口,都建一张防重表,主键严格定为上游传入的业务唯一 ID
- 业务层面:优先使用状态机这种具有业务属性的幂等操作,它比技术化的 Token 或锁在跨系统时更稳固
- 终局保障:对所有非幂等的下游调用,都做"异步化 + 可重试的查询"处理。支付失败了向用户报错,好过重复扣款
九、方案对比与选型指南
9.1 方案对比表
| 方案 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| 唯一索引 | 新增数据场景 | 实现简单,可靠性高 | 仅适用于插入场景 |
| 状态机 | 更新场景、多状态流转 | 业务语义清晰,设计优雅 | 复杂状态设计成本高 |
| Token 机制 | 表单提交、防重复点击 | 通用性强,前后端都可用 | 需要额外的 Token 管理 |
| 分布式锁 | 极端并发串行化 | 精确控制并发执行顺序 | 锁管理复杂,有死锁风险 |
| 乐观锁 | 高并发更新场景 | 性能好,无死锁 | 有 ABA 问题,需重试机制 |
9.2 选型建议
- 新增场景:优先使用数据库唯一索引
- 更新场景:优先使用状态机或乐观锁
- 表单提交:使用 Token 机制
- 极端并发:使用分布式锁
- 跨系统调用:全局唯一 ID + 防重表 + 状态机
9.3 总结
幂等性设计的哲学可以概括为一句话:“在不确定的世界中,构建确定性的系统。” 这不仅仅是技术问题,更是一种对复杂系统的理解和对可靠性的追求。