“世间可称之为天经地义的事情没几样,复杂的互联网架构也是如此,万丈高楼平地起,架构都是演变而来,那么演变的本质是什么?”
— 1 —
引子
软件复杂性来源于几个方面:高并发高性能、高可用、可扩展、低成本、低规模、可维护、安全等。架构演化、发展都是为了试图降低复杂性:
本文主要从如何实现高并发、高性能系统角度,剖析网络应用架构演进过程中,解决的那些关键点,并找到一些规律。也可指导我们构建高并发、高性能系统时,应该注意哪些环节。
注:太多的调用链路,性能是有很大损耗的。
... ...
篇幅有限,文章不会铺开讲所有细节。
— 2 —
从网络连接开始
浏览器/app与后端通信一般使用http、https协议,底层都是使用TCP(Transmission Control Protocol 传输控制协议),而RPC远程调用可直接使用TCP连接。我们从TCP连接开始文章。
断开连接双方都可以主动发起, 分别发起、回复一共四次交互(中间会有状态),关闭连接。
注:详细细节请参阅相关文档,Windows和Linux服务器都可以使用netstat -an命令查看。
网络编程中,关于连接这块我们一般会关注以下指标:
1、连接相关
服务端能保持,管理,处理多少客户端的连接。
关于tcp连接数量,在linux下,跟文件句柄描述项有关,可以ulimit -n查看,也可修改。其它就是跟硬件资源cpu、内存、网络带宽有关。单机可以做到数十万级的并发连接数,如何实现呢?后面IO模型时讲解。
2、流量相关
主要是网络带宽的配置。
3、数据包数
数据包是TCP三次握手建立连接后,传输的内容封装
关于TCP/IP包的细节请查阅相关文档。但是有一点一定注意,我们单次请求可能会分成多个包发送,拆包、粘包问题网络中间件都会为我们处理(比如消息补齐、回车结尾、自定义消息头体、自定义协议等解决方案)。如果我们传递的用户数据较小,那么效率肯定会提升。反过来无限制的压缩传输包的大小,解压也会耗费cpu资源,需平衡处理。
4、应用传输协议
传输协议压缩率好,传输性能好,对并发性能提升高。但是也需要看调用双方的语言可以使用协议才行。可以自己定义,也可以使用成熟的传输协议。比如redis的序列化传输协议、json传输协议、Protocol Buffers传输协议、http协议等。 尤其在 rpc调用过程中,这个传输协议选择需要仔细甄别选型。
5、长、短连接
选择建议:
在客户端数量少场景一般使用长连接。后端中间件、微服务之间通信最好使用长连接。如:数据库连接,duboo默认协议等。
而大型web、app应用,使用http短连接(http1.1的keep alive变相的支持长连接,但还是串行请求/响应交互)。http2.0支持真正的长连接。
长连接会对服务端耗费更多的资源,上百万用户,每个用户独占一个连接,对服务端压力多大,成本多高。IM、push应用会使用长连接,但是会做很多优化工作。
由于https需要加解密运算等,最好使用http2.0(强制ssl),传输性能很好。但是服务端需要维持更多的连接。
6、关于并发连接与并发量
从系统整体层面、各个服务个体、服务中某个方法都需综合考虑。
举例如下:
系统整体吞吐量、RT响应时间、支持并发数 是由小的操作、微服务组成的,各个微服务、操作也需要分别评估。平衡组合后,形成系统整体的各项指标。
7、小节
首先看一个典型的互联网服务端处理网络请求的典型过程:
注:另外关于用户态、内核态数据转换,有些特殊场景中,中间件如kafka可以使用zero copy技术,避免两态切换开销。
a、(1,2,3 )三个步骤表示客户端网络请求,建立连接(管理连接),发送请求,服务器接收请求数据。
b、(4)构建响应,在用户空间处理客户端的请求,构建响应完成。
c、(5,6,7) 服务器把响应,通过a中fd连接,send发送响应客户端。
可以把上面分为两个关键点:
网络应用应该考虑平衡a+c和b,处理这些连接的能力 与 能管理的连接请求达到平衡。
比如:有个应用并发连接数十万;而这些连接大约每秒请求2万次;需要管理10万连接,每秒处理2万请求能能力,才能达到平衡。如何达到处理高qps呢,两个方向:
注:一般系统管理连接能力远远大于处理能力。
如上图,客户端的请求会形成一个大队列;服务器会处理这个大队列中的任务。这个队列能有多大,看连接管理能力;如何保证进入队列任务的速率和处理移除任务的速度平衡,是关键。达到平衡是目的。
— 3 —
网络编程中常用IO模型
客户端与服务器的交互都会产生个连接,linux中在服务器端由文件描述项 fd、socket编程中socket连接、java语言api中channel等体现。而IO模型,可以理解为管理fd,并通过fd从客户端read获取数据(客户端请求)和通过fd往客户端write数据(响应客户端)的机制。
关于同步,异步、阻塞、非阻塞 IO操作,网上、书籍上描述都不相同,也找不到准确描述。我们按照《UNIX网络编程:卷一》第六章——I/O复用为标准。书中向我们提及了5种类UNIX下可用的I/O模型:阻塞式I/O、非阻塞式I/O、I/O复用(selece,poll,epoll)、信号驱动式I/O、异步I/O。(详细可以查阅相关书籍资料)
1、阻塞式I/O:进程会卡在recvfrom的调用,等到最终结果数据返回。肯定属于同步。
2、非阻塞式I/O:进程反复轮训调用recvfrom,直到最终结果数据返回。也是同步调用,但是IO内核处理时非阻塞的。没什么实用意义,不讨论应用。
3、I/O复用也属于同步:进程卡在select、epoll调用上,不会卡在recvfrom上,直到最终结果返回。
注:select 模型:把要管理的fd放到一个数据里,循环这个数据。数组大小1024,可管理连接有限。poll 与select类似,只是把数组类型改为链表,没有1024大小限制。
而epoll 为 event poll,只会管理有事件发生的 fd,也就是只会处理活跃的连接。epoll通过内核和用户空间共享一块mmap()文件映射内存来实现的消息传递。参考 http://libevent.org/
4、信号驱动式I/O:也是同步。只有unix实现,不讨论。
5、异步:只有异步I/O属于异步。底层操作系统只有window实现,不讨论。nodejs中间件通过回调实现,java AIO也有实现。开发难度较大。
IO模型中同步/异步、阻塞/非阻塞的差别(好绕):
而我们平时在编程、函数接口调用过程中,除了超时以外,都会返回一个结果。同步异步调用按照以下区分:
注:select关键字可别混淆!!!IO多路复用从技术实现上有多种:select、poll、epoll 详细自己参阅资料,几乎所有中间件都会使用epoll模式。另外由于各个操作系统对多路复用实现机制不同,epoll、kqueue、IOCP接口都有自己的特点,第三方库的封装了这些差异,提供统一的API,如Libevent。另外如java语言,netty提供更高层面的封装,javaNIO和netty使用保留了select方法,也引起一些混淆。
小节:现在网络中间件都是用 阻塞IO和IO多路复用这两个模型来管理连接,通过网络IO获取数据。下节讲解,使用IO模型的一些中间件案例。
— 4 —
同步阻塞IO模型的具体实现模型-PPC,TPC
服务器处理数据问题,从纯网络编程技术角度看,主要思路有两个:
每个进程/线程处理一个连接,叫PPC或TPC。PPC是Process Per Connection TPC是Thread Per Conection ,传统阻塞IO模型实现的网络服务器采用这种模式。
注:close特指主进程对连接的计数,连接实际在子进程中关闭。而多线程实现中,主线程不需要close操作,因为父子线程共享存储。如:java中jmm
注:pre模式,预先创建线程和进行,连接进来,分配到预先创建好的线程或进程。多进程时有惊群现象。
申请线程或进程会占用很多系统资源,操作系统cpu、内存有限度,能同时管理的线程有限,处理连接的线程不能太多。虽然可以提前建立好进程或线程来处理数据(prefork/prethead)或通过线程池来减少线程建立压力。但是线程池的大小是个天花板。另外父子进程通信也比较复杂。
apache MPM prefork(ppc),可支持256的并发连接,tomcat 同步IO(tpc)采用阻塞IO方式工作,可支持500个并发连接。java可以创建线程池来降低一定创建线程资源开销来处理。
网络连接fd可以支持上万个,但是每个线程需要占有系统内存,线程同时存在的总数有限。linux下用命令ulimit -s可以查看栈内存分配。线程多了对cup的资源调度开销。失衡情况发生,如何解决呢?
小节:ppc、tpc瓶颈是能够管理的连接数少。本来多线程处理业务能力够,这下与fd绑定了,线程生命周期与fd一样了,限定了线程处理能力。拆分:把fd生命周期与线程的生命周期拆分开来。
— 5 —
IO模型的具体实现模型-Reactor
每个进程/线程同时处理多个连接(IO多路复用),多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回(还有更好优化,见下小节),开始进行业务处理;就是Reactor模式思想。
Reactor 模式,是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。服务端程序处理客户端传入的多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式。即 I/O 多了复用统一监听事件,收到事件后分发(Dispatch 给某进程),是编写高性能网络服务器的必备技术之一。很多优秀的网络中间件都是基于该思想的实现。
注:由于epoll比select管理的连接数大了好多,libevent,netty等框架中底层实现都是epoll方式,但是编程API保留了select关键字。所以文章中epoll_wait跟select等同。
Reactor模式有几个关键的组成:
对于IO密集型(IO bound)场景,可以使用Reactor场景,但是ThreadLocal将不能使用。开发调试难度较大,一般不建议自己实现,使用现有框架即可。
小节:Reactor解决可管理的网络连接数量提升到几十万。但是如此多连接上请求任务,还是需要通过多线程、多进程机制处理。甚至负载转发到其它服务器处理。
— 6 —
Reactor模式实践案例(C语言)
通过几个开源框架的例子,了解不同场景下的网络框架,是如何使用Reactor模式,做了哪些细节调整。
注:实际实现肯定与图差别很大。客户端io及send比较简单,图中省略。
A、单Reactor+单线程处理(整体一个线程)redis为代表
如图所示:
把请求转化为命令队列,单进程处理。注意图中 队列,单线程处理,是没有竞争的。
优点:
模型简单。这个模型是最简单的,代码实现方便,适合计算密集型应用
不用考虑并发问题。模型本身是单线程的,使得服务的主逻辑也是单线程的,那么就不用考虑许多并发的问题,比如锁和同步
适合短耗时服务。对于像redis这种每个事件基本都是查内存,是十分适合的,一来并发量可以接受,二来redis内部众多数据结构都是非常简单地实现
缺点:
性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。
顺序执行影响后续事件。因为所有处理都是顺序执行的,所以如果面对长耗时的事件,会延迟后续的所有任务,特别对于io密集型的应用,是无法承受的
这也是为什么redis禁止大家使用耗时命令
注:redis是自己实现的io多路复用,没有使用libevent,实现与图不符,更加轻巧。
这种模型对于处理读写事件操作很短很短时间内执行完。大约可达到10万QPS吞吐量(redis各种命令差别很大)。
注:redis发布版本中自带了redis-benchmark性能测试工具,可以使用它计算qps。示例:使用50个并发连接,发出100000个请求,每个请求的数据为2kb,测试host为127.0.0.1端口为6379的redis服务器性能:./redis-benchmark -h127.0.0.1 -p 6379 -c 50 -n 100000 -d 2
对于客户端数量多的网络系统,强调多客户端,也就是并发连接数。 对于后端连接数少的的网络系统,采用长连接,并发连接数少,但是每个连接发起的请求数多。
B、单 Reactor+单队列+业务线程池
如图所示,我们按把真正的业务处理从 Reactor线程中剥离出来,通过业务线程池来实现。那么Reactor中每个fd的Handler对象如何与 Worker线程池通信的,通过待处理请求队列 。客户端对服务器的请求,本来可以想象成一个请求队列IO, 这里经过Reactor(多路复用)处理后,(拆分)转化为一个待处理工作任务的队列。
注:处处是拆分啊!
业务线程池线程池分配独立的线程池,从队列中拿到数据进行真正的业务处理,将结果返回Handler。Handler收到响应结果后,send结果给客户端。
与A模型相比,利用线程池技术加快了客户端请求处理能力。例如:thrift0.10.0版本中 nonblocking server 采用这种模型,能达到几万级别的QPS。
缺点:这种模型的缺点就在于这个队列上,是性能瓶颈。线程池从队列获取任务需要加锁,会采用高性能的读写锁实现队列。
C、单 Reactor+N队列+N线程
这种模型是 A和B的变种模型,memcached采用这种模型。待处理工作队列分为多个,每个队列绑定一个线程来处理,这样最大的发挥了IO多路复用对网络连接的管理,把单队列引起的瓶颈得到释放。QPS估计可达到20万。
但是这种方案有个很大的缺点,负载均衡可能导致有些队列忙,有些空闲。好在memcached 也是内存的操作,对负载问题不是很敏感,可以使用该模型。
D、单进程Reactor监听+N进程(accept+epoll_wait+处理)模型
流程:
master(Reactor主进程)进程监听新连接的到来,并让其中一个worker进程accept。这里需要处理惊群效应问题,详见nginx的accept_mutex设计
worker(subReactor进程)进程accept到fd之后,把fd注册到到本进程的epoll句柄里面,由本进程处理这个fd的后续读写事件
worker进程根据自身负载情况,选择性地不去accept新fd,从而实现负载均衡
优点:
进程挂掉不会影响这个服务
是由worker主动实现负载均衡的,这种负载均衡方式比由master来处理更简单
缺点:
多进程模型编程比较复杂,进程间同步没有线程那么简单
进程的开销比线程更多
nginx使用这种模型,由于nginx主要提供反向代理与静态内容web服务功能,qps指标与被nginx代理的处理服务器有关系。
注:nodejs多进程部署方式与nginx方式类似。
小节:期望从这几个 Reactor的实例中,找到拆分解决了哪些问题,引起的哪些问题。
— 7 —
Reactor模式实践案例(Java语言Netty)
Netty是 一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端,java语言的很多开源网络中间件使用了netty,本文只描述针对NIO多路复用相关部分,很多拆包粘包、定时任务心跳监测、序列化钩子等等可参阅资料。如图所示:
netty可以通过配置,来实现各个模块在哪个线程(池)中运行:
1、单Reactor单线程
2、单Reactor多线程subReactor
3、单Reactor、多线程subReactor、指定线程池处理业务
https://netty.io/4.1/api/io/netty/channel/ChannelPipeline.html
我们在一个pipeline中定义多个ChannelHandler,用以接收I / O事件(例如,读取)和请求I / O操作(例如,写入和关闭)。例如,典型的服务器在每channel的pipiline中,都有以下Handler:(具体取决于使用的协议和业务逻辑的复杂性和特征):
如下例所示:
前文中提到过,web应用程序接受百万、千万的网络连接,并管理转化为请求、响应,就像一个大队列一样,如何更好的处理队列里面的任务,牵扯到负载均衡分配、锁、阻塞、线程池、多进程、转发、同步异步等一系列负载问题。 单机及分布式都要优化,netty做了很多优化,这部分netty源码不好读懂:
业务处理与IO任务公用线程池
自定义线程池处理业务
如图所示:netty中, 不固定数量的channel、固定的NioEventLoop、可外置线程池的EventExecutor,在众多channel不定时的事件驱动下,如何协调线程很是复杂。
留个问题:基于netty的spring webflux 、nodejs,为什么能支撑大量连接,而cpu成为瓶颈?
小节:这样我们从 客户端发起请求->到服务端建立连接->服务端非阻塞监听传输->业务处理->响应 整个流程,通过IO多路复用、线程池、业务线程池 让整个处理链条没有处理瓶颈、处理短板,达到整体高性能、高吞吐。
但是耗时处理能力远远低于IO连接的管理能力,单机都会达到天花板,继续拆分(专业中间件干专业事),RPC、微服务调用是解决策略。
— 8 —
分布式远程调用(不是结尾才是开始)
由前文看出,单机的最终瓶颈会出在业务处理上。对java语言来说,线程数量不可能无限扩大。就算使用go语言更小开销的协程,cpu也会成为单机瓶颈。所以跨机器的分布式远程调用肯定是解决问题的方向。业内已经有很多实践,我们从三个典型架构图,看看演进解决的问题是什么,靠什么解决的:
注:本文不从soa,rpc,微服务等方面讨论,只关注拆分的依据和目标。
A、单体应用
B、把网络连接管理和静态内容拆分
C、业务功能性拆分
A:典型单体应用。
A->B:连接管理与业务处理拆分。使用网络连接管理能力强大的nginx,业务处理单独拆分为多台机器。
B->C:业务处理从功能角度拆分。有些业务侧重协议解析、有些侧重业务判断、有些侧重数据库操作,继续拆分。
通过图C,从高性能角度,看服务分层(各层技术选型也有很多)的准则及需要注意点:
1、反向代理层(关联https连接)
2、网关层(通用无业务的操作)
反向代理层通过http协议连接网关层,二者之间通过内网ip通信,效率高很多。我们假定网关层往下游都使用tcp长连接,java语言中dobbo等rpc框架都可以实现。
网关层主要做几个事情:
网关层可以由:有开源的Zuul,spring cloud gateway,nodejs等实现。nginx也可以做网关需要定制开发,与反向代理层物理上合并。
3、业务逻辑层(业务层面的操作)
从这层可以考虑按照业务逻辑垂直分层。例如:用户逻辑层、订单逻辑层等。如果这样拆分,可能会抽象一层通过的业务逻辑层。我们尽量保证业务逻辑层不横向调用,只上游调用下游。
4、数据访问层(数据库存储相关的操作)
注:本节引用了孙玄老师《百万年薪架构师课程》中一些观点,推荐一下这门课,从架构实践、微服务实现、服务治理等方面,从本质到实战面面俱到。
网关层以下,数据库以上,RPC中间件技术选型及技术指标如下(来源dubbo官网):
小节:
单机时代:从每个线程管理一个网络连接;再到通过io多路复用,单个线程管理网络连接,腾出资源处理业务;再到io线程池和业务线程池分离;大家能发现个规律,客户端连接请求是总起点->后端处理能力逐步平衡加强的过程。业务处理能力总是赶不上接受处理的能力。
反向代理时代:nginx能够管理的连接足够的多了,后端可以转发到N台应用服务器tomcat。从某种程度上,更加有效的利用的资源,通过硬件、软件选型,把 管理连接(功能)和处理连接(功能)物理上拆分开,软件和硬件配合处理自己更擅长的事情。
SOA、微服务时代:(SOA的出现其实是为了低耦合,跟高性能高并发关系不大)业务处理有很多种类型。有的是运算密集型;有的需要操作数据库;有的只需从cache读一些数据;有些业务使用率很高;有些使用频度很低。为了更好利用又有了两种拆分机制。把操作数据库的服务单独拆出来(数据访问层),把业务逻辑处理的拆分出来(业务逻辑层);按照以上逻辑推断:可能一台nginx+3台tomcat网关+5台duboo业务逻辑+10台duboo数据访问配置合适。 我们配置的目的是,各层处理的专属的业务都能把服务器压到60%资源占用。
注:文章只关注了功能层面的水平分层。而垂直层面也需要分层。例如:用户管理和订单管理是两类不同的业务,业务技术特点、访问频次也不同。 存储层面也需要垂直分库、分表。 本文暂且略过。
单机阶段,多线程多进程其实相当于一种垂直并发拆分,尽量保证无状态,尽量避免锁等,跟微服务无状态、分布式锁原理上是一致的。
— 9 —
总结
回顾前文,客户端连接到服务器端后都要干什么呢?性能瓶颈是维护这么多连接?还是针对每个连接的处理达不到要求失衡?如何破局?从单机内部、再到物理机器拆分的描述看来,有三点及其重要:
关注平衡:达到平衡的架构,才可能是高性能、高并发架构。任何性能问题都会由某个点引起。甚至泛指业务需求与复杂度也要平衡。
拆分之道:合适的事情,让合适的技术、合适的中间件解决。具体:如何横向、纵向拆分还需分析场景。
了解业务场景、问题本质&&了解常用场景下解决方案: 按照发现问题、分析问题、解决问题思路来看,我们把弹药库备齐,解决问题的过程,就是个匹配的过程。
除了文中提到的技术以及拆分方案,很多技术点,都可以提升吞吐及性能,列举如下:
如果觉得我的文章对您有用,请点赞。您的支持将鼓励我继续创作!
赞5
添加新评论1 条评论
2019-07-25 14:52