性能优化方法论系列三性能优化的核心思想

Posted 明明如月学长

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了性能优化方法论系列三性能优化的核心思想相关的知识,希望对你有一定的参考价值。

3.3 提高资源利用率

3.3.1 空间换时间

空间换时间是性能优化最常用的手段之一。

其中缓存就是空间换时间的一种典型应用。

CPU 缓存、浏览器缓存、CDN 缓存、DNS 缓存、内存缓存、 Redis 缓存等,它们都是将数据缓存在离使用者更近的地方,或者读取速度更快的存储介质中,通过空间换时间的方式实现性能优化的。

在展开讲解之前,大家想想如果电商平台自营商品都从中央仓库发货,近的用户收货时间会短一些,远的用户收货时间可能会很长。

但是,很多朋友知道某东热门商品收货非常快,不知道大家是否知道原因。

据我了解,某东在全国热门城市建立仓库,每个仓库都有热门产品的储备。当用户下单后,通常会选择离用户最近的仓库发货。

和上面的案例类似,如下图所示, CDN 加速也采用类似的机制。

比如之前只有一个节点,较远的用户访问耗时为 1秒钟,通过 CSN 加速之后,较远的用户也可以有较好的响应速度。



前面给出了不同系统组件的操作时差图,下面给出一张 CPU 缓存、内存和磁盘操作的时差参考图[4]。

鉴于内存的读写速度比磁盘快很多,通常设计方案时会选择将热点数据缓存在内存中,这样就可以加快访问速度。

Hbase 会在 RegionServer 中包含 BlockCache ,当读请求到 HBase 后,会先尝试查询 BlockCache, 如果获取不到再去 HFile 和 MemStore 中获取,如果获取到了该数据,则返回给上游的同时将 Block 块缓存到 BlockCache 中[6]。

实际开发中最常用的是类似 Redis 的 KV 缓存或如 Guava 内存缓存。基本流程是先读缓存 ,如果未命中则读数据库,将数据缓存到缓存中。

在实际开发过程中,如果整个业务流程中需要多次调用同一个接口,可以采用线程级别(包括在同一个线程中,也包括在父子线程)缓存,避免在同一个流程中对同一个接口相同参数重复发起请求。

如使用模板模式设计某个业务流程时,子步骤中都需要对同一个订单号的订单内容进行查询或者对同一个课程信息进行查询。

此时,可以直接使用 java.lang.ThreadLocal 或者 java.lang.InheritableThreadLocal (可以完成父线程到子线程的值传递),也可以使用 com.alibaba.ttl.TransmittableThreadLocal (使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。具体用法请参考其官方文档)。

参考代码示例如下:


另外一种空间换时间的方式是预留(侧重于空间维度)。

比如 ArrayList 是一种基于底层基于数组的“变成数组”,当数据超过能够存储的长度时,并不会增加一个元素就扩容一次,而是每次扩容到原来数组长度的 1.5 倍。

Redis 中字符串也采用预分配冗余的存储空间的方式减少内存的频繁分配。如下图所示,当字符串分配的实际空间为 capacity 一般要高于实际的字符串长度 length。当字符串占用的存储空间小于 1M 时,扩容为当前空间的一倍;当字符串占用的存储空间大于 1M 时,扩容只扩容 1M , 但是最大长度为 512M[3]。


还有一种空间换时间的方式为预先处理/预热/ 静态化(侧重时间维度)。

可以将一些不容易变动且瞬间访问量可能较大的数据,可以预先加载到缓存中。可以将不变动的热点页面静态化,放到 CDN 节点中加速访问。

比如在某个确定的环节,报表内容就已经可以确定下来,在用户需要下载报表之前提前将报表放在缓存或者专门的文件加速服务器中,这样用户来的时候就可以快速下载。

其实上面的某东快速送货的案例也体现出了预留的思想。一般来说,热门商品不可能用户买的时候再去生产厂家订做,都会提前在仓库中储备一定量的货物,用户下单时直接发货即可。


其实索引 也是典型的空间换时间的做法。

这里的索引包括文件系统索引、数据库系统索引等。他们的核心思想都是一样的,先为要检索的内容建好索引,在查找时根据索引快速寻找对应的数据。

我们在开发时,通常会用到 mysql 、Oracle 数据库,为了提高查询速度,通常会设计索引,让索引能够覆盖我们的查询条件。

以 MySQL 的 InnoDB 引擎来说,使用 B+ 树的结构为索引字段建好索引。在查询时如果命中索引,可以通过索引快速找到对应的数据。


很多架构的设计都是用空间换时间的思想实现性能优化的,如集群架构、读写分离、分库分表、分布式架构

由于单机承载量的有限性,可以通过加机器化整为零,分担请求。

然而,机器之间不同的组织方式形成了不同的架构模式。

通过 nginx 负载均衡将请求分发到不同的实例上,称为集群模式。通过写操作写主实例,读操作读从实例,通过主从复制读写分离的方式,提高了整体吞吐量。

以上两种模式的共同点是每个节点都拥有相同的数据。

在某种条件下,需要将存放在一个数据库中的数据分散存储到多个数据库(主机)上,达到分散单机设备负载的效果。就涉及到了数据的切分,主要包括水平拆分和垂直拆分。

垂直切分,通过将不同业务的数据,放到不同的数据库中,实现不同业务之间数据库层面的隔离。

水平切分可以按照某种规则将某些字段分散到多个库中,每个表中只包含一部分数据[5]。


随着互联网的不断发展,访问量、数据量不断增多。为了突破单机的CPU 处理能力、内存、磁盘的限制,很多分布式中间件应运而生。

分布式系统是由一组通过网络进行通信、为了完成共同的任务而协调工作的计算机节点组成的系统。分布式系统的出现是为了用廉价的、普通的机器完成单个计算机无法完成的计算、存储任务。其目的是利用更多的机器,处理更多的数据

HBase 的架构就鲜明地体现着分布式的思想。

下图为 HBase 的整体架构。

Master 负责各种协调工作,ZooKeeper 扮演着管家的角色,管理 HBase 中所有 RegionServer 中的信息。Region 用来存放数据, RegionServer 是 存放 Region 的容器。最终可以通过 HDFS 持久化数据[6]。

通过增加 RegionServer 和 HDFS 机器就可以增加整体的承载能力。

3.3.2 同步转异步

通常比较耗时或者不必放在主流程中执行的任务,可以考虑使用异步的方式来处理。

可以使用线程池或者结合 CountDownLatchCyclicBarrierCompletableFuture 等 API 并发执行

比如用户在系统上报名,后端在处理完报名逻辑后,如果报名成功需要给用户推送公众号发送报名成功的消息,此时直接返回给用户成功即可。

发送公众号消息的任务可以放在线程池中异步执行。

假如后端逻辑需要执行 100 毫秒,发送公众号消息需要执行 100 毫秒,如果同步发送消息,耗时为 200 毫秒。如果发送公众号消息的环节采用异步的方式,那么对用户而言只需要等待 100 毫秒即可。

同步转异步还包括延迟加载

比如在领域驱动设计时,某个聚合根中的实体并不是所有场景都需要用到,构造该实体需要查询其他表,此时可以采用延迟加载的方式,在获取该属性时再去查询构造该实体。

3.3.3 串行转并行

如果某个任务包含多个步骤,串行执行时间时每个步骤耗时相加才是最终的耗时,容易超时或者体验不好。

可以考虑将这些子任务使用 ForkJoinPool、并行 Stream 的流操作。还可以将大任务拆分成多个小任务,通过消息队列或者大数据框架并行执行。

比如需要快速将100 万条消息发送出去,可以每 100 条封装成一个子任务丢到 MQ 中,负载均衡到多个消费者服务器上,并行执行,而且每台机器还可以使用 10个线程并发发送。

3.3.4 降低冲突的范围

常见的降低冲突范围的方法有:如偏向锁、分段加锁、读写锁、CopyOnWrite、使用乐观锁、隔离等。

为了实现没有多线程竞争情况下,减少传统重量级锁使用操作系统互斥量带来的性能损耗, JDK 1.6 之后还引入了轻量级锁。

为了提高性能, JDK 1.6 引入了偏向锁,它偏向于第一个获取它的线程,如果在接下来的执行过程中,没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。

对于临界资源的访问,如果不得不加锁,要尽量降低锁的范围。

如采用分段加锁机制,不在同一段的数据就不会因为这一段内有写操作而相互影响。

如数据库中能锁行就不要锁表。读锁和读锁之间并不互斥,读锁和写锁、写锁和写锁才互斥。

数据的操作一般就分为两类,一种是读,一种是写。在读多写少的场景下,可以通过读写锁、乐观锁的方式降低冲突的范围或者说降低冲突带来的性能损耗来提高性能。

为了更好地帮助大家理解什么是降低锁的范围,下面举一个🌰:

比如某个用户只能对某个商品下一个订单,为了防止用户重发下单一般需要先查询是否有订单,如果已经有一个订单则不允许再次下单。

在高并发场景下,不加锁,同时有两个并发下单请求过来,都查询订单表,发现没有数据,都插入下单记录,这就尴尬。

可能有经验的同学已经有思路了,加锁呗!

有些同学可能就开始设计了key:lockKey = userId +"_"+shopId。 ( 实际开发中可能很多人并不会这么傻,这里只是举个例子,帮助大家理解)

产品要求的是不能对同一个商品重复下单,没说是不能对同一个店铺的不同商品重复下单。

显然上面锁的范围扩大了。

应该将用户id 和 商品id 组合在一起构成分布式锁的 key:lockKey = userId +"_"+goodsId

3.3.5 空间局部性

先介绍下时间局部性和空间局部性的概念。

时间局部性:如果在某一点时访问了存储器的特定位置,则很可能在不久的将来将再次访问相同的位置。

空间局部性:如果特定存储位置在特定时间被访问,则很可能在不久的将来访问附近的存储位置。

其实时间局部性是加缓存的最主要依据

那么我们如何利用空间局部性进行性能优化呢?

我们先看一下 MySQL 中的一个案例:

我们知道读写磁盘的速度非常慢,和内存读写差了几个数量级。

如果我们想从表中读取一些数据时, InnoDB 存储引擎会一条一条把记录从磁盘中读出来吗?

InnoDB采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取 16KB 的内容到内存中,一次最少把内存中的16KB 内容刷新到磁盘中[7]。

其实 MySQL InnoDB 存储引擎这么做的主要理论依据就是空间局部性,这点和合并操作非常类似。


创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。

以上是关于性能优化方法论系列三性能优化的核心思想的主要内容,如果未能解决你的问题,请参考以下文章

性能优化方法论系列三性能优化的核心思想

性能优化方法论系列二性能优化方法论的思想源泉

性能优化方法论系列六总结

性能优化方法论系列三性能优化的核心思想

性能优化方法论系列三性能优化的核心思想

性能优化方法论系列四性能优化的注意事项