并发环境的接口幂等与防重

并发环境的接口幂等与防重

每次生产环境压测和安全渗透,或多或少会发生并发重复插入等等问题,困扰许久,通过不断测试,终于摸清了点门道,记录下来。

1、并发的意思和典型场景

1.1 概念

​ 并发,或者说并行执行,是相对于串行的一个概念。举个例子,在只有一个车道的路上,所有小汽车从起点出发到重点,所有小车一辆接着一辆像火车车厢一节一节向前走(执行代码),叫做串行执行,并行执行,见名知义,车道变宽了,可以两辆车或者三辆车并排着往前走,直到终点前再一个一个冲线(对于某些业务也可以同时冲线)。

​ 相对于串行,并行执行可以大大节约“比赛”时间,但同时,并行执行面临着汽车碰撞的危险(数据紊乱等)。在追求性能的条件下,你可以将代码串行化,减少发生错误的机会,但是一旦有QPS等技术要求,一些不那么重要的操作等等,必须做异步处理,或者并发执行,那就必须要用一些方法,保证并发的安全性。

1.2 两种并发场景

​ 其实并发有2种场景,同时又有2~3种环境,即:

  • 单一用户并发->相同请求的多线程同时访问接口
  • 多用户并发->不同请求的多线程同时访问接口
  • 单机部署环境 单jvm
  • 集群部署环境 多服务器多jvm
  • 微服务部署环境 多服务器多实例

一个一个解释

单一用户并发:解决的问题是单一用户对某个接口短时间内的大量线程访问,举个例子就是使用Jmeter设置100个线程压力测试,参数相同,造成的服务器压力

多用户并发:解决的问题是不同用户对某个接口的短时间内大量线程访问,举个例子就是100个用户同时访问某个接口,参数不同,造成的服务器压力

单机部署、集群部署、微服务多实例部署:除了单机部署以外,其它都涉及到分布式的解决方案

1.3 多种并发场景可能造成的问题

首先设定一个场景,以最近遇到的为例

例如一个接口,用户访问后,增加或减少用户的积分总量,同时插入一条积分增加或扣减的记录
这里涉及到2张表,1、用户积分总表 2、用户积分记录表

单一用户并发时,假定用户增加1积分,使用100个线程压测,如果不做接口的幂等校验,此时可能会发生的问题的流程:

100个线程同时进入controller->100个线程同时进入修改用户积分总表,插入用户积分记录表记录->造成数据库重复插入

多用户并发时,多线程对数据库表操作,可能造成某一个事务还未提交,就对表进行修改,造成并发问题,这里按下不表,使用分布式锁例如Redisson等就可以解决这个问题

用户积分总表中,存有用户唯一id,且每一个用户只对应一条数据,记录他的总可用积分,总积分,已消耗的积分等情况。

用户积分记录表中,用户唯一id可以重复,因为每个用户可以有多条记录,同时,用户积分的变动情况也可以重复,例如刚消耗了5积分,第二次又消耗了5积分,对于这些记录来说,可以是除了创建时间和更新时间以及自增主键外,其它字段的值都可以重复的,相当于没办法根据字段的状态或值知晓他是否插入成功了我们想要的只是插入了1条记录。

那么,应该怎样解决这个问题呢?

2、解决并发问题的方法

2.1 微服务/分布式场景

对于分布式和微服务场景,他们主要首要要解决的问题是多用户多线程并发访问,是为了防止多用户并发时,造成的“成员变量内容”或者“账单内容”等紊乱,这就要求,在涉及到增删改操作时,必须串行化。

如何串行化?分布式锁。

利用redis的单线程执行的特点,在分布式系统之间,即使是redis集群部署,相对于分布式系统,redis也是只有一个客户端,且单线程执行,相当于一把放在最外层的大门上的锁

利用redis的setnx命令,以用户唯一的标识和此次请求的来源作为key,使用redisTemplate的setnx命令,如果set成功,相当于拿到一把唯一的锁,其它任意分布式实例进来的线程此时都在阻塞中,当操作完成时,释放锁。

对于其它分布式实例阻塞的线程来说,当上一个线程释放锁本线程拿到所,再去修改,这样就保证了多线程执行的串行化。

2.2 单机部署

对于单一用户并发,主要解决的问题就是,相同的并发请求进来,如何保证相同的参数只执行一次,即接口幂等。

幂等的定义等内容百度搜索就知道了。

这里也需要获取分布式锁,或者利用redis的单线程特点,获取一把锁,在执行具体的增删改代码之前,将此时的操作串行化。

但是,更重要的是,如果获取锁这里有20个请求代码都阻塞在了锁这里,因为用户积分总表和积分流水表,没有什么字段可以作为是否插入/修改成功的判断依据,导致一个线程释放锁之后,剩下的线程依旧不知道事务提交之后的影响,继续修改/插入,导致重复插入问题。这里该怎么解决?

2.3 几次尝试

第一次尝试

并发请求进来,首先获取分布式锁

 RLock lock = distributedLockUtil.getLock(redissonLockKey);
  //获取锁
  boolean locked = lock.tryLock(10, TimeUnit.SECONDS);
if (!locked) return BaseResponse.errorResponseData("系统繁忙");

1.根据用户id作为唯一锁的key,获取锁

2.获取锁之后,尝试加锁,超时时间为10s,100个请求,如果都阻塞到了加锁这里,如果超过10s前面的还没执行完成并且释放锁,就放弃获取锁返回系统繁忙

Object cacheObject = redisService.getCacheObject(redisKey);
      if (cacheObject != null) {
          log.info("redis锁:{}", (String) cacheObject);
         throw new BizException(ServiceErrorEnum.EXECUTING_PLEASE_WAITE);
  }

3.获取锁之后,再次尝试从redis中获取一个key的value(这个在下面的代码会讲到为什么),如果获取不到,直接放弃执行并返回系统繁忙

执行修改和插入操作,这两个操作放在一个事务中

注意:2个数据库操作放在一个事务中可以,也可以是单独事务进行2次调用(最好放在一个事务中),同时,如果使用Spring声明式事务,要注意事务的隔离级别和事务切面失效的问题,采用注入自身service,可以解决问题;或者使用编程式事务,不仅可以解决问题,还可以减小事务的粒度,防止因为事务造成的数据库链接长时间占用,减少资源占用浪费,减少执行时间。

} finally {
         log.info("释放锁,增加redisKey");
         distributedLockUtil.unlock(redissonLockKey);
         redisService.setCacheObject(redisKey, openid, 3, TimeUnit.SECONDS);
  }

4.完成数据库操作后,释放分布式锁并且加一个redis key锁,这里假如第一个获取锁的线程完成了操作,加上了redis key锁,其它线程就算获取到了锁,也无法执行,直接返回失败

结论:一次不正确但是正确的尝试,在一定程度上解决了单用户100乃至1000并发时的重复插入问题,但是并不是严格意义上的幂等

ps:虽然安全渗透过去了,也没发生问题,但是这是错误的示范

第二次尝试

并发请求进来,利用总表的更新作为单一执行的条件,结合乐观锁

在用户积分总表中,创建一个version字段,利用更新前先对比version的方法,如果更新了,说明是成功的调用,此时更新方法返回int=1,再执行插入;后续因为对比version和期望的version不同,不再更新,int=0,也不再插入,保证更新和插入都只执行一次。

流程:

1、100请求进来,在最开头获取当前积分总表的version(避免在加锁前获取,防止因执行时间导致的version查询到的值不一样,另外在获取锁之后,将要执行更新前再次查询一遍version,如果此时不一样,直接返回失败,如果一样再向下执行)

2、获取分布式锁,并尝试加锁

3、一个线程获取到了锁,利用sql

update user_point set total=#{total},used=#{used},useful=#{useful},version=version+1 where uniqe_id=#{uniqe_id} and version= #{version}

4、将上面的version传递到sql中,假如100个线程进来获取到的version都是0,此时where version=#{version}的这个version=0,如果前面有线程获取锁并且更新成功了,这里的version就是1了,并不等于0,更新也就不会执行,返回的值就是0不是1

i=userpointMapper.updateByVersion(uniqeId,Num,DateUtil.getCurrentLocalTimeStr(), id, version);
   log.error("=====================本次乐观锁是否更新:{}", i);
	//如果更新成功了,再插入,否则不插入
      if (i == 1) {
          UserPointRecord userPointRecord = new UserPointRecord();
          //填充插入参数
        userPointRecordMapper.insert(userPointRecord);
     }

5、完成后,释放分布式锁

结论:第二次不正确但是正确的尝试,在一定程度上能解决并发重发插入的问题,但是也不是严格意义上的幂等,是错误的

第三次尝试

并发请求进来,利用插入记录除了时间不同其它都相同的条件,以及只需要一条最晚的线程获取的时间进行更新的条件,结合唯一id和创建时间的唯一索引

不依靠version,这次在在并发测试的时候发现,重复插入导致的创建时间一致的问题,从时间入手

1、100个请求进来,在方法最开头获取当前系统时间

2、创建一个volatile的成员变量用来存储线程获取的系统时间,保证所有线程可见

private volatile AtomicReference<LocalDateTime> threadTime;

3、获取系统时间后,如果threadTime没被初始化,当前线程的时间将其初始化;否则,和成员变量threadTime进行对比,如果当前时间比他晚,将当前时间赋值给成员变量

LocalDateTime now = null;
synchronized (this){
       //获取时间
       now = LocalDateTime.now();
       log.error("当前线程:{},获取的时间,:{}",Thread.currentThread().getName(),now);
  	if (threadTime == null) {
       log.error("线程{}",Thread.currentThread().getName());
       threadTime=new AtomicReference<>(now);
  	} else {
          
   	 if (now.isAfter(threadTime.get())) {
    	log.error("设置更晚的时间,线程:{}",Thread.currentThread().getName());
                    threadTime.set(now);
                }
            }
        }

4、同样获取分布式锁,在执行插入前查询是否有和现在即将插入的所有字段内容全部相同的,且时间比当前拿着的threadTime还晚的,如果有,证明已经插入过了,不再执行

//查询是否插入过
List<UserPointRecord> userPointRecords = userPointRecordMapper.selectList(new LambdaQueryWrapper<UserPointRecord>()
       .eq(UserPointRecord::getUniqeId, uniqeId)
       .eq(UserPointRecord::getNum, Num)
       .eq(UserPointRecord::getPoint, point)
       .eq(UserPointRecord::getRecordType, DataStatusEnum.InValid.getCode())
       .eq(UserPointRecord::getReceiveType, DataStatusEnum.InValid.getCode())
       .ge(UserPointRecord::getCreateTime,threadTime)
 );

5、这里遇到一个问题,因为所有线程进来获取时间,可能还没获取完,例如还有5个线程获取好了时间比之前的线程最晚时间要晚,还没有进行比较完复制给threadTime,此时已经有之前的线程获取了锁,拿到threadTime进行比较,发现没有比他晚的,就执行插入了,导致还是会造成重复插入的结果

所以将线程获取系统时间串行化,通过Synchronized一个一个拿并且去比较,确保volatile AtomicReference是最晚的时间之后再去拿锁,执行插入

  log.error("已经完成插入的数量:{}",diamondCardRecords.size());
   if(!userPointRecords.isEmpty()){
   String errmsg = String.format("重复插入了,重复了:%s条",diamondCardRecords.size());
       throw new RuntimeException(errmsg);
    }
         //没有插入过,执行插入   
      	UserPointRecord userPointRecord = new UserPointRecord();
          //填充插入参数
        userPointRecordMapper.insert(userPointRecord);
		//执行更新
		userPointMapper.updateById(userPointDTO);
     }
        

6、这个方法的核心思想是,将并发线程进来之后串行化获取一个时间,再通过比较他们的最晚时间,将最晚的时间赋值给线程可见变量threadTime,然后再去竞争锁,拿到锁之后,用最晚的时间和其它完全相同的参数去数据库查询有没有更晚的插入记录,期望是没有,就说明这是第一个执行插入和更新的线程;当这个线程成功执行事务并且提交之后,下个线程拿到锁,拿着没再更改过的最晚时间threadTime去查询,最多找到一条和他相等的,且肯定是有一条,就不会再更新

注:mybatis-plus判断符

原符号       <       <=      >       >=      <>
对应函数    lt()     le()    gt()    ge()    ne()
Mybatis-plus写法:  queryWrapper.ge("create_time", localDateTime);

结论:能抗住一定量的并发,但是也不是真正意义上的幂等,是错误的

3、真正的幂等

3.1 真正的幂等方法

接口幂等性是指无论客户端对一个接口发起多少次请求最终的结果应该是一致的。为了确保接口的幂等性,可以采用以下几种常见方法:

唯一性约束: 在数据库中为需要保持幂等性的字段添加唯一性约束。这将确保同一个数据在数据库中只有一份,即使多次插入也不会重复。

使用 UUID 或分布式 ID: 为每次请求生成一个唯一的标识符(UUID或分布式ID),将其与请求一起存储在数据库中。在每次插入前,检查是否已存在相同标识符的记录,如果存在则不再插入。

Token 防重放: 为每个请求生成一个唯一的令牌(Token),客户端在每次请求中带上这个令牌。服务器端收到请求后,检查令牌是否已被使用,如果已使用则拒绝处理。

版本号/时间戳: 在每次插入记录时,记录版本号或时间戳。在插入前检查相同版本号或时间戳的记录是否已存在,如果存在则不再插入。

数据库事务: 使用数据库事务来确保每次插入操作是原子的。如果多次请求同时发生,只有一个请求能够插入成功,其他请求会失败。

请求幂等标识: 在请求中添加一个幂等性标识,服务端根据该标识来判断是否执行插入操作。这需要客户端在每次请求中携带一个唯一的标识,服务端进行幂等性校验。

限流措施: 使用限流工具来限制每个用户或客户端的请求频率,避免疯狂刷接口。

删除重复数据: 定期检查数据库中是否有重复的数据,如果发现则删除。

选择哪种方法取决于具体的应用需求和场景。通常,数据库唯一性约束和数据库事务是较为可靠的方法,但在一些分布式系统中,需要结合多种方法来确保接口的幂等性。

方法分析:
比如记录是一条条的流水,里面有uniqeId和其他的字段,但是uniqeId是可以重复的,所以第一条没有条件做;第二条,使用uuid或分布式id,如果生成id的代码在service方法内,同时进来100条请求生成的uuid什么的也会不一样,同样会造成重复插入;第三条,为每个请求生成token,一是消耗网络资源,二是获取token和发起实际插入请求必须得是串行的,还是可能有问题;第四条版本号,同样生成版本号的代码在service方法内,100个请求进来生成的版本号同样不一样,会造成重复插入;第六条请求幂等标识,和第三条token一个意思,第七条限流,时间太短比如5秒,如果1000并发压测5s内跑不完,同样会重复插入2条以上,但不会太多,第八条需要手动,不太可行 ......

3.2 选择请求Token令牌完成接口幂等性

虽然每次要增删改(一些特定的请求,其它请求如果不是很必须就不用)需要获取一次增删改token,消耗一些网络资源,但是对比完成的结果来说,是可以接受的

3.3 具体实现

1、创建一个RequestToken自定义注解,放在需要幂等的接口controller方法上

/**
* 幂等性注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReqToken {
}

2、添加一个这个注解的拦截器

public class ReqTokenInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        // 如果添加了该注解
        ReqToken annotation = method.getAnnotation(ReqToken.class);
        if (annotation != null){
            //进行幂等性校验
            checkToken(request);
        }

        return true;
    
    }

    @Autowired
    private RedisService redisService;

    //幂等性校验
    private void checkToken(HttpServletRequest request) {
        String token = request.getHeader("req-token");
        if (StringUtils.isEmpty(token)){
            throw new RuntimeException("非法参数");
        }
        String cacheObject = (String)redisService.getCacheObject(token);
        System.out.println("请求token:    "+cacheObject);
        boolean delResult = redisService.deleteObject(token);
        if (!delResult){
            //删除失败
            throw new BizException(ServiceErrorEnum.EXECUTING_PLEASE_WAITE);
        }
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
    }
}

3、注册拦截器

 registry.addInterceptor(reqTokenInterceptor()).addPathPatterns("/**")
                .excludePathPatterns("/swagger**/**")
                .excludePathPatterns("/webjars/**")
                .excludePathPatterns("/v3/**")
                .excludePathPatterns("/v2/**")    // swagger api json
                .excludePathPatterns("/doc.html/**")
                .excludePathPatterns("/doc.html#/**")
                .excludePathPatterns("/error")
        ;
    }

  @Bean
    public ReqTokenInterceptor reqTokenInterceptor() {
        return new ReqTokenInterceptor();
    }

4、创建一个专门用来获取增删改请求token的控制器,并且使用redis存储每次获取的token值,在拦截器中判断如果有,删除并且放行;如果没有,直接返回失败,这样就保证了并发请求只能执行一次

@Slf4j
@RestController
@Api(value = "请求token幂等接口", tags = "请求token幂等接口")
@RequestMapping("api/request-token")
@RequiredArgsConstructor
public class RequestTokenController {
  
  @Resource
  private Snowflake snowflake;
  
  @Resource
  private RedisService redisService;

  @ApiOperation("获取请求token")
  @PostMapping("/get")
  public String getToken() {
      String token = snowflake.nextIdStr();
      redisService.setCacheObject(token,String.valueOf(0),30, TimeUnit.MINUTES);
      //生成雪花id
      return token;
  }
  
}

5、同时,为了防止请求token的重复,使用hutool的雪花算法生成token、

结论:经过Jmeter并发测试,完美解决问题

4.后记

对于分布式系统中,解决多用户高并发的问题其实是包含单用户高并发的解决内容的,先获取分布式锁将操作串行化,同时要保证幂等。

参考

Chat-GPT

https://blog.csdn.net/A_art_xiang/article/details/132106309

https://zhuanlan.zhihu.com/p/89195796

https://blog.csdn.net/weixin_41141219/article/details/80659600

https://doc.hutool.cn/pages/IdUtil/#objectid
贴一个有启发的B站动态:https://www.bilibili.com/opus/855310614962110488?spm_id_from=333.999.0.0

posted @   YURainnn  阅读(241)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示