一、幂等性的概念

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不同 拒绝第二个并发请求
Google 请求体字段 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 幂等黄金法则

  1. 宁可让请求失败,也不让数据变脏
  2. 任何包含 insert/update 的外部接口,都必须考虑幂等
  3. 永远不要信任上游的"我只发一次"承诺

8.4 跨系统幂等的金句

“单系统的幂等是技术(唯一索引、锁),跨系统的幂等是协议(全局 ID、状态机)。”

  • 设计层面:画架构图时,每画一条服务间的连线,都要标注出此处的幂等协议是什么
  • 技术层面:每个服务入口,都建一张防重表,主键严格定为上游传入的业务唯一 ID
  • 业务层面:优先使用状态机这种具有业务属性的幂等操作,它比技术化的 Token 或锁在跨系统时更稳固
  • 终局保障:对所有非幂等的下游调用,都做"异步化 + 可重试的查询"处理。支付失败了向用户报错,好过重复扣款

九、方案对比与选型指南

9.1 方案对比表

方案 适用场景 优势 劣势
唯一索引 新增数据场景 实现简单,可靠性高 仅适用于插入场景
状态机 更新场景、多状态流转 业务语义清晰,设计优雅 复杂状态设计成本高
Token 机制 表单提交、防重复点击 通用性强,前后端都可用 需要额外的 Token 管理
分布式锁 极端并发串行化 精确控制并发执行顺序 锁管理复杂,有死锁风险
乐观锁 高并发更新场景 性能好,无死锁 有 ABA 问题,需重试机制

9.2 选型建议

  • 新增场景:优先使用数据库唯一索引
  • 更新场景:优先使用状态机或乐观锁
  • 表单提交:使用 Token 机制
  • 极端并发:使用分布式锁
  • 跨系统调用:全局唯一 ID + 防重表 + 状态机

9.3 总结

幂等性设计的哲学可以概括为一句话:“在不确定的世界中,构建确定性的系统。” 这不仅仅是技术问题,更是一种对复杂系统的理解和对可靠性的追求。