系统高性能架构

系统高性能架构

性能简介

要设计高性能的系统架构,应该有以下的思维步骤:

首先,要明确影响性能的因素有哪些?性能的指标有哪些?——做到有的放矢。

其次,要了解如何测试性能指标?性能优化,必须要有前后的效果对比,才能证明性能确实有改善。

接下来,学习针对不同场景下,不同性指标的优化策略以及具体实施方案。——见招拆招。

计算机资源

了解性能指标前,需要先知道哪些计算机资源会影响性能。一般来说,影响性能的计算机资源包括:

  • CPU
  • 内存
  • 磁盘 I/O
  • 网络 I/O
  • 数据库
  • 锁竞争

性能指标

性能测试的主要指标有:

  • 响应时间
  • 并发数
  • 吞吐量
    • QPS
    • TPS
  • 资源分配使用率

响应时间

响应时间(RT)是指从客户端发一个请求开始计时,到客户端接收到从服务器端返回的响应结果结束所经历的时间,响应时间由请求发送时间、网络传输时间和服务器处理时间三部分组成。

响应时间越短,性能越好,一般一个接口的响应时间是在毫秒级。

响应时间可以进一步细分:

  • 客户端响应时间
  • 网络响应时间
  • 服务端响应时间
  • 数据库响应时间

并发数

并发数是指系统能同时处理的请求、事务数

系统自身的 CPU 处理能力、内存、以及系统自身的线程复用、锁竞争等都会影响并发数。

吞吐量

吞吐量计算公式:

1
吞吐量 = 并发数 / 平均响应时间

吞吐量越大,性能越好

一般,系统呈现给外部的最常见的吞吐量指标,就是:

  • QPS(每秒查询数) - 即系统每秒可以处理的读请求。
  • TPS(每秒事务数) - 即系统每秒可以处理的写请求。

而在系统内部,存在以下吞吐量:

  • 磁盘吞吐量 - 体现了磁盘随机读写的性能。
  • 网络吞吐量 - 除了受限于网络带宽,CPU 的处理能力、网卡、防火墙、外部接口以及 I/O、系统 IO 算法都会影响到网络吞吐量。

资源分配使用率

通常由 CPU 占用率、内存使用率、磁盘 I/O、网络 I/O 、对象与线程数来表示资源使用率。这些指标也是系统监控的重要参数。

性能测试

性能测试手段:

  • 性能测试
  • 负载测试
  • 压力测试
  • 稳定性测试

对于 Java 应用而言,最简单的,可以使用 Jmeter 进行性能测试。

性能测试报告示例:

#### 性能测试的问题

性能测试时,需要注意一些问题:

  • 热身问题 - 系统刚开始运行时,自身可能加载缓存,JVM 可能会优化热点代码等,这些行为都可能使得前后有较大的性能差异。所以,性能测试时,应该先跳过一段热身时间,等趋于稳定后,再开始性能测试。
  • 测试结果不稳定 - 性能测试中,有很多不稳定的因素,如环境、网络等,几乎不可能每次都是一样的结果。所以应该多次测试,求平均值。
  • 多 JVM 情况下的影响 - 应尽量避免一台机器部署多个 JVM 的情况。因为任意一个 JVM 都拥有整个系统的资源使用权,所以在性能测试时,可能会彼此干扰。

性能优化策略

  1. 性能分析 - 如果请求响应慢,存在性能问题。需要对请求经历的各个环节逐一分析,排查可能出现性能瓶颈的地方,定位问题。检查监控数据,分析影响性能的主要因素:内存、磁盘、网络、CPU,可能是代码或架构设计不合理,又或者是系统资源确实不足。
  2. 性能优化 - 性能优化根据网站分层架构,大致可分为前端性能优化、应用服务性能优化、存储服务性能优化。

应用服务性能优化

缓存

网站性能优化第一定律:第一优先考虑使用缓存提升性能

缓存是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。

  • 单点应用可以使用进程内缓存(如:ConcurrentHashMap、Caffeine);
  • 分布式应用可以使用分布式缓存(如:Redis、Memcached),或进程缓存+分布式缓存的多级缓存方案。

缓存解决方案请参考:缓存基本原理

并发模型

高并发需要根据两个条件划分:连接数量,请求数量。

  • 海量连接(成千上万)海量请求:例如抢购,双十一等
  • 常量连接(几十上百)海量请求:例如中间件
  • 海量连接常量请求:例如门户网站
  • 常量连接常量请求:例如内部运营系统,管理系统

单服务器高性能的关键之一就是服务器采取的并发模型

  • 服务器如何管理连接。
  • 服务器如何处理请求。

以上两个设计点最终都和操作系统的 I/O 模型及进程模型相关。

  • I/O 模型:阻塞、非阻塞、同步、异步。
  • 进程模型:单进程、多进程、多线程。

PPC

PPC 是 Process Per Connection 的缩写,其含义是指每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的 UNIX 网络服务器所采用的模型。基本的流程图是:

img

  • 父进程接受连接(图中 accept)。
  • 父进程“fork”子进程(图中 fork)。
  • 子进程处理连接的读写请求(图中子进程 read、业务处理、write)。
  • 子进程关闭连接(图中子进程中的 close)。

这种模式的缺点:

  • fork 代价高
  • 父子进程通信复杂
  • 支持的并发连接数量有限

prefork

PPC 模式中,当连接进来时才 fork 新进程来处理连接请求,由于 fork 进程代价高,用户访问时可能感觉比较慢,prefork 模式的出现就是为了解决这个问题。

顾名思义,prefork 就是提前创建进程(pre-fork)。系统在启动的时候就预先创建好进程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去 fork 进程的操作,让用户访问更快、体验更好。prefork 的基本示意图是:

img

prefork 的实现关键就是多个子进程都 accept 同一个 socket,当有新的连接进入时,操作系统保证只有一个进程能最后 accept 成功。但这里也存在一个小小的问题:“惊群”现象,就是指虽然只有一个子进程能 accept 成功,但所有阻塞在 accept 上的子进程都会被唤醒,这样就导致了不必要的进程调度和上下文切换了。幸运的是,操作系统可以解决这个问题,例如 Linux 2.6 版本后内核已经解决了 accept 惊群问题。

prefork 模式和 PPC 一样,还是存在父子进程通信复杂、支持的并发连接数量有限的问题,因此目前实际应用也不多。Apache 服务器提供了 MPM prefork 模式,推荐在需要可靠性或者与旧软件兼容的站点时采用这种模式,默认情况下最大支持 256 个并发连接。

TPC

TPC 是 Thread Per Connection 的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。因此,TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子进程通信复杂的问题。

TPC 的基本流程是:

img

  • 父进程接受连接(图中 accept)。
  • 父进程创建子线程(图中 pthread)。
  • 子线程处理连接的读写请求(图中子线程 read、业务处理、write)。
  • 子线程关闭连接(图中子线程中的 close)。

注意,和 PPC 相比,主进程不用“close”连接了。原因是在于子线程是共享主进程的进程空间的,连接的文件描述符并没有被复制,因此只需要一次 close 即可。

TPC 虽然解决了 fork 代价高和进程通信复杂的问题,但是也引入了新的问题,具体表现在:

  • 创建线程虽然比创建进程代价低,但并不是没有代价,高并发时(例如每秒上万连接)还是有性能问题。
  • 无须进程间通信,但是线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题。
  • 多线程会出现互相影响的情况,某个线程出现异常时,可能导致整个进程退出(例如内存越界)。

除了引入了新的问题,TPC 还是存在 CPU 线程调度和切换代价的问题。因此,TPC 方案本质上和 PPC 方案基本类似,在并发几百连接的场景下,反而更多地是采用 PPC 的方案,因为 PPC 方案不会有死锁的风险,也不会多进程互相影响,稳定性更高。

prethread

TPC 模式中,当连接进来时才创建新的线程来处理连接请求,虽然创建线程比创建进程要更加轻量级,但还是有一定的代价,而 prethread 模式就是为了解决这个问题。

和 prefork 类似,prethread 模式会预先创建线程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去创建线程的操作,让用户感觉更快、体验更好。

由于多线程之间数据共享和通信比较方便,因此实际上 prethread 的实现方式相比 prefork 要灵活一些,常见的实现方式有下面几种:

  • 主进程 accept,然后将连接交给某个线程处理。
  • 子线程都尝试去 accept,最终只有一个线程 accept 成功,方案的基本示意图如下:

img

Apache 服务器的 MPM worker 模式本质上就是一种 prethread 方案,但稍微做了改进。Apache 服务器会首先创建多个进程,每个进程里面再创建多个线程,这样做主要是为了考虑稳定性,即:即使某个子进程里面的某个线程异常导致整个子进程退出,还会有其他子进程继续提供服务,不会导致整个服务器全部挂掉。

prethread 理论上可以比 prefork 支持更多的并发连接,Apache 服务器 MPM worker 模式默认支持 16 × 25 = 400 个并发处理线程。

Reactor

I/O 多路复用技术归纳起来有两个关键实现点:

  • 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 selectepollkqueue 等。
  • 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。

I/O 多路复用结合线程池,完美地解决了 PPC 和 TPC 的问题

Reactor 模式的核心组成部分包括 Reactor 和处理资源池(进程池或线程池),其中 Reactor 负责监听和分配事件,处理资源池负责处理事件。初看 Reactor 的实现是比较简单的,但实际上结合不同的业务场景,Reactor 模式的具体实现方案灵活多变,主要体现在:

  • Reactor 的数量可以变化:可以是一个 Reactor,也可以是多个 Reactor。
  • 资源池的数量可以变化:以进程为例,可以是单个进程,也可以是多个进程(线程类似)。

最终 Reactor 模式有这三种典型的实现方案:

  • 单 Reactor 单进程 / 线程。
  • 单 Reactor 多线程。
  • 多 Reactor 多进程 / 线程。

异步操作

异步处理不仅可以减少系统服务间的耦合度,提高扩展性,事实上,它还可以提高系统的性能。异步处理可以有效减少响应等待时间,从而提高响应速度。

异步处理一般是通过分布式消息队列的方式。

异步处理可以解决以下问题:

  • 异步响应
  • 应用解耦
  • 流量削锋
  • 日志处理
  • 消息通讯

负载均衡

在高并发场景下,使用负载均衡技术为一个应用构建一个由多台服务器组成的服务器集群,将并发访问请求分发到多台服务器上处理,避免单一服务器因负载压力过大而响应缓慢,使用户请求具有更好的响应延迟特性。

高性能集群的复杂性主要体现在需要增加一个任务分配器,以及为任务选择一个合适的任务分配算法

缓存解决方案请参考:负载均衡

代码优化

多线程

从资源利用的角度看,使用多线程的原因主要有两个:IO 阻塞和多 CPU。

线程数并非越多越好,那么启动多少线程合适呢?

有个参考公式:

1
启动线程数 = (任务执行时间 / (任务执行时间 - IO 等待时间)) * CPU 内核数

最佳启动线程数和 CPU 内核数成正比,和 IO 阻塞时间成反比。

  • 如果任务都是 CPU 计算型任务,那么线程数最多不要超过 CPU 内核数,因为启动再多线程,CPU 也来不及调度;
  • 相反,如果是任务需要等待磁盘操作,网络响应,那么多启动线程有助于任务并发,提高系统吞吐量。
线程安全问题

线程安全问题时指多个线程并发访问某个资源,导致数据混乱。

解决手段有:

  • 将对象设计为无状态对象 - 典型应用:Servlet 就是无状态对象,可以被服务器多线程并发调用处理用户请求。
  • 使用局部对象
  • 并发访问资源时使用锁 - 但是引入锁会产生性能开销,应尽量使用轻量级的锁。

资源复用

应该尽量减少那些开销很大的系统资源的创建和销毁,如数据库连接、网络通信连接、线程、复杂对象等。从编程角度,资源复用主要有两种模式:单例模式和对象池。

数据结构

根据具体场景,选择合适的数据结构。

垃圾回收

如果 Web 应用运行在 JVM 等具有垃圾回收功能的环境中,那么垃圾回收可能会对系统的性能特性产生巨大影响。立即垃圾回收机制有助于程序优化和参数调优,以及编写内存安全的代码。

存储性能优化

数据库

数据库读写分离

读写分离的基本原理是将数据库读写操作分散到不同的节点上

详细解决方案参考:读写分离

数据库分库分表

数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果

详细解决方案参考:分库分表

Nosql

关系型数据库的优势在于:存储结构化数据,有利于进行各种复杂查询。

但是,它也存在一些缺点:

  • 关系数据库存储的是行记录,无法存储数据结构
  • 关系数据库的 schema 扩展很不方便
  • 关系数据库在大数据场景下 I/O 较高
  • 关系数据库的全文搜索功能比较弱

为了解决上述问题,分别诞生了解决不同问题的 Nosql 数据库。

常见的 NoSQL 数据库可以分为四类:

  • K-V 数据库:KV 存储非常适合存储不涉及过多数据关系业务关系的数据,同时能有效减少读写磁盘的次数,比 SQL 数据库存储拥有更好的读写性能,能够解决关系型数据库无法存储数据结构的问题。以 Redis 为代表。
  • 列式数据库适合于批量数据处理和即时查询,解决关系数据库大数据场景下的 I/O 问题。以 HBase 为代表。
  • 文档数据库:文档数据库(也称为文档型数据库)是旨在将半结构化数据存储为文档的一种数据库,它可以解决关系型数据库表结构 schema 扩展不方便的问题。文档数据库通常以 JSON 或 XML 格式存储数据。以 MongoDB 为代表。
  • 全文搜索引擎解决关系型数据库全文搜索功能较弱的问题。以 Elasticsearch 为代表。

详情参考:Nosql 技术选型

文件存储

机械键盘和固态硬盘

考虑使用固态硬盘替代机械键盘,因为它的读写速度更快。

B+数和 LSM 树

传统关系数据库的数据库索引一般都使用两级索引的 B+ 树 结构,树的层次最多三层。因此可能需要 5 次磁盘访问才能更新一条记录(三次磁盘访问获得数据索引及行 ID,然后再进行一次数据文件读操作及一次数据文件写操作)。

由于磁盘访问是随机的,传统机械键盘在数据随机访问时性能较差,每次数据访问都需要多次访问磁盘影响数据访问性能。

许多 Nosql 数据库中的索引采用 LSM 树 作为主要数据结构。LSM 树可视为一个 N 阶合并树。数据写操作都在内存中进行。在 LSM 树上进行一次数据更新不需要磁盘访问,速度远快于 B+ 树

RAID 和 HDFS

RAID 是 Redundant Array of Independent Disks 的缩写,中文简称为独立冗余磁盘阵列。

RAID 是一种把多块独立的硬盘(物理硬盘)按不同的方式组合起来形成一个硬盘组(逻辑硬盘),从而提供比单个硬盘更高的存储性能和提供数据备份技术。

HDFS(分布式文件系统) 更被大型网站所青睐。它可以配合 MapReduce 并发计算任务框架进行大数据处理,可以在整个集群上并发访问所有磁盘,无需 RAID 支持。

HDFS 对数据存储空间的管理以数据块(Block)为单位,默认为 64 MB。所以,HDFS 更适合存储较大的文件。

前端性能优化

浏览器访问优化

  1. 减少 HTTP 请求 - HTTP 请求需要建立通信链路,进行数据传输,开销高昂,所以减少 HTTP 请求数可以有效提高访问性能。减少 HTTP 的主要手段是合并 Css、JavaScript、图片。
  2. 使用浏览器缓存 - 因为静态资源文件更新频率低,可以缓存浏览器中以提高性能。设置 HTTP 头中的 Cache-ControlExpires 属性,可设定浏览器缓存。
  3. 启用压缩 - 在服务器端压缩静态资源文件,在浏览器端解压缩,可以有效减少传输的数据量。由于文本文件压缩率可达 80% 以上,所以可以对静态资源,如 Html、Css、JavaScrip 进行压缩。
  4. CSS 放在页面最上面,JavaScript 放在页面最下面 - 浏览器会在下载完全部的 Css 后才对整个页面进行渲染,所以最好的做法是将 Css 放在页面最上面,让浏览器尽快下载 Css;JavaScript 则相反,浏览器加载 JavaScript 后立即执行,可能会阻塞整个页面,造成页面显示缓慢,因此 JavaScript 最好放在页面最下面。
  5. 减少 Cookie 传输 - Cookie 包含在 HTTP 每次的请求和响应中,太大的 Cookie 会严重影响数据传输。

CDN

CDN 一般缓存的是静态资源。

CDN 的本质仍然是一个缓存,而且将数据缓存在离用户最近的地方,使用户已最快速度获取数据,即所谓网络访问第一跳。

反向代理

传统代理服务器位于浏览器一侧,代理浏览器将 HTTP 请求发送到互联网上,而反向代理服务器位于网站机房一侧,代理网站服务器接收 HTTP 请求。

反向代理服务器可以配置缓存功能加速 Web 请求,当用户第一次访问静态内容时,静态内容就会被缓存在反向代理服务器上。

反向代理还可以实现负载均衡,通过负载均衡构建的集群可以提高系统总体处理能力。

因为所有请求都必须先经过反向代理服务器,所以可以屏蔽一些攻击 IP,达到保护网站安全的作用。

参考资料