记一次api接口出现重复请求的处理过程
背景
在学校的Java项目中,开发了一个文本聊天的api接口,由于当时考虑欠缺,加上调用方没有做限制,导致出现了一个用户在一秒内重复发出了多次请求的情况,从而出现了脏数据。
既然是接口重复请求了,那么就需要限制一个用户在一秒内只能发起一次请求。这时候就会引出一个概念【接口的幂等性】。
幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次。
现实的场景如下
1. 订单与支付类操作
- 订单创建:用户多次点击提交订单,需保证仅生成一个有效订单。
- 订单支付:支付接口因网络超时重试时,需确保扣款仅一次,避免重复扣款。
- 订单状态变更:如订单发货、完成等操作,重复请求需维持最终状态一致。
示例:
用户支付时因网络延迟重复调用支付接口,若未做幂等,可能生成两笔扣款记录。
2. 资源分配与扣减
- 库存扣减:秒杀场景中,多次请求需确保库存不被超卖。
- 优惠券/积分发放:用户领取优惠券或积分时,需防止重复领取。
- 账号注册:同一手机号或邮箱多次注册,需返回已存在的结果。
示例:
库存扣减时,若重复扣减未校验幂等性,可能导致库存变为负数。
接口幂等性主要应用于 可能因重复请求导致数据错误或资源冲突 的业务场景,以下是典型业务操作分类:
1. 订单与支付类操作
- 订单创建:用户多次点击提交订单,需保证仅生成一个有效订单。
- 订单支付:支付接口因网络超时重试时,需确保扣款仅一次,避免重复扣款。
- 订单状态变更:如订单发货、完成等操作,重复请求需维持最终状态一致。
示例:
用户支付时因网络延迟重复调用支付接口,若未做幂等,可能生成两笔扣款记录。
2. 资源分配与扣减
- 库存扣减:秒杀场景中,多次请求需确保库存不被超卖。
- 优惠券/积分发放:用户领取优惠券或积分时,需防止重复领取。
- 账号注册:同一手机号或邮箱多次注册,需返回已存在的结果。
示例:
库存扣减时,若重复扣减未校验幂等性,可能导致库存变为负数。
3. 数据更新与状态流转
- 状态机操作:如工单从“处理中”变更为“已完成”,需确保状态仅变更一次。
- 字段更新:如用户余额增减(充值、提现),需避免重复累加或扣减。
- 配置修改:系统参数调整多次提交,需保证最终值与最后一次提交一致。
示例:
用户提现时接口超时重试,若未做幂等,可能多次扣减余额。
4. 消息队列消费场景
- 消息重复消费:MQ因ACK失败重发消息时,需保证业务逻辑仅执行一次。
- 异步任务处理:如批量导入数据,需确保任务ID唯一,避免重复导入。
示例:
订单支付成功后发送MQ通知物流系统,若消息重复消费,需避免重复创建物流单。
为了保持接口的幂等性,一般的解决方案有以下
1. Token 机制(一次性令牌)
- 原理:客户端在发起请求前先获取一个唯一Token(如UUID),服务端存储该Token(如Redis)。接口处理时校验Token是否存在,存在则执行业务并删除Token,确保仅一次有效。
2. 唯一索引/数据库约束
- 原理:利用数据库的唯一索引(如订单号、业务流水号),插入重复数据时触发唯一键冲突,拦截重复请求。
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) UNIQUE, -- 唯一约束
...
);
try {
orderDao.insert(order);
} catch (DuplicateKeyException e) {
// 捕获重复异常,返回幂等结果
}
3. 状态机校验
- 原理:业务数据具有明确状态流转(如订单从“待支付”到“已支付”),接口处理前校验状态是否允许变更。
Order order = orderDao.findById(orderId);
if (!order.getStatus().equals(Status.PENDING)) {
throw new IllegalStateException("状态已更新,拒绝操作");
}
4. 乐观锁(版本号/时间戳)
- 原理:在数据更新时,通过版本号或时间戳实现乐观锁,确保只有匹配当前版本的请求生效
UPDATE table SET value = 'new', version = version + 1
WHERE id = #{id} AND version = #{currentVersion};
int rows = jdbcTemplate.update(sql, params);
if (rows == 0) {
throw new OptimisticLockException("数据已被修改");
}
5. 分布式锁控制
- 原理:使用分布式锁(如Redis或ZooKeeper)保证同一业务ID的请求串行处理。
String lockKey = "order_lock_" + orderId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusyException("请勿重复提交");
}
try {
// 处理业务
} finally {
redisTemplate.delete(lockKey);
}
6. 请求去重表
- 原理:单独维护一张去重表,记录请求的唯一标识(如业务ID + 场景),处理前先查询是否已存在。
CREATE TABLE idempotent_record (
id VARCHAR(64) PRIMARY KEY, -- 业务ID + 类型
created_time TIMESTAMP
);
String recordId = bizId + "_" + type;
if (idempotentDao.exists(recordId)) {
return; // 直接返回历史结果
}
idempotentDao.insert(recordId);
7. 限流与重试策略
- 辅助方案:通过限流(如令牌桶)降低重复请求频率,结合客户端退避重试(Exponential Backoff)减少无效调用。
- 工具:使用Guava RateLimiter或Sentinel实现限流。
由于调用方目前无法配合预请求token的动作,我这次采用的是第五种方式,采用SpringAop+redis+注解的方式来做接口限制。
注解
import java.lang.annotation.*;
/**
* 在需要保证接口幂等性的Controller的方法上使用此注解
* 重复提交校验注解
*/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ReSubmitCheck {
//校验几秒内重复提交
int seconds() default 2;
}
核心逻辑,主要是通过注解+aop 的形式,在请求该接口前,先做判断,利用redis的原子操作,如果key在redis中存在,则说明在指定时间内,已经请求过了。
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
@Slf4j
public class PreventReSummitAspect {
/**
* redis工具类
*/
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Before("@annotation(reSubmitCheck)")
public void preventReSubmit(JoinPoint joinPoint, ReSubmitCheck reSubmitCheck) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
//获取用户登录的accesstoken
HttpServletRequest request = attributes.getRequest();
String studentId = request.getParameter("studentId");
if (studentId == null) {
throw new RuntimeException("学生ID不能为空");
}
String senderType = StringUtils.isBlank(request.getParameter("senderType"))?"1":request.getParameter("senderType");
String lockKey = "ReSubmit:" + studentId + "_" +senderType+"_"+request.getServletPath();
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, reSubmitCheck.seconds(), TimeUnit.SECONDS);
if (!result) {
System.out.println("重复请求:"+lockKey);
throw new RuntimeException("重复请求:"+lockKey);
}
}
}
使用
@PostMapping(value = "/sendMsg")
@ReSubmitCheck(seconds=2)
public Result<String> sendMsg() throws IOException {
}
postman测试
第一次请求
紧接着第二次请求