业务背景
在业务上有很多需要防止重复提交的场景,例如大部分的创建方法要求同样的数据不能创建两次。对于此种业务处理一般可以分为前端处理和后端处理。前端可以在点击后将按钮置灰1s,做防抖处理,1s后才可以再次调用接口。后端这里需要在业务上做处理,我们在做入库操作时,需要校验:
❝
- 待插入数据在数据库中是否存在?
- 存在则不能插入
- 不存在则可插入
❞
常规插入
重复提交的场景一般是同一个用户连续的点击按钮2次以上,那么这里出现重复提交的条件为:
❝
- 同一用户
- 短时间内操作多次
❞
那么为什么短时间多次操作就能出现多次插入呢,我们在插入时后端不是先查数据库做校验了么。
原来我们在短时间操作同一接口,虽然会先查询数据库,但是可能操作1还没有完成,操作2就开始了。操作1和操作2查询的数据就可能是一样的。
并发插入
这个问题在面试时也经常会被问到:
❝如何实现接口的幂等性? ❞
幂等要求我们多次操作,其产生的结果要跟一次操作一样。防重复提交就属于幂等问题。
对于保证幂等性,解决方案有很多。比如采用数据库的唯一索引,Redis相同Key是否有值,在查库时使用锁,使用Semaphore
限流等等。
Redis实现
今天我们采用Redis限流操作来控制实现接口幂等。主要操作为:
❝相同key调用的接口,给对应值 1 在指定范围内,值小于指定数,则接口可调用 ❞
说干就干,我们先定义一个注解RateLimiter
,用在需要防重复提交的方法上。RateLimiter
定义如下:
这个注解我们要注意几个元素:
代码语言:javascript复制needUserLimit() //key设定为 接口名称 userId
limit()//单位时间限制通过的请求数
expire()//过期时间,单位s
这里我们利用Redis
的过期时间,在过期时间内请求数不超过指定的limit()
数,则接口可以执行,否则接口执行前会被拦截。我们使用接口全路径名称 登录用户的id作为Redis
的key。limit()
和expire()
可以使用默认值,即1秒内只能执行一次接口。
来看看如何实现这个注解:
我们写一个RateLimiterHandler
类,在注入时加载Lua脚本
@PostConstruct
public 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
代码如下:
代码语言:javascript复制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脚本:
代码语言:javascript复制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秒后再进行操作 ❞
单元测试
代码到这里就结束了,其实思路也比较简单,我们写一个单元测试试试:
代码语言:javascript复制@ResponseBody
@RequestMapping("ratelimiter")
@RateLimiter(needUserLimit = true)
public String testLimit() {
return "限流注解测试专用";
}
运行上面的方法:
代码语言:javascript复制@Test
public void testPage() throws InterruptedException {
payCommonController.testLimit();
payCommonController.testLimit();
payCommonController.testLimit();
}
我们连续执行3次目标方法,发现控制台已有提示。
Redis上我们也看到了对应的key。
我们将调用时间间隔为:2s
代码语言:javascript复制@Test
public void testPage() throws InterruptedException {
payCommonController.testLimit();
Thread.sleep(2000);
payCommonController.testLimit();
Thread.sleep(2000);
payCommonController.testLimit();
}
测试通过
至此,我们用限流处理器来防止重复提交的需求达成。