后端接口性能优化分析-多线程优化(中):https://developer.aliyun.com/article/1413669
缓存雪崩
描述:缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿是并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案
1)缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
2)如果缓存系统是分布式部署,将热点数据均匀分布在不同的缓存节点中。
3)设置热点数据永远不过期。
缓存更新
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
总结
缓存的刷新策略选择:失效刷新还是定时刷新。 因为监控到很多接口RT总是有规律的变慢,这是因为都是在缓存失效的时候,需要从db及其他模块组装数据,然后推到缓存,这时所有请求都走不了缓存,在流量大的时候也有可能成为致命的因素。如果是这种情况,例如首页推荐商品、推荐帖子等等访问量大且相同的场景可以通过定时刷新的方式。 Keys*命令线上严禁使用:Redis是单线程,该命令的执行将会导致所有后续请求阻塞,影响整个系统性能。
Redis相比传统数据库更快且具有更强的抗并发能力。然而,与本地缓存相比,Redis缓存仍然较慢。前面提到的Redis访问速度大约在3-5毫秒之间,而使用本地缓存几乎可以忽略不计。
如果频繁访问Redis获取大量数据,将会导致大量的序列化和反序列化操作,这会显著增加young gc频率,也会增加CPU负载。
- 序列化和反序列化操作:在 Java 应用程序中,当需要从 Redis 获取大量数据时,数据首先需要从 Redis 服务器传输到客户端,然后需要将 Redis 中的字节流转换为 Java 对象。这个过程被称为反序列化。当应用程序处理完数据后,Java 对象需要转换为字节流并发送回 Redis 服务器,这个过程被称为序列化。序列化和反序列化操作会占用大量的 CPU 资源,因为它们需要执行大量的计算和内存操作。
- young gc 频率增加:当应用程序执行大量的序列化和反序列化操作时,会产生大量的临时对象。这些临时对象的生命周期非常短,很快就会被 Java 虚拟机 (JVM) 的 young gc(年轻代垃圾回收)清除。因此,频繁的序列化和反序列化操作会导致 young gc 频率增加,从而影响系统的性能。
- 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 个进程,开销太大
- 进程与进程之间的地址空间是私有、独立的,使得进程之间的数据共享变得困难
进程间的消息通信主要有以下几种方式:
- 管道(Pipe):这是最基本的进程间通信方式,数据只能单向流动,并且只能在具有亲缘关系的进程之间使用。管道分为匿名管道和命名管道,匿名管道主要用于父子进程间的通信,命名管道则允许无亲缘关系进程间的通信。
- 信号(Signal):这是一种比较复杂的通信方式,信号是一种异步通信方式,可以将信号看作是一种软件中断。进程间可以发送各种信号,而系统定义了每种信号的默认行为,如终止进程、忽略信号等。
- 消息队列(Message Queue):消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冡区大小受限等缺点。
- 共享内存(Shared Memory):共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是在已有的物理内存上开辟出一片可以被其他或多个进程共同使用的内存区域,多个进程可以直接对这片内存区域进行读写操作。
- 信号量(Semaphore):主要作为控制多个进程之间的同步关系。信号量是一个非负整数的计数器,是系统范围的资源,可以由多个进程共享。
- 套接字(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 主线程阻塞的时间,提高执行的效率。