网络知识 娱乐 开发必备-如何实现防重复提交

开发必备-如何实现防重复提交

业务背景

在业务上有很多需要防止重复提交的场景,例如大部分的创建方法要求同样的数据不能创建两次。对于此种业务处理一般可以分为前端处理和后端处理。前端可以在点击后将按钮置灰1s,做防抖处理,1s后才可以再次调用接口。后端这里需要在业务上做处理,我们在做入库操作时,需要校验:

待插入数据在数据库中是否存在?

存在则不能插入

不存在则可插入

重复提交的场景一般是同一个用户连续的点击按钮2次以上,那么这里出现重复提交的条件为:

同一用户

短时间内操作多次

那么为什么短时间多次操作就能出现多次插入呢,我们在插入时后端不是先查数据库做校验了么。

原来我们在短时间操作同一接口,虽然会先查询数据库,但是可能操作1还没有完成,操作2就开始了。操作1和操作2查询的数据就可能是一样的。

这个问题在面试时也经常会被问到:

如何实现接口的幂等性?

幂等要求我们多次操作,其产生的结果要跟一次操作一样。防重复提交就属于幂等问题。

对于保证幂等性,解决方案有很多。比如采用数据库的唯一索引,Redis相同Key是否有值,在查库时使用锁,使用Semaphore限流等等。

Redis实现

今天我们采用Redis限流操作来控制实现接口幂等。主要操作为:

相同key调用的接口,给对应值+1

在指定范围内,值小于指定数,则接口可调用

说干就干,我们先定义一个注解RateLimiter,用在需要防重复提交的方法上。RateLimiter定义如下:

这个注解我们要注意几个元素:

needUserLimit() //key设定为 接口名称 + userIdlimit()//单位时间限制通过的请求数expire()//过期时间,单位s

这里我们利用Redis的过期时间,在过期时间内请求数不超过指定的limit()数,则接口可以执行,否则接口执行前会被拦截。我们使用接口全路径名称+登录用户的id作为Redis的key。limit()和expire()可以使用默认值,即1秒内只能执行一次接口。

来看看如何实现这个注解:

我们写一个RateLimiterHandler类,在注入时加载Lua脚本

@PostConstructpublic void init() { getRedisScript = new DefaultRedisScript<>(); getRedisScript.setResultType(String.class); getRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("rateLimiter.lua"))); log.info(">>>>>>>>>>>>>>>>RateLimiterHandler[分布式限流处理器] lua脚本加载完成");}

rateLimiter.lua脚本如下:

这个lua脚本主要做自增操作,当自增的值操作指定次数时,返回0,也就是false。否则返回1。

在RateLimiterHandler中如果我们按用户限流。needUserLimit需要设定为true。用于存Redis的key为:

固定前缀 + 方法全路径 + 登录用户id

代码如下:

boolean needUserLimit = rateLimiter.needUserLimit();if (needUserLimit) { //获取目标方法名(目标类型+方法名) String targetClsName = targetCls.getName(); String targetObjectMethodName = targetClsName + "." + signature.getName(); Long userId = getCurrentUserId(); Preconditions.checkNotNull(userId); limitKey = "redis:limit:".concat(targetObjectMethodName).concat(":").concat(String.valueOf(userId));}

然后之心lua脚本:

String resultStr = stringRedisTemplate.execute(getRedisScript, Collections.singletonList(limitKey), String.valueOf(expireTimes), String.valueOf(limitTimes));long result = resultStr == null ? 0 : Long.parseLong(resultStr);StringBuilder sb = new StringBuilder();if (result == 0) { String msg = sb.append("超过单位时间=").append(expireTimes).append("允许的请求次数=").append(limitTimes).append("[触发限流]").toString(); log.info("key:[{}],{}", limitKey, msg); throw new BusinessException(String.format("您的操作过于频繁,请在%s秒后再进行操作", expireTimes));}

如果执行脚本返回0,我们给出提示:

您的操作过于频繁,请在%s秒后再进行操作

单元测试

代码到这里就结束了,其实思路也比较简单,我们写一个单元测试试试:

@ResponseBody@RequestMapping("ratelimiter")@RateLimiter(needUserLimit = true)public String testLimit() { return "限流注解测试专用";}

运行上面的方法:

@Testpublic void testPage() throws InterruptedException { payCommonController.testLimit(); payCommonController.testLimit(); payCommonController.testLimit();}

我们连续执行3次目标方法,发现控制台已有提示。

Redis上我们也看到了对应的key。

我们将调用时间间隔为:2s

@Testpublic void testPage() throws InterruptedException { payCommonController.testLimit(); Thread.sleep(2000); payCommonController.testLimit(); Thread.sleep(2000); payCommonController.testLimit();}

测试通过

至此,我们用限流处理器来防止重复提交的需求达成。