幂等性设计:从“重复提交“到“稳如磐石“的系统防护

幂等性设计:从"重复提交"到"稳如磐石"的系统防护

在分布式系统和高并发场景下,幂等性是保障数据一致性的最后一道防线。本文将从生活场景出发,深入剖析六种主流幂等方案的原理、实现与选型策略,帮你构建"无论调用多少次,结果始终一致"的健壮系统。


一、为什么你的系统需要幂等性?

1.1 那些让人崩溃的"重复"问题

想象这样的场景:

  • 用户重复点击:提交订单时网络卡顿,用户狂点"确认支付",结果银行卡被扣了3次钱
  • 消息队列重试:订单服务消费消息失败,MQ自动重试,用户收到3条"您的订单已发货"短信
  • 定时任务补偿:对账系统因为网络超时重试,给同一笔订单重复发放了优惠券
  • 第三方回调:支付成功后,微信/支付宝回调你的系统,因为网络抖动回调了2次,用户账户余额加了2次

这些问题的根源在于:网络是不可靠的,重试是必然的,而系统没有做好"防重"准备。

1.2 什么是幂等性?

幂等性(Idempotency):一个操作执行一次和执行N次,对系统状态的影响是完全相同的。

用大白话说:同样的请求,你来一遍和来一百遍,结果都一样,不会多扣钱、不会多下单、不会多发货。

生活类比

  • 幂等操作:电梯按按钮。你按1次和按10次,电梯都是去1楼,不会跑10趟。
  • 非幂等操作:ATM取钱。你操作1次扣100元,操作2次就扣200元。

二、幂等设计的核心心法:"一锁二判三更新"

业界总结了一套通俗易记的口诀------"一锁二判三更新"

步骤 含义 目的
一锁 对唯一业务标识加锁 防止并发请求同时穿透
二判 判断请求是否已处理过 识别重复请求
三更新 执行业务逻辑并更新状态 保证原子性完成

基于这个心法,我们来看六种主流方案。


三、六大幂等方案深度解析

3.1 Token机制 ------ "一次性门票"

原理:请求前先向服务端申请一张"门票"(Token),服务端将Token存起来;真正请求时带上这张票,服务端验票后立即撕票(删除Token),同一张票无法使用第二次。

完整流程

复制代码
┌─────────┐         1.申请Token          ┌─────────┐
│  客户端  │ ───────────────────────────→ │  服务端  │
│         │ ←─────────────────────────── │         │
│         │         2.返回Token          │  Redis  │
│         │                              │  (存储)  │
│         │  3.携带Token执行业务请求      │         │
│         │ ───────────────────────────→ │         │
│         │                              │  4.校验  │
│         │                              │  Token   │
│         │                              │  存在?   │
│         │                              │    ↓     │
│         │                              │  5.删除  │
│         │                              │  Token   │
│         │                              │    ↓     │
│         │  6.返回成功                  │  6.执行业务│
│         │ ←─────────────────────────── │         │
└─────────┘                              └─────────┘

代码示例(Redis + Lua保证原子性)

java 复制代码
// 1. 申请Token
public String generateToken() {
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set(
        "idempotent:token:" + token, 
        "1", 
        5, TimeUnit.MINUTES
    );
    return token;
}

// 2. 校验并删除Token(Lua脚本保证原子性)
public boolean verifyToken(String token) {
    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("idempotent:token:" + token),
        "1"
    );
    return result != null && result > 0;
}

适用场景

  • 表单提交(订单创建、用户注册)
  • 绝大多数非高并发场景
  • 需要前端配合的交互流程

优点

  • 逻辑清晰,实现简单
  • 通用性强,不侵入业务表结构

注意事项(坑点)

  • Token必须原子性删除:先查后删会导致并发问题,必须用Lua脚本或Redis事务
  • Token有效期:设置合理的过期时间,防止Token堆积或长期占用内存
  • Token生成时机:必须在真正业务操作前生成,且一个Token只能对应一次业务操作

3.2 数据库唯一索引 ------ "最稳的兜底方案"

原理 :利用数据库的唯一约束(Unique Index),让重复数据根本插不进去。数据库会抛出DuplicateKeyException,捕获异常即表示该请求已处理过。

实现方式

sql 复制代码
-- 订单表:对业务唯一键加唯一索引
CREATE TABLE `order` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `order_no` VARCHAR(64) NOT NULL COMMENT '业务订单号',
    `user_id` BIGINT NOT NULL,
    `amount` DECIMAL(10,2) NOT NULL,
    -- ... 其他字段
    UNIQUE KEY `uk_order_no` (`order_no`)  -- 唯一索引防重
) ENGINE=InnoDB;

代码示例

java 复制代码
@Transactional
public void createOrder(OrderRequest request) {
    try {
        Order order = new Order();
        order.setOrderNo(request.getOrderNo()); // 业务唯一号(如:用户ID+时间戳+序列)
        order.setUserId(request.getUserId());
        order.setAmount(request.getAmount());
        orderMapper.insert(order);  // 重复插入会抛异常
        
        // 后续业务逻辑...
        
    } catch (DuplicateKeyException e) {
        // 已存在,直接返回成功或查询已有数据返回
        log.warn("重复订单请求,orderNo={}", request.getOrderNo());
        return orderMapper.selectByOrderNo(request.getOrderNo());
    }
}

适用场景

  • 有明确唯一业务标识的新增操作(订单号、流水号、请求ID)
  • 对数据一致性要求极高的场景

优点

  • 实现最简单,只需一条SQL
  • 可靠性最高,数据库是唯一真理源
  • 无需额外组件,不引入Redis等中间件

缺点

  • 仅适用于插入场景,更新场景无法使用
  • 依赖业务唯一ID的生成策略
  • 高并发下,唯一索引冲突会导致数据库压力

最佳实践

  • 业务唯一号生成策略:用户ID + 时间戳 + 随机数 或 分布式ID(Snowflake)
  • 捕获异常后,建议返回已有数据,保证接口返回一致性

3.3 乐观锁 ------ "版本号控制并发"

原理 :在数据表中增加version字段,更新时先读取版本号,更新时要求版本号与读取时一致,否则更新失败。

流程

复制代码
读取数据: SELECT id, amount, version FROM account WHERE id = 1;
         → 得到: id=1, amount=100, version=1

更新数据: UPDATE account 
          SET amount = amount - 10, version = version + 1 
          WHERE id = 1 AND version = 1;
          
如果此时另一个线程已修改: version变为2
→ 本次更新影响行数为0 → 需要重试或返回失败

代码示例

java 复制代码
public boolean deductBalance(Long accountId, BigDecimal amount) {
    // 1. 查询当前版本号
    Account account = accountMapper.selectById(accountId);
    Integer currentVersion = account.getVersion();
    
    // 2. 带版本号更新
    int affectedRows = accountMapper.update(
        new UpdateWrapper<Account>()
            .setSql("balance = balance - " + amount)
            .setSql("version = version + 1")
            .eq("id", accountId)
            .eq("version", currentVersion)  // 关键:版本号必须匹配
    );
    
    // 3. 判断更新结果
    if (affectedRows == 0) {
        // 版本冲突,说明已有其他请求修改过数据
        // 策略A:重试(有限次数)
        // 策略B:直接返回"处理中"或失败
        return false;
    }
    return true;
}

适用场景

  • 更新操作(扣减库存、扣减余额、更新状态)
  • 读多写少、并发冲突概率较低的场景

优点

  • 不加锁,性能优于悲观锁
  • 无死锁风险
  • 适合大多数互联网高并发读场景

缺点

  • 冲突严重时,重试会导致性能下降
  • 需要业务表增加version字段,有一定侵入性

与悲观锁对比

维度 乐观锁 悲观锁
实现方式 版本号/CAS SELECT ... FOR UPDATE
性能 高(无锁等待) 低(锁等待)
冲突处理 重试或放弃 排队执行
适用场景 读多写少 写多读少,强一致性
死锁风险

3.4 状态机 ------ "有限状态自动机"

原理:业务实体有明确的状态流转路径(如订单:待支付→已支付→已发货→已完成),通过限制合法的状态流转,非法重复请求会被拒绝。

订单状态机示例

复制代码
        ┌──────────┐
        │  待支付   │
        └────┬─────┘
             │ 支付成功
             ▼
        ┌──────────┐    发货     ┌──────────┐
        │  已支付   │ ─────────→ │  已发货   │
        └────┬─────┘            └────┬─────┘
             │ 退款                  │ 签收
             ▼                       ▼
        ┌──────────┐            ┌──────────┐
        │  已退款   │            │  已完成   │
        └──────────┘            └──────────┘

代码示例

java 复制代码
public enum OrderStatus {
    PENDING_PAYMENT(1, "待支付"),
    PAID(2, "已支付"),
    SHIPPED(3, "已发货"),
    COMPLETED(4, "已完成"),
    REFUNDED(5, "已退款");
    
    // 定义合法的状态流转
    private static final Map<OrderStatus, List<OrderStatus>> TRANSITIONS = new HashMap<>();
    static {
        TRANSITIONS.put(PENDING_PAYMENT, Arrays.asList(PAID));  // 待支付只能→已支付
        TRANSITIONS.put(PAID, Arrays.asList(SHIPPED, REFUNDED)); // 已支付可→已发货/已退款
        TRANSITIONS.put(SHIPPED, Arrays.asList(COMPLETED));      // 已发货只能→已完成
    }
    
    public boolean canTransitionTo(OrderStatus newStatus) {
        return TRANSITIONS.getOrDefault(this, Collections.emptyList())
                          .contains(newStatus);
    }
}

// 使用
public void payOrder(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    
    // 幂等性保障:如果已经是已支付,直接返回成功
    if (order.getStatus() == OrderStatus.PAID) {
        log.info("订单已支付,无需重复处理,orderId={}", orderId);
        return;
    }
    
    // 状态合法性校验
    if (!order.getStatus().canTransitionTo(OrderStatus.PAID)) {
        throw new IllegalStateException("非法状态流转: " + order.getStatus() + " → PAID");
    }
    
    // 执行业务...
    orderMapper.updateStatus(orderId, OrderStatus.PAID);
}

适用场景

  • 有明确生命周期的业务实体(订单、工单、审批流)
  • 异步回调/消息队列消费(防止重复处理同一状态变更)

优点

  • 业务语义清晰,状态流转一目了然
  • 天然防重:已处于目标状态的请求直接返回成功
  • 适合复杂业务流程管理

缺点

  • 需要预先定义完整的状态机,实现相对复杂
  • 状态过多时,维护成本增加

3.5 防重表 ------ "专门记账的账本"

原理:单独建立一张"防重表",将请求的唯一标识(如请求ID、业务单号)作为唯一索引存入。每次处理请求前,先尝试插入防重表,插入成功则执行业务,插入失败说明已处理过。

表结构

sql 复制代码
CREATE TABLE `idempotent_log` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `unique_key` VARCHAR(128) NOT NULL COMMENT '业务唯一标识',
    `business_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
    `request_params` TEXT COMMENT '请求参数(可选,用于审计)',
    `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_unique_key_type` (`unique_key`, `business_type`)
) ENGINE=InnoDB;

代码示例

java 复制代码
@Transactional
public void processPayment(PaymentRequest request) {
    String uniqueKey = request.getRequestId(); // 或:orderNo + "_" + type
    
    try {
        // 1. 先插入防重表(利用唯一索引)
        IdempotentLog log = new IdempotentLog();
        log.setUniqueKey(uniqueKey);
        log.setBusinessType("PAYMENT");
        idempotentLogMapper.insert(log);
        
    } catch (DuplicateKeyException e) {
        // 已处理过,直接返回
        log.warn("重复请求,已处理,uniqueKey={}", uniqueKey);
        return;
    }
    
    // 2. 执行业务逻辑(扣款、更新订单等)
    doPayment(request);
}

适用场景

  • 无法修改原有业务表结构(遗留系统)
  • 需要记录请求历史用于审计
  • 业务唯一索引不方便直接加在主表上

优点

  • 逻辑清晰,与业务表解耦
  • 可扩展性强,可记录完整请求信息

缺点

  • 增加一次数据库插入,性能开销较大
  • 需要定期清理历史数据,防止表膨胀
  • 与业务操作不在同一事务时,可能产生不一致

3.6 分布式锁 ------ "分布式系统的看门人"

原理:使用Redis或ZooKeeper对唯一请求标识加锁,同一时刻只有一个请求能执行业务逻辑,其他请求等待或拒绝。

Redis分布式锁实现(Redisson)

java 复制代码
@Service
public class OrderService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    public void createOrder(OrderRequest request) {
        String orderNo = request.getOrderNo();
        String lockKey = "lock:order:" + orderNo;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试加锁,最多等待3秒,锁持有10秒自动释放
            boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
            if (!locked) {
                throw new RuntimeException("获取锁失败,请稍后重试");
            }
            
            // 双重检查:加锁后再查一次,防止等待期间其他线程已处理
            Order existOrder = orderMapper.selectByOrderNo(orderNo);
            if (existOrder != null) {
                return; // 已处理过
            }
            
            // 执行业务逻辑
            doCreateOrder(request);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("操作被中断");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

适用场景

  • 高并发的分布式环境
  • 无法使用数据库唯一索引的场景
  • 需要串行化执行的复杂业务

优点

  • 适用于分布式系统
  • 锁粒度可控,灵活性高

注意事项(坑点)

  • 锁超时问题:业务执行时间超过锁超时时间,锁提前释放,导致并发问题
  • 锁续期:使用Redisson等支持看门狗自动续期的方案
  • 锁粒度:锁的key必须精细(如订单号级别),不能用全局锁,否则性能极差
  • 锁释放:必须在finally块中释放,且判断是否为当前线程持有(防止误释放)

四、方案选型决策树

面对不同场景,如何选择最合适的方案?以下决策树帮你快速判断:

复制代码
                    ┌─────────────────┐
                    │   是什么操作?   │
                    └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
           新增/插入       更新/修改       查询操作
              │              │              │
              ▼              ▼              ▼
        ┌──────────┐   ┌──────────┐   ┌──────────┐
        │有唯一业务ID?│   │有状态流转? │   │无需幂等 │
        └────┬─────┘   └────┬─────┘   │(天然幂等)│
             │              │          └──────────┘
        ┌────┴─────┐   ┌────┴─────┐
        ▼          ▼   ▼          ▼
       是         否  是          否
        │          │   │          │
        ▼          ▼   ▼          ▼
   ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
   │唯一索引 │ │防重表/ │ │状态机+ │ │乐观锁  │
   │(推荐) │ │Token   │ │唯一键  │ │(推荐)│
   └────────┘ └────────┘ └────────┘ └────────┘
        │                        │
        └────────┐    ┌─────────┘
                 ▼    ▼
            ┌────────────┐
            │ 高并发分布式? │
            └─────┬──────┘
                  │
            ┌─────┴─────┐
            ▼           ▼
           是          否
            │           │
            ▼           ▼
       ┌────────┐  ┌────────┐
       │分布式锁+│  │上述方案 │
       │数据库兜底│  │直接可用 │
       └────────┘  └────────┘

五、实际场景的最佳实践

5.1 场景一:用户下单(表单提交)

问题:用户点击"提交订单"后网络卡顿,重复点击导致生成多笔订单。

推荐方案Token机制 + 数据库唯一索引双保险

复制代码
前端页面加载 → 向后端申请Token(存入Redis)→ 用户点击提交
                                                      ↓
前端携带Token请求 → 后端校验Token(Lua删Token)→ 插入订单表(唯一索引)
                                                      ↓
                                              Token无效或唯一索引冲突
                                              → 均返回"订单已提交"

为什么双保险?

  • Token机制防正常用户的重复点击
  • 唯一索引防绕过前端的恶意重放(如直接调用API)

5.2 场景二:库存扣减(秒杀场景)

问题:高并发下,100个用户同时抢10件商品,不能超卖。

推荐方案乐观锁 + 数据库唯一索引

java 复制代码
// 1. 扣减库存(乐观锁)
UPDATE product 
SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND stock > 0 AND version = #{version};

// 2. 创建订单(唯一索引防重)
INSERT INTO order (order_no, ...) VALUES (...);

关键点

  • 乐观锁保证库存不扣成负数
  • 唯一索引保证同一用户不会重复下单

5.3 场景三:支付回调(异步通知)

问题:支付宝/微信支付成功后,会多次回调商户系统通知支付结果。

推荐方案状态机 + 防重表

java 复制代码
@Transactional
public void handlePayCallback(String orderNo, String tradeNo) {
    // 1. 防重:记录回调流水号
    try {
        callbackLogMapper.insert(new CallbackLog(tradeNo));
    } catch (DuplicateKeyException e) {
        return; // 已处理过该回调
    }
    
    // 2. 状态机校验
    Order order = orderMapper.selectByOrderNo(orderNo);
    if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
        return; // 已支付或已取消,无需处理
    }
    
    // 3. 更新状态并执行业务
    orderMapper.updateStatus(orderNo, OrderStatus.PAID);
    // 发货、积分、通知...
}

5.4 场景四:消息队列消费

问题:MQ消息消费失败重试,或分区 rebalance 导致消息重复消费。

推荐方案业务幂等(唯一索引/状态机)+ MQ去重配置

java 复制代码
@KafkaListener(topics = "order-topic")
public void consume(Message msg) {
    // 业务层保证幂等:对消息唯一ID做唯一索引
    processOrder(msg);
    
    // 手动提交offset,确保业务成功后才确认消费
    ack.acknowledge();
}

Kafka去重配置

  • 开启幂等生产者:enable.idempotence=true
  • 配置事务或至少一次语义 + 业务层去重

六、避坑指南:幂等设计的五大原则

原则1:幂等性必须在服务端实现

前端防重(按钮置灰、Loading)只是体验优化,不能作为幂等性保障。 恶意用户可以直接调用API绕过前端。

原则2:唯一标识的选择至关重要

  • 好的标识:业务订单号、请求ID(UUID)、用户ID+操作类型+时间戳
  • 差的标识:自增ID(不同请求可能相同)、不含业务语义的时间戳(毫秒级并发冲突)

原则3:数据库是唯一真理源

Redis可能丢数据,应用内存可能重启,最终一致性必须以数据库为准。建议关键业务采用"Redis防重 + 数据库唯一索引兜底"的双层架构。

原则4:注意分布式事务

如果幂等判断和业务操作不在同一个事务中,可能出现:

  1. 判断通过(未重复)
  2. 执行业务时系统宕机
  3. 请求重试,判断通过(因为上次没提交)
  4. 重复执行业务

解决方案:将幂等记录和业务操作放在同一数据库事务中。

原则5:返回值要一致

幂等接口的重复请求,返回值必须与第一次相同

java 复制代码
// ❌ 错误:第一次返回成功,第二次返回"重复请求"
if (isDuplicate) {
    throw new RuntimeException("重复请求"); // 客户端以为失败了!
}

// ✅ 正确:返回与第一次相同的结果
if (isDuplicate) {
    return getPreviousResult(); // 查询已有结果返回
}

七、总结

方案 适用操作 并发能力 实现复杂度 推荐场景
Token机制 新增 表单提交、前端交互
数据库唯一索引 新增 极低 有唯一业务ID的插入
乐观锁 更新 库存扣减、余额更新
状态机 更新 订单流转、审批流程
防重表 新增/更新 无法改原表结构、需审计
分布式锁 通用 高(串行) 高并发分布式复杂业务

终极建议

简单场景用唯一索引,更新场景用乐观锁,复杂流程用状态机,高并发用分布式锁,前端配合用Token。关键业务永远要有多层防护。

幂等性不是"可选项",而是分布式系统的必选项 。在设计接口时,先问自己:如果这个方法被调用100次,系统还正确吗? 如果答案是否定的,就该考虑幂等性设计了。

相关推荐
呜喵王阿尔萨斯1 小时前
C/C++ const -- 多义混乱
c语言·开发语言·c++
spider_xcxc1 小时前
Helm 部署 K8s 集群完整笔记
java·开发语言·kubernetes
海清河晏1111 小时前
Qt实战:从零构建美化登录界面
开发语言·c++·qt
祐樹1 小时前
1987年5月23日下午15-17点出生性格、运势和命运
java
zhangjw341 小时前
第35篇:Spring Boot入门:自动配置+快速搭建,简化企业开发
java·spring boot·后端
一只小灿灿1 小时前
C++ 各类特殊符号、运算符
开发语言·c++
Fu_Lin_1 小时前
《Qt嵌入式从零基础到精通》前言与阅读指南
开发语言·qt
名字还没想好☜1 小时前
Go 的 select 实战:超时、非阻塞收发与优雅退出的三个套路
开发语言·数据库·golang·go·并发
geovindu1 小时前
java: Backtracking Algorithm
java·开发语言·windows·后端·算法·回溯算法