积分商城 防止生成重复订单 注解实现接口幂等性处理

什么是幂等性?

一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。

什么情况下会出问题?随便举几个例子

1.表单重复提交

  用户在前端购买商品,买了之后点击购买,因为网络卡了,点击一次没反应,所以用户多次点击,这个时候请求会发送多次,如果不处理的话后台会出现多个订单。

  这个时候有人就会说了,这还不简单,前端写个js呗,用户点击一次,就把按钮置灰,不让点击第二次,这样不就搞定了吗?

  前端是搞定了,但是我们写代码校验,从来都是前后端一起做,前端是防用户的,后端是防恶意请求的,而且绕过js是相当容易的一件事情,用户把页面保存到本地,然后删除js代码,就可以发送请求了

  如果你说防用户就好了,恶意请求的我不管,正常人谁会去删你的js啊?

  那就回忆一下古老的ie浏览器,写过前端的都知道,ie浏览器下的js脚本经常失效,有的甚至要用户点击才有效果,而且各种语法冲突,我就记得当初写js,火狐内核,欧鹏内核,谷歌内核,ie内核,

  不同的浏览器内核不同,一些js代码要写多份,痛苦至极,所以后台校验是必不可少的。

2.mq消息队列重试机制

  分布式微服务下的情况,mq消息队列必不可少的一部分,接口请求过来,可能持久化到mq去了,做成异步的。比如我下单,请求存入mq,然后由mq来保证我的消息可靠性,它为了保证我的消息可靠性,会一直循环

  列如rabitMq,他把消息持久化到本地磁盘,然后发送请求,对方返回ask,才表示这个请求执行完成,如果在等待一定时间后对方没有返回,就认为该消息丢失,mq会重新发送消息。

  问题就来了,如果一个请求发送出去之后,对方请求完成,返回ask的时候网络阻塞,这个时候mq等了半天,一直没接收到ask,时间到了,重新发送请求,这个时候就会产生两条数据。

解决方案?

1.数据库主键去重

  如果业务逻辑允许的情况下,可以使用数据库来保证幂等性,情况比较少,大部分业务下处理不了

2.redis 实现

  redis实现有2种方法,一种在执行方法的时候去删除redis key,下一次删除失败就表示重复提交,这种写法在查询列表的时候需要先存redis,然后给前端。

  还有一种是在执行的时候存redis key,往redis存key,存不进去表示这个请求处理过,第一次存肯定是可以存入的,同一个key存入失败,表示请求是重复的,这个和mysql主键去重相似。

 

  第一种方法,调用查询接口的时候,后端存redis一个token,然后把token给前端,前端在下单时候,带着这个token过来,我直接去redis里删除这个token,如果删除成功,表示是第一次请求过来的,这个时候走业务逻辑,

  如果删除失败,那说明这个请求已经处理过一次了。返回状态码201给前端,不要问为什么201,京东金融也是这样返回的,201表示这个请求后端处理了,不过走的是别的流程。

  问题来了,这个token要不要设置过期时间?答案是肯定要的,如果不设置过期时间,用户在查询接口上获得了token,一直没使用,多次查询会产生多条token,redis淘汰机制是不会淘汰没有设置过期时间的key的

  那设置了过期时间,会产生另一个问题,这个时间的长短怎么控制,如果你设置3分钟,用户打开页面的时候获得了token,他等了好久超过了3分钟,然后去下单,这个时候token过期了,那用户这单返回了201,后台

  并没有这个订单,订单丢失。

  第二种方法,这个也是京东金融使用的方法,it老齐教的,b站搜一搜可以找到,这个方法和上面的方法相反,前端传的token不由后端生成,由前端自行产生,请求过来的时候带上这个token。

  后端如何处理呢?后端把token存入redis,存的时候不要用set方法,用setnx方法,这个方法是直接去修改,修改的时候判断,如果key存在返回false,不存在返回true。如果先查询,后修改是有并发问题的,

  可能A线程查询redis key不存在,B线程这个时候也查询redis key不存在,然后A线程添加key,B线程添加key,这样等于AB两个线程都执行了。这些算是基础把,setnx就不多说了。

  前端第一次请求token:abcdefg 存入redis一个key abcdefg 这个时候,如果重复提交,第二次存入redis abcdefg 返回false,表示这个请求是处理过的,返回201即可。这里需要过期时间吗?

  也是需要的,不设置那redis key只会无限增加,所以设置个几十秒就好,在这几十秒内的相同请求是可以保证幂等性的。具体时间自行定义。

落地实现?

  自定义注解+AOP切面实现,也有拦截器写法,都差不多

  1.自定义注解 Idempotent

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

}

  2.自定义AOP切面 IdempotentAspect

@Aspect
@Component
public class IdempotentAspect {

  /**
    * 过期时间 单位秒
    */
  public static final long EXPIRE = 180L;

  /**
    * 日志
    */
  private final Logger logger = LoggerFactory.getLogger(IdempotentAspect.class);

  @Autowired
  private RedisLockUtil redisLockUtil;

  @Pointcut("@annotation(com.ehaomiao.common.redis.lock.annotation.Idempotent)")
  private void idempotent() {

  }

  /**
    * @Author xzj
    * @Description 幂等性接口 环绕通知 处理接口重复调用
    * @Date 9:38 2022/1/13
    * @param pjp
    * @return
    **/
  @Around("idempotent()")
  public Object around(ProceedingJoinPoint pjp) throws Throwable {
      try {
          //1.从请求头中拿到idempotent_token
          ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
          HttpServletRequest request = attributes.getRequest();
          String idempotentToken = request.getHeader(IdempotentRedisKeyConstant.IDEMPOTENT_TOKEN);
          //2.获得redisKey
          String redisKey = IdempotentRedisKeyConstant.getRedisKey(idempotentToken);
          //3.在redis中添加key key如果存在,说明这个请求是处理过的 过期时间单位s
          if (!redisLockUtil.tryLock(redisKey,redisKey, EXPIRE)) {
              //方法重复提交
              HttpServletResponse response = attributes.getResponse();
              response.sendError(201,"方法重复提交");
              return null;
          }
          return pjp.proceed();
      } catch (Exception e) {
          logger.error("execute idempotent method occured an exception", e);
          throw e;
      }
  }

}

  3.redis工具类 redisUtil 这个不同公司的不同,根据自己公司的使用就好,百度上也一大堆

/**
* Redis分布式锁
*
* @author pangu
*/
public class RedisLockUtil {

  public RedisLockUtil(RedisTemplate<String, Object> redisTemplate) {
      this.redisTemplate = redisTemplate;
  }

  private RedisTemplate<String, Object> redisTemplate;

  private static final byte[] SCRIPT_RELEASE_LOCK = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end".getBytes();

  /**
    * 尝试获取分布式锁
    *
    * @param key       键
    * @param requestId 请求ID
    * @param expire   锁的有效时间(秒)
    */
  public synchronized Boolean tryLock(String key, String requestId, long expire) {
      return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> redisConnection.set(key.getBytes(), requestId.getBytes(), Expiration.from(expire, TimeUnit.SECONDS), RedisStringCommands.SetOption.SET_IF_ABSENT));
  }

  /**
    * 释放分布式锁
    *
    * @param key       键
    * @param requestId 请求ID
    */
  public synchronized Boolean releaseLock(String key, String requestId) {
      return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> redisConnection.eval(SCRIPT_RELEASE_LOCK, ReturnType.BOOLEAN, 1, key.getBytes(), requestId.getBytes()));
  }

}

上述redis工具类用的 redisConnection.set() 方法,四个参数 key value expireTime RedisStringCommands.SetOption.SET_IF_ABSENT,第四个参数就是“NX”,表示这个set方法如果key存在不做操作,不存在设置key,

加锁方法很多,逻辑对就行。代码还是很简单的。

 

posted @ 2022-01-13 14:15  java架构师1  阅读(407)  评论(0)    收藏  举报