重复提交

11/15/2022

# 一、背景与问题

很多时候我们需要限制用户的并发请求次数,比如对于某个接口,不能请求太频繁,限制 一个用户每秒钟只能请求一次;

举例场景:

  • 短信验证码,对于同一个用户,60秒才能请求一次;
  • 登录,对于一个用户,10秒才能请求一次

# 二、架构与思想

分析如上需求,我们能很清楚的知道这是一个 请求信息记录 的问题,核心在于:

  • 以什么为 凭证 作为记录
  • 如果已经请求过,记录在哪里
  • 请求的时候需要记录请求时间

几个专有名词:

凭证:ticket   
重复提交:repeat submit
1
2

# 三、具体使用

系统提供了注解@RepeatSubmit 用于解决此问题。

# 3.1、配置

首先需要决策是使用redis还是caffine来存储凭证 ticket? 这里我们建议:

  • 如果是 集群部署,建议用 redis
  • 如果是 单体部署,建议用 内存caffine

如下: 使用caffine作为存储,
具体凭证使用的是 [url + userid]

@Configuration
public class RepeatSubmitConfig {

    @Bean
    public RepeatSubmitAspect repeatSubmitAspect() {
        RepeatSubmitCaffeineTicket caffeineTicket = new RepeatSubmitCaffeineTicket(this::ticket);
        return new RepeatSubmitAspect(caffeineTicket);
    }

    /**
     * 获取指明某个用户的凭证
     */
    private String ticket(String servletPath) {
        Long userId = SmartRequestUtil.getRequestUserId();
        if (null == userId) {
            return StringConst.EMPTY;
        }
        return servletPath + "_" + userId;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 3.2、@RepeatSubmit注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RepeatSubmit {
    int value() default 300;//重复提交间隔时间/毫秒
    int MAX_INTERVAL = 30000;//最长间隔30s
}

1
2
3
4
5
6
7

默认重复提交为 300毫秒,具体按照实际业务需要

接下来可以将此注解加到 方法上,如下

@RestController
@Api(tags = {SwaggerTagConst.Support.TABLE_COLUMN})
public class TableColumnController extends SupportBaseController {

    @Autowired
    private TableColumnService tableColumnService;

    @ApiOperation("修改表格列 @author 卓大")
    @PostMapping("/tableColumn/update")
    @RepeatSubmit
    public ResponseDTO<String> updateTableColumn(@RequestBody @Valid TableColumnUpdateForm updateForm) {
        return tableColumnService.updateTableColumns(SmartRequestUtil.getRequestUser(), updateForm);
    }

    @ApiOperation("恢复默认(删除) @author 卓大")
    @GetMapping("/tableColumn/delete/{tableId}")
    @RepeatSubmit
    public ResponseDTO<String> deleteTableColumn(@PathVariable Integer tableId) {
        return tableColumnService.deleteTableColumn(SmartRequestUtil.getRequestUser(), tableId);
    }

    @ApiOperation("查询表格列 @author 卓大")
    @GetMapping("/tableColumn/getColumns/{tableId}")
    public ResponseDTO<String> getColumns(@PathVariable Integer tableId) {
        return ResponseDTO.ok(tableColumnService.getTableColumns(SmartRequestUtil.getRequestUser(), tableId));
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 四、实现原理

# 4.1、切面

因为是使用注解来解决问题,所以离不开 AOP,这里使用RepeatSubmitAspect.java来实现的;核心代码如下:

    @Around("@annotation(net.lab1024.sa.common.module.support.repeatsubmit.annoation.RepeatSubmit)")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        //拿到 ticket
        String ticketToken = attributes.getRequest().getServletPath();
        String ticket = this.repeatSubmitTicket.getTicket(ticketToken);
        if (StringUtils.isEmpty(ticket)) {
            return point.proceed();
        }
        // 获取tick的时间
        Long timeStamp = this.repeatSubmitTicket.getTicketTimestamp(ticket);
        if (timeStamp != null) {
            Method method = ((MethodSignature) point.getSignature()).getMethod();
            RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
            int interval = Math.min(annotation.value(), RepeatSubmit.MAX_INTERVAL);
            if (System.currentTimeMillis() < timeStamp + interval) {
                // 提交频繁
                return ResponseDTO.error(UserErrorCode.REPEAT_SUBMIT);
            }
        }
        Object obj = null;
        try {
            obj = point.proceed();
            this.repeatSubmitTicket.putTicket(ticket);
        } catch (Throwable throwable) {
            log.error("", throwable);
            throw throwable;
        } finally {
            this.repeatSubmitTicket.removeTicket(ticket);
        }
        return obj;
    }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 4.2、存储

存储到redis: RepeatSubmitRedisTicket.java

    @Override
    public Long getTicketTimestamp(String ticket) {
        Long timeStamp = System.currentTimeMillis();
        boolean setFlag = redisValueOperations.setIfAbsent(ticket, String.valueOf(timeStamp), RepeatSubmit.MAX_INTERVAL, TimeUnit.MILLISECONDS);
        if (!setFlag) {
            timeStamp = Long.valueOf(redisValueOperations.get(ticket));
        }
        return timeStamp;
    }
1
2
3
4
5
6
7
8
9

存储到 caffine: RepeatSubmitCaffeineTicket.java

 /**
     * 限制缓存最大数量 超过后先放入的会自动移除
     * 默认缓存时间
     * 初始大小为:100万
     */
    private static Cache<String, Long> cache = Caffeine.newBuilder()
            .maximumSize(100 * 10000)
            .expireAfterWrite(RepeatSubmit.MAX_INTERVAL, TimeUnit.MILLISECONDS).build();

    public RepeatSubmitCaffeineTicket(Function<String, String> ticketFunction) {
        super(ticketFunction);
    }

    @Override
    public Long getTicketTimestamp(String ticket) {
        return cache.getIfPresent(ticket);
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 联系我们

1024创新实验室-主任:卓大 (opens new window),混迹于各个技术圈,研究过计算机,熟悉点 java,略懂点前端。
1024创新实验室(河南·洛阳) (opens new window) 致力于成为中原领先、国内一流的技术团队,以技术创新为驱动,合作各类项目。

加 主任 “卓大” 微信
拉你入群,一起学习
关注 “小镇程序员”
分享代码与生活、技术与赚钱
请 “1024创新实验室” 喝咖啡
支持我们的开源与分享

告白气球 (钢琴版)
JESSE T