1. 并行处理
简要说明
举个例子:在价格查询链路中,我们需要获取多种独立的价格配置项信息,如基础价、折扣价、商户活动价、平台活动价等等。为了加快处理速度,可以使用多线程并行处理的方式,利用并发计算的优势。而 CompletableFuture
是一种流行的实现多线程的方式,它可以轻松地管理线程的创建、执行和回调,提高程序的可扩展性和并发性。
然而,多线程的使用也存在一些弊端,例如硬件资源的限制和线程间的通信开销等。因此,我们需要在使用多线程的同时,考虑到 I/O 密集型和 CPU 密集型的差异,以避免过度开启线程导致性能下降。同时,对于线程池的运行情况,我们也需要有一定的了解和控制,以确保程序的高效稳定运行。
CompletableFuture 是银弹吗?
我们常说“手拿锤子看什么都像钉子”,使用 CompletableFuture
的确能够帮助我们解决许多独立处理逻辑的问题,但是如果使用过多的线程,反而会导致线程调度时间不能得到保障,线程会被浪费在等待 CPU 时间片上,特别是对于那些本来执行速度就很快的任务,使用 CompletableFuture 之后反而会拖慢整体执行时长。
因此,在使用 CompletableFuture
时,我们需要根据具体的场景和任务,仔细考虑是否需要并行处理。如果需要并行处理,我们需要根据任务的性质和执行速度,选择合适的线程池大小和并行线程数量,以避免线程调度时间的浪费和执行效率的下降。
测试案例
执行 a,b,c,d4 个方法,比较同步执行与异步执行的耗时情况。
全同步执行
全异步执行
结果统计
测试结论
在分配了相对合理的线程池的情况下,通过以上分析,可以得出下列两个结论:
- • 方法耗时越少,同步比异步越好。
- • 方法数量越少,同步比异步越好。
半异步,半同步
有时候,如果方法较多,为了减少高并发时 P99 较高,我们可以让耗时多的方法异步执行,耗时少的方法同步执行。
通过以下数据可以看出,耗时是差不多的,但可以节省不少线程资源。
总结
CompletableFuture
提供了一种优雅而强大的方式来处理并发请求和任务。然而,正如在处理高并发时使用过多的线程会导致资源浪费和效率下降一样,使用过多的 CompletableFuture
也会导致同样的问题。这种现象被称为 "线程调度问题",它会导致性能下降和吞吐量下降(P99 值较高)。
因此,我们需要在使用 CompletableFuture
时考虑实际场景和负载情况,并根据需要使用恰当的技术来优化性能。
2. 最小化事务范围
简要说明
首先,我们需要明确的是,事务的存在势必会对性能产生影响,特别是在高并发的情况下,因为锁的竞争,会带来极大的性能损耗。因此,在处理数据交互的过程中,我们始终坚持尽可能地减少事务的范围,从而提升接口的响应速度。
一般来说,我们可以利用@Transactional
注解轻松实现事务的控制。但是,由于@Transactional
注解的最小粒度仅限于方法级别,因此,为了更好地控制事务的范围,我们需要通过编程式事务来实现。
在编程式事务中,我们可以更灵活地控制事务的开启和结束,以及对数据库操作的处理。通过适当的设置事务参数和操作规则,我们可以实现事务的最小化,从而提升系统的性能和可靠性。
编程式事务模板
3. 缓存
简要说明
缓存,这一在性能提升方面堪称万金油的技术手段,它的重要性在各种计算机应用领域中无可比拟。
缓存作为一种高效的数据读取和写入的优化方式,被广泛应用于各种领域,包括电商、金融、游戏、直播等。
虽然在网络上关于缓存的文章不胜枚举,但要想充分发挥缓存的作用,需要针对具体的业务场景进行深入分析和探讨。因此,在本节中,我们将不过多赘述缓存的具体使用方法,而是重点列举一些使用缓存时的注意事项.
使用缓存时的注意事项
- • 缓存过期时间: 设置合适的过期时间可以保证缓存的有效性,但过期时间过长可能会浪费内存空间,过期时间过短可能会导致频繁刷新缓存,影响性能。
- • 缓存一致性: 如果缓存的数据与数据库中的数据不一致,可能会导致业务逻辑出现问题。因此,在使用缓存时需要考虑缓存一致性的问题。
- • 缓存容量限制: 缓存容量有限,如果缓存的数据量过大,可能会导致内存溢出或者缓存频繁清理。因此,在使用缓存时需要注意缓存容量的限制。
- • 缓存需要考虑负载均衡: 在高并发场景下,需要考虑缓存的负载均衡问题,避免某些缓存服务器因为热点数据等问题负载过重导致系统崩溃或者响应变慢。
- • 缓存需要考虑并发读写: 当多个用户同时访问缓存时,需要考虑并发读写的问题,避免缓存冲突和数据一致性问题。
- • 缓存穿透问题: 当大量的查询请求都无法命中缓存时,导致每次查询都会落到数据库上,从而造成数据库压力过大。
- • 缓存击穿问题: 当缓存数据失效后,导致大量的请求直接打到数据库中,从而造成数据库压力过大。
- • 查询时间复杂度: 需额外注意缓存查询的时间复杂度问题,如果是 O(n),甚至更差的时间复杂度,则会因为缓存的数据量增加而跟着增加。
考虑到这些问题通常优化的手段
- • 数据压缩: 选择合理的数据类型,举个例子:如果用
Integer[]
和int[]
来比较,Integer 占用的空间大约是 int 的 4 倍。其他情况下,使用一些常见数据编码压缩技术也是常见的节省内存的方式,比如:BitMap、字典编码等。 - • 预加载: 当行为可预测时,那么提前加载便可解决构建缓存时的压力。
- • 热点数据: 热点数据如果不能打散,那么通常就会构建多级缓存,比如将应用服务设为一级缓存,Redis 设为二级缓存,一级缓存,缓存全量热点数据,从而实现压力分摊。
- • 缓存穿透、击穿: 针对命中不了缓存的查询也可以缓存一个额外的标识;而针对缓存失效,要么就在失效前,主动刷新一次,要么就分散失效时间,避免大量缓存同时失效。
- • 时间复杂度: 在设计缓存时,优先考虑选择常数级的时间复杂度的方法。
4. 合理使用线程池
简要说明
在本文开始提到的使用 CompletableFuture
并行处理时,实际上就已经使用到线程池了,池化技术的好处,我想应该不用再过多阐述了,但关于线程池的使用还是有很多注意点的。
使用场景
异步任务
简单来说就是某些不需要同步返回业务处理结果的场景,比如:短信、邮件等通知类业务,评论、点赞等互动性业务。
并行计算
就像 MapReduce
一样,充分利用多线程的并行计算能力,将大任务拆分为多个子任务,最后再将所有子任务计算后的结果进行汇总,ForkJoinPool
就是 JDK 中典型的并行计算框架。
同步任务
前面讲到的 CompletableFuture
使用,就是典型的同步改异步的方式,如果任务之间没有依赖,那么就可以利用线程,同时进行处理,这样理论上就只需要等待耗时最长的步骤结束即可(实际情况可参考 CompletableFuture
分析)。
线程池的创建
不要直接使用 Executors
创建线程池,应通过 ThreadPoolExecutor
的方式,主动明确线程池的参数,避免产生意外。
每个参数都要显示设置,例如像下面这样:
参数的配置建议
CorePoolSize(核心线程数)
一般在配置核心线程数的时候,是需要结合线程池将要处理任务的特性来决定的,而任务的性质一般可以划分为:CPU 密集型、I/O 密集型。
比较通用的配置方式如下
- • CPU 密集型: 一般建议线程的核心数与 CPU 核心数保持一致。
- • I/O 密集型: 一般可以设置 2 倍的 CPU 核心数的线程数,因为此类任务 CPU 比较空闲,可以多分配点线程充分利用 CPU 资源来提高效率。
通过Runtime.getRuntime().availableProcessors()
可以获取核心线程数。
另外还有一个公式可以借鉴
- • 线程核心数 = cpu 核心数 / (1-阻塞系数)
- • 阻塞系数 = 阻塞时间/(阻塞时间+使用 CPU 的时间)
实际上大多数线上业务所消耗的时间主要就是 I/O 等待,因此一般线程数都可以设置的多一点,比如 tomcat 中默认的线程数就是 200,所以最佳的核心线程数是需要根据特定场景,然后通过实际上线上允许结果分析后,再不断的进行调整。
MaximumPoolSize
maximumPoolSize
的设置也是看实际应用场景,如果设置的和 corePoolSize
一样,那就完全依靠阻塞队列和拒绝策略来控制任务的处理情况,如果设置的比 corePoolSize
稍微大一点,那就可以更好的应对一些有突发流量产生的场景。
KeepAliveTime
由 maximumPoolSize
创建出来的线程,在经过 keepAliveTime
时间后进行销毁,依据突发流量持续的时间来决定。
WorkQueue
那么阻塞队列应该设置多大呢?我们知道当线程池中所有的线程都在工作时,如果再有任务进来,就会被放到阻塞队列中等待,如果阻塞队列设置的太小,可能很快队列就满了,导致任务被丢弃或者异常(由拒绝策略决定),如果队列设置的太大,又可能会带来内存资源的紧张,甚至 OOM,以及任务延迟时间过长。
所以阻塞队列的大小,又是要结合实际场景来设置的。
一般会根据处理任务的速度与任务产生的速度进行计算得到一个大概的数值。
假设现在有 1 个线程,每秒钟可以处理 10 个任务,正常情况下每秒钟产生的任务数小于 10,那么此时队列长度为 10 就足以。
但是如果高峰时期,每秒产生的任务数会达到 20,会持续 10 秒,且任务又不希望丢弃,那么此时队列的长度就需要设置到 100。
监控 workQueue 中等待任务的数量是非常重要的,只有了解实际的情况,才能做出正确的决定。
在有些场景中,可能并不希望因为任务被丢进阻塞队列而等待太长的时间,而是希望直接开启设置的
MaximumPoolSize
线程池数来执行任务,这种情况下一般可以直接使用SynchronousQueue
队列来实现
ThreadFactory
通过 threadFactory 我们可以自定义线程组的名字,设置合理的名称将有利于你线上进行问题排查。
Handler
最后拒绝策略,这也是要结合实际的业务场景来决定采用什么样的拒绝方式,例如像过程类的数据,可以直接采用 DiscardOldestPolicy
策略。
线程池的监控
线上使用线程池时,一定要做好监控,以便根据实际运行情况进行调整,常见的监控方式可以通过线程池提供的 API,然后暴露给 Metrics 来完成实时数据统计。
监控示例
线程池自身提供的统计数据
通过 micrometer API 完成统计,这样就可以接入Prometheus
了
线程池的资源隔离
在生产环境中,一定要注意好资源隔离的问题,尽量不要将不同类型,不同重要等级的任务放入一个线程池中,以免因为线程资源争抢而互相影响。
5. 服务预热
服务预热也是很常见的一种优化手段,例如数据库连接、线程池中的核心线程,缓存等信息可以利用服务启动阶段预先加载,从而避免请求到来后临时构建的耗时。
下面提供一些预加载的方式
线程池
线程池本身提供了相关的 API:prestartAllCoreThreads()
通过该方法可以提前将核心线程创建好,非常方便。
Web 服务
常见的如 Tomcat,其本身也用到了线程池,只是其自身已经考虑到了预加载的问题,不需要我们额外处理了。
连接池
连接池常用的一般就是数据库连接池以及Redis连接池,大多数这些连接的客户端也都做了连接提前加载的工作,遇到没有预加载的参考其他客户端方式搞一下即可。
缓存
一般本地缓存可以在每次服务启动时预先加载好,以免出现缓存击穿的情况。
静态代码块
在服务启动时,静态代码块中的相关功能会优先被加载,可以有效避免在运行时再加载的情况。
其他扩展
预热实际上可聊的内容很多,一般有用到池化技术的方式,都是需要预热的,为了能够提升响应性能,将不在内存中的数据提前查好放入内存中,或者将需要计算的数据提前计算好,这都是很容易想到的解决方式。
此外还有一些服务端在设计之初就会针对性地对一些热点数据进行特殊处理,比如JVM中的JIT、内存分配比;OS中的page cache
;MySQL中的innodb_buffer_pool
等,这些一般可以通过流量预热的方式来使其达到最佳状态。
6. 缓存对齐
CPU的多级缓存
CPU缓存通常分为大小不等的三级缓存
来自百度百科对三级缓存分类的介绍:
一级缓存都内置在CPU内部并与CPU同速运行,可以有效的提高CPU的运行效率。一级缓存越大,CPU的运行效率越高,但受到CPU内部结构的限制,一级缓存的容量都很小。
二级缓存,它是为了协调一级缓存和内存之间的速度。cpu调用缓存首先是一级缓存,当处理器的速度逐渐提升,会导致一级缓存就供不应求,这样就得提升到二级缓存了。二级缓存它比一级缓存的速度相对来说会慢,但是它比一级缓存的空间容量要大。主要就是做一级缓存和内存之间数据临时交换的地方用。
三级缓存是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率。其运作原理在于使用较快速的储存装置保留一份从慢速储存装置中所读取数据并进行拷贝,当有需要再从较慢的储存体中读写数据时,缓存(cache)能够使得读写的动作先在快速的装置上完成,如此会使系统的响应较为快速。
效果演示
逐行写入
逐列写入
虽然两种方式得到的结果是一样的,但性能对比却相差巨大,这就是缓存行带来的影响。
原因分析
CPU的缓存是由多个缓存行组成的,以缓存行为基本单位,一个缓存行的大小一般为64字节,二维数组在内存中保存时,实际上是以按行遍历的方式进行保存,比如:arr[0][0]
,arr[0][1]
,arr[1][0]
,arr[1][1]
,arr[2][0]
,arr[2][1]
...
所以当按行访问时,是按照内存存储的顺序进行访问,那么CPU缓存后面的元素就可以利用到,而如果是按列访问,那么CPU的缓存是没有用的。
缓存行对齐
同样的含有public volatile long p1, p2, p3, p4, p5, p6, p7;
这一行代码与不含性能也相差巨大,这同样也是因为缓存行的原因,当运行在两个不同CPU上的两个线程要写入。
7. 减少对象的产生
避免使用包装类型
因为包装类型的创建和销毁都会产生临时对象,因此相比基本数据类型来说,会带来额外的消耗。
两个方法不仅执行时间相差百倍,在CPU和内存的消耗上Integer也明显弱于int。
Integer内存和CPU都能看到明显的波动
int几乎没波动
使用不可变对象
最为典型的案例就是String,我想应该不会有人去通过new的方式再去构建一个String字符串了吧!
同时,在实现字符串连接时通常使用StringBuilder或StringBuffer,这样可以避免使用连接符,导致每次都创建新的字符串对象。
静态方法
静态对象
静态工厂(单例模式)
枚举
视图
视图是返回引用的一种方式。
map的keySet方法,实际上每次返回的都是同一个对象的引用。
对象池
对象池可以有效减少频繁的对象创建和销毁的过程,一般情况下如果每次创建对象的过程较为复杂,且对象占用空间又比较大,那么就建议使用对象池的方式来优化。
使用示例
org.apache.commons
提供了对象池的工具类,可以直接拿来使用
池化的对象
池化对象工厂
对象池工具
使用对象池
不适用对象池
8. 并发处理
锁的粒度控制
并发场景下就要考虑线程安全的问题,常见的解决方式:volatile、CAS、自旋锁、对象锁、类锁、分段锁、读写锁,理论上来说,锁的粒度越小,并行效果就越高。
volatile
volatile是Java中的一个关键字,用于修饰变量。它的作用是保证被volatile修饰的变量在多线程环境下的可见性和禁止指令重排序。
volatile虽然不能保证原子性,但如果对共享变量是纯赋值或读取的操作,那么因为volatile保证了可见性,因此也是可以实现线程安全的。
CAS
compare and swap(比较并交换),CAS主要有三个参数,
- • V:内存值
- • A:当前时
- • B:待更新的值
当且仅当V等于A时,就将A更新为B,否则什么都不做。V和A的比较是一个原子性操作保证线程安全。
Random通过cas的方式保证了线程安全,但在高并发下很有可能会失败,造成频繁的重试。
ThreadLocalRandom进行了优化,其主要方式就是分段,通过让每个线程拥有独立的存储空间,这样即保证了线程安全,同时效率也不会太差。
对象锁、类锁
主要就是通过synchronized实现,是最基础的锁机制。
自旋锁
在自旋锁中,当一个操作需要访问一个共享资源时,它会检查这个资源是否被其他操作占用。如果是,它会一直等待,直到资源被释放。在等待期间,这个操作会进入一个自旋状态,也就是不会被系统挂起,但是也不会继续执行其他任务。当资源被释放后,这个操作会立即返回并继续执行下一步操作。
自旋锁是一种简单而有效的同步机制,自旋锁的优点是减少线程上下文切换的开销,但是它也有一些缺点。由于它需要一直进行自旋操作,所以会消耗一定的CPU资源。因此,在使用自旋锁时需要仔细考虑并发问题和性能问题。
分段锁
在分段锁的模型中,共享数据被分割成若干个段,每个段都被一个锁所保护,同时只有一个线程可以在同一时刻对同一段进行加锁和解锁操作。这种锁机制可以降低锁的竞争,提高并发访问的效率。
ConcurrentHashMap的设计就是采用分段锁的思想,其会按照map中的table capacity(默认16)来划分,也就是说每个线程会锁1/16的数据段,这样一来就大大提升了并发访问的效率。
读写锁
读写锁主要根据大多数业务场景都是读多写少的情况,在读数据时,无论多少线程同时访问都不会有安全问题,所以在读数据的时候可以不加锁,不过一旦有写请求时就需要加锁了。
- • 读、读:不冲突
- • 读、写:冲突
- • 写、写:冲突
典型的如:ReentrantReadWriteLock
写时复制
写时复制最大的优势在于,在写数据的过程时,不影响读,可以理解为读的是数据的副本,而只有当数据真正写完后才会替换副本,当副本特别大、写数据过程比较漫长时,写时复制就特别有用了。
CopyOnWriteArrayList
、CopyOnWriteArraySet
就是集合操作时,为保证线程安全,使用写时复制的实现
写时复制也存在两个问题,可以看到在add方法时使用了synchronized,也就是说当存在大量的写入操作时,效率实际上是非常低的,另一个问题就是需要copy一份一模一样的数据,可能会造成内存的异常波动,因此写时复制实际上适用于读多写少的场景。
对比说明
经过测试可以发现在读多写少时CopyOnWriteArraySet
会明显优于ConcurrentHashMap.newKeySet()
,但在写多读少时又会明显弱于ConcurrentHashMap.newKeySet()
。
当然使用
CopyOnWriteArraySet
还需要注意一点,写入的数据可能不会被及时的读取到,因为遍历的是读取之前获取的快照。
这段代码可以测试CopyOnWriteArraySet
写入数据不能被及时读取到的问题。
9. 异步
异步是提升系统响应能力的重要手段之一,异步思想的应用也非常的广泛,常见的有:线程、MQ、事件通知、响应式编程等方式,有些概念在前面的章节中也涉及到了,异步最核心的思想就是,先快速接收,后查询结果,比如:如果接口处理时间较长,那么可以优先响应中间状态(处理中),然后提供回调和查询接口,这样就可以大大提升接口的吞吐量!
10. for循环优化
减少循环
通常可以通过一些高效的算法或者数据结构来减少循环次数,尤其当出现嵌套循环时要格外小心。 常见的方式比如:有序的查找可以用二分,排序可以用快排,检索可以构建Hash索引等等。
批量获取
优化前:每次查询一次数据库
优化后:先批量查询出来,再处理
缓存结果
优化前:每次都要根据每个用户的roleId去数据库查询一次。
优化后:每次根据roleId查询过以后就暂记下来,后面再遇到相同roleId时即可直接获取,这比较适用于一次循环中roleId重复次数较多的场景。
并行处理
典型的如parallelStream
11. 减少网络传输的体积
精简字段
1.数据库查询时要避免频繁查询大文本字段,常见的如下面几种:select url, describe, remark from t
2.接口传输时同样要注意尽量减少内容传输的大小。
3.精简字段除了通过减少不必要的字段传输之外,也可以通过改变数据结构,数据类型来实现。
数据传输格式
常用的如JSON,语法简单,相比XML来说传输体积更小,解析更快,但如果需要频繁传输大量数据时,使用protobuf则更会更加高效,因为其采用结构化的数据描述语言,并使用二进制编码,因为体积更小,速度更快。
压缩
常见的数据压缩方式如:GZIP、zlib,而zip常用于文件压缩。
借助Hutool工具包,可以看下压缩的效果
gzip压缩
zlib压缩
12. 减少服务之间的依赖
依赖越多,不但会给服务的稳定性、可靠性造成影响,同时也会成为性能提升的瓶颈,因此我们在设计之初就应当充分考虑到这个问题,通过合理的手段来减少服务之间的依赖。
链路治理
通过合理的微服务划分,可以有效的减少链路上的依赖,链路调用之间要避免出现重复调用,循环依赖,以及上、下层级互相调用的情况。
重复调用
循环依赖
服务上、下层级混乱,互相调用
数据冗余
数据冗余是指将非自身维护的数据通过某种手段保存下来,以便在之后使用时避免多次发起数据请求,从而实现减少服务依赖的手段。
常见的方式如:通用的基础数据,字典数据等各个需求方可复制一份存在本地;建立宽表,冗余部分数据,减少关联查询。
结果缓存
将需要频繁使用的结果存储在缓存服务中,也是有效减少服务依赖的方式之一。
消息队列
消息队列天然就有简化系统复杂性的作用,它通过异步的方式将任务与任务之间的关系进行解耦,也就达到了减少服务之间依赖的效果。