一、概念
幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次
比如:
- 订单接口, 不能多次创建订单
- 支付接口, 重复支付同一笔订单只能扣一次钱
- 支付宝回调接口, 可能会多次回调, 必须处理重复回调
- 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次
等等
二、常见解决方案
- 唯一索引 -- 防止新增脏数据
- token机制 -- 防止页面重复提交
- 悲观锁 -- 获取数据的时候加锁(锁表或锁行)
- 乐观锁 -- 基于版本号version实现, 在更新数据那一刻校验数据
- 分布式锁 -- redis(jedis、redisson)或zookeeper实现
- 状态机 -- 状态变更, 更新数据时判断状态
三、本文实现
本文采用第2种方式实现, 即通过redis + token机制实现接口幂等性校验
四、实现思路
本文主要处理场景:同一个用户,一个请求,在规定的时间内只能发起1次请求。
这边主要处理的防止页面重复提交,为保证幂等性,请求接口时,后端通过header或者接口请求参数获取登录信息+请求路径判断redis中是否存在此key。
- 如果不存在, 正常处理业务逻辑, 并把此key存入redis中并设置过期时间, 那么, 如果是重复请求, 由于key已存在于redis, 则不能通过校验, 返回
请勿重复操作
提示 - 如果存在, 说明在redis里的key还未过期,当前是重复请求, 返回提示即可
五、项目简介
- springboot
- redis
- @ReSubmitCheck注解 + AOP切面对请求进行拦截
- @SharException全局异常处理
- 压测工具: jmeter
说明:
- 本文重点介绍幂等性核心实现, 关于springboot如何集成redis、ServerResponse、ResponseCode等细枝末节不在本文讨论
六、代码实现
- 新增redis 配置 :pom+配置文件
<!-- Redis 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
#########################本地开发环境#########################
##spring boot 配置
server.port=8004
spring.application.name=share
############################################################
## MySQL配置
spring.datasource.url=jdbc:mysql://192.168.1.12:3306/share?characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
############################################################
## Redis配置
spring.redis.host=192.168.1.12
#spring.redis.password=
spring.redis.database=1
spring.redis.port=6379
############################################################
2.自定义注解 @ReSubmitCheck
import java.lang.annotation.*;
/**
* 在需要保证接口幂等性的Controller的方法上使用此注解
* 重复提交校验注解
*/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ReSubmitCheck {
//校验几秒内重复提交
int seconds() default 3;
}
3. 防止重复提交切面处理器 PreventReSummitAspect
import com.city.share.annotation.ReSubmitCheck;
import com.city.share.enums.ResultEnum;
import com.city.share.exception.ShareException;
import lombok.extern.slf4j.Slf4j;
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.StringRedisTemplate;
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 StringRedisTemplate redisTemplate;
@Before("@annotation(reSubmitCheck)")
public void preventReSubmit(JoinPoint joinPoint, ReSubmitCheck reSubmitCheck) {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
//获取用户登录的accesstoken
HttpServletRequest request = attributes.getRequest();
String token = request.getParameter("accesstoken");
if (token == null) {
throw new ShareException(ResultEnum.ON_LOGIN);
}
String lockKey = "ReSubmit:" + token + "_" + request.getServletPath();
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, reSubmitCheck.seconds(), TimeUnit.SECONDS);
if (!result) {
System.out.println("重复请求:"+lockKey);
throw new ShareException(ResultEnum.RESUBMIT_ERROR);
}
}
}
4.测试controller HolleContraller
import com.city.share.Dto.Result;
import com.city.share.Utils.ResultUtil;
import com.city.share.annotation.ReSubmitCheck;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.Serializable;
@RestController
public class HolleContraller implements Serializable{
@GetMapping("/holle")
@ReSubmitCheck(seconds=10)//这边设置了10秒内不能重复访问
public Result holleTest(){
System.out.println("hello spring boot");
return ResultUtil.success("hello spring boot");
}
}
OK, 目前为止, 校验代码准备就绪, 接下来测试验证
七、测试验证
访问:127.0.0.1:8004/holle?accesstoken=123456
查看redis
测试接口安全性: 利用jmeter测试工具模拟10个并发请求
请求结果:因为都是在10秒内,所以只有第一个请求成功
八、总结
其实思路很简单, 就是每次请求保证唯一性, 从而保证幂等性, 通过spring aop+注解, 就不用每次请求都写重复代码, 其实也可以利用拦截器实现。
如果小伙伴有什么疑问或者建议欢迎提出