后端接口性能优化分析-多线程优化

  • 👏作者简介:大家好,我是爱吃芝士的土豆倪,24届校招生Java选手,很高兴认识大家
  • 📕系列专栏:Spring源码、JUC源码
  • 🔥如果感觉博主的文章还不错的话,请👍三连支持👍一下博主哦
  • 🍂博主正在努力完成2023计划中:源码溯源,一探究竟
  • 📝联系方式:nhs19990716,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬👀

常见思路

1.批量思想:

这个其实是一个最容易想到的代码层次的修改,其实对业务上来说,结果都是一样的,只不过这个涉及到了一件事就是,像数据库中发请求,是发十次还是发一次的问题。其原因最根本的还是,每次数据库请求都会引入额外的网络开销和数据库连接管理开销,如果多次,性能会有一定的折扣,但是实际上批量查询并不像传的那么神,也会存在潜在的风险,比如说,可能会引入内存占用过高的问题,特别是数据量非常大的时候,需要谨慎处理以避免内存溢出的问题。当然,这种情况,可以把批量划分开多份,这样能做到尽可能避免内存占用过高问题,不过代价是适当增加一些连接请求。

优化前:

//for循环单笔入库
for(TransDetail detail:transDetailList){
  insert(detail);  
}

优化后:

batchInsert(transDetailList);

大数据量:

List<List<TransDetail>> TransDetaillists = SplitList(transDetailList);// 将大批量分割成小批量
for(List<TransDetail> transDetailList : TransDetaillists){
	batchInsert(transDetailList);
}

这种方式同样适合调用类型,比如说如果是在某个接口中需要获取2000个用户的信息,它考虑的就需要更多一些。

除了需要考虑远程调用接口的耗时之外,还需要考虑该接口本身的总耗时,也不能超时500ms。

可以使用多个线程异步调用:

List<List<Long>> allIds = Lists.partition(ids,200);

final List<User> result = Lists.newArrayList();
allIds.stream().forEach((batchIds) -> {
   CompletableFuture.supplyAsync(() -> {
        result.addAll(remoteCallUser(batchIds));
        return Boolean.TRUE;
    }, executor);
})

2.异步思想:

耗时操作,考虑用异步处理,这样可以降低接口耗时。

其实这个优化点思路是这样的,之前在开发中为了方便,会将一些逻辑都放在接口中同步执行,这样势必会对接口性能造成一定影响,但是实际上通过梳理业务逻辑,会发现只有业务逻辑才是核心逻辑,其他功能都是非核心逻辑,那么就有一个原则:核心逻辑可以同步执行,同步写库。非核心逻辑,可以异步执行,异步写库。

在这里插入图片描述

上面这个例子中,发站内通知和用户操作日志功能,对实时性要求不高,即使晚点写库,用户无非是晚点收到站内通知,或者运营晚点看到用户操作日志,对业务影响不大,所以完全可以异步处理。

当然不仅仅只有这些,比如注册的时候,成功后,短信邮件通知消息,就是异步处理。

再以一个带超时时间的调用链举例:(来自阿里云分享)

场景:从链路上看A系统调用B系统,B系统调用C系统完成计算再把结论返回给A,A系统超时时间400ms,通常A系统调用B系统300ms,B系统调用C系统200ms。

在这里插入图片描述

现在C系统需要将调用结论返回给D系统,耗时150ms

在这里插入图片描述

此时A系统- B系统- C系统已有的调用链路可能会超时失败,因为引入D系统之后,耗时增加了150ms,整个过程是同步调用的,因此需要C系统将调用D系统更新结论的非强依赖改成异步调用。

而异步的实现方式,其中最典型的就是多线程(线程池)、消息队列。

线程池

在这里插入图片描述

发站内通知和用户操作日志功能,被提交到了两个单独的线程池中。

这样接口中重点关注的是业务操作,把其他的逻辑交给线程异步执行,这样改造之后,让接口性能瞬间提升了。

但使用线程池有个小问题就是:如果服务器重启了,或者是需要被执行的功能出现异常了,无法重试,会丢数据,这个应该怎么处理呢?

解决方案

持久化数据: 在执行功能之前,将需要执行的数据进行持久化存储,比如写入到数据库中。当服务器重启后,可以从持久化存储中重新加载需要执行的数据,并进行处理。

异常处理和重试机制: 在功能执行过程中,捕获可能出现的异常,并实现相应的重试机制。例如,可以在捕获到异常时将任务重新放回线程池队列中等待重新执行,直到执行成功为止。

监控和报警系统: 建立监控系统,及时发现功能执行异常或服务器重启等情况,并通过报警系统通知相关人员进行处理。

日志记录: 在功能执行过程中记录详细的日志信息,包括执行结果、异常信息等。当出现数据丢失时,可以通过日志进行故障排查和数据恢复。

幂等性设计: 对于可能重复执行的操作,设计具有幂等性的功能。这样即使因重试或重复执行而导致数据重复,也不会产生业务上的影响。

幂等性设计方案之一:

任务状态设计:为每个任务设计一个状态机,包括待执行、执行中、已完成、失败等状态。在执行任务之前,首先检查任务的状态。如果任务状态为待执行,则执行任务;如果任务状态为执行中或已完成,则直接返回执行结果;如果任务状态为失败,则检查失败原因,根据原因进行重试或记录错误信息。

消息队列

在这里插入图片描述

对于发站内通知和用户操作日志功能,在接口中并没真正实现,它只发送了mq消息到mq服务器。然后由mq消费者消费消息时,才真正的执行这两个功能。

这样改造之后,接口性能同样提升了,因为发送mq消息速度是很快的,我们只需关注业务操作的代码即可。

但是将异步消息发送给消息队列的话,也可能出现问题。

如何保证消息不丢(可用性一般是业务中最重要的)?如何处理重复消息(如何保证业务中的幂等性)?如何保证消息的有序性(业务场景要求顺序性怎么办)?如何应对消息堆积(内存是有限的,磁盘容量也是有限的,当消息堆积到一定量,消息消费速度,检索速度都会大打折扣)?

解决方案
如何保证消息不丢

一共三个阶段,生产消息,存储消息和消费消息,我们来看看这三个阶段怎么保证消息不丢失。

在这里插入图片描述

  • 生产消息

生产者发消息到Broker,需要等待Broker的响应(不能用单向),不论是同步还是异步都要做好异常处理,如果Broker返回失败,需要重试发送。当多次发送失败,需要预警或日志记录,然后人工处理或者异步补偿调度处理发送失败的消息。这样可以保证生产消息阶段不丢消息。

  • 存储消息

消息刷盘有两种策略:同步刷盘和异步刷盘。这里需要选择同步刷盘,也就是消息需要刷到文件里再返回给生产者响应。而且Broker需要集群部署,即消息不仅要写到master上,还要同步到slave上,这样当master宕机时,slave还可以补上。

**解释:**在消息队列中,消息刷盘是指将消息从内存中的缓冲区刷写到磁盘上的过程,以保证消息的持久化和可靠性。有两种策略可供选择:同步刷盘和异步刷盘。同步刷盘是指在消息被写入内存的缓冲区后,立即将消息刷写到磁盘上,然后再返回给生产者响应。这种方式可以确保消息被完整地写入磁盘,但可能会导致性能下降,因为需要等待磁盘操作完成。异步刷盘则是指在消息被写入内存的缓冲区后,立即返回给生产者响应,而将消息刷写到磁盘上的过程异步进行。这种方式可以提高性能,但可能会导致消息丢失,因为如果刷盘操作失败,消息可能没有被写入磁盘。在需要保证消息可靠性和持久性的场景中,通常选择同步刷盘策略。此外,如果消息队列需要集群部署,即消息不仅要写到主节点(master),还要同步到从节点(slave)上,以便在主节点宕机时,从节点可以补上。这种方式可以提高系统的可用性和容错性。

  • 消费消息

如果消息获取到然后消费者宕机了怎么办?你需要保证消费者真正执行完业务逻辑后再返回给Broker消费成功的标识,这样的话消费者宕机了大不了其他消费者重新消费。

如果Broker宕机了怎么办?消费者这边维护了消费队列的索引,这样当Broker恢复之后也可以重新消费。

消息重复了怎么处理

先来分析下消息重复的场景:

  • 发消息发重复:生产者往Broker发消息得等到Broker的响应,如果因为网络原因生产者迟迟收不到Broker的响应,生产者就会重发一次。
  • 消费消息重复:消费者消费消息,业务逻辑走完事务也提交了,此时需要更新Consumer offset,此时这个消费者挂了,另一个消费者顶上,由于Consumer offset还没更新,于是又拿到了刚才的消息,业务又被执行了一遍

解释: 这句话描述了消息队列中的一种消费消息的重复情况。在这种情况下,消费者在消费消息后,完成了业务逻辑处理并提交了事务。然后,消费者需要更新自身的偏移量(Consumer offset),以便知道已经处理过的消息位置。但是,如果在更新偏移量之前,消费者意外地崩溃或停止工作,另一个消费者将顶替它继续处理消息。由于原消费者的偏移量尚未更新,新消费者将从上次消费的位置开始处理消息,这可能导致已经处理过的消息被再次消费,从而引发业务重复执行。为了避免这种情况,可以考虑使用幂等处理或去重逻辑来确保业务不会因为重复消费消息而出现问题。同时,可以考虑使用高可用和故障切换机制来确保消费者在崩溃或停止工作后能够正确地更新偏移量,从而避免重复消费消息。

处理消息重复关键是幂等(同样的参数调用同一个接口n次产生的结果都是一致的)

  • 可以用数据库版本号控制,对比消息中的版本号和数据库中的版本号,相等才做更新,数据库乐观锁机制
  • 通过数据库的约束例如唯一键,例如 insert into update on duplicate key…
  • 或者记录某个业务id,有消息过来,先通过id查一下缓存或者数据库,如果id已经存在则表示已经处理过了
如何保证消息的有序性

看你是需要全局有序还是局部有序了

全局有序

这种情况,只能由一个生产者往Topic发送消息,并且Topic里只有一个队列/分区,消费者也必须单线程消费这个队列。这样的消息就是全局有序的。

部分有序

绝大部分需求都只是要求部分有序,这种情况下我们可以将Broker内部划分成我们需要的队列数,某一个Topic/Tag的消息发送到固定的队列中,然后这些队列对应一个单线程处理的消费者。

在这里插入图片描述

如何处理消息堆积

不考虑代码bug,消息堆积最常见的原因是:消费者的消费速度跟不上生产者的生产消息的速度。

还可能是因为消息消费失败反复重试造成的。因此我们要先定位消费慢的原因,如果是bug则处理bug,如果消费代码性能不佳就考虑优化逻辑。假如逻辑我们优化了消费的还是很慢,那就要考虑水平扩容了,增加Topic的队列数和消费者数量,注意队列数和消费者数一定要同时增加,不然新增加的消费者是没东西消费的,因为一个Topic中,一个队列只会分配给一个消费者

3.多线程思想:

串行改并行

假设我们设计一个APP首页的接口,它需要查用户信息、需要查banner信息、需要查弹窗信息等等。如果是串行一个一个查,比如查用户信息200ms,查banner信息100ms、查弹窗信息50ms,那一共就耗时350ms了,如果还查其他信息,那耗时就更大了。

在这里插入图片描述

其实我们可以改为并行调用,即查用户信息、查banner信息、查弹窗信息,可以同时并行发起

在这里插入图片描述

最后接口耗时将大大降低

public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
    final UserInfo userInfo = new UserInfo();
    CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteUserAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);

    CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteBonusAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);

    CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
        getRemoteGrowthAndFill(id, userInfo);
        return Boolean.TRUE;
    }, executor);
    CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();

    userFuture.get();
    bonusFuture.get();
    growthFuture.get();

    return userInfo;
}

以阿里云开发社区举例:

应急定位场景下,A系统调用B系统获取诊断结论,TR超时时间是500ms,对于一个异常ID事件,需要执行多个诊断项服务,并记录诊断流水;每个诊断的耗时大概在100ms以内,随着业务的增长,超过5个诊断项,计算耗时累加到500ms+,这时候服务会出现高峰期短暂不可用。

在这里插入图片描述

将这段代码改成异步执行,这样执行诊断的时间是耗时最大的诊断服务

// 提交future任务并发执行
futures = executor.invokeAll(tasks, timeout, timeUnit);
// 遍历读取结果
for (Future<Res> future : futures) {
    try {
        // 获取结果
        Res singleResult = future.get();
        if (singleResult != null) {
            result.add(singleResult);
        }
    } catch (Exception e) {
        LogUtil.error(e, logger, "并发执行发生异常!,poolName={0}.", threadPoolName);
    }
}

通过上面的两个场景举例,可以看出,实际上针对一些耗时较长的任务运行,适当地利用,可以达到加速的效果。但是凡事都是双刃剑,有利有弊。

线上对响应时间要求较高的场合尽量少用多线程,尤其是服务线程需要等待任务线程的场合(很多重大事故就是和这个息息相关),如果一定要用,可以对服务线程设置一个最大等待时间。

这句话的核心是在线上高响应时间的场景下,需要谨慎使用多线程,特别是当服务线程需要等待任务线程时。因为在多线程环境中,线程间的调度和同步可能会引入额外的等待时间,这可能导致响应时间增加,影响系统的性能

举个例子来说,假设我们有一个在线服务,它需要处理大量的用户请求每个用户请求都会被分配到一个服务线程去处理。为了提高处理速度,每个服务线程可能会启动多个任务线程去并行执行一些计算密集型的任务。这种情况下,服务线程就需要等待所有的任务线程完成才能继续执行。

然而,由于操作系统的线程调度策略,任务线程可能并不会立即执行。此外,如果任务线程的数量超过了CPU的核心数,那么这些线程就需要在CPU核心之间进行切换,这也会引入额外的等待时间。这些都可能导致服务线程需要花费更多的时间等待任务线程,从而导致响应时间增加。

因此,这句话的建议是,在这种场景下,最好尽量少用多线程,或者至少要对服务线程设置一个最大等待时间,以防止服务线程无限期地等待任务线程。这样可以避免因为线程同步和调度问题导致的性能下降,保证在线服务的响应时间。

当然,这并不是说多线程就一定会导致性能下降。如果使用得当,多线程还是可以大大提高系统的性能的。但是在高响应时间的场景下,我们需要更加谨慎地使用多线程,以防止潜在的性能问题。

常见做法

如果单机的处理能力可以满足实际业务的需求,那么尽可能地使用单机多线程的处理方式,减少复杂性;反之,则需要使用多机多线程的方式。

单机多线程

对于单机多线程,可以引入线程池的机制,作用有二:

1) 提高性能,节省线程创建和销毁的开销。

2) 限流,给线程池一个固定的容量,达到这个容量值后再有任务进来,就进入队列进行排队,保障机器极限压力下的稳定处理能力在使用JDK自带的线程池时,一定要仔细理解构造方法的各个参数的含义,如core pool size、max pool size、keepAliveTime、worker queue等,在理解的基础上通过不断地测试调整这些参数值达到最优效果。

多机多线程

如果单机的处理能力不能满足需求,这个时候需要使用多机多线程的方式。这个时候就需要一些分布式系统的知识了,可以选用一些开源成熟的分布式任务调度系统如xxl-job

4.空间换时间思想:恰当使用缓存

在适当的业务场景,恰当地使用缓存,是可以大大提高接口性能的。缓存其实就是一种空间换时间的思想,就是你把要查的数据,提前放好到缓存里面,需要时,直接查缓存,而避免去查数据库或者计算的过程

这里的缓存包括:Redis缓存,JVM本地缓存,memcached,或者Map等等。

一级缓存

以一段优化举例:

转账接口的优化,老代码,每次转账,都会根据客户账号,查询数据库,计算匹配联行号。

在这里插入图片描述

因为每次都查数据库,都计算匹配,比较耗时,所以使用缓存,优化后流程如下:

在这里插入图片描述

但不能为了缓存而缓存,还是要看具体的业务场景。毕竟加了缓存,会导致接口的复杂度增加,它会带来数据不一致问题。

在有些并发量比较低的场景中,比如用户下单,可以不用加缓存。

还有些场景,比如在商城首页显示商品分类的地方,假设这里的分类是调用接口获取到的数据,但页面暂时没有做静态化。

二级缓存

上面的方案其实是基于Redis实现的,虽说redis访问速度很快。但毕竟是一个远程调用,而且菜单树的数据很多,在网络传输的过程中,是有些耗时的。

有没有办法,不经过请求远程,就能直接获取到数据呢?

那就是使用 二级缓存,即基于内存的缓存。

在这里插入图片描述

该方案的性能更好,但有个缺点就是,如果数据更新了,不能及时刷新缓存。此外,如果有多台服务器节点,可能存在各个节点上数据不一样的情况。

由此可见,二级缓存给我们带来性能提升的同时,也带来了数据不一致的问题。使用二级缓存一定要结合实际的业务场景,并非所有的业务场景都适用。

设计关键

什么时候更新缓存?如何保障更新的可靠性和实时性?

更新缓存的策略,需要具体问题具体分析。基本的更新策略有两个:

1) 接收变更的消息,准实时更新。

2) 给每一个缓存数据设置5分钟的过期时间,过期后从DB加载再回设到DB。这个策略是对第一个策略的有力补充,解决了手动变更DB不发消息、接收消息更新程序临时出错等问题导致的第一个策略失效的问题。通过这种双保险机制,有效地保证了缓存数据的可靠性和实时性。

缓存是否会满,缓存满了怎么办?

对于一个缓存服务,理论上来说,随着缓存数据的日益增多,在容量有限的情况下,缓存肯定有一天会满的。如何应对?

1) 给缓存服务,选择合适的缓存逐出算法,比如最常见的LRU。

2) 针对当前设置的容量,设置适当的警戒值,比如10G的缓存,当缓存数据达到8G的时候,就开始发出报警,提前排查问题或者扩容。

3) 给一些没有必要长期保存的key,尽量设置过期时间。

缓存是否允许丢失?丢失了怎么办?

根据业务场景判断,是否允许丢失。如果不允许,就需要带持久化功能的缓存服务来支持,比如Redis。更细节的话,可以根据业务对丢失时间的容忍度,还可以选择更具体的持久化策略,比如Redis的RDB或者AOF。

缓存问题
缓存穿透

描述:缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决方案:

1) 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截。

2) 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用),这样可以防止攻击用户反复用同一个id暴力攻击。

缓存击穿

描述:缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

解决方案:

1) 设置热点数据永远不过期。

2) 加互斥锁,业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。类似下面的代码:

public String get(key) {
    String value = redis.get(key);
    if (value == null) { //代表缓存值过期
        //设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
        if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {  //代表设置成功
            value = db.get(key);
            redis.set(key, value, expire_secs);
            redis.del(key_mutex);
        } else {  //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
            sleep(50);
            get(key);  //重试
        }
    } else {
        return value;
    }
}
缓存雪崩

描述:缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿是并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决方案

1)缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。

2)如果缓存系统是分布式部署,将热点数据均匀分布在不同的缓存节点中。

3)设置热点数据永远不过期。

缓存更新

失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

命中:应用程序从cache中取数据,取到后返回。

更新:先把数据存到数据库中,成功后,再让缓存失效。

总结

缓存的刷新策略选择:失效刷新还是定时刷新。 因为监控到很多接口RT总是有规律的变慢,这是因为都是在缓存失效的时候,需要从db及其他模块组装数据,然后推到缓存,这时所有请求都走不了缓存,在流量大的时候也有可能成为致命的因素。如果是这种情况,例如首页推荐商品、推荐帖子等等访问量大且相同的场景可以通过定时刷新的方式。 Keys*命令线上严禁使用:Redis是单线程,该命令的执行将会导致所有后续请求阻塞,影响整个系统性能。

Redis相比传统数据库更快且具有更强的抗并发能力。然而,与本地缓存相比,Redis缓存仍然较慢。前面提到的Redis访问速度大约在3-5毫秒之间,而使用本地缓存几乎可以忽略不计。

如果频繁访问Redis获取大量数据,将会导致大量的序列化和反序列化操作,这会显著增加young gc频率,也会增加CPU负载。

  1. 序列化和反序列化操作:在 Java 应用程序中,当需要从 Redis 获取大量数据时,数据首先需要从 Redis 服务器传输到客户端,然后需要将 Redis 中的字节流转换为 Java 对象。这个过程被称为反序列化。当应用程序处理完数据后,Java 对象需要转换为字节流并发送回 Redis 服务器,这个过程被称为序列化。序列化和反序列化操作会占用大量的 CPU 资源,因为它们需要执行大量的计算和内存操作。

  2. young gc 频率增加:当应用程序执行大量的序列化和反序列化操作时,会产生大量的临时对象。这些临时对象的生命周期非常短,很快就会被 Java 虚拟机 (JVM) 的 young gc(年轻代垃圾回收)清除。因此,频繁的序列化和反序列化操作会导致 young gc 频率增加,从而影响系统的性能。

  3. CPU 负载增加:序列化和反序列化操作会占用大量的 CPU 资源,因为它们需要执行大量的计算和内存操作。当应用程序需要从 Redis 获取大量数据时,CPU 需要执行大量的序列化和反序列化操作,这会导致 CPU 负载增加。如果 CPU 负载过高,可能会导致系统性能下降,甚至导致系统崩溃。

以下是针对 “当应用程序执行大量的序列化和反序列化操作时,会产生大量的临时对象 ”的解释

在 Java 应用程序中,当执行大量的序列化和反序列化操作时,会产生大量的临时对象。这是因为在序列化和反序列化过程中,Java 对象会被转换为字节流(序列化),然后字节流会被转换为 Java 对象(反序列化)。在这个过程中,每个 Java 对象在转换为字节流和反序列化为 Java 对象时,都会产生一个临时的字节流对象和 Java 对象。例如,假设有一个 Java 对象 person,当对其进行序列化操作时,首先会将 person 对象转换为一个字节流(例如,保存在一个 byte[] 数组中)。在这个过程中,会产生一个临时的 person 对象和一个临时 byte[] 对象。然后,当需要将这个字节流反序列化为一个 Java 对象时,会将这个 byte[] 对象转换为一个新的 person 对象。在这个过程中,又会产生一个临时 byte[] 对象和一个临时 person 对象。因此,执行大量的序列化和反序列化操作时,会产生大量的临时对象。这些临时对象的生命周期非常短,很快就会被 Java 虚拟机 (JVM) 的垃圾回收机制清除。尽管这些临时对象的生命周期短暂,但它们仍然会占用一定的内存资源,并且在被垃圾回收之前,它们会经历 young gc(年轻代垃圾回收)过程。因此,大量的临时对象会导致 young gc 频率增加,从而影响系统的性能。

5.预取思想:提前初始化到缓存

对于访问量较低的接口来说,通常首次接口的响应时间较长。原因是JVM需要加载类、Spring Aop首次动态代理,以及新建连接等。这使得首次接口请求时间明显比后续请求耗时长。

然而在流量较低的接口中,这种影响会更大。用户可能尝试多次请求,但依然经常出现超时,严重影响了用户体验。每次服务发布完成后,接口超时失败率都会大量上升!

预取思想很容易理解,就是提前把要计算查询的数据,初始化到缓存。如果你在未来某个时间需要用到某个经过复杂计算的数据,才实时去计算的话,可能耗时比较大。这时候,我们可以采取预取思想,提前把将来可能需要的数据计算好,放到缓存中,等需要的时候,去缓存取就行。

场景举例:

  • 例如地区数据或者一些数据字典数据,可以在项目启动时预加载到缓存中,在使用时从缓存获取,提升性能;
  • 部分报表类数据,关联业务表很多,实时计算比较耗时,可以通过定时任务,在晚上业务不繁忙时,将数据生成好存放到ElasticSearch中,从Es中查询,提供性能。

项目启动执行方法:

  • 可以通过实现ApplicationRunner接口中的run方法,实现启动时执行。方法执行时,项目已经初始化完毕,是可以正常提供服务
public class DataInitUtil implements ApplicationRunner{
  @Override
    public void run(ApplicationArguments args) throws Exception {
        System.out.println("在项目启动时,会执行这个方法中的代码");
    }
}

在这里还有一个数据过期策略,其实也和该思想息息相关

参考阿里云社区文章,当一张表的数据量太大的情况下,如果不按照索引和日期进行部分扫描而出现全表扫描的情况,对DB的查询性能是非常有影响的,建议合理的设计数据过期策略,历史数据定期放入history表,或者备份到离线表中,减少线上大量数据的存储。

在这里插入图片描述

6.池化思想:预分配与循环使用

大家应该都记得,我们为什么需要使用线程池

线程池可以帮我们管理线程,避免增加创建线程和销毁线程的资源损耗。

如果你每次需要用到线程,都去创建,就会有增加一定的耗时,而线程池可以重复利用线程,避免不必要的耗时。 池化技术不仅仅指线程池,很多场景都有池化思想的体现,它的本质就是预分配与循环使用

比如TCP三次握手,大家都很熟悉吧,它为了减少性能损耗,引入了Keep-Alive长连接,避免频繁的创建和销毁连接。当然,类似的例子还有很多,如数据库连接池、HttpClient连接池。

  • TCP 三次握手是建立 TCP 连接的过程,其中涉及到客户端和服务器之间的三次交互。这个过程虽然保证了连接的可靠性,但同时也带来了性能损耗,因为每次连接都需要进行序列化、反序列化、验证等操作。为了解决这个问题,TCP 协议引入了 Keep-Alive 机制,允许在一段时间内保持连接处于活动状态,而无需进行三次握手。在 Keep-Alive 连接期间,如果需要发送数据,可以直接发送,而不需要重新进行三次握手。

池化思想本质

  • 如果你每次需要用到线程,都去创建,就会有增加一定的耗时;

  • 线程池可以重复利用线程,避免不必要的耗时;

  • 池化技术不仅仅指线程池,很多场景都有池化思想的体现,它的本质就是预分配与循环使用。

7.事件回调思想:拒绝阻塞等待

如果你调用一个系统B的接口,但是它处理业务逻辑,耗时需要10s甚至更多。然后你是一直阻塞等待,直到系统B的下游接口返回,再继续你的下一步操作吗?这样显然不合理

我们参考IO多路复用模型。即我们不用阻塞等待系统B的接口,而是先去做别的操作。等系统B的接口处理完,通过事件回调通知,我们接口收到通知再进行对应的业务操作即可。

IO多路复用模型

IO多路复用模型是一种并发模型,它允许我们在一个线程中同时处理多个I/O操作,例如读取和写入。在这种模型中,我们可以使用事件驱动的方式,即在调用系统B的接口时,我们不等待其返回,而是通过事件回调机制来通知具体来说,当我们的应用程序调用系统B的接口时,如果该接口需要处理的业务逻辑比较耗时,我们的应用程序可以继续执行其他任务,而不是等待系统B的接口返回。当系统B的接口处理完后,它会通过事件回调机制通知我们的应用程序我们的应用程序在收到通知后,再进行对应的业务操作即可。这种方式的优点是,它提高了应用程序的并发性能,因为它允许我们的应用程序在等待系统B的接口返回时,继续执行其他任务。这样,我们不仅可以减少等待时间,还可以提高CPU资源的利用率。此外,通过事件回调机制,我们的应用程序可以更加灵活地处理各种事件,例如系统B的接口返回成功、失败或出现异常等。总之,采用IO多路复用模型和事件驱动的方式,我们可以更加高效地处理耗时的系统B接口调用,提高应用程序的并发性能和用户体验。

在IO多路复用中,其中最典型的就是Redis,Redis明明是单线程的,但是为什么这么快?

回到这个问题本身,其实就是两个因素,完全基于内存、IO多路复用。

下面从多进程、多线程、基于单进程的 IO 多路复用 三个角度来分析:

多进程

对于并发情况,假如一个进程不行,那搞多个进程不就可以同时处理多个客户端连接了么?

多进程这种方式的确可以解决了服务器在同一时间能处理多个客户端连接请求的问题,但是仍存在一些缺点:

  • fork()等系统调用会使得进程上下文进行切换,效率较低

  • 进程创建的数量随着连接请求的增加而增加。比如 10w 个请求,就要 fork 10w 个进程,开销太大

  • 进程与进程之间的地址空间是私有、独立的,使得进程之间的数据共享变得困难

进程间的消息通信主要有以下几种方式:

  1. 管道(Pipe):这是最基本的进程间通信方式,数据只能单向流动,并且只能在具有亲缘关系的进程之间使用。管道分为匿名管道和命名管道,匿名管道主要用于父子进程间的通信,命名管道则允许无亲缘关系进程间的通信。
  2. 信号(Signal):这是一种比较复杂的通信方式,信号是一种异步通信方式,可以将信号看作是一种软件中断。进程间可以发送各种信号,而系统定义了每种信号的默认行为,如终止进程、忽略信号等。
  3. 消息队列(Message Queue):消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冡区大小受限等缺点。
  4. 共享内存(Shared Memory):共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是在已有的物理内存上开辟出一片可以被其他或多个进程共同使用的内存区域,多个进程可以直接对这片内存区域进行读写操作。
  5. 信号量(Semaphore):主要作为控制多个进程之间的同步关系。信号量是一个非负整数的计数器,是系统范围的资源,可以由多个进程共享。
  6. 套接字(Socket):可用于不同机器之间的进程通信,是最灵活的进程间通信方法。
多线程

线程是运行在进程上下文的逻辑流,一个进程可以包含多个线程,多个线程运行在同一进程上下文中,因此可共享这个进程地址空间的所有内容,解决了进程与进程之间通信难的问题。

同时,由于一个线程的上下文要比一个进程的上下文小得多,所以线程的上下文切换,要比进程的上下文切换效率高得多。

IO 多路复用

简单理解就是:一个服务端进程可以同时处理多个套接字描述符。

  • 多路:多个客户端连接(连接就是套接字描述符)
  • 复用:使用单进程就能够实现同时处理多个客户端的连接

以上是通过增加进程和线程的数量来并发处理多个套接字,免不了上下文切换的开销,而 IO 多路复用只需要一个进程就能够处理多个套接字,从而解决了上下文切换的问题。

其发展可以分 select->poll→epoll 三个阶段来描述。

如何简单理解 select/poll/epoll 呢?

举例说明

领导分配员工开发任务,有些员工还没完成。如果领导要每个员工的工作都要验收 check,那在未完成的员工那里,只能阻塞等待,等待他完成之后,再去 check 下一位员工的任务,造成性能问题。

select

领导找个 Team Leader(后文简称 TL),负责代自己 check 每位员工的开发任务。

TL 的做法是:遍历问各个员工“完成了么?”,完成的待 CR check 无误后合并到 Git 分支,对于其他未完成的,休息一会儿后再去遍历…

但是这样存在一个问题就是,这个TL存在能力短板问题,最多只能管理1024个员工,并且很多员工的任务没有完成,而且短时间内也完不成的话,TL 还是会不停的去遍历问询,影响效率。

select函数:

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);

select 函数监视的文件描述符分 3 类,分别是 writefds、readfds、和 exceptfds。调用后 select 函数会阻塞,直到有描述符就绪(有数据可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。

select 具有良好的跨平台支持,其缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024。

poll

换一个能力更强的 New Team Leader(后文简称 NTL),可以管理更多的员工,这个 NTL 可以理解为 poll。

poll函数:

intpoll(structpollfd*fds, nfds_t nfds,int timeout);
typedef struct pollfd{ 
    int fd; // 需要被检测或选择的文件描述符 
    short events; // 对文件描述符fd上感兴趣的事件 
    short revents; // 文件描述符fd上当前实际发生的事件
} 
pollfd_t;

poll 改变了文件描述符集合的描述方式,使用了 pollfd 结构而不是 select 的 fd_set 结构,使得 poll 支持的文件描述符集合限制远大于 select 的 1024。

epoll

在上一步 poll 方式的 NTL 基础上,改进一下 NTL 的办事方法:遍历一次所有员工,如果任务没有完成,告诉员工待完成之后,其应该做 xx 操作(制定一些列的流程规范)。这样 NTL 只需要定期 check 指定的关键节点就好了。这就是 epoll。

epoll 是 Linux 内核为处理大批量文件描述符而作了改进的 poll,是 Linux 下多路复用 IO 接口 select/poll 的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率。

IO 多路复用在 Redis 中的应用

Redis 服务器是一个事件驱动程序, 服务器处理的事件分为时间事件(fork出的子进程中,处理如AOF持久化任务等)和文件事件(Redis主进程中,主要处理客户端的连接请求与响应)两类。

由于 Redis 的文件事件是单进程,单线程模型,但是确保持着优秀的吞吐量,IO 多路复用起到了主要作用。

文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。

IO 多路复用程序负责监听多个套接字并向文件事件分派器传送那些产生了事件的套接字。文件事件分派器接收 IO 多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。

总结

Redis 6.0 之后的版本开始选择性使用多线程模型。

Redis 选择使用单线程模型处理客户端的请求主要还是因为 CPU 不是 Redis 服务器的瓶颈,使用多线程模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;

而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率。

凡事不能有绝对,寻找到适中的平衡点最重要!

优秀相关博客

后端接口性能优化分析-问题发现&问题定义-CSDN博客

后端接口性能优化分析-程序结构优化-CSDN博客

后端接口性能优化分析-数据库优化-CSDN博客

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱吃芝士的土豆倪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值