马阳阳
去哪儿网高级研发,4+年从业经验,专注于云原生、效能领域。曾参与移动集团私有云 PaaS、企业级 DevOps 平台。目前负责代码瘦身平台、测试环境效率,致力于提升研发效能,助力业务快速发展。一、引言
高性能、高可用、可扩展、可伸缩、可维护、安全性 等都是技术人员常聊的话题,也是每个后端工程师必须了解和掌握的通用技能。本篇文章将从软件性能入手,探究性能到底是什么、由什么组成,推演出性能提升主脉络,总结通用思想及方法,并配合跨领域的数个具体案例辅助理解,最终形成一套完整的高性能思维框架。二、什么是高性能
性能是个抽象而通用的词汇,很多场景都会提到,比如 新电脑性能很好、后端服务具备高性能,有硬件类有软件类,我们这里将范围缩小为开发同学关注的领域,软件性能。考虑一下,软件性能是什么,由哪些要素组成?把性能组成的模型定义清楚,提高性能的主脉络自然就有了,针对模型里的每个点进行提升。“软件性能是软件的一种非功能特性,它关注的不是软件是否能够完成特定的功能,而是在完成该功能时展示出来的及时性”,这句话出自百度百科,这个定义还是比较抽象。查了很多资料都没有找到关于性能 比较具象的描述,只能抛出个人观点:“快慢” 这个词间接的体现了单位时间内,这里我们把单位时间弱化掉,因为要达到的目标是高性能,固然是越快越好。抛开“快”这一确定特性,定义中还包含两部分:执行器、完成事情。“执行器”是做事的主体,在单机情况下,硬件层面的执行器可以认为是 CPU。而 OS 层会有进程相关概念以及调度机制,从这个角度看执行器也可以是进程。从宏观层面在一个大型分布式系统中,执行器也可以视为一台物理机。“完成事情”还有些宽泛,在计算机领域的“事情”无非就是 IO 和计算。除此之外,还需要某个东西把 IO 和计算编排起来,就叫它“流程”吧。最终,关于性能的定义变得明确了:性能 = 执行器 + (流程 + IO + 计算)执行器代表做事情的主体(谁);做的事情包括两种:IO 和计算;流程会决定做哪些 IO 和 计算(做什么),以及将它们按什么顺序组合(怎么做)。三、何时考虑性能
性能是非功能性需求,因此它不像产品初期定义的功能性需求那样明确,也并不是每个系统都需要考虑性能因素,通常什么情况下需要考虑性能因素?这个问题在不同研发团队上可能有不同的结果,这里分两种情况来谈。1. 常规研发流程
目前大部分团队都属于该模式,从研发流程上主要在这三个阶段涉及性能:- 系统设计阶段:在系统设计时需要估算业务压力,根据日均/峰值 QPS 、业务可接受的响应时间等指标,决定系统需要支撑怎么样性能,设计符合性能要求的架构。
- 系统上线前:在最终上线前,通过全方位性能测试来验证是否正真符合要求,不符合则进行优化,优化完再测,螺旋上升,直到满足性能要求。
- 系统运行期间:在系统运行期间,通过监控、告警等发现了问题,深入分析后发现是性能原因导致,此时就需要进行性能优化。创建约束理论的艾利·高德拉特告诉我们,“在瓶颈之外的任何地方做出的改进都是假象”,因此一定要先找出性能瓶颈点,再针对瓶颈进行改进。
2. DevPerfOps
近几年 DevOps 非常火热,国内相关讨论和实践也非常多。DevPerfOps 是一个比较新的理念,大家可能听说过 DevSecOps,两者从思想上是类似的。在 DevSecOps 中,将安全保证活动嵌入到研发过程的各个环节,最终保证交付产物的安全性符合要求。将“安全性”换成“性能”,就有了 DevPerfOps。传统模式中,通过最终的性能测试来保证交付产物是否符合性能要求,此时如果发生性能瓶颈,定位和修复问题的成本会非常高,很可能导致延期风险,或没完全满足性能要求就“被迫”上线了。DevPerfOps 的理念是将性能活动融入到研发流程的各个环节中,通过更早的切入、更细粒度、更全面、更及时的反馈来发现和解决性能问题,所带来效率提升的原理和测试左移思想类似。下图从软件研发全流程的视角给出了 DevPerfOps 的各种实践:四、如何实现高性能
性能 = 执行器 + (流程 + IO + 计算)- 执行器的质量及数量,会直接影响 IO 和 计算的速度
- IO 与计算的花费时间,当执行器和流程确定后,时间是固定的,因此不会影响性能
可以从执行器和流程两部分讨论提升性能的通用思想,流程优化又可以细分成三类,IO 型、计算型、通用型(IO 和 计算都适用)。计算型和执行器关联性很强,计算相关优化基本都基于多执行器,因此放在执行器中聊更合适。下文将从执行器、IO 型流程优化、通用流程优化三类开展讨论,提出常见的优化思想。每个思想会配合多个具体案例,使得思想更易理解、理解更深。这些案例会尽可能跨越 不同层次、不同语言,甚至是不同领域,这样更能体现思想之通用与强大:上层千姿百态,深入分析后,发现底层思想上有相通之处,这时会有一种奇妙的感觉,一通百通。另外,深刻掌握思想后,能基于思想通过逻辑推演,自行衍生出新的上层形态。当进入一个新的领域、碰到一个不熟悉的问题时,基于已掌握的思想也能提出合适的、富有创新性方案。1. 执行器
提高执行器的质量就是使用更好的硬件资源,自然会对性能有所提升。涉及硬件领域,笔者对此研究尚浅,不多展开。增加执行器的数量后,多个执行器可以同时工作,在计算机领域这叫做“并发”。当执行器成为瓶颈时,适当增加并发数会带来性能提升。在执行器数量变多后,哪个执行器处理哪些任务?多个执行器争抢同一资源导致性能下降怎么办?这类问题可以通过分片进行优化。如果任务只有一个没办法分片,可以考虑将任务拆分成多个子任务,这样子任务间能利用好多个执行器。1.1. 并发
无论是读业务还是写业务,将串行流程改成多线程并行是常用的方法,并发能带来性能提升是显而易见的,但并发数并不能无限增加。针对并发举两个案例:Noah 环境构建、冗余请求,这两个案例在场景上差别较大,最终都达到了提升性能的效果。案例一:Noah 环境构建流程
Noah 是公司统一的环境管理平台,以提升一线工程师工作效率为目标,从环境切入,提供环境创建、系统编排、资源分配、应用部署、环境运维等能力。Noah 核心业务是快速的创建一套完整的环境,环境中常包含多个应用、数据库、中间件、环境变量、网络配置等资源。
环境构建是指 根据配置创建整套环境的过程,如果全串行,那整个环境构建会非常慢,所以必须尽可能并行。可以根据各类型间依赖关系可以分为四个阶段,中间件阶段 -> 数据库阶段 -> 应用阶段 -> 网络阶段,如下图:
应用创建前需要等全部中间件和数据库都就绪,网络配置前需要全部应用都就绪。在一个阶段里,多个数据库间一定没有依赖,可以并行处理。这种按照资源类型分阶段创建的设计会使流程很清晰,并且不用担心依赖关系问题,无论应用依赖哪个或哪些数据库,都会等全部数据库都就绪后才开始部署。但是多阶段设计使得原本没有强依赖关系的两个不同阶段的组件,现在必须串行化进行,并行度还能进一步提升。为了进一步增加并行度,不再按类型分阶段,而是精细化控制依赖关系:一个资源何时创建,取决于其强依赖的组件是否就绪,不受无依赖的组件影响,新方案的构建拓扑图如下:因为应用 1 不依赖任何中间件和数据库,所以可以一开始就进行部署。可以发现环境构建总时长,相比于之前的方案,少了一个中间件的创建时间。此时环境构建总时长,由最耗时的链路决定,而不是各阶段内最耗时链路只和。
案例二:冗余请求
当一个用户请求到达后端服务后,需要调用 10 台服务器进行联合处理时,假设每台服务器只有 1% 概率发生延迟(响应时间超过 1s),那么对于用户来说,这个请求总响应时间大于 1s 的概率是 10%(1-0.99^10≈0.10),10 台中的任何一台发生延迟,都会导致这个请求的延迟。如果需要 100 台服务器联合处理,每台服务器延迟的概率仍是 1%,但对于用户来说,请求延迟的概率高达 63%(1-0.99^100≈0.63),这意味着越是大规模的分布式系统,一个用户请求调动的机器也越多,延迟概率就越大。可以通过冗余请求的办法来优化这个延迟问题,即并发地发送更多请求。客户端同时对多个服务器发起请求,哪个返回的快就用哪个,其它的响应忽略掉,但这样会导致请求数量成倍增长,不可行。再优化一下,先请求一个服务器,如果在一定时间(95% 正常时间)内没有响应,则再向另一台发起请求。当第一个响应到达时,终止其它还未响应的请求。优化后将增加的负载控制在可接受范围内,同时大大缓解延迟问题。该方法更详细的说明可以看文章末尾参考论文“The Tail at Scale”,论文中提到了这一优化量化提升:仅用 2% 额外请求将系统 99.9% 的响应延迟时间从 1800ms 降低到了 74ms,针对长尾问题效果很好。1.2 分片
分片是为了让多个执行器各干各的,发生资源竞争时能减少参与竞争的数量 或者完全避免竞争,从而提高性能。分片的维度非常多,各种资源都可以分片,比如 数据分片、任务分片。数据分片是最常见的,在单机中通过分片减少竞争提高并发度,进而提高性能,比如 java 中的 ConcurrentHashMap;在分布式场景中,除了性能考虑,分片还可以带来数据的横向扩展性,因此完备的、涉及数据的中间件基本都利用了数据分片,比如 MySQL 中的分库分表、Redis-Cluster 中 16384 个槽位设计、Kafka 中的 Partition、RocketMQ 中的 Queue、ES 中的 sharding。分片时要注意尽可能均衡,否则会导致负载不均、性能降低。针对分片举三个案例:java 中线程安全 Map、TLAB、GPM 模型,分别代表了数据分片、内存分片、任务分片的场景。案例一:java 中线程安全 Map
在 java 中线程安全的 Map 有很多,一般来说越新的性能会越好。本节通过分析 Hashtable、1.5 版本 ConcurrentHashMap、1.8 版本 ConcurrentHashMap 都是如何实现线程安全的,来体会分片带来的性能提升。Hashtable 在 jdk 1.0 版本就存在了,使用一个 Entry 数组存放数据,Entry 包含 key、value 等属性。它实现线程安全的手段很简单,通过在方法上加 synchronized 关键字来实现线程安全。synchronized 相当于一把全局互斥锁,同一对象的多个 synchronized 修饰的方法不能并行,就保证了线程安全。
Hashtable 现在很少用到了,因为有新的替代品 ConcurrentHashMap,性能比 Hashtable 高。
ConcurrentHashMap 在 1.5 版本基于分片思想,将整个数组分成了多段(segment),每一段包含属性 HashEntry 数组,HashEntry 带着 key、value 等数据。有了 segment 这层后 锁就可以降低到 segment 粒度,而不是一把大锁锁整个数组。Segment 继承了 ReentrantLock,这样 Segment 类即代表了一段数据也代表了当前段对应的锁,通过多把锁实现多段的线程安全。
ConcurrentHashMap 在 1.8 版本改动比较大,性能更好。存放 key、value 数据的是 Node 数组,跟之前类似,有差异的是保证线程安全这部分的实现。在新版实现中不再使用分段锁+ ReentrantLock 的模式了,而是将锁粒度进一步缩小成单个 Node 维度,这样整个数组的并发度会更高,主要通过这些优化来提升性能:- 内部包含大量 CAS 操作和 synchronized 代码块来实现线程安全。CAS 是自旋锁也是一种乐观锁,更新某个共享变量值时先判断当前值是否符合预期,符合的话就尝试更新,不符合则说明有冲突,重新尝试更新一下,这在竞争不激烈的场景下性能表现好。对于复杂的处理流程,使用 CAS 会导致代码复杂度非常高,因此用 synchronized 代码块的方式来保证线程安全。随着 jdk 版本升级 synchronized 也在不断优化,增加锁升级特性来降低锁的使用成本,所以在 1.8 中性能也还行。
- 当发生哈希冲突时仍使用拉链法,当链拉的很长时性能会急剧下降。为了缓解性能下降问题,当拉链长度达到或超过 8 时,判断数组大小是否大于等于 64。如果不大于则先扩容,大于则将链表转换成红黑树(treeify),时间复杂度从O(N) 下降到 O(logN)
案例二:TLAB
TLAB (Thread Local Allocation Buffer)是 jvm 中关于内存分配的一个概念,它是在 java 堆中针对每个线程都单独划一片线程自己的内存区域,当线程要创建对象 申请内存时优先使用自己的 TLAB 区域,这样就可以避免在多线程环境下对同一片内存资源产生竞争,最终提高了内存分配和回收的速度。当 TLAB 用完后再申请内存时,需要获取锁来申请共享的内存资源,否则内存会不安全,而锁会带来更多开销。大对象通常会在 TLAB 之外分配,因为大对象失去了减少同步内存分配频率的优势,并且可能一下子就把 TLAB 耗光。TLAB 的缺点是可能会消耗更多内存,有的线程只会使用很少的内存空间,这样预留的空间浪费比较多。案例三:GPM 模型
Golang 作为云原生第一语言,在语言层面提供通用的生产者-消费者模型,极大降低并发编程的难度。基于 GPM 模型以及完善的封装,只需几行代码就可以实现高性能服务器,满足高并发的要求。在 java 中最小的执行器是线程,线程归属于操作系统管理,操作系统维护了线程的结构体,保存线程相关寄存器状态和栈空间,内存资源消耗比较多(Mb级);当用户态程序需要创建线程时,必须调用操作系统 API 来完成,这一过程需要从用户态切换到内核态,时间开销比较大;当线程处理 IO 请求时,如果调用的是阻塞版 API (如 read)则线程会等待,直到 API 返回后才能继续执行。- 首先将每个 CPU 和一个线程进行绑定,这样可以避免同一个 CPU 上的线程切换。CPU 数量是确定的,因此线程数量也直接确定,这样就可以减少创建、销毁操作次数;
- 新增了用户态的“线程”,即 Goroutine 的概念。Goroutine 和线程一样可以调度执行,只不过是在用户态,调度也是由 Go 在用户态完成的;
- 多个 Goroutine 会和 OS 层的一个线程进行绑定,因为最终执行还是得依靠线程;
- Goroutine 很轻量,一个 Goroutine 消耗的内存资源是 Kb 级,一台几 G 服务器可以同时存在百万 Goroutine;
- Goroutine 的创建、销毁、调度和切换都可以在用户态完成,没有用户态、内核态切换,因此开销很小;
- 当在 Goroutine 中调用阻塞的 API 时,比如 sleep,本来会导致对应的线程也阻塞,但 Go 做了个巧妙地处理,它将所有系统调用中阻塞的 API 悄悄(对开发者无感)改成非阻塞版本,这样 API 能立刻返回,然后在当前 Goroutine 里类似 yield 一下,让出位置,使其他 Goroutine 立刻调度到这个线程上,这样线程将一直在做计算,理论上能让 CPU 利用率一直在 100%;
有了前面的介绍,再来看 GPM 模型就好理解了,GPM 是由三个单词的首字母组成:- G:Goroutine,协程,被调度的基本单位,相当于任务;
- P:Processor,调度器,每个 M 对应一个调度器,一个调度器会负责多个 Goroutine 的调度;
- M:Machine,内核线程,可配置数量,数量最好和 CPU 一致,并且进行绑定,这样性能最佳;
回到分片上,在 Go 1.1 版本之前,使用的是 GM 模型和全局队列的方式,也就是没有 P,没有本地队列,只有全局队列,由 M 到全局队列中获取 G。因为有多个 M 所以存在 G 的竞争,需要在 G 全局队列上加把锁,这样性能就降低了,模型如下:
为了更好的性能,1.1 版本开始对 G 进行了分片,让每个 P 都有自己的本地 G 队列,这样从本地队列中取 G 时就不存在竞争,性能会有提升。1.3 拆分
将一个大任务(包含多个计算或 IO)拆分成多个小任务,可以打破原始的依赖顺序,更精细化的控制每个小任务所用的执行器资源,更容易针对局部进行优化,最终带来相当可观的整体性能提升。典型的实践就是流水线,很多日常活动是流水线化的。在一个自助餐厅流水线上,顾客按照相同的顺序、相近的速度走过各个菜品,即使不需要某些菜。流水线对计算机行业的影响也颇为广泛。硬件层面,现代高性能处理器都依赖于指令流水线;软件层面,最近几年很火的 DevOps,也是将流水线引入到软件研发的各个阶段,进行产能提效。流水线带来的重要改变是提升了整体的吞吐量,即单位时间内能够处理完成的总数量。针对任务拆分思想举两个案例:指令流水线、Noah 环境构建流程,分别代表了硬件层面和软件层面场景。案例一:指令流水线
A. 计算机指令就是指挥机器工作的指示和命令,对于 CPU 来讲是可接受命令的最小单元。通常处理一条指令包含 6 个操作: C. 译码:简单来讲,就是将指令翻译一下,变成机器认识的机器码;D. 执行:正真的执行,不同指令所做的执行操作不同;E. 访存:与内存的交互,包括写入数据到内存、从内存读出数据;更新 PC:PC 是 program counter(程序计数器)的简称,其值存放的是当前正在执行的指令的地址。更新 PC 就是将 PC 设置成下一条指令的地址,因为当前指令已经执行完,到最后一步了。操作一条指令能拆分成多个固定的阶段,这就很适合流水线化。建立一个普通的处理模型,假设每条指令要花费 300 ps(ps 为皮秒,10^-12 秒),要执行 3 条指令。因为指令间存在依赖,因此需要顺序执行,此时执行完成 3 条指令花费的总时间是 300*3=900ps。建立一个流水线化的处理模型,一条指令仍然是 300ps,要执行 3 条。将一条指令等分成 3 个阶段 1、2、3,即每个阶段耗时 100ps。当指令 1 的 1 阶段执行完成后就可以开始指令 2 的 1 阶段了,以此类推。
显然,对于同样的任务量,流水线化后完成时间缩短了 400ps,相当于整体提升了约 44%,代价是增加了一些硬件。如果将指令拆分成更多步骤,进一步增加流水线并行度,提升会更多。- 任务拆分后可能导致损耗,存在更多上下文切换,即拆分后各阶段耗时之和会比一条指令完成时间略长
- 可并行度没这么高。指令 2 的 1 阶段可能需要等指令 1 的 2 阶段完成才能开始
不管怎样,从实际落地效果看,指令流水线化后整个系统的吞吐有提升数倍。案例二:Noah 环境构建流程
还是之前创建环境的例子,在环境构建中,假设现在环境的依赖关系如下图:
这个工作流中包含四个资源,橘色部分创建出了一个数据库;青色部分搭建了一个中间件;绿色部分部署了一个应用,蓝色部分是完成网络配置。因为应用会依赖中间件和数据库,因此需要等它们都就绪后才开始部署。网络配置是最后一步,需要等待应用服务都正常后才能进行配置。不同类型资源的创建会包含多个步骤,因此可以将大任务拆分成多个小任务:- 应用类型:1、创建运行时(如 docker、虚机);2、安装自定义软件;3、运行服务启动前脚本;4、部署服务;5、运行服务启动后脚本;
- 数据库类型:1、创建运行时;2、安装数据库;3、初始化测试数据;
- 中间件类型:1、创建运行时;2、预装软件;3、安装中间件;
- 网络配置:1、到 Openresty 上更新配置;
拆分完成后会发现,小任务之间的并行度可以进一步提升,比如 应用部署前的工作,不需要依赖中间件或数据库;应用部署只依赖数据库服务,而不依赖数据库中的数据。通过将拆分后的小任务重新进行编排,新的构建拓扑图如下:
当前只包含 4 个资源,如果环境规模很大有上百个资源时,此时带来的性能提升效果会更好。这个例子也体现了并发的思想,但如果没有拆分工作,是很难发现并行度还可以进一步提升的。2、IO 型流程优化
2.1 缓存
在计算机的世界里,缓存无处不在,缓存也是最常用的提升性能方式之一。缓存是原始数据的副本,利用了空间换时间的思想,增加了空间成本和流程复杂度,提升的是读业务的性能,因此只适用于 IO 型。本节提的缓存只是 cache,不包含 buffer。buffer 一般用于写操作,将要写的数据放到缓冲区中,让 IO 设备可以批量处理,减少写的成本从而提升性能。
- 本地缓存:缓存数据就在服务器本地,比如内存或磁盘上。常见的实现包括 Ehcache、Guava、Caffeine;
- 集中式缓存:缓存数据在一台单独的远程服务器上,常见的实现包括 Redis、Memcached。
不是什么场景都适合用缓存,需要开发者根据场景进行分析。以去哪儿旅行 App 为例,列几个适合缓存的情况:- 很少发生变化的数据:机票航班的数据,变更是比较少的。之所以要变更少,因为一旦原始数据变化,缓存一般也需要进行同步,此时会增加缓存维护的成本,这种高维护成本可能比不上缓存带来的性能收益;
案例:一个读请求的处理
通过缓存进行提速的例子很多,开发同学对缓存也都比较熟悉。这里只看一个场景,以浏览器发送一个读(GET)请求到服务端,服务端收到请求进行处理为例,假设经过的组件是这样的:
在这个场景下,看看哪些地方会用到缓存,以组件经过顺序依次来聊。- DNS 缓存:第一件事要对请求的域名进行解析,获取域名的 IP 列表。先查看本机 hosts 文件,找不到则继续,查找本机的 dns 缓存,没有的话则向 DNS 服务器发送请求,将 DNS 服务器解析的结果(即 IP 列表)在本地缓存起来。这样下次访问同样的域名时,可以直接使用缓存,不再需要发送解析请求。
- 浏览器缓存:HTTP 协议在 Request header 中提供了缓存相关的属性,如 Cache-Control、ETag。在 HTTP1.1 规范中,Cache-Control 用来标识能否进行缓存、缓存有效期等信息。有些情况下浏览器甚至不会向服务端发起请求,直接使用了浏览器本地的缓存数据。
- CDN 服务:一般对于静态资源,可以通过 CDN 进行访问加速,CDN 可使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度。可以认为 CDN 网络就是浏览器和服务端之间的缓存。
Nginx 是最常见的网关层中间件,自带全局内存缓存功能。Nginx 使用模块化设计,通过加载模块使 Nginx 可以具备多种缓存能力。常见缓存模块包括:lua-resty-lrucache(内存级 LRU 缓存)、proxy_cache(本地磁盘缓存)。- 页面缓存:tomcat 工作目录下会存在一个 work 目录用来存放页面缓存,如 jsp。
- processorCache:Tomcat 在处理每个连接时会将 socket 上下文封装成一个任务 SocketProcessor 进行处理。在 BIO 或 APR 模式下,每次有新的请求就会创建一个新的 SocketProcessor 对象。在 NIO 中为了提升性能,支持对 SocketProcessor 也进行缓存,用完后将状态重置重新放回缓存中。
- Mybatis 缓存:使用 Mybatis 框架查询 MySQL 时,Mybatis 也提供了两级缓存。一级缓存是会话(session)级,当使用同一会话先查询一次数据后,又立马再查询一次时,第二次查询会直接使用缓存而不请求 MySQL。二级缓存是 mapper 粒度的全局缓存,多个会话能共享,尽可能减少访问 MySQL 的次数来提高性能。
Redis 作为缓存组件,本身就是缓存的典型体现。- 查询缓存(query cache):在 8.0 之前版本的 MySQL 中,MySQL 服务端专门有一个缓存组件,叫“查询缓存”。当执行查询语句时,会先去查询缓存中查看结果,之前执行过的 sql 语句及其结果以 key-value 之类的形式存储在缓存中,如果能找到则直接返回,如果找不到,就继续执行后续阶段。在 8.0 版本开始,查询缓存组件被移除了,主要是因为该缓存命中率低,数据存在频繁变化,因此使用缓存效果不好。
- Prepared Statements 缓存:MySQL 服务端在收到一条 SQL 语句后,其中一个处理步骤是分析器对 SQL 进行语法/词法分析,这个过程一般比较耗时。MySQL 会使用一个内部结构体来承接解析出来的结果,并将其缓存,该缓存避免了在回话期间需要再次解析 SQL 语句时的开销。
- Buffer Pool:InnoDB 引擎提供了 Buffer Pool,虽然名字是 buffer,但它也起了 cache 的作用。buffer pool 是内存中的一个重要区域,占空的物理内存很大,用于对数据和索引进行缓存,使用 LRU 算法作为淘汰策略。这样在查询经常访问的数据时可以直接访问 buffer pool 而不是磁盘,从而加快处理速度。
2.2 压缩
压缩是一种通过特定的算法来减小数据大小的机制,针对 IO 型中传输的数据可以通过压缩来减少数据量,进而提升传输性能。压缩还可以减少文件占用的磁盘空间。压缩的基本原理是找文件中重复的字节,建立一个相同字节的“词典”文件,并用一个代码表示,比如在文件里有几处有一个相同的词"cache"用一个代码表示并写入“词典”文件,这样就可以达到缩小文件的目,这里也体现了复用的思想。- 有损:解压后的数据对比压缩前的数据可能存在丢失,如果个别数据差异不会造成太大影响,就可以使用有损压缩,有损压缩常应用与直播、音频、图像等。
- 无损:解压后和压缩前的数据完全一致,准确无误。对于数据准确性要求极高的场景,适合使用无损压缩。日常用的压缩软件一般都是无损压缩类型,比如常见的 zip、rar、gzip 等。
案例:HTTP 压缩
- HTTP 是基于 TCP 实现的请求-响应协议,存在大量的数据传输。为了提高传输速度,HTTP 协议中定义了压缩相关功能。HTTP 压缩通常使用的是 gzip 压缩算法来压缩 HTML、JavaScript、CSS 等文件,是否开压缩和具体压缩算法是由服务端决定的,具体细节如下:
- 用户通过浏览器发送请求时,浏览器会在 request header 里放一个关于压缩的参数 “Accept-Encoding: gzip, deflate, br”,表明支持 gzip、deflate、br 三种压缩算法;
- 服务器接收到浏览器的 HTTP 请求后,看浏览器是否支持压缩,支持哪些压缩;
- 如果浏览器支持压缩,服务器看自己开了哪些后缀文件的压缩。假设服务端启用了对 CSS 文件的压缩,则当请求 CSS 文件时,服务器到压缩缓冲目录中检查是否已经存在请求文件最新的压缩文件;
- 如果不存在压缩文件,则服务器向浏览器返回请求文件,并进行压缩,将压缩产物放到压缩缓冲目录下;
- 如果已存在压缩文件,则直接返回请求文件的压缩文件,并在 response header 里放一个参数 “Content-Encoding: gzip”,告知浏览器是 gzip 格式;
- 浏览器收到响应数据后,根据 header 中的参数决定是否需要解压、用什么算法进行解压,解压完成后再使用。
3. 通用流程优化
通用流程优化代表即适合 IO 型也适合计算型流程。先看一个基本原则:有且仅有。意思是只有必须的流程,不会有多余,它的目的是避免浪费。浪费一定会导致变慢,因此我们应该遵守这一基本原则。3.1 异步
在计算机领域,“异步”这个词太常见了。在不同的语境下含义也不同。在操作系统层面,存在“异步I/O”的概念,它是特定的一种技术,比较狭义。我们这里谈的异步,是指上层应用中的异步,这种会更广泛,以常见的 客户端-服务端 模式为例:- 调用异步:客户端角度的异步,是请求服务器 API,然后不等服务端返回结果,就继续去做其他事情;
- 流程异步:服务端角度的异步,是接收到一个客户端请求后,不占用连接线程立刻进行处理,而是丢给其他线程慢慢去处理,当处理完后再将结果返回。
异步能带来性能提升是显而易见的,执行器不需要等待结果,能继续干其他事情,减少了主干流程中的步骤,性能自然有提高。- 逻辑解耦,提高扩展性:在不使用异步时,任务提交和执行是绑定的,一旦提交就必须等待执行完毕。在使用异步后,将任务的提交 和 任务的执行进行解耦,不再有强绑定关系。
- 提高可靠性:主干流程中的步骤减少了,出错的可能点 数量降低,因此可用性有了提升。
- 通常能和 批量(下一节会讲)一起使用,进一步提升性能。
1、异步会带来流程复杂度。在全链路同步调用中,最简单的情况下一个业务只会有一条流程。当改成异步后,必然会产生旁路分支,此时需要根据实际业务场景,考虑是否需要分支回到主干,形成逻辑闭环。如果需要回到主干逻辑中,通常有两种实现:- 分支逻辑通知主干结果:由分支通知主干,这种方式时效性好,对主干的执行器资源消耗少,但需要主干提供回调 API
- 主干逻辑轮询分支结果:主干执行器向分支执行器轮询结果,这种方案对结果的获取存在延迟,最大延迟时间是一个轮询周期间隔。同时主干执行器要用额外资源执行轮询任务,轮询并不是每次执行都能得到结果,因此存在不少浪费
2、除了流程上的复杂性,异步调用相对于同步,代码也会更复杂。大量异步回调的嵌套会导致代码可读性变差,最终难以维护。一个不好的示例是 JavaScript 语言中的“回调函数地狱”:
3、在支持多线程的语言中,大量的异步还可能产生线程爆炸。4、异步的另一个弊端是会丢失顺序,任务提交和执行进行解耦后,此时无法确保先提交的先执行,在顺序敏感的业务场景下需要仔细考虑。凡是耗时的操作,可以考虑下能否异步;凡是不影响主流程的,都可以异步化。针对异步思想举三个案例:客户端异步调用、MySQL 的 Relay log、短信验证码登录业务,分别代表了异步调用、中间件中流程异步、业务领域流程异步的场景。案例一:客户端异步调用
在 java 体系中,成熟的客户端框架一般都会提供异步调用的 API,形式主要有两种:- 传入 Callable:异步回调 API 的入参中,会有一个 Callable 类型对象,代表回调函数。当异步任务完成后会调用注册的回调函数,从而让调用者感知到任务结束了。
- 返回 Future:调用异步 API 时会方法返回一个 Future 对象,通过 Future 对象可以获取返回结果和状态相关信息。
以常用的 apache HttpAsyncClient 为例,提供的 API 如下:
每个方法即提供了 Callable 入参,也提供了 Future 返回值,用户可以自行选择。案例二:MySQL 的 Relay log
为解决单点故障问题 MySQL 提供了主从架构模式,此时 MySQL 不再是单个实例,而是由至少一个主库和一个从库组成,称为“集群”。主库可以负责读写操作,从库作为主库的“仆从”,可分担读请求,但是没有写的权力。为了保证多个实例间的数据一致性,MySQL3.23.15 版本引入异步复制功能,从库会开一个 I/O 线程,专门从主库的 binlog 读数据并写入本地 Relay log 中。再开一个 SQL 线程,读 Relay log 并更新本地数据。
使用异步复制,当主库收到一个写请求时,处理流程和单机是一样的,包括 SQL 解析、执行、记录 binlog、返回等。数据同步会异步进行,这种模式性能表现最好,同步数据对现有流程完全没有任何影响。案例三:短信验证码登录业务
在 App 端登录时一般都支持使用手机号+验证码的方式进行,发短信这个操作通常会依赖第三方平台,专门提供发验证码的服务,调用流程如下:
请求第三方短信服务 API 需要走公网,速度比内网慢很多,并且不稳定,一个请求可能会花费数秒的时间。如果应用服务器使用的是同步调用,就会将线程卡住数秒。一般的 Java 应用服务器最多同时处理的请求数是几百量级,这样很容易把应用服务器打垮。将调用短信服务 API 的流程改成就异步调用可以解决这个问题。在内存中增加一个待发送短信的队列,处理请求的线程只需要将信息丢到队列中后就进行响应,这步操作是极快的,不会阻塞处理请求的线程。然后使用另外的线程,依次读取队列中内容,实现调用短信服务 API 的功能,新的流程如下:
这样当请求量很大时,处理请求的线程能很快响应并处理新的请求,这里不会成为瓶颈,最多就是内存中的队列有堆积。将发短信这个流程改成异步后还有个好处,当调用短信服务 API 超时或出错时可以更方便的实现重试。3.2 批量/合并
批量与合并类似,能带来性能提升 本质上是因为减少了操作的次数。批量/合并思想在生活中也是随处可见,比如餐厅后厨出餐,多份同一道菜会一起制作,而后同时出餐数份。
批量与合并也存在一点差异,批量仅指多个事情一批次处理;合并不仅能表示多个事情一批次处理,还包括将多个事情提前进行某些处理,常见的处理有:- 抵消:将两个逆向的操作进行抵消,比如 新增一条数据、删除一条数据;
- 聚合:将多个操作进行聚合,比如 3 次转入 100 元,可聚合成一次转入 300 元。
合并操作可能会导致版本明细不可追溯,拿三次转账聚合成一次的例子来说,最终都是余额 +300,但这 300 是 3 个 100 还是 2 个 150 就不清楚了,需要用额外的日志进行记录。针对批量/合并思想举两个案例:Kafka 流转消息、Redis rewrite 机制,分别代表了批量、合并的场景。案例一:Kafka 流转消息
在大数据领域 Kafka 是最常用的消息中间件,Kafka 追求极致性能,在各种开源 MQ 中其性能是顶尖的,所以以 Kafka 为例,看看如何使用批量来提升性能。Kafka 提供了跨进程的生产者消费者模型,此处我们将 Kafka 黑盒化,不探究其内部组件,只当作一个服务器。生产者投递消息到 Kafka 中,然后消费者从 Kafka 消费消息,简化的模型如下图,箭头代表数据流向:
在生产、消费消息时,有两种实现模式,push 和 pull,下面以消费消息为例,列举两种实现的差异性。Kafka 主动将消息推送给消费者,这样延迟可以很低。但实际也没这么好,如果 push 的太快,消费者网卡可能被打爆,或者消费业务被打爆,因此要加额外的控制,例如消费者告诉 Kafka 消费的速度跟不上了,慢点 push。这个机制会增加复杂度,并且消息的延迟也会提高。由 Kafka 触发的消息推送,所以已经消费的消息 offset 需要记录在 Kafka 端。此外,Kafka 服务端需要花更多资源进行消息推送,消费者越多对 Kafka 服务端的负载压力越大。消费者轮询拉取消息,特点基本和 push 模式相反,存在短暂延迟,最大延迟为一个轮询周期间隔。消费者可以主动控制消费进度,需要自行保存消费的 offset。每个消费者都要开轮询线程,当没有消息时会存在无意义轮询,这个浪费可以通过其他机制进行缓解,比如长轮询,没有消息时 Kafka 将消费者阻塞。消费者可以批量 pull,一次拉一批,比一条条 push 效率高得多。push 也可以按批次来,但这样也会增加消息延迟。对于消息生产,Kafka 只支持 push,即生产者向 Kafka 服务投递消息;对于消息消费,Kafka 只支持 pull,即消费者向 Kafka 拉取消息。无论是生产还是消费消息,只要是异步的,都支持批量。Kafka 的客户端会在内存中为每个 partition 维护一个队列,当有消息时先放到队列里,然后通过另一个 Sender 线程从队列里批量的取消息并发送给 Kafka 服务。拿生产消息来说,假设生产者在很短时间内形成了 100 条消息,每条消息大小是 1KB,那么不使用批量情况下,需要产生 100 次请求和 100 次响应。当使用了批量后,Kafka 会把这 100 条消息合成一批,大小为 100KB,发起 1 次请求和 1 次响应就完成了任务。这一改进所带来的性能提升是巨大的,其他 MQ 后续也纷纷加入了批量这一特性。案例二:Redis rewrite 机制
Redis 是常用的缓存中间件,它本身也提供了持久化的机制,有 RDB 和 AOF 两种。RDB 利用 OS 提供的 fork 机制,以很低的资源消耗实现了周期性的对内存数据进行一次快照,快照内容是二进制数据,将快照作为之后的恢复文件。这种机制有个缺点,因为是周期性持久化,所以两个周期之间产生数据容易丢失,丢失的数据量不可控。为了解决 RDB 丢失数据多的问题,Redis 提供了另一种持久化机制叫做 AOF。AOF 实现原理简单,每次发生一笔写操作,就把操作记录到 aof 文件中。在恢复的时候,从上往下一条条执行命令即可。aof 文件像一个记录了全量操作的日志文件,那么问题来了:- 根据 aof 进行数据恢复时,一条一条的命令执行,恢复时间会很长呀,速度慢。
为解决上面两个问题,Redis 提供重写(rewrite)功能,重写体现的是合并思想。重写功能在 4.0 版本前后实现方式不一样,分开讨论:- 在 4.0 之前,aof 文件是一个纯指令文件,触发重写后会进行运算,实现以下两点:
- 删除抵消的命令。如set k1 aa后又进行了rem k1,那么这两条命令就在 aof 文件中移除;
- 合并重复命令。如执行了一万次INCR k1,可以合并成执行一次INCRBY k1 10000。
- 在 4.0 之后,默认是让重写功能不再进行运算,而是会生成一个 rdb 文件,并将全量 rdb 内容放到 aof 的开始。之后增量地记录新来的写操作并 append 到 aof 文件中。使得 aof 变成了一个混合体,既有 rdb 磁盘消耗小、恢复快,又有 aof 的数据全特性。
3.3 复用
复用也能带来性能提升,因为省掉了大量重复的资源创建、销毁操作,以及资源本身的消耗。在计算机世界里,池化技术是复用思想的典型体现,很多资源都涉及到池化技术,常见的包括:常量池、连接池、线程池、内存池、对象池。针对复用思想举两个案例:常量池和连接池,分别代表了无状态资源、有状态资源池化场景。案例一:常量池
很多技术实现中都有常量池的设计,这里我们聊聊 java 字节码中的常量池。一段简单的 java 代码:public class Demo {
public static void main(String[] args) {
String s1 = "123";
String s2 = "123";
}
}
s1 和 s2 的字面值一样,我们就可以把 “123” 这个字面值放到常量池中,只放一份即可,让 s1 和 s2 都指向常量池中的字面值。这样能节约内存空间,并且少创建一次字面值,性能也会有提升。
通过 javap 查看字节码,会发现实际就是这么设计的。字节码中有个区域就叫常量池(Constant pool),其中 #14 就是 “123” 这个字面值,只有一份,图中箭头代表指向关系:
案例二:连接池
常量池比较简单,因为常量是无状态的,新建、销毁操作也很轻。连接池复杂些,连接的创建和销毁通常会很重,因此池化带来的性能提升效果会更好。与此同时,连接池需要考虑何时、如何进行初始化。连接具备生命周期,因此连接池需要对连接的生命周期进行管理,包括连接的创建、释放、状态检测等。常见的连接池包括数据库连接池、TCP 连接池、Redis 连接池等,都是类似的。3.4 转移
转移是指 当业务 A 耗时很久需要优化时,可以考虑将 A 业务的操作减少一些,减少的操作将转移到其他流程中,或前置或后置,最终达到即提升了业务 A 的性能,又从整体上对业务无损的效果。举三个案例:MySQL 索引、Noah 虚机创建、计算向数据移动。前两个案例分别代表了技术场景/业务场景下,通过将操作后置/前置到其它流程中来提速。第三个案例体现了在一个业务流程中,将操作在不同组件间转移来提速。案例一:MySQL 索引
后端开发同学对 MySQL 索引都比较熟悉,索引其实就体现了操作转移的思想。索引本质上是一种数据结构,为了加快查询,需要在数据变更时对这数据结构进行维护。相比不建索引,有索引后会增加变更数据时的操作数量,当然 MySQL 也会利用其他机制 尽可能降低更新索引带来的性能损失。以 InnoDB 存储引擎为例,当数据发生更新且涉及相关索引时,如果索引是主键索引或唯一索引,数据更新会立刻产生索引的更新,这种更新是局部的,对索引结构增加或删除值。如果是非唯一索引,InnoDB 引擎会判断要更新的页是否在缓冲池中,如果不在,则将更新操作放到一个变更缓冲区(change buffer)就完事了。之后由后台线程在适当的时机读变更缓冲区,正真的让变更对数据和索引生效。为了更高效的更新,还会进行合并处理,减少更新次数。
数据页被访问时
当数据库空闲时
数据库正常关闭时
数据库缓冲池不够用时
案例二:Noah 虚机创建
对于应用、中间件、数据库资源,第一步就是创建运行时环境,如 虚拟机、容器。对于容器类型,因其具备秒级拉起的特性,所以新建一个会很快。但虚机类型就不一样了,四个情况导致环境创建很慢:- 虚机的创建和销毁操作非常重,创建一个虚机至少是分钟级的;
- 虽然多个虚机的创建能并行,但并行度也很有限。当环境规模比较大 同时需要上百台虚机时,此时如果全并行,会对底层 openstack 平台产生很大压力;
- 虚机创建的成功率也不是百分百的,当调用平台接口超时或失败时,还需要重试,进一步拉长了处理时间;
- 创建虚机是核心步骤,必须要等虚机就绪了才能开始后续操作。
针对环境构建时间长的问题,我们利用了转移的思想,将虚机的创建操作前置,提前创建好数台不同规格的虚拟机,形成虚机资源池。这样创建环境时,不再需要任何创建虚机的过程,只要从虚机资源池中筛选出指定配置的虚机,最终环境构建整体时长提升了数分钟,带来超过 15% 的速度提升。案例三:计算向数据移动
计算向数据移动,指的是当 A 服务要操作数据(做计算),但数据在 B 服务中时,常规操作是 A 服务从 B 服务中查询出数据,然后计算,最后再通过 B 服务 API 保存数据。这种实现模式有一种提升性能的思路,让 B 服务直接提供对数据计算并更新的 API,A 服务则直接请求 B 服务新的 API 就能完成全部事情。从整体上看,该有的计算不会少,只是从 A 服务转移到了 B 服务,但节约了 IO,数据不需要再从 B 到 A,又从 A 回写 B 了,因此会有性能提升。- SQL:DQL 语句中提供了很多计算能力,limit、order by、count 之类,这样就可以直接在 MySQL 服务端计算好,不需要全量数据来回传;
- 查询列表:业务系统一般都会有列表功能,并配备分页、过滤、排序功能。在前后端分离架构下,会让后台 API 支持分页、过滤、排序参数,而不会在前端查询全部数据后前端进行处理。
3.5 算法优化
算法优化比较好理解,计算和 IO 越少性能越好,专业叫法是“时间复杂度”。同样实现一个排序需求,在大数据量下快速排序就是比插入排序性能好。平时注重算法的积累,对每个算法的特点和适用场景保持敏感。当遇到特定场景下问题时搜索脑中的算法库,看看有没有能匹配的算法。此外,可以通过搜索或者请教他人的方式 来找到更优的算法。五、小节
以性能为中心,通过推演形成性能的明确定义:性能 = 执行器 + (流程 + IO + 计算)。然后介绍了何时需要考虑性能,在常规研发流程下,一般在系统设计阶段、系统上线前和系统运行出问题时考虑性能;在 DevPerfOps 模式下,性能活动将融合到软件研发的各个阶段。最后根据性能的定义,从三大方向(执行器、IO 型流程优化、通用流程优化)提出了多种通用的性能优化思想,配合不同类型的具体案例,帮助大家建立起性能优化思维框架~参考资料: