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设计原则
- 全局唯一:确保在整个系统中唯一
- 可读性强:便于排查问题
- 长度适中:避免过长影响性能
- 包含业务信息:便于追溯
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 实现一套完整的幂等性控制方案。
核心要点:
- 理解幂等性的概念和重要性
- 根据业务场景选择合适的方案
- Redis 去重是性能和可靠性的平衡
- 注意分布式环境下的并发问题
- 做好监控和告警
适用场景:
- 订单创建
- 支付提交
- 表单提交
- 消息消费
- 接口调用
如果本文对你有帮助,欢迎关注「服务端技术精选」公众号,获取更多后端技术干货。
互动:
- 在你的项目中,如何处理重复提交问题?
- Redis 去重和数据库唯一索引,你更倾向于哪种方案?为什么?
- 如何设计一个支持分布式环境的高性能幂等性控制方案?
欢迎在评论区分享你的想法和经验,我们一起交流学习!
标题:SpringBoot + 消息生产幂等 + 唯一 ID 去重:前端重复点击,后端只处理一次
作者:jiangyi
地址:http://jiangyi.space/articles/2026/03/08/1772942581970.html
公众号:服务端技术精选
- 引言:重复提交的噩梦
- 一、幂等性:概念与重要性
- 1.1 什么是幂等性?
- 1.2 HTTP 方法与幂等性
- 1.3 为什么需要幂等性?
- 二、幂等性控制方案对比
- 2.1 方案一:前端防抖
- 2.2 方案二:数据库唯一索引
- 2.3 方案三:Redis 去重(推荐)
- 三、Spring Boot + Redis 实现幂等性控制
- 3.1 整体架构
- 3.2 核心实现
- 3.2.1 自定义幂等性注解
- 3.2.2 幂等性切面
- 3.2.3 使用示例
- 3.3 唯一ID生成策略
- 3.3.1 基于请求参数生成
- 3.3.2 基于用户ID + 操作类型生成
- 3.3.3 基于Token生成
- 四、进阶:分布式环境下的幂等性控制
- 4.1 分布式锁方案
- 4.2 Redisson 分布式锁
- 4.3 消息队列幂等性
- 五、最佳实践
- 5.1 唯一ID设计原则
- 5.2 过期时间设置
- 5.3 异常处理
- 5.4 监控告警
- 六、常见问题与解决方案
- 6.1 Redis 故障导致幂等性失效
- 6.2 并发请求同时到达
- 6.3 唯一ID 重复
- 6.4 过期时间设置不合理
- 七、总结
评论
0 评论