前言
业务开发中,经常会遇到重复提交的情况,无论是由于网络问题无法收到请求结果而重新发起请求,或是前端的操作抖动而造成重复提交情况。 在交易系统,支付系统这种重复提交造成的问题有尤其明显,比如:
- C端用户在APP上连续点击了多次提交订单,后台应该只产生一个订单;(生成多个订单那岂不是乱了套了)
- 向支付宝发起支付请求,由于网络问题或系统BUG重发,支付宝应该只扣一次钱。(扣多次钱那岂不是也乱了套了) 很显然,声明幂等的服务认为,外部调用者会存在多次调用的情况,为了防止外部多次调用对系统数据状态的发生多次改变,将服务设计成幂等。
提示:以下是本篇文章正文内容,下面案例可供参考
一、什么是幂等性?
用户对于同一操作,无论是发起一次请求还是多次请求,最终的执行结果是一致的,不会因为多次点击而产生副作用。
二、常见的幂等操作
- select查询天然幂等;
- delete删除也是幂等,删除同一个数据多次其效果一样;
-
update直接更新某个值时,幂等;
-
update更新累加操作的的结果,非幂等;
-
insert操作会每次都新增一条,非幂等;
三、什么情况下会产生重复提交(非幂等性)
-
连续点击提交两次按钮;
-
点击刷新按钮;
-
使用浏览器后退按钮重复之前的操作,导致重复提交表单;
-
使用浏览器历史记录重复提交表单;
-
浏览器重复地HTTP请求等。
四、解决方案
1.前端
- 前端js提交禁止按钮可以用一些js组件
- 使用Post/Redirect/Get模式:
在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。
这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。
2.后端
- 数据库唯一索引:借助数据库唯一索引,可以保证在业务场景中多次重复的提交只有一条数据可以插入成功;
- 乐观锁:如果只是更新已有的数据,没有必要对业务进行加锁,设计表结构时使用乐观锁,一般通过version来做乐观锁,这样既能保证执行效率,又能保证幂等。例如: UPDATE tab1 SET col1=1,version=version+1 WHERE version=#version# 不过,乐观锁存在失效的情况,就是常说的ABA问题,不过如果version版本一直是自增的就不会出现ABA的情况。
- token令牌:每次接口请求前先获取一个token,然后再下次请求的时候在请求的header体中加上这个token,后台进行验证,如果验证通过删除token,下次请求再次判断token;以支付场景为例,这种方式分成两个阶段:申请token阶段和支付阶段。 第一阶段,在进入到提交订单页面之前,需要订单系统根据用户信息向支付系统发起一次申请token的请求,支付系统将token保存到Redis缓存中,为第二阶段支付使用。 第二阶段,订单系统拿着申请到的token发起支付请求,支付系统会检查Redis中是否存在该token,如果存在,表示第一次发起支付请求,删除缓存中token后开始支付逻辑处理;如果缓存中不存在,表示非法请求。 实际上这里的token是一个信物,支付系统根据token确认,你是你妈的孩子。不足是需要系统间交互两次,流程较上述方法复杂。
五、Redis Token解决方案
1.原理图
2.搭建RedisService
引入spring-boot-starter-data-redis依赖,通过RedisTemplate操作Redis缓存。
代码如下(示例):
@Slf4j
@Component
public class RedisService {
@Resource
private RedisTemplate redisTemplate;
/**
* 写入缓存
* @param key
* @param value
*/
public void set(final String key, Object value){
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new RuntimeException("数据缓存至redis失败");
}
}
/**
* 写入缓存,设置超时时间
* @param key
* @param value
* @param expireTime
*/
public void setEx(final String key, Object value, Long expireTime){
try{
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
}catch (Exception e) {
log.error(e.getMessage(), e);
throw new RuntimeException("数据缓存至redis失败");
}
}
/**
* 判断缓存中是否存在key
* @param key
* @return
*/
public boolean exists(final String key){
return redisTemplate.hasKey(key);
}
/**
* 读取缓存
* @param key
* @return
*/
public Object get(final String key){
return redisTemplate.opsForValue().get(key);
}
public boolean remove(final String key){
return redisTemplate.delete(key);
}
}
3.token创建和校验
3.1 token服务接口
创建token接口,用来创建和校验token。
代码如下(示例):
public interface TokenService {
/**
* 创建token
* @return
*/
public String createToken();
/**
* 校验token
* @param request
* @return
* @throws Exception
*/
public boolean checkToken(HttpServletRequest request) throws Exception;
}
3.2 token服务实现类
代码如下(示例):
@Service
public class TokenServiceImpl implements TokenService {
private final RedisService redisService;
public TokenServiceImpl(RedisService redisService){
this.redisService = redisService;
}
@Override
public String createToken() {
try{
String token = RedisConst.REDIS_AUTO_IDEMPOTENT_PREFIX+RandomUtil.randomBigDecimal().toString();
redisService.setEx(token, token, 1000L);
return token;
}catch (Exception e){
e.printStackTrace();
}
return null;
}
@Override
public boolean checkToken(HttpServletRequest request) throws Exception {
String token = request.getHeader(RedisConst.AUTO_IDEMPOTENT_TOKEN_NAME);
if(StrUtil.isBlank(token)){
token = request.getParameter(RedisConst.AUTO_IDEMPOTENT_TOKEN_NAME);
}
if(StrUtil.isNotBlank(token) && redisService.remove(token)){
return true;
}
return false;
}
}
4.自定义注解
自定义一个注解,定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现自动幂等。
代码如下(示例):
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}
@Target——表示该注解用于什么地方。默认值为任何元素,表示该注解用于什么地方。可用的ElementType 参数包括(1:N,一个target可以包含多个ElementType):
ElementType.CONSTRUCTOR
: 用于描述构造器ElementType.FIELD
: 成员变量、对象、属性(包括enum实例)ElementType.LOCAL_VARIABLE
: 用于描述局部变量ElementType.METHOD
: 用于描述方法ElementType.PACKAGE
: 用于描述包ElementType.PARAMETER
: 用于描述参数ElementType.TYPE
: 用于描述类、接口(包括注解类型) 或enum声明@Retention——定义该注解的生命周期
RetentionPolicy.SOURCE
: 在编译阶段丢弃。这些注解在编译结束之后就不再有任何意义,所以它们不会写入字节码。@Override
,@SuppressWarnings
都属于这类注解。RetentionPolicy.CLASS
: 在类加载的时候丢弃。在字节码文件的处理中有用。注解默认使用这种方式RetentionPolicy.RUNTIME
: 始终不会丢弃,运行期也保留该注解,因此可以使用反射机制读取该注解的信息。我们自定义的注解通常使用这种方式。
5.拦截器实现
5.1 拦截处理器
主要的功能是拦截扫描AutoIdempotent注解到的方法,然后调用tokenService的checkToken()方法校验token是否正确,进行业务接口的放行及拦截。
代码如下(示例):
@Slf4j
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {
private final TokenService tokenService;
public AutoIdempotentInterceptor(TokenService tokenService){
this.tokenService = tokenService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(!(handler instanceof HandlerMethod)){
return true;
}
HandlerMethod handlerMethod = (HandlerMethod)handler;
Method method = handlerMethod.getMethod();
//扫描包含AutoIdempotent注解的方法
AutoIdempotent autoIdempotent = method.getAnnotation(AutoIdempotent.class);
if(null != autoIdempotent){
return tokenService.checkToken(request);
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
5.2 拦截器配置及加载
通过实现WebMvcConfigurer,实现对拦截器的加载及设置拦截器的过滤路径规则。
代码如下(示例):
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Resource
private AutoIdempotentInterceptor autoIdempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(autoIdempotentInterceptor).addPathPatterns("/doudou/**");
}
}
六、测试用例
1.获取token
代码如下(示例):
@Slf4j
@RestController
@RequestMapping("doudou")
public class IdempotentController {
private final TokenService tokenService;
public IdempotentController(TokenService tokenService){
this.tokenService = tokenService;
}
@ApiOperation(value = "获取Idempotent token", notes = "获取Idempotent token")
@GetMapping("/getToken")
public ResponseEntity<Response<Map<String, String>>> getToken() {
log.info("获取token IdempotentController getToken。");
Map<String, String> resultMap = Maps.newHashMap();
resultMap.put("idempToken", tokenService.createToken());
return ResponseEntity.ok(Response.ok(resultMap));
}
}
2.幂等注解测试接口
代码如下(示例):
@Slf4j
@RestController
@RequestMapping("doudou")
public class TestController {
private final RedisService redisService;
public TestController(RedisService redisService){
this.redisService = redisService;
}
@ApiOperation(value = "首个测试接口", notes = "首个测试接口")
@AutoIdempotent
@GetMapping("/test-1")
public ResponseEntity<Response<Map<String, Object>>> graphSearch(@ApiParam(value = "节点类型") @RequestParam String label) {
log.info("我被调用了:{}", label);
Map<String, Object> resultMap = Maps.newHashMap();
redisService.setEx(label, label, 100L);
return ResponseEntity.ok(Response.ok(resultMap));
}
}
3.postman请求测试
首先访问/getToken路径获取到具体到token:
利用获取到到token,然后放到具体请求到header中,可以看到第一次请求成功:
第二次请求,返回到是重复性操作,可见重复性验证通过,再多次请求到时候我们只让其第一次成功,第二次就是失败:
暂未对异常进行捕获
总结
本文对幂等性的概念以及常见的解决接口幂等性的方式进行了介绍,同时对通过spring boot、拦截器、自定义注解、Redis优雅的实现了Redis Token接口幂等方案。