积分商城 防止生成重复订单 注解实现接口幂等性处理
一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
什么情况下会出问题?随便举几个例子
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,
加锁方法很多,逻辑对就行。代码还是很简单的。

浙公网安备 33010602011771号