经验首页 前端设计 程序设计 Java相关 移动开发 数据库/运维 软件/图像 大数据/云计算 其他经验
当前位置:技术经验 » Java相关 » Spring Boot » 查看文章
如何防止用户重复提交订单?(下)
来源:cnblogs  作者:程序员志哥  时间:2022/11/23 18:51:27  对本文有异议

一、摘要

在上一篇文章中,我们详细的介绍了随着下单流量逐渐上升,为了降低数据库的访问压力,通过请求唯一ID+redis分布式锁来防止接口重复提交,流程图如下!

每次提交的时候,需要先调用后端服务获取请求唯一ID,然后才能提交。

对于这样的流程,不少的同学可能会感觉到非常鸡肋,尤其是单元测试,需要每次先获取submitToken值,然后才能提交!

能不能不用这么麻烦,直接服务端通过一些规则组合,生成本次请求唯一ID呢

答案是可以的!

今天我们就一起来看看,如何通过服务端来完成请求唯一 ID 的生成?

二、方案实践

我们先来看一张图,这张图就是本次方案的核心流程图。

实现的逻辑,流程如下:

  • 1.用户点击提交按钮,服务端接受到请求后,通过规则计算出本次请求唯一ID值
  • 2.使用redis的分布式锁服务,对请求 ID 在限定的时间内尝试进行加锁,如果加锁成功,继续后续流程;如果加锁失败,说明服务正在处理,请勿重复提交
  • 3.最后一步,如果加锁成功后,需要将锁手动释放掉,以免再次请求时,提示同样的信息

引入缓存服务后,防止重复提交的大体思路如上,实践代码如下!

2.1、引入 redis 组件

本次 demo 项目是基于SpringBoot版本进行构建,添加相关的redis依赖环境如下:

  1. <!-- 引入springboot -->
  2. <parent>
  3. <groupId>org.springframework.boot</groupId>
  4. <artifactId>spring-boot-starter-parent</artifactId>
  5. <version>2.1.0.RELEASE</version>
  6. </parent>
  7. ......
  8. <!-- Redis相关依赖包,采用jedis作为客户端 -->
  9. <dependency>
  10. <groupId>org.springframework.boot</groupId>
  11. <artifactId>spring-boot-starter-data-redis</artifactId>
  12. <exclusions>
  13. <exclusion>
  14. <groupId>redis.clients</groupId>
  15. <artifactId>jedis</artifactId>
  16. </exclusion>
  17. <exclusion>
  18. <artifactId>lettuce-core</artifactId>
  19. <groupId>io.lettuce</groupId>
  20. </exclusion>
  21. </exclusions>
  22. </dependency>
  23. <dependency>
  24. <groupId>redis.clients</groupId>
  25. <artifactId>jedis</artifactId>
  26. </dependency>
  27. <dependency>
  28. <groupId>org.apache.commons</groupId>
  29. <artifactId>commons-pool2</artifactId>
  30. </dependency>

2.2、添加 redis 环境配置

在全局配置application.properties文件中,添加redis相关服务配置如下

  1. # 项目名
  2. spring.application.name=springboot-example-submit
  3. # Redis数据库索引(默认为0)
  4. spring.redis.database=1
  5. # Redis服务器地址
  6. spring.redis.host=127.0.0.1
  7. # Redis服务器连接端口
  8. spring.redis.port=6379
  9. # Redis服务器连接密码(默认为空)
  10. spring.redis.password=
  11. # Redis服务器连接超时配置
  12. spring.redis.timeout=1000
  13. # 连接池配置
  14. spring.redis.jedis.pool.max-active=8
  15. spring.redis.jedis.pool.max-wait=1000
  16. spring.redis.jedis.pool.max-idle=8
  17. spring.redis.jedis.pool.min-idle=0
  18. spring.redis.jedis.pool.time-between-eviction-runs=100

2.3、编写服务验证逻辑,通过 aop 代理方式实现

首先创建一个@SubmitLimit注解,通过这个注解来进行方法代理拦截!

  1. @Retention(RetentionPolicy.RUNTIME)
  2. @Target({ElementType.METHOD})
  3. @Documented
  4. public @interface SubmitLimit {
  5. /**
  6. * 指定时间内不可重复提交(仅相对上一次发起请求时间差),单位毫秒
  7. * @return
  8. */
  9. int waitTime() default 1000;
  10. /**
  11. * 指定请求头部key,可以组合生成签名
  12. * @return
  13. */
  14. String[] customerHeaders() default {};
  15. /**
  16. * 自定义重复提交提示语
  17. * @return
  18. */
  19. String customerTipMsg() default "";
  20. }

编写方法代理服务,增加防止重复提交的验证,实现了逻辑如下!

  1. @Order(1)
  2. @Aspect
  3. @Component
  4. public class SubmitLimitAspect {
  5. private static final Logger LOGGER = LoggerFactory.getLogger(SubmitLimitAspect.class);
  6. /**
  7. * redis分割符
  8. */
  9. private static final String REDIS_SEPARATOR = ":";
  10. /**
  11. * 默认锁对应的值
  12. */
  13. private static final String DEFAULT_LOCK_VALUE = "DEFAULT_SUBMIT_LOCK_VALUE";
  14. /**
  15. * 默认重复提交提示语
  16. */
  17. private static final String DEFAULT_TIP_MSG = "服务正在处理,请勿重复提交!";
  18. @Value("${spring.application.name}")
  19. private String applicationName;
  20. @Autowired
  21. private RedisLockService redisLockService;
  22. /**
  23. * 方法调用环绕拦截
  24. */
  25. @Around(value = "@annotation(com.example.submittoken.config.annotation.SubmitLimit)")
  26. public Object doAround(ProceedingJoinPoint joinPoint){
  27. HttpServletRequest request = getHttpServletRequest();
  28. if(Objects.isNull(request)){
  29. return ResResult.getSysError("请求参数不能为空!");
  30. }
  31. //获取注解配置的参数
  32. SubmitLimit submitLimit = getSubmitLimit(joinPoint);
  33. //组合生成key,通过key实现加锁和解锁
  34. String lockKey = buildSubmitLimitKey(joinPoint, request, submitLimit.customerHeaders());
  35. //尝试在指定的时间内加锁
  36. boolean lock = redisLockService.tryLock(lockKey, DEFAULT_LOCK_VALUE, Duration.ofMillis(submitLimit.waitTime()));
  37. if(!lock){
  38. String tipMsg = StringUtils.isEmpty(submitLimit.customerTipMsg()) ? DEFAULT_TIP_MSG : submitLimit.customerTipMsg();
  39. return ResResult.getSysError(tipMsg);
  40. }
  41. try {
  42. //继续执行后续流程
  43. return execute(joinPoint);
  44. } finally {
  45. //执行完毕之后,手动将锁释放
  46. redisLockService.releaseLock(lockKey, DEFAULT_LOCK_VALUE);
  47. }
  48. }
  49. /**
  50. * 执行任务
  51. * @param joinPoint
  52. * @return
  53. */
  54. private Object execute(ProceedingJoinPoint joinPoint){
  55. try {
  56. return joinPoint.proceed();
  57. } catch (CommonException e) {
  58. return ResResult.getSysError(e.getMessage());
  59. } catch (Throwable e) {
  60. LOGGER.error("业务处理发生异常,错误信息:",e);
  61. return ResResult.getSysError(ResResultEnum.DEFAULT_ERROR_MESSAGE);
  62. }
  63. }
  64. /**
  65. * 获取请求对象
  66. * @return
  67. */
  68. private HttpServletRequest getHttpServletRequest(){
  69. RequestAttributes ra = RequestContextHolder.getRequestAttributes();
  70. ServletRequestAttributes sra = (ServletRequestAttributes)ra;
  71. HttpServletRequest request = sra.getRequest();
  72. return request;
  73. }
  74. /**
  75. * 获取注解值
  76. * @param joinPoint
  77. * @return
  78. */
  79. private SubmitLimit getSubmitLimit(JoinPoint joinPoint){
  80. MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
  81. Method method = methodSignature.getMethod();
  82. SubmitLimit submitLimit = method.getAnnotation(SubmitLimit.class);
  83. return submitLimit;
  84. }
  85. /**
  86. * 组合生成lockKey
  87. * 生成规则:项目名+接口名+方法名+请求参数签名(对请求头部参数+请求body参数,取SHA1值)
  88. * @param joinPoint
  89. * @param request
  90. * @param customerHeaders
  91. * @return
  92. */
  93. private String buildSubmitLimitKey(JoinPoint joinPoint, HttpServletRequest request, String[] customerHeaders){
  94. //请求参数=请求头部+请求body
  95. String requestHeader = getRequestHeader(request, customerHeaders);
  96. String requestBody = getRequestBody(joinPoint.getArgs());
  97. String requestParamSign = DigestUtils.sha1Hex(requestHeader + requestBody);
  98. String submitLimitKey = new StringBuilder()
  99. .append(applicationName)
  100. .append(REDIS_SEPARATOR)
  101. .append(joinPoint.getSignature().getDeclaringType().getSimpleName())
  102. .append(REDIS_SEPARATOR)
  103. .append(joinPoint.getSignature().getName())
  104. .append(REDIS_SEPARATOR)
  105. .append(requestParamSign)
  106. .toString();
  107. return submitLimitKey;
  108. }
  109. /**
  110. * 获取指定请求头部参数
  111. * @param request
  112. * @param customerHeaders
  113. * @return
  114. */
  115. private String getRequestHeader(HttpServletRequest request, String[] customerHeaders){
  116. if (Objects.isNull(customerHeaders)) {
  117. return "";
  118. }
  119. StringBuilder sb = new StringBuilder();
  120. for (String headerKey : customerHeaders) {
  121. sb.append(request.getHeader(headerKey));
  122. }
  123. return sb.toString();
  124. }
  125. /**
  126. * 获取请求body参数
  127. * @param args
  128. * @return
  129. */
  130. private String getRequestBody(Object[] args){
  131. if (Objects.isNull(args)) {
  132. return "";
  133. }
  134. StringBuilder sb = new StringBuilder();
  135. for (Object arg : args) {
  136. if (arg instanceof HttpServletRequest
  137. || arg instanceof HttpServletResponse
  138. || arg instanceof MultipartFile
  139. || arg instanceof BindResult
  140. || arg instanceof MultipartFile[]
  141. || arg instanceof ModelMap
  142. || arg instanceof Model
  143. || arg instanceof ExtendedServletRequestDataBinder
  144. || arg instanceof byte[]) {
  145. continue;
  146. }
  147. sb.append(JacksonUtils.toJson(arg));
  148. }
  149. return sb.toString();
  150. }
  151. }

部分校验逻辑用到了redis分布式锁,具体实现逻辑如下:

  1. /**
  2. * redis分布式锁服务类
  3. * 采用LUA脚本实现,保证加锁、解锁操作原子性
  4. *
  5. */
  6. @Component
  7. public class RedisLockService {
  8. /**
  9. * 分布式锁过期时间,单位秒
  10. */
  11. private static final Long DEFAULT_LOCK_EXPIRE_TIME = 60L;
  12. @Autowired
  13. private StringRedisTemplate stringRedisTemplate;
  14. /**
  15. * 尝试在指定时间内加锁
  16. * @param key
  17. * @param value
  18. * @param timeout 锁等待时间
  19. * @return
  20. */
  21. public boolean tryLock(String key,String value, Duration timeout){
  22. long waitMills = timeout.toMillis();
  23. long currentTimeMillis = System.currentTimeMillis();
  24. do {
  25. boolean lock = lock(key, value, DEFAULT_LOCK_EXPIRE_TIME);
  26. if (lock) {
  27. return true;
  28. }
  29. try {
  30. Thread.sleep(1L);
  31. } catch (InterruptedException e) {
  32. Thread.interrupted();
  33. }
  34. } while (System.currentTimeMillis() < currentTimeMillis + waitMills);
  35. return false;
  36. }
  37. /**
  38. * 直接加锁
  39. * @param key
  40. * @param value
  41. * @param expire
  42. * @return
  43. */
  44. public boolean lock(String key,String value, Long expire){
  45. String luaScript = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";
  46. RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
  47. Long result = stringRedisTemplate.execute(redisScript, Collections.singletonList(key), value, String.valueOf(expire));
  48. return result.equals(Long.valueOf(1));
  49. }
  50. /**
  51. * 释放锁
  52. * @param key
  53. * @param value
  54. * @return
  55. */
  56. public boolean releaseLock(String key,String value){
  57. String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  58. RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
  59. Long result = stringRedisTemplate.execute(redisScript, Collections.singletonList(key),value);
  60. return result.equals(Long.valueOf(1));
  61. }
  62. }

部分代码使用到了序列化相关类JacksonUtils,源码如下:

  1. public class JacksonUtils {
  2. private static final Logger LOGGER = LoggerFactory.getLogger(JacksonUtils.class);
  3. private static final ObjectMapper objectMapper = new ObjectMapper();
  4. static {
  5. // 对象的所有字段全部列入
  6. objectMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS);
  7. // 忽略未知的字段
  8. objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
  9. // 读取不认识的枚举时,当null值处理
  10. objectMapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);
  11. // 序列化忽略未知属性
  12. objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
  13. //忽略字段大小写
  14. objectMapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true);
  15. objectMapper.configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true);
  16. SimpleModule module = new SimpleModule();
  17. module.addSerializer(Long.class, ToStringSerializer.instance);
  18. module.addSerializer(Long.TYPE, ToStringSerializer.instance);
  19. objectMapper.registerModule(module);
  20. }
  21. public static String toJson(Object object) {
  22. if (object == null) {
  23. return null;
  24. }
  25. try {
  26. return objectMapper.writeValueAsString(object);
  27. } catch (Exception e) {
  28. LOGGER.error("序列化失败",e);
  29. }
  30. return null;
  31. }
  32. public static <T> T fromJson(String json, Class<T> classOfT) {
  33. if (json == null) {
  34. return null;
  35. }
  36. try {
  37. return objectMapper.readValue(json, classOfT);
  38. } catch (Exception e) {
  39. LOGGER.error("反序列化失败",e);
  40. }
  41. return null;
  42. }
  43. public static <T> T fromJson(String json, Type typeOfT) {
  44. if (json == null) {
  45. return null;
  46. }
  47. try {
  48. return objectMapper.readValue(json, objectMapper.constructType(typeOfT));
  49. } catch (Exception e) {
  50. LOGGER.error("反序列化失败",e);
  51. }
  52. return null;
  53. }
  54. }

2.4、在相关的业务接口上,增加SubmitLimit注解即可

  1. @RestController
  2. @RequestMapping("order")
  3. public class OrderController {
  4. @Autowired
  5. private OrderService orderService;
  6. /**
  7. * 下单,指定请求头部参与请求唯一值计算
  8. * @param request
  9. * @return
  10. */
  11. @SubmitLimit(customerHeaders = {"appId", "token"}, customerTipMsg = "正在加紧为您处理,请勿重复下单!")
  12. @PostMapping(value = "confirm")
  13. public ResResult confirmOrder(@RequestBody OrderConfirmRequest request){
  14. //调用订单下单相关逻辑
  15. orderService.confirm(request);
  16. return ResResult.getSuccess();
  17. }
  18. }

其中最关键的一个步就是将唯一请求 ID 的生成,放在服务端通过组合来实现,在保证防止接口重复提交的效果同时,也可以显著的降低接口测试复杂度

三、小结

本次方案相比于上一个方案,最大的改进点在于:将接口请求唯一 ID 的生成逻辑,放在服务端通过规则组合来实现,不需要前端提交接口的时候强制带上这个参数,在满足防止接口重复提交的要求同时,又能减少前端和测试提交接口的复杂度!

需要特别注意的是:使用redis的分布式锁,推荐单机环境,如果redis是集群环境,可能会导致锁短暂无效

原文链接:https://www.cnblogs.com/dxflqm/p/16914670.html

 友情链接:直通硅谷  点职佳  北美留学生论坛

本站QQ群:前端 618073944 | Java 606181507 | Python 626812652 | C/C++ 612253063 | 微信 634508462 | 苹果 692586424 | C#/.net 182808419 | PHP 305140648 | 运维 608723728

W3xue 的所有内容仅供测试,对任何法律问题及风险不承担任何责任。通过使用本站内容随之而来的风险与本站无关。
关于我们  |  意见建议  |  捐助我们  |  报错有奖  |  广告合作、友情链接(目前9元/月)请联系QQ:27243702 沸活量
皖ICP备17017327号-2 皖公网安备34020702000426号