幂等性问题
Http/1.1中幂等性的定义:一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。也就说,任意多次执行对资源本身所产生的影响均与一次执行的影响相同
什么情况下需要幂等(场景)
业务中经常会遇到重复提交的情况,无论是网络问题无法收到请求结果而重新发起请求,还是前端操作造成重复提交情况。
交易、支付系统中:这种重复提交尤为明显:用户连续点击多次提交订单,后台应该只产生一个订单
声明幂等的服务认为,外部调用者会存在多次调用的情况,为了防止外部多次调用对系统数据的状态发生多次改变,将服务设计成幂等
-
前端重复提交
-
接口超时重试
-
消息重复消费等等
接口为什么要实现幂等
重复提交,后台只产生对应这个数据的一个反应结果
幂等性核心思想
通过唯一的业务单号保证幂等
防重复提交策略(解决方案)
-
乐观锁
-
防重表(防止数据重复的表)
- 实现方式利用mysql唯一索引的特性
- 流程:
- 建立一张去重表,标准某个字段建立唯一索引,客户端请求服务器,服务端把这次请求的一些信息插入到这张表中
- 因为表中某个字段是唯一索引,所以如果插入成功则表明没有这次请求的信息,继续执行请求的业务逻辑。若插入失败则表示已经执行过当前请求,直接返回
-
分布式锁
- 基于redis的setNX命令
- setnx key v :将key的值为v,当且仅当key不存在。若给定的key存在,则setnx不做任何操作。该命令设置成功时返回1,失败时返回0
- 流程:
- 客户端先请求服务端,会拿到一个能代表这次请求业务的唯一字段
- 将该字段以 SETNX 的方式存入 redis 中,并根据业务设置相应的超时时间
- 如果设置成功,证明这是第一次请求,则执行后续的业务逻辑
- 如果设置失败,则代表已经执行过当前请求,直接返回
-
token令牌
- 当客户端请求页面时,服务器会生成全局唯一的ID作为token,并保存在redis
- 然后再次请求业务接口时,把token携带过去,一般放在请求头部。
- 服务器会校验 token,校验成功则执行业务,并删除redis中的token
- 如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给client,这样就保证了业务代码,不被重复执行
Get、Post、Put、Delete的幂等性
- get用于获取资源,不会有副作用,所以是幂等的
- post常用于往数据库添加、修改数据,每调用一次会产生新的数据,数据经常发生变化,所以不是幂等的
- put常用于创建和更新指定的一条数据,如果数据不存在则新建,如果存在则更新数据,多次和一次调用产生的副作用是一样的,所以是满足幂等
- delete调用一次或多次对系统产生的副作用是一样的,所以是幂等的
应该在那一层进行幂等性设计
第一层:app、pc等等
第二层:负载均衡
第三层:网关层,主要做路由转发、请求鉴权、身份认证、限流等。如果在网关层实现幂等性,那需要把业务代码写在网关层一般不推荐
第四层:业务层:主要处理业务逻辑,所以也不合适
第五层:持久层:和数据库打交道,这块不做的话可能对数据产生一定的影响,所以一般是在持久层做幂等性校验
第六层:数据库层
项目中如何实现防止重复提交的
基于注解+aop切面方式实现防止重复提交,采用AOP切面解析注解,实现接口首次请求提交时,将接口请求标记(由接口方法、参数、请求token、等等组成)存储至redis,并设置超时时间T,接口每次请求都先检查redis中接口标记,若存在接口请求标记,则判定为接口重复提交,进行拦截返回处理
-
自定义注解(可以设置失效时间)
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface PreventDuplication { /** * 防重复提交 过期时间10s(利用redis key的特点) * @return */ long expireSecond() default 10; }
-
定义切面、拦截Controller进行环绕通知(可以自定义需要拦截的东西)
/** * @description:防止重复提交 切面类 * @author:xiaoyige * @createTime:2022/4/21 20:44 * @version:1.0 */ @Slf4j //@Aspect @Component public class PreventDuplicationAspect { private static final String point_cut = "execution(public * *..controller..*.*(..))"; @Autowired RedisTemplate redisTemplate; @Around(point_cut) public Object interceptor(ProceedingJoinPoint p) throws Throwable { MethodSignature signature = (MethodSignature) p.getSignature(); Method method = signature.getMethod(); if (method != null) { log.info("当前执行的方法为:{}.{},参数为:{}", p.getTarget().getClass(), method.getName(), p.getArgs()); } //获取重复提交注解 PreventDuplication preventDuplication = AnnotatedElementUtils.findMergedAnnotation(method, PreventDuplication.class); if (preventDuplication == null) { return p.proceed(); } //生成key String redisCacheKey = getRedisCacheKey(method, p.getArgs()); //查询redis里面是否有key的缓存 if (redisTemplate.hasKey(redisCacheKey)) { throw new RuntimeException("数据已经提交,请等待!"); } else { //key 存放在redis redisTemplate.opsForValue().set(redisCacheKey, "testUser", preventDuplication.expireSecond(), TimeUnit.SECONDS); } //正常执行方法并返回 try { return p.proceed(); } catch (Exception e) { //确保方法执行异常时,释放限时标记 redisTemplate.delete(redisCacheKey); } return null; } /** * 组装key * * @param method * @param args * @return */ private String getRedisCacheKey(Method method, Object[] args) { StringBuilder sb = new StringBuilder(); sb.append(method.getName()).append(":"); for (Object arg : args) { sb.append(arg.toString()); } return sb.toString(); } }