173|幂等设计:幂等键、去重、状态机、Token 与唯一约束

分布式理论、事务与一致性约 14 分钟阅读7,632 字符
知识库文档 175 / 251

一、问题定义

1.1 什么是幂等

在数学中,f(x) = f(f(x)) 表示一个函数多次执行的结果与执行一次相同。在分布式系统中,幂等 指:同一个操作执行一次和执行多次,对系统产生的影响相同,即最终状态一致。

1.2 为什么需要幂等

分布式系统中,网络不可靠是常态。一个请求可能因为网络超时、重试机制、消息队列的 at-least-once 投递等原因被执行多次。举几个典型场景:

  • 支付回调:支付宝/微信支付的回调通知可能因为网络抖动重发,如果扣款逻辑重复执行,用户会被扣两次钱。
  • 消息队列:RocketMQ/Kafka 在消费者 rebalance 或 ACK 超时时,同一消息可能被投递多次。
  • RPC 重试:Dubbo 的 failover 策略、HTTP 客户端的自动重试,都会导致同一个请求被服务端处理多次。
  • 前端重复提交:用户点击"提交订单"按钮后,页面没来得及禁用按钮,用户又点了一次。

文章配图
文章配图

1.3 幂等 ≠ 不重复执行

幂等不要求系统不重复执行同一个请求,而是要求重复执行的结果与执行一次的结果一致。例如:

  • UPDATE account SET balance = 100 WHERE id = 1 是幂等的,执行多少次结果都是 balance=100。
  • UPDATE account SET balance = balance + 100 WHERE id = 1 不是幂等的,每次执行都会加 100。

文章配图
文章配图

二、核心方案

2.1 唯一约束(数据库唯一索引)

原理:利用数据库的唯一索引来保证某个业务维度上的数据不重复。

实现:在数据库中创建一个唯一索引,将幂等键作为唯一约束字段。第一次插入成功,后续相同的插入会因为唯一约束冲突而失败。

sql
-- 创建幂等表
CREATE TABLE idempotent_records (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    idempotent_key VARCHAR(128) NOT NULL,
    business_type VARCHAR(64) NOT NULL,
    status TINYINT DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_idempotent_key (idempotent_key)
);

-- 业务处理时
INSERT INTO idempotent_records (idempotent_key, business_type) VALUES ('order_123_pay', 'PAYMENT');
-- 如果重复,捕获 DuplicateKeyException,直接返回之前的结果

文章配图
文章配图

优点:简单可靠,数据库天然保证。 缺点:依赖数据库,高并发下有性能压力;需要额外的表。

2.2 去重表 + 本地事务

原理:将业务操作和幂等记录放在同一个数据库事务中,保证原子性。

实现:

java
@Transactional
public void processPayment(String idempotentKey, PaymentRequest request) {
    // 1. 检查幂等键是否已存在
    if (idempotentRecordMapper.exists(idempotentKey)) {
        return; // 已处理过,直接返回
    }
    // 2. 插入幂等记录
    idempotentRecordMapper.insert(idempotentKey, "PROCESSING");
    // 3. 执行业务逻辑
    accountMapper.deduct(request.getUserId(), request.getAmount());
    // 4. 更新幂等记录状态
    idempotentRecordMapper.updateStatus(idempotentKey, "SUCCESS");
}

关键:检查-插入-业务操作必须在同一个事务中,否则并发时会出问题。

2.3 状态机

原理:利用状态流转的不可逆性来保证幂等。同一个操作只能在特定状态下执行,执行后状态迁移,后续相同操作发现状态不匹配自动跳过。

实现:

java
public enum OrderState {
    CREATED, PAID, SHIPPED, COMPLETED, CANCELLED
}

public void payOrder(String orderId) {
    Order order = orderMapper.selectById(orderId);
    // 只有在 CREATED 状态下才能支付
    if (order.getState() != OrderState.CREATED) {
        return; // 已支付或已取消,幂等返回
    }
    // 使用 CAS 更新,防止并发
    int rows = orderMapper.updateState(orderId, OrderState.CREATED, OrderState.PAID);
    if (rows == 0) {
        return; // 并发时被其他线程更新了
    }
    // 执行支付逻辑
    paymentGateway.charge(order);
}

文章配图
文章配图

状态机设计要点:

  • 状态必须是有限的、可枚举的。
  • 状态转移必须是单向的(或有限方向的)。
  • 使用数据库的 UPDATE ... WHERE state = ? 实现 CAS 语义。

2.4 Token 机制

原理:客户端在调用接口前先获取一个 Token,服务端校验 Token 是否有效,用完即失效。

流程:

  1. 客户端请求 Token:POST /api/token,服务端返回一个全局唯一 Token 并存入 Redis。
  2. 客户端发起业务请求时携带 Token:POST /api/order?token=xxx。
  3. 服务端校验 Token 是否存在且未使用,如果有效则执行业务,然后删除或标记 Token。
java
public Result createOrder(String token, OrderRequest request) {
    // 使用 Redis 的 SET NX 或 DEL 原子操作
    // Lua 脚本保证原子性
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList("token:" + token), token);
    if (result == 0) {
        return Result.fail("重复提交");
    }
    // 执行业务
    return orderService.create(request);
}

文章配图
文章配图

优点:不依赖数据库唯一约束,适合高并发。 缺点:需要额外的 Token 获取步骤,增加了交互复杂度;Token 需要设置过期时间。

2.5 乐观锁(版本号)

原理:通过版本号控制并发更新,相同的版本号只能更新一次。

java
public void deductStock(String productId, int quantity) {
    Product product = productMapper.selectById(productId);
    int rows = productMapper.update(
        "UPDATE product SET stock = stock - ?, version = version + 1 WHERE id = ? AND version = ?",
        quantity, productId, product.getVersion()
    );
    if (rows == 0) {
        throw new OptimisticLockException("版本冲突,重试");
    }
}

注意:乐观锁本身不解决幂等,它解决的是并发冲突。但结合正确的重试逻辑,可以实现幂等。

2.6 分布式锁

原理:使用 Redis/ZooKeeper 分布式锁,保证同一时间只有一个请求在处理。

java
public void process(String idempotentKey) {
    String lockKey = "lock:" + idempotentKey;
    if (!redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS)) {
        return; // 获取锁失败,说明已有请求在处理
    }
    try {
        // 获取锁后再次检查是否已处理
        if (processed(idempotentKey)) return;
        doBusiness();
        markProcessed(idempotentKey);
    } finally {
        redisLock.unlock(lockKey);
    }
}

注意:分布式锁有超时风险,需要配合续期机制(如 Redisson 的 WatchDog)。

三、幂等键设计

3.1 幂等键的要素

一个好的幂等键需要具备以下特征:

  • 全局唯一:在分布式系统中,不同节点、不同业务类型的幂等键不能冲突。
  • 业务含义明确:幂等键应该携带业务上下文,方便排查问题。
  • 长度可控:不能太长,影响存储和索引性能。

3.2 幂等键的生成策略

策略 示例 适用场景
业务自然键 orderId + operationType 订单支付、退款
请求 ID requestId (UUID) RPC 调用
消息 ID topic + partition + offset 消息队列消费
客户端生成 clientId + timestamp + seq 前端防重提交
组合键 userId + productId + timestamp 秒杀等场景

3.3 幂等键的存储

  • 短期幂等(几小时到几天):Redis,带 TTL,内存效率高。
  • 长期幂等(几个月到永久):MySQL 唯一索引,持久可靠。
  • 混合策略:热数据用 Redis + 冷数据用 MySQL,通过 Bloom Filter 减少穿透。

四、各层级的幂等实践

4.1 接口层幂等

HTTP 方法的天然幂等性:

  • GET、PUT、DELETE 是幂等的(HTTP 规范)。
  • POST 不是幂等的,需要额外处理。

实践:在网关层或拦截器层统一处理幂等。

java
@Idempotent(key = "#request.orderId", expire = 3600)
@PostMapping("/order/create")
public Result createOrder(@RequestBody OrderRequest request) {
    // 业务逻辑
}

4.2 消息队列消费幂等

消息队列的 at-least-once 语义决定了消费端必须幂等。

java
@RocketMQMessageListener(topic = "order-paid", consumerGroup = "order-service")
public class OrderPaidConsumer implements RocketMQListener<MessageExt> {
    @Override
    public void onMessage(MessageExt message) {
        String msgId = message.getMsgId(); // 消息 ID 可作为幂等键
        // 或者使用业务 key
        String bizKey = message.getKeys();
        if (processed(msgId)) return;
        doProcess(message);
        markProcessed(msgId);
    }
}

文章配图
文章配图

4.3 数据库层面幂等

  • INSERT:通过唯一索引防止重复插入。
  • UPDATE:使用 UPDATE ... SET ... WHERE ... AND status = ? 状态机方式。
  • DELETE:天然幂等。

五、生产实践与常见陷阱

5.1 并发幂等

仅仅检查"是否已处理"是不够的,必须考虑并发场景:

code
线程A:检查幂等键 → 未处理 → 处理中...
线程B:检查幂等键 → 未处理 → 处理中...(并发问题!)

文章配图
文章配图

解决:检查 + 插入必须原子化,使用唯一索引或分布式锁。

5.2 幂等键的过期

幂等记录不能无限存储,需要设置合理的过期时间。

  • 太短:用户重试时幂等记录已过期,导致重复处理。
  • 太长:存储压力大,查询性能下降。

经验值:支付类 30 天,订单类 7 天,一般接口 1 小时。

5.3 幂等返回结果的一致性

重复请求不仅要阻止重复处理,还需要返回与第一次一致的结果。

java
public Result pay(String idempotentKey) {
    IdempotentRecord record = mapper.selectByKey(idempotentKey);
    if (record != null && record.getStatus() == SUCCESS) {
        return Result.success(record.getResult()); // 返回第一次的结果
    }
    // 处理...
}

5.4 幂等与重试的配合

java
public Result processWithRetry(String idempotentKey) {
    int maxRetries = 3;
    for (int i = 0; i < maxRetries; i++) {
        try {
            // 幂等检查在业务方法内部
            return doProcess(idempotentKey);
        } catch (DuplicateKeyException e) {
            // 并发冲突,查询已处理的结果
            return queryResult(idempotentKey);
        } catch (OptimisticLockException e) {
            // 乐观锁冲突,重试
            continue;
        }
    }
    throw new BizException("处理失败");
}

六、故障案例

案例1:支付回调重复扣款

现象:某支付系统收到微信支付回调后,用户被扣款两次。

原因:回调接口没有幂等处理。微信支付回调因网络问题重发了通知,服务端直接执行了两次扣款。

修复:以 transactionId(微信订单号)作为幂等键,在数据库建唯一索引,回调前先检查是否已处理。

案例2:消息队列重复消费导致库存超扣

现象:秒杀系统使用 RocketMQ 异步扣库存,出现库存扣为负数。

原因:消费端没有幂等处理,RocketMQ 因为 Consumer 重启导致消息重复投递。

修复:以 msgId 为幂等键,消费前先检查;同时使用 UPDATE stock SET stock = stock - 1 WHERE id = ? AND stock > 0 做防护。

案例3:Token 机制在高并发下失效

现象:使用 Redis Token 机制防重,大促时仍有重复提交。

原因:GET + DEL 不是原子操作,高并发下两个请求同时 GET 到 Token 都是有效的。

修复:使用 Lua 脚本保证 GET + DEL 的原子性。

七、方案选型对比

方案 复杂度 性能 可靠性 适用场景
唯一约束 低 中 高 通用场景
状态机 中 高 高 有状态流转的业务
Token 中 高 中 前端防重提交
乐观锁 低 高 中 并发更新场景
分布式锁 中 中 中 需要排他锁的场景
去重表 中 中 高 需要存储结果的场景

文章配图
文章配图

八、面试追问

Q1:幂等和防重有什么区别?

参考答案:幂等是服务端的能力,保证同一个请求多次执行的结果一致;防重是客户端的策略,避免同一个请求被重复发送。幂等是兜底,防重是前置优化。两者应该配合使用:客户端做防重(按钮置灰、Token),服务端做幂等(唯一约束、状态机)。

Q2:分布式事务中的幂等如何处理?

参考答案:在 TCC、Saga 等分布式事务模式中,每个参与方都需要实现幂等。TCC 的 Try/Confirm/Cancel 每个阶段都要支持幂等调用。通常使用业务唯一键 + 状态机来实现:Try 阶段检查状态是否为 INIT,Confirm 阶段检查状态是否为 TRYING,Cancel 阶段检查状态是否为 TRYING 或 INIT。

Q3:如何设计一个通用的幂等框架?

参考答案:核心组件包括:

  1. 幂等键解析器:从请求中提取或生成幂等键(支持 SpEL、注解等)。
  2. 幂等存储:支持 Redis、MySQL 等多种存储后端。
  3. 幂等处理器:统一的检查-处理-记录流程。
  4. 结果缓存:返回首次处理的结果,保证一致性。
  5. 过期策略:按业务类型配置不同的过期时间。

Q4:为什么 UUID 不能直接作为幂等键?

参考答案:UUID 可以作为幂等键,但需要结合业务场景。UUID 的问题在于:

  • 它是随机的,相同的业务请求每次生成的 UUID 不同,无法起到去重作用。
  • 幂等键需要是"同一个请求"的标识,UUID 如果由客户端在每次请求时重新生成,就失去了幂等键的意义。
  • 正确做法:客户端在首次请求时生成 UUID,后续重试使用同一个 UUID。

Q5:如果幂等记录存储(Redis/MySQL)宕机了怎么办?

参考答案:这是一个典型的可用性 vs 一致性权衡:

  • 降级策略:允许短暂的非幂等,依靠业务层面的补偿(如对账)来修复。
  • 多级存储:Redis 不可用时降级到 MySQL,MySQL 不可用时降级到本地缓存。
  • 熔断:当幂等存储不可用时,拒绝服务比重复处理更安全(对于支付等敏感场景)。
  • 兜底:即使幂等检查失败,业务逻辑本身也要有防护(如 UPDATE ... WHERE stock > 0)。

Q6:Saga 模式中,补偿操作失败怎么办?

参考答案:补偿操作本身也必须是幂等的。如果补偿失败:

  1. 重试:补偿操作支持幂等,可以安全重试。
  2. 持久化:将失败的补偿任务持久化到数据库,由定时任务重试。
  3. 人工介入:超过最大重试次数后,告警通知人工处理。
  4. 最终一致性:通过各种对账机制发现不一致并修复。

Q7:设计一个秒杀系统的幂等方案

参考答案:秒杀场景的特点是高并发、低延迟要求。推荐方案:

  1. 前端:按钮防重(点击后置灰)+ Token 机制。
  2. 网关层:基于 userId + activityId 的限流,同一用户同一活动只能通过一个请求。
  3. 业务层:Redis 分布式锁 + Lua 脚本原子扣库存。
  4. 数据库层:userId + activityId 唯一索引兜底。
  5. 异步层:消息队列消费时用 msgId 去重。

九、面试官视角深度追问

以下问题从面试官视角出发,考察候选人对幂等设计的深层理解,而非对文档内容的复述。每个问题都聚焦实际工程中的权衡与边界判断。

Q8:为什么 INSERT ... ON DUPLICATE KEY UPDATE 是幂等的,但你说它「安全」吗?

面试官意图:考察候选人是否理解幂等的局限——幂等只保证结果一致,不保证业务正确。

参考答案:这句话本身是幂等的,但业务上可能是灾难性的。假设两个并发请求:请求A要将余额设为 100(充值 100),请求B要将余额设为 200(充值 200)。如果 A 先执行 balance=100,B 再执行 balance=200,最终 balance=200,A 的充值被覆盖了——这是一个丢失更新问题。幂等不解决并发写冲突,解决并发写冲突需要乐观锁(版本号)或悲观锁。所以面试中不要只说「这个 SQL 是幂等的」,要补充「但需要配合并发控制手段」。

Q9:你设计了一个基于 Redis 的幂等方案,业务高峰期 Redis 集群发生了主从切换,会有什么问题?你怎么解决?

面试官意图:考察候选人对 Redis 高可用与幂等组合的边界条件理解。

参考答案:Redis 主从切换可能导致两个问题:

  1. 幂等记录丢失:如果主节点宕机时幂等 key 尚未同步到从节点,新主节点上没有这条记录,重复请求会穿透幂等检查。解决方案:对核心业务(支付、转账),幂等记录必须同时写 MySQL 做持久化兜底;或使用 Redis 的 WAIT 命令强制同步到至少 N 个从节点后再返回。
  2. 分布式锁失效:如果使用 Redisson 的分布式锁,主从切换时 WatchDog 续期机制可能中断,导致锁提前释放。解决方案:使用 RedLock 算法(多节点独立加锁),或使用 ZooKeeper 的临时顺序节点方案。

Q10:你有一个支付接口,调用支付宝后进行扣款。如果支付宝返回了「支付成功」但你的幂等记录写入失败了,你怎么处理?反过来,如果幂等记录写入成功但扣款失败了,又怎么处理?

面试官意图:考察分布式系统中「写本地」和「写远程」不是原子的情况下,候选人如何设计补偿与对账。

参考答案:这是典型的分布式不一致问题,两种场景需要不同策略:

场景A(第三方成功,本地记录失败):

  • 用户已扣款但本地未记录,后续重试会再次调用支付宝,可能重复扣款。
  • 解决方案:先写幂等记录(状态=PROCESSING),再调支付宝。如果支付宝返回成功但更新幂等状态失败,依靠定时对账任务:以支付宝的交易流水为准,发现本地状态仍为 PROCESSING 的订单,通过 transactionId 查询支付宝确认状态后推进。
  • 不要把「调用支付宝」和「写幂等记录」放在一个本地事务里——它们根本不在同一个系统。

场景B(本地记录成功,第三方失败):

  • 幂等记录标记为 FAILED,允许用户重试。重试时幂等键相同,但状态机允许从 FAILED 重试到 PROCESSING。
  • 关键设计:幂等键不绑定结果状态,只绑定请求。是否允许重试由状态机决定,而非幂等键本身。

Q11:你提到幂等记录要设置过期时间。假设一个订单的幂等记录过期后被删除了,用户恰好在这之后重试了支付请求,会发生什么?你怎么设计来避免?

面试官意图:考察候选人对幂等过期的边界条件处理,以及是否理解「幂等不是银弹」。

参考答案:幂等记录过期后用户重试,系统会认为这是一个新请求,再次执行支付逻辑。这会导致重复扣款。解决方案是一个多层次的防护体系:

  1. 上游拦截:支付宝/微信的支付回调带有 transactionId,即使你的幂等记录过期了,也可以通过 transactionId 去支付宝查询这笔订单的真实状态,而不是直接执行扣款。
  2. 业务状态机兜底:订单状态机已经流转到 PAID,即使幂等记录过期,payOrder 方法检查订单状态时发现已经是 PAID,直接返回,不会重复扣款。幂等键是防止重复执行的第一道防线,业务状态机是第二道防线。
  3. 过期时间分层:核心支付类的幂等记录保留 30 天以上,覆盖支付宝回调的最长重试周期;非核心接口的幂等记录可以更短。
  4. 对账机制:即使以上全部失效,T+1 对账也会发现资金不平,触发人工处理。

Q12:在你的架构中,幂等键是由客户端生成的还是服务端生成的?各有什么利弊?

面试官意图:考察分布式系统中信任边界的设计理念。

参考答案:

维度 客户端生成 服务端生成
生成方式 客户端用 UUID/雪花算法生成 服务端接收请求后生成
优点 无需额外请求,延迟低 可控,不会被恶意构造
缺点 客户端可能恶意构造冲突键 需要额外请求或服务端首次生成后返回
安全风险 恶意客户端可以伪造他人的幂等键 无此风险

推荐方案:服务端生成 + 客户端透传。客户端首次请求时不带幂等键,服务端生成后返回给客户端;客户端后续重试时携带这个幂等键。这样既保证了安全性,又减少了额外请求。对于查询类接口,客户端可以直接用 requestId 作为幂等键,因为查询天然幂等。

Q13:RocketMQ 消费端你用了 msgId 做幂等,但 msgId 在 Producer 重试时是相同的吗?如果不是,你的幂等方案还 work 吗?

面试官意图:考察候选人对消息队列消息 ID 机制的深入理解。

参考答案:RocketMQ 中 msgId 由 Producer 端生成,由 clientIP、processId、时间戳和计数器组成。关键问题在于:Producer 发送重试时,msgId 是否相同取决于重试方式:

  • 如果 Producer 捕获 send 失败后,用同一个 Message 对象重试 send,则 msgId 相同(因为 msgId 在 Message 构造时就确定了)。
  • 如果 Producer 重新构造了一个 Message 对象来重试,则 msgId 不同。

因此,不能完全依赖 msgId 做幂等。推荐做法:使用业务 key(message.setKeys)作为幂等键,而不是 msgId。业务 key 由业务逻辑生成,无论 Producer 重试多少次,同一个业务请求的 key 始终相同。同时,将 msgId 作为辅助标识,用于排查消息轨迹。

Q14:说说你在生产环境中遇到过的最棘手的幂等问题,以及你是怎么解决它的。

面试官意图:考察候选人是否有真实的线上问题处理经验,以及问题分析和沟通能力。

参考答案(面试官期待的答案结构):

  1. 描述场景:什么业务、什么量级、什么触发条件。
  2. 描述现象:用户反馈什么、监控发现什么。
  3. 分析过程:怎么定位根因的,用了什么工具/日志。
  4. 解决方案:临时止血方案 + 长期根治方案。
  5. 复盘总结:学到了什么,系统层面做了什么改进。

示例回答框架:「我们有一个跨境支付系统,用户反馈一笔订单被扣了两次款。排查发现是支付网关的回调通知因为网络问题重发了,而我们的幂等逻辑有一个 bug——幂等检查用的是 SELECT FOR UPDATE,但在 Spring 事务中,FOR UPDATE 的锁在事务提交前就释放了(因为我们在 Service 层用了 REQUIRES_NEW 传播级别,导致幂等记录的插入和支付扣款不在同一个事务中)。临时方案是加 Redis 分布式锁兜底,长期方案是重构事务边界,把幂等检查和业务操作放在同一个事务中,并上线了自动化对账任务。」


全文完 | 共 8 张配图 | 14 道面试追问 | 预计阅读时间:45 分钟