高性能和高并发,听着就有点类似,并且他们还经常一起提及,比如提高我们的并发性能,显然,高性能可以提高我们的并发,但是细化来看,他们是有区别的,他们的考量点的维度不同。高性能需要我们从单机维度到整体维度去考虑,更多的是先从编码角度、架构使用角度去让我们的单机(单实例)有更好的性能,然后再从整个系统层面来拥有更好的性能;高并发则直接是全局角度来让我们的系统在全链路下都能够抗住更多的并发请求。
一、高性能架构和系统设计的几个层面
高性能架构设计主要集中在单机优化、服务集群优化、编码优化三方面。但架构层面的设计是高性能的基础,如果架构层面的设计没有做到高性能,仅依靠优化编码,对整体系统的提升是有限的。我们从一个全局角度来看高性能的系统设计,需要整体考虑的包括如下几个层面:
- 前端层面。后端优化的再好,如果前端(客户端)的性能不 ok,那么对用户而言,他们的体感还是很差的,因此前端层也是有必要考虑的,只是不在我们本文的设计范围之内,在实际工作中是需要进行探讨的。
- 编码实现层面:代码逻辑的分层、分模块、协程、资源复用(对象池,线程池等)、异步、IO 多路复用(异步非阻塞)、并发、无锁设计、设计模式等。
- 单机架构设计层面:IO 多路复用、Reactor 和 Proactor 架构模式
- 系统架构设计层面:架构分层、业务分模块、集群(集中式、分布式)、缓存(多级缓存、本地缓存)、消息队列(异步、削峰)
- 基础建设层面:机房、机器、资源分配
- 运维部署层面:容器化部署、弹性伸缩
- 性能测试优化层面:性能压测、性能分析、性能优化
二、前端层面
后端优化的再好,如果前端(客户端)的性能不 ok,那么对用户而言,他们的体感还是很差的,因此前端层也是有必要考虑的,只是不在我们本文的设计范围之内,在实际工作中是需要进行探讨的。
这里简单说明下,从我个人工作的经历来看,前端(客户端)这里可以优化的点包括但不限于:数据预加载、数据本地缓存、业务逻辑前置处理、CDN 加速、请求压缩、异步处理、合并请求、长连接、静态资源等
三、编码实现层面
编码实现层面:代码逻辑的分层、分模块、协程、资源复用(对象池,线程池等)、异步、IO 多路复用(异步非阻塞)、并发、无锁设计、设计模式等。
多线程、多协程
大多数情况下,多进程、多线程、多协程都可以大大提高我们的并发性能,尤其是是多协程。
在网络框架层面,现在一般成熟的后端系统框架(服务化框架)都是支持多线程、多协程的,因此对于网络框架这点,我们只要是引用相对成熟的服务化框架来实现我们的业务,基本上可以不用过多考虑和设计。
在业务层面,如果是 Go 语言,天然支持大量并发,并且创建 Go 的协程非常容易,一个 go 关键字就搞定,因此多协程那就非常容易了,Go 里面可以创建大量协程来提高我们的并发性能。如果是其他语言,我们尽可能的使用多协程、多线程去执行我们的业务逻辑。
无锁设计(lock free)
在多线程、多协程的框架下,如果我们并发的线程(协程)之间访问共享资源,那么需要特别注意,要么通过加锁、要么通过无锁化设计,否则没有任何处理的访问共享资源会产生意想不到的结果。而加锁的设计,在并发较大的时候,如果锁的力度不合适,或者频繁的加锁解锁,又会使我们的性能严重下降。
为此,在追求高性能的时候,大家就比较推崇无锁化的设计。目前很多后台底层设计,为了避免共享资源的竞争,都采用了无锁化设计,特别是在底层框架上。无锁化主要有两种实现,无锁队列和原子操作。
- 无锁队列。可以通过 链表或者 RingBuffer(循环数组)来实现无锁队列。
- 原子操作。利用硬件同步原语 CAS 来实现各种无锁的数据结构。比如 Go 语言中的 atomic 包、C++11 语言中的 atomic 库。
数据序列化
为什么要说 数据序列化协议?因为我们的系统,要么就是各个后端微服务之间通过 RPC 做交互,要么就是通过 HTTP/TCP 协议和前端(终端)做交互,因此不可避免的需要我们进行网络数据传输。而数据,只有序列化后,才方便进行网络传输。
序列化就是将数据结构或对象转换成二进制串的过程,也就是编码的过程,序列化后,会把数据转换为二进制串,然后可以进行网络传输;反序列化就是在序列化过程中所生成的二进制串转换成数据结构或者对象的过程,将二进制转换为对象后业务才好进行后续的逻辑处理。
常见的序列化协议如下
- Protocol Buffer(PB)
- JSON
- XML
- 内置类型(如 java 语言就有 java.io.Serializable)
常见的序列化协议的对比在网上有各种性能的对比,这里就不在贴相关截图了,只说结论:从性能上和使用广泛度上来看,后端服务之间现在一般推荐使用 PB。如果和前端交互,由于 HTTP 协议只能支持 JSON,因此一般只能 JSON。
池化技术(资源复用)
池化技术是非常常见的一个提高性能的技术,池化的核心思想就是对资源进行复用,减少重复创建销毁所带来的开销。复用就是创建一个池子,然后再在这个池子里面对各种资源进行统一分配和调度,不是创建后就释放,而是统一放到池子里面来复用,这样可以减少重复创建和销毁,从而提高性能。而这个资源就包括我们编程中常见到如 线程资源、网络连接资源、内存资源,具体到对应的池化技术层面就是 线程池(协程池)、连接池、内存池等。
- 线程池(协程池)。本质都是进程、线程、协程这些维度的一个池子,先创建合适数量的线程(协程)并且初始处于休眠状态,然后当需要用到的时候,从池子里面唤醒一个,然后执行业务逻辑,处理完业务逻辑后,资源并不释放,而是直接放回池子里面休眠,等待后续的请求被唤醒,这样重复利用。
创建线程的开销是很大的,因此如果来一个请求就频创建一个线程、进程,那么请求的性能肯定不会太高。
- 连接池。这个是最常用的,一般我们都要操作 MySQL、Redis 等存储资源,同样的,我们并不是每次请求 MySQL、Redis 等存储的时候就新创建一个连接去访问数据,而是初始化的时候就创建合适数量的连接放到池子里面,当需要连接去访问数据的时候,从池子里面获取一个空闲的连接去访问数据,访问完了之后不释放连接,而是放回池子里面。
连接池需要保证连接的可用性,就是这个连接和 MySQL、Redis 等存储是必须要定期发送数据来保证连接的,要不然会被断开。同时我们要针对已经失效(断开)的连接进行检测和摘除。
- 内存池。常规的情况下,我们都是直接调用 new、malloc 等 Linux 操作系统的 API 来申请分配内存,而每次申请的内存块的大小不定,所以,当我们频繁 分配内存、回收内存的时候,会造成大量的内存碎片,同时每次使用内存都要重新分配也会降低性能。内存池就是先预先分配足够大的一块内存,当做我们的内存池,然后每次用户请求分配内存的时候,就会返回内存池中的一块空闲的内存,并将这块内存的标志置为已使用,当内存使用完毕释放内存的时候,也不是真正地调用 free 或 delete 来释放内存,而是把这块内存直接放回内存池内并且同时把标志置为空闲。一般业内都有相关的套件来帮我们来做这个事情,比如在 C/C++ 语言里面,都有相关库去封装原生的 malloc,glibc 实现了一个 ptmalloc 库,Google 实现了一个 tcmalloc 库。
- 对象池。其实前面几种类型的池化技术,其实都可以作为对象池的各种应用,因为各种资源都可以当做一个对象。对象池就是避免大量创建同一个类型的对象,从而进行池化,保证对象的可复用性。
异步IO 和 异步流程
异步有两个层面的意思:
- IO 层面的异步调用
- 业务逻辑层面的异步流程
异步是相对同步而言的,同步就是要等待前面一个事情执行完毕才能继续执行,异步就是可以不用等待,可想而知,异步的性能要比同步好很多。
IO 层面的异步调用
针对 IO 层面的异步调用,就是我们常说的 I/O 模型,有 阻塞、非阻塞、同步、异步这几种类型。在 Linux 操作系统内核中,内置了 5 种不同的 IO 交互模式,分别是阻塞 IO、非阻塞 IO、多路复用 IO、信号驱动 IO、异步 IO。
针对网络 IO 模型而言,Linux 下,使用最多性能较好的是同步非阻塞模型,具体代表是 AIO,而 Windows 下的代表作 IOCP 则实现了真正的异步非阻塞 I/O。
业务逻辑层面的异步流程
业务逻辑层面的异步流程,就是指让我们的应用程序在业务逻辑上可以异步的执行。通常比较复杂的业务,都会有很多步骤流程,如果所有步骤都是同步的话,那么当这些步骤中有一步卡住,那么整个流程都会卡住,这样的流程显然性能不会很高。为此,在业内,我们如果想要提高性能,提高并发,那么基本上都会采用异步流程的方式。
举个实际的应用案例,针对 IM 系统的发送消息的这个场景,比如微信发送消息,那么当客户端发送的消息,服务端收到后,这个消息肯定要落地存储,这个发送的流程才能算完毕,但是,如果每条消息,服务端都真正存储到 DB 后再返回给客户端说已经正确收到,那么这个性能显然会很低,因为我们知道,写 DB 的性能是很低的,尤其是像微信这种每天有大量消息的 APP。那么这个流程就可以异步化,服务端收到消息后,先把消息写入消息队列,写入队列成功就返回给客户端,然后异步流程去从消息队列里面消费数据然后落地存储到 DB 里面,这样性能就非常高了,因为消息队列的性能会很高。而比较低性能的操作都是异步处理。
并发流程
并发流程,同样是针对我们上层的应用程序而言的,我们在处理业务逻辑的时候,尤其是相对负责的业务逻辑,一般下游都可能会有多个请求,或者说多个流程,如果依赖的下游多个请求之间没有强依赖关系,那么我们可以将这些请求的流程并发处理,这个是后端系统设计里面非常常见的优化手段。
通过并发的处理流程,可以将串行的叠加处理耗时优化为单个处理耗时,这样就大大的降低了整体耗时,举个例子,一个商品活动页面,渲染的数据包括 用户基本信息、用户活动积分、用户推荐商品列表。那么当收到这个用户的请求的时候,我们需要 查询用户的基本信息、用户的活动积分,还有用户的商品推荐,而这 3 个步骤完全是没有相互依赖关系的,因此,我们可以并发去分别查询,这样可以极大的减少耗时,从而提高我们的性能。
四、单机架构设计层面
单机优化的关键点
单机优化层面就是要尽量提升单机的性能,将单机的性能发挥到极致的其中一个关键点就是我们服务器采取的并发模型,然后在这个模型下,去设计好我们的服务器对连接的管理、对请求的处理流程。而这些就涉及到我们的多协程、多线程的进程模型和异步非阻塞、同步非阻塞的 IO 模型。
在具体实现细节上,针对连接的管理,要想提高性能,那么就要采用 IO 多路复用技术,可以参考I/O Multiplexing查看,I/O 多路复用技术的两个关键点在于:
- 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 select、epoll、kqueue 等。
- 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。
IO 多路复用(epoll 模型)
基本上来说,异步 I/O 模型的发展技术是:select -> poll -> epoll -> aio -> libevent -> libuv。
而且现在大家比较熟悉和使用的最多的恐怕就是 epoll 和 aio ,尤其是 epoll 模型,基本是 Linux 后端系统下的大部分框架和软件都是采用 epoll 模型。
但是,需要特别强调的是,仅仅依靠 epoll 不是万能的,连接数太多的时候单进程的 epoll 也是不行的。
Reactor 和 Proactor 架构模式
epoll 只是一个 IO 多路复用的模型,在后端系统设计里面,要想实现单机的高性能,那在 IO 多路复用基础之上,我们的整个网络框架,还需要配合池化技术来提高我们的性能。因此,业界一般都是采用 I/O 多路复用 + 线程池(协程池、进程池)的方式来提高性能。与之对应的,在业界常用的两个单机高性能的架构模式就是Reactor 和 Proactor 模式。Reactor 模式属于非阻塞同步网络模型,Proactor 模式属于非阻塞异步网络模型。
在业内开源软件里面,Redis 采用的是 单 Reactor 单进程的方式,Memcache 采用的是 多 Reactor 多线程的方式,Nginx 采用的是多 Reactor 多进程的方式。关于 的详细介绍,可以查看The Design and Implementation of the Reactor。
Redis 可以用单进程 Reactor 模式的是因为 Redis 的应用场景是内部访问,并发数一般不会超过 1w,而 Nginx 必须用多进程 Reactor 模式是因为 Nginx 是外网访问,并发数很容易超过 1w,因此我们的网络架构模式,必须要通过 I/O 多路复用 + 线程池(协程池、进程池)来配合。
可以看到,单机优化层面其实和编码层面上的多协程、异步 IO、 池化技术都是有强关联的。这里也是一个知识相通的典型,我们所学的一些基础层面的知识点,在架构层面、模型层面都是有用武之地的。
五、系统架构设计层面
架构设计层面:架构分层、业务分模块、集群(集中式、分布式)、缓存(多级缓存、本地缓存)、消息队列(异步、削峰)
架构和模块划分的设计
整个系统想要有一个高性能,那么首先就需要有个合理的架构设计,这里需要根据一些架构设计原则,比如高内聚低耦合,职责单一等来去构建我们的架构。最有效的方式包括架构分层设计、业务分模块设计。
这么设计之后,在整体的系统性能优化上,后面就会有比较大的优化空间,从而不至于后面想要优化就根本无从下手,只能重构系统。
服务化框架的设计
目前的互联网时代,我们基本上都是采用微服务来搭建我们的系统,而微服务化的必要条件就是要有一套服务化框架,这个服务化框架最核心的功能包括 RPC 请求和最基础的服务治理策略(服务注册和发现、负载均衡等)。
为此,这里服务化框架的性能就尤为重要,这里主要包括这个服务化框架里面实现:
- 数据处理。
数据序列化协议,一般有些采用 PB 协议,不管是从性能还是维护都是最优的。
数据压缩,一般采用 gzip 压缩,压缩后可以减少网络上的数据传输。
- 网络模型。
同步还是异步流程,如果是 Go 语言,那么可以来一个请求 go 一个协程来处理。
是否有相关连接池的能力。
其他的一些优化。
负载均衡
负载均衡系统是水平扩展的关键技术,通过负载均衡,相当于可以把流量分散到不同的机器的不同的服务实例里面,这样每个服务实例都可以承担一部分请求,从而可以提高我们的整体系统的性能。
对于负载均衡的方式,大都是在客户端发现模式(client-side) 来实现服务路和负载均衡,一般也都会支持常见的负载均衡策略,如随机,轮训,hash,权重,连接数【连接数越少,优先级越高】。
合理采用各种队列
在后端系统设计里面,很多流程和请求并不要求实时处理,更不需要做到强一致,大部分情况下,我们只需要实现最终一致性就可以了。故而,我们通过队列,就可以使我们的系统能够实现异步处理逻辑、流程削峰、业务模块解耦、柔性事务等多种效果,从而可以完成最终一致性,并且能够极大的提高我们系统的性能。
我们常见的队列包括
- 消息队列:使用的最为广泛的队列之一,代表作有 RabbitMQ、RocketMQ、Kafka 等。可以用来实现异步逻辑、削峰、解耦等多种效果。从而可以极大的提高我们的性能
- 延迟队列:延时队列相比于普通队列最大的区别就体现在其延时的属性上,普通队列的元素是先进先出,按入队顺序进行处理,而延时队列中的元素在入队时会指定一个延迟时间,表示其希望能够在经过该指定时间后处理。延迟队列的目的是为了异步处理。延迟队列的应用场景其实也非常的广泛,比如说以下的场景:
到期后自动执行指定操作。
在指定时间之前自动执行某些动作
查询某个任务是否完成,未完成等待一定时间再次查询
回调通知,当回调失败时,等待后重试
- 任务队列:将任务提交到队列中异步执行,最常见的就是线程池的任务队列。
各级缓存的设计
分布式缓存
分布式缓存的代表作有 Redis、Memcache。通过分布式缓存,我们可以不直接读数据库,而是读取缓存来获取数据,可以极大的提高我们读数据的性能。而一般的业务都是读多写少,因此,对我们的整体性能的提高是非常有效的手段,而且是必须的手段。
本地缓存
本地缓存可以从几个维度来看:
- 客户端的本地缓存:针对一些不常改变的数据,客户端也可以缓存,这样就可以避免请求后端,从而可以改善性能
- 后端服务的本地缓存:后端服务中,一般都会采用分布式缓存,但是,有些场景下,如果我们的数据量比较小,那么可以直接将这些数据缓存到进程里面,这样直接通过内存读取,而不用网络耗时,性能会更高。但是本地缓存一般只会缓存少量数据。数据量太大就不合适。
多级缓存
多级缓存是一个更为高级的缓存架构设计,比如最简单的模式可以是 本地缓存 + 分布式缓存这样形成一个多级缓存架构。
我们把全量要缓存的数据都放到分布式缓存里面,然后把一些热点的少量缓存放到本地缓存里面,这样大部分热点数据都能够从本地直接读取,而其他非热点的数据还是通过分布式缓存读取,这样可以极大的提高我们的性能,提高并发能力。
举个例子,电商系统里面,我们做一个活动页,活动页的前面 10 个商品是特卖商品,然后后面的其他商品就是常规商品,因为是活动页面,那么这个页面的访问肯定就会非常大。而活动页面的前 10 个商品,必然是用户首先进来页面就一定会看到的,而用户想要继续看其他商品,那么就需要在手机上手动上滑刷新一下。这个场景下,前面 10 个商品的访问量无疑是最大的,而用户手动上滑刷新后的请求就会少很多。为此,我们可以把全量商品都缓存在分布式缓存如 redis 里面,然后再在这个基础之上,把前面 10 个商品的信息缓存到本地,这样,当活动开始后,拉取的第一页 10 个商品数据,都是从本地缓存拉取的,本地读取性能会非常高,因为内存读取就行,完全不需要网络交互。
其他的模式,可以 本地缓存 + 二级分布式缓存 + 一级分布式缓存,也就是针对分布式缓存再做一层分级,这样每一级的缓存都能抗一部分的量,因此整体来看,能够对外提供的性能就足够高。
缓存预热
通过异步任务提前将接下来要大量访问的数据预热到我们缓存里面。这样当有请求的突峰的时候,可以从容应对。
其他高性能的 NoSQL
除了 Redis、本地缓存这些,其他的一些 NoSQL 中,MongoDB、Elasticserach 也是常见的性能很高的组件,我们可以根据适用场景,合理选用。
比如我们在电商系统里面,我们针对商品的搜索、推荐都是采用 Elasticserach 来实现。
存储的设计
数据分区
数据分区是把数据按一定的方式分成多个区(比如通过地理位置),不同的数据区来分担不同区的流量,这需要一个数据路由的中间件,但会导致跨库的 Join 和跨库的事务非常复杂。
将数据分布到多个分区有两种比较典型的方案:
- 根据键做哈希,根据哈希值选择对应的数据节点。
- 根据范围分区,某一段连续的键都保存在一个数据节点上。
分库分表
一般来说,影响数据库最大的性能问题有两个,一个是对数据库的操作,一个是数据库中数据的大小。对于前者,我们需要从业务上来优化。一方面,简化业务,不要在数据库上做太多的关联查询,而对于一些更为复杂的用于做报表或是搜索的数据库操作,应该把其移到更适合的地方。比如,用 ElasticSearch 来做查询,用 Hadoop 或别的数据分析软件来做报表分析。对于后者,一般就是拆分。分库分表技术,有些地方也称为 Sharding、分片,通过分库分表可以提高我们的读写性能,
分库分表有垂直切分和水平切分两种:
- 垂直切分(分库),一般按照业务功能模块来划分,分库后分表部署到不同的库上。分库是为了提高并发能力,比如读写请求量大就需要分库。
- 水平切分(分表),当一个表中的数据量过大时,我们可以把该表的数据通过各种 ID 的 hash 散列来划分,比如 用户 ID、订单 ID 的 hash。分表更多的是应对性能问题,比如查询慢的问题。单表一般情况下,千万级别后各种性能就开始下降了,就要考虑开始分表了。
分表包括垂直切分和水平切分,而分区只能起到水平切分的作用。
读写分离
互联网系统大多数都是读多写少,因此读写分离可以帮助主库抗量,读写分离就是将读的请求量改为从库承担,写还是主库来承担。一般我们都是一主多从的架构,既可以抗量,又可以保证数据不丢。
冷热分离
针对业务场景而言,如果数据有冷热之分的话,可以将历史冷数据与当前热数据分开存储,这样可以减轻当前热数据的存储量,可以提高性能。
我们常见的存储系统比如 MySQL、Elasticserach 等都可以支持。
分布式数据库
分布式数据库的基本思想是将原来集中式数据库中的数据分散存储到多个通过网络连接的数据存储节点上,以获取更大的存储容量和更高的并发访问量,从而提高我们的性能。现在传统的关系型数据库已经开始从集中式模型向分布式架构发展了。一般云服务厂商,都会提供分布式数据库的解决方案,比如腾讯云的 TDSQL MySQL 版,TDSQL for MySQL 是腾讯打造的一款分布式数据库产品,具备强一致高可用、全球部署架构、分布式水平扩展、高性能、企业级安全等特性,同时提供智能 DBA、自动化运营、监控告警等配套设施,为客户提供完整的分布式数据库解决方案。
六、基础建设层面
基础建设层面,大体分为 3 大块:
- 机房层面,主要关注机房的网络出口带宽、入口带宽。一般这个对我们业务开发来说,都接触不到,但是这里还是需要注意,如果机房带宽不够,那么我们的服务就支撑不了大的并发,从而也没法让我们的系统有一个好的性能。
- 机器配置层面,服务器本身的性能要足够好,包括 CPU、内存、磁盘(SSD)等资源。同理,一般这个对我们业务开发来说,都接触不到,但是如果机器配置较差,那么我们的服务部署在这样的机器上面,也无法充分发挥,从而使得我们的业系统也无法拥有一个好的性能。
- 资源使用层面,我们要合理的分配 CPU 和内存等相关资源,一般 CPU 的使用率不要超过 70%-80%,超过这个阈值后,我们服务的性能就会开始下降,因此一般我们在 70% 的时候就要开始执行扩容。如果是 K8s 容器部署的话,我们可以设置 CPU 使用率超过指定阈值后就自动扩容。当然,如果是物理机部署,或者其他方式,可以同样的进行监控和及时扩容。也就是说,要保证我们所需的各种资源(CPU、内存、磁盘、带宽)都在一个合理的范围。
七、运维部署层面
在运维部署层面做好相关建设,是有助于提高我们系统的整体性能的。比如,我们可以通过容器化部署做到弹性伸缩,通过弹性伸缩的能力,可以使得我们的服务,在资源分配使用上,一直保持合理的 CPU、内存等资源的使用率。
八、性能测试优化层面
我们从架构设计层面、编码实现层面按照高性能的解决方案和思路实现了我们系统之后,理论上,我们的系统性能不会太差,但是,具体我们的系统性能如何?是否存在可优化点?代码的实现是否有性能问题?我们的依赖服务是否存在性能问题?等等,这些对我们大部分人来说,如果没有一个合理的性能压测和分析,那么可能还是黑盒的。
因此,针对我们研发人员而言,在高性能架构设计方面的最后一个环节,就是进行性能测试优化,具体包括三个环节:
- 性能压测。针对系统的各个环节先分别做压测,然后有条件的情况下,最好能够做全链路压测。
- 性能分析。压测后,最优的分析方式是结合火焰图去分析,看看性能最差的是哪里,是否有可优化的点。一定是先找到性能最差的进行优化,这样事半功倍。
- 性能优化。找到可优化点后,进行优化。然后反复这三个步骤,直到你认为性能已经完全符合预期。
本文转载自微信公众号「 后端系统和架构」,作者「 AllenWu」,可以通过以下二维码关注。
转载本文请联系「后端系统和架构」公众号。