SpringBoot + 消息生产幂等 + 唯一 ID 去重:前端重复点击,后端只处理一次

引言:重复提交的噩梦

去年公司的秒杀系统因为用户疯狂点击"立即购买"按钮,导致同一个订单被重复提交了10次。虽然前端做了按钮禁用,但用户可以通过刷新页面、网络重试等方式绕过限制。最终导致库存超卖,用户投诉,运营背锅。

重复提交是 Web 应用中常见的问题,特别是在以下场景:

  • 用户快速点击提交按钮
  • 网络超时导致用户重复提交
  • 浏览器后退后重新提交
  • 前端表单重复提交

**幂等性(Idempotence)**是解决这个问题的关键。一个幂等的操作,无论执行多少次,结果都是一样的。

本文将带你深入理解幂等性,并使用 Spring Boot + Redis 实现一套完整的幂等性控制方案。


一、幂等性:概念与重要性

1.1 什么是幂等性?

定义:一个操作,无论执行一次还是多次,其产生的结果都是相同的。

数学表达:f(x) = f(f(x))

生活中的例子

  • 幂等操作:设置手机铃声(无论设置多少次,结果都是同一铃声)
  • 非幂等操作:银行转账(转100元,转两次就是200元)

1.2 HTTP 方法与幂等性

HTTP 方法幂等性说明
GET✅ 幂等获取资源,多次获取结果相同
HEAD✅ 幂等获取资源头信息
PUT✅ 幂等更新资源,多次更新结果相同
DELETE✅ 幂等删除资源,多次删除结果相同
POST❌ 非幂等创建资源,多次创建多个资源

1.3 为什么需要幂等性?

┌─────────────────────────────────────────────────────────────┐
│                  重复提交的危害                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 业务数据异常                                           │
│     └─> 订单重复创建、库存超卖、余额重复扣减                   │
│                                                             │
│  2. 数据一致性问题                                           │
│     └─> 主从数据不一致、缓存不一致                            │
│                                                             │
│  3. 性能问题                                               │
│     └─> 重复处理消耗资源、数据库压力增大                        │
│                                                             │
│  4. 用户体验差                                             │
│     └─> 用户困惑、投诉增加                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

二、幂等性控制方案对比

2.1 方案一:前端防抖

// 前端按钮防抖
let isSubmitting = false;

function submitOrder() {
    if (isSubmitting) {
        alert('请勿重复提交');
        return;
    }
    
    isSubmitting = true;
    
    // 发送请求
    axios.post('/api/order/create', orderData)
        .then(response => {
            alert('提交成功');
        })
        .finally(() => {
            isSubmitting = false;
        });
}

优点

  • 实现简单
  • 减少无效请求

缺点

  • 无法防止网络重试
  • 无法防止多窗口提交
  • 无法防止接口被恶意调用

2.2 方案二:数据库唯一索引

-- 订单表添加唯一索引
CREATE TABLE `order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(32) NOT NULL COMMENT '订单号',
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `amount` decimal(10,2) NOT NULL COMMENT '金额',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`) COMMENT '订单号唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

优点

  • 数据库层面保证唯一性
  • 可靠性高

缺点

  • 性能开销大
  • 需要预先知道唯一键
  • 数据库压力大

2.3 方案三:Redis 去重(推荐)

┌─────────────────────────────────────────────────────────────┐
│              Redis 去重方案流程                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  前端              后端              Redis                  │
│   │                  │                  │                 │
│   │ 1.提交请求        │                  │                 │
│   ├─────────────────>│                  │                 │
│   │                  │                  │                 │
│   │                  │ 2.检查唯一ID      │                 │
│   │                  ├─────────────────>│                 │
│   │                  │                  │                 │
│   │                  │ 3.返回结果        │                 │
│   │                  │<─────────────────┤                 │
│   │                  │                  │                 │
│   │                  │ 4.不存在则设置     │                 │
│   │                  ├─────────────────>│                 │
│   │                  │                  │                 │
│   │                  │ 5.执行业务逻辑     │                 │
│   │                  │                  │                 │
│   │ 6.返回结果        │                  │                 │
│   │<─────────────────┤                  │                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

优点

  • 性能高(Redis 内存操作)
  • 支持过期时间
  • 实现简单
  • 灵活性强

缺点

  • Redis 故障时可能失效
  • 需要处理并发问题

三、Spring Boot + Redis 实现幂等性控制

3.1 整体架构

┌─────────────────────────────────────────────────────────────┐
│              幂等性控制架构                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────┐    ┌─────────┐    ┌─────────┐            │
│  │  前端   │    │  后端   │    │  Redis  │            │
│  └────┬────┘    └────┬────┘    └─────────┘            │
│       │              │                                    │
│       │ 1.提交请求     │                                    │
│       ├─────────────>│                                    │
│       │              │                                    │
│       │              │ 2.@Idempotent 注解                  │
│       │              │                                    │
│       │              │ 3.AOP 拦截                          │
│       │              │                                    │
│       │              │ 4.生成唯一ID                        │
│       │              │                                    │
│       │              │ 5.Redis Setnx                      │
│       │              ├─────────────────────────────────────>│
│       │              │                                    │
│       │              │ 6.判断是否已处理                     │
│       │              │<─────────────────────────────────────┤
│       │              │                                    │
│       │              │ 7.执行业务逻辑                       │
│       │              │                                    │
│       │ 8.返回结果     │                                    │
│       │<─────────────┤                                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.2 核心实现

3.2.1 自定义幂等性注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    
    /**
     * 唯一ID的前缀
     */
    String prefix() default "";
    
    /**
     * 唯一ID的过期时间(秒)
     */
    int expireTime() default 60;
    
    /**
     * 提示信息
     */
    String message() default "请勿重复提交";
    
    /**
     * 是否在方法参数中指定唯一ID
     */
    boolean paramId() default false;
    
    /**
     * 唯一ID在参数中的位置
     */
    int paramIndex() default 0;
}

3.2.2 幂等性切面

@Aspect
@Component
public class IdempotentAspect {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 生成唯一ID
        String uniqueId = generateUniqueId(joinPoint, idempotent);
        
        // Redis Key
        String redisKey = "idempotent:" + idempotent.prefix() + ":" + uniqueId;
        
        // 尝试设置 Key
        Boolean success = redisTemplate.opsForValue().setIfAbsent(
            redisKey, 
            "1", 
            idempotent.expireTime(), 
            TimeUnit.SECONDS
        );
        
        if (!success) {
            throw new IdempotentException(idempotent.message());
        }
        
        try {
            // 执行业务逻辑
            return joinPoint.proceed();
        } catch (Exception e) {
            // 执行失败,删除 Key,允许重试
            redisTemplate.delete(redisKey);
            throw e;
        }
    }
    
    private String generateUniqueId(ProceedingJoinPoint joinPoint, Idempotent idempotent) {
        if (idempotent.paramId()) {
            // 从参数中获取唯一ID
            Object[] args = joinPoint.getArgs();
            return String.valueOf(args[idempotent.paramIndex()]);
        } else {
            // 生成唯一ID(基于方法签名和参数)
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            Method method = signature.getMethod();
            Object[] args = joinPoint.getArgs();
            
            return DigestUtils.md5Hex(method.toString() + Arrays.toString(args));
        }
    }
}

3.2.3 使用示例

@RestController
@RequestMapping("/order")
public class OrderController {
    
    /**
     * 创建订单(使用幂等性控制)
     */
    @PostMapping("/create")
    @Idempotent(prefix = "order:create", expireTime = 300)
    public Result createOrder(@RequestBody OrderDTO orderDTO) {
        // 业务逻辑
        orderService.createOrder(orderDTO);
        return Result.success();
    }
    
    /**
     * 创建订单(使用参数中的唯一ID)
     */
    @PostMapping("/create2")
    @Idempotent(prefix = "order:create", paramId = true, paramIndex = 0)
    public Result createOrder2(@RequestParam String orderNo, @RequestBody OrderDTO orderDTO) {
        // 业务逻辑
        orderService.createOrder(orderNo, orderDTO);
        return Result.success();
    }
}

3.3 唯一ID生成策略

3.3.1 基于请求参数生成

// 使用 MD5 对请求参数生成唯一ID
String uniqueId = DigestUtils.md5Hex(JSON.toJSONString(requestParams));

3.3.2 基于用户ID + 操作类型生成

// 使用用户ID和操作类型生成唯一ID
String uniqueId = userId + ":" + operationType;

3.3.3 基于Token生成

// 前端生成 Token,后端验证
String token = request.getHeader("X-Idempotent-Token");
String uniqueId = token;

四、进阶:分布式环境下的幂等性控制

4.1 分布式锁方案

public boolean tryLock(String key, long expireTime) {
    return redisTemplate.opsForValue().setIfAbsent(
        key, 
        "1", 
        expireTime, 
        TimeUnit.SECONDS
    );
}

public void unlock(String key) {
    redisTemplate.delete(key);
}

4.2 Redisson 分布式锁

@Autowired
private RedissonClient redissonClient;

public void createOrder(OrderDTO orderDTO) {
    String lockKey = "order:lock:" + orderDTO.getUserId();
    
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 尝试获取锁
        if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
            // 执行业务逻辑
            doCreateOrder(orderDTO);
        } else {
            throw new BusinessException("请勿重复提交");
        }
    } finally {
        // 释放锁
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

4.3 消息队列幂等性

@RocketMQMessageListener(topic = "order-topic", consumerGroup = "order-consumer-group")
public class OrderConsumer implements RocketMQListener<String> {
    
    @Override
    public void onMessage(String message) {
        // 解析消息
        OrderMessage orderMessage = JSON.parseObject(message, OrderMessage.class);
        
        // 生成唯一ID
        String uniqueId = "order:" + orderMessage.getOrderNo();
        
        // Redis 去重
        Boolean success = redisTemplate.opsForValue().setIfAbsent(
            uniqueId, 
            "1", 
            24, 
            TimeUnit.HOURS
        );
        
        if (!success) {
            log.info("消息已处理,跳过: {}", orderMessage.getOrderNo());
            return;
        }
        
        // 处理消息
        processOrder(orderMessage);
    }
}

五、最佳实践

5.1 唯一ID设计原则

  1. 全局唯一:确保在整个系统中唯一
  2. 可读性强:便于排查问题
  3. 长度适中:避免过长影响性能
  4. 包含业务信息:便于追溯

5.2 过期时间设置

场景过期时间说明
订单提交5-10分钟订单处理时间
支付回调24小时支付处理时间
消息消费24-48小时消息处理时间
表单提交1-5分钟表单处理时间

5.3 异常处理

try {
    // 执行业务逻辑
    result = joinPoint.proceed();
} catch (Exception e) {
    // 判断是否为业务异常
    if (isBusinessException(e)) {
        // 业务异常,删除 Key,允许重试
        redisTemplate.delete(redisKey);
    }
    throw e;
}

5.4 监控告警

// 监控重复提交次数
@Aspect
@Component
public class IdempotentMonitorAspect {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    @Around("@annotation(idempotent)")
    public Object monitor(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        String uniqueId = generateUniqueId(joinPoint, idempotent);
        String redisKey = "idempotent:" + idempotent.prefix() + ":" + uniqueId;
        
        Boolean success = redisTemplate.opsForValue().setIfAbsent(
            redisKey, "1", idempotent.expireTime(), TimeUnit.SECONDS
        );
        
        if (!success) {
            // 记录重复提交
            meterRegistry.counter("idempotent.duplicate", 
                "prefix", idempotent.prefix()
            ).increment();
            
            throw new IdempotentException(idempotent.message());
        }
        
        return joinPoint.proceed();
    }
}

六、常见问题与解决方案

6.1 Redis 故障导致幂等性失效

问题:Redis 宕机,无法进行去重

解决方案

  • Redis 集群部署,保证高可用
  • 数据库唯一索引作为兜底
  • 本地缓存作为降级方案

6.2 并发请求同时到达

问题:多个请求同时到达,都通过 Redis 检查

解决方案

  • 使用分布式锁
  • 数据库唯一索引
  • 乐观锁(版本号)

6.3 唯一ID 重复

问题:不同请求生成相同的唯一ID

解决方案

  • 使用更复杂的生成算法
  • 增加时间戳、随机数等
  • 使用分布式 ID 生成器

6.4 过期时间设置不合理

问题:过期时间太短或太长

解决方案

  • 根据业务场景设置合理的过期时间
  • 支持动态调整过期时间
  • 监控 Key 的命中率

七、总结

幂等性控制是保证系统稳定性和数据一致性的重要手段。本文介绍了多种幂等性控制方案,重点讲解了如何使用 Spring Boot + Redis 实现一套完整的幂等性控制方案。

核心要点:

  1. 理解幂等性的概念和重要性
  2. 根据业务场景选择合适的方案
  3. Redis 去重是性能和可靠性的平衡
  4. 注意分布式环境下的并发问题
  5. 做好监控和告警

适用场景:

  • 订单创建
  • 支付提交
  • 表单提交
  • 消息消费
  • 接口调用

如果本文对你有帮助,欢迎关注「服务端技术精选」公众号,获取更多后端技术干货。


互动:

  1. 在你的项目中,如何处理重复提交问题?
  2. Redis 去重和数据库唯一索引,你更倾向于哪种方案?为什么?
  3. 如何设计一个支持分布式环境的高性能幂等性控制方案?

欢迎在评论区分享你的想法和经验,我们一起交流学习!


标题:SpringBoot + 消息生产幂等 + 唯一 ID 去重:前端重复点击,后端只处理一次
作者:jiangyi
地址:http://jiangyi.space/articles/2026/03/08/1772942581970.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消