高并发的概念及应对方法
为什么学习高并发?
作为一名非CS科班出生的同学,在经过多年IT从业之后,明显能感受到职业生涯发展的后继无力,由于从事的是传统金融行业,对应的公司其实内心深处是不重视IT部门的,而我这种IT从业人员虽然已经是团队或者是部门非常重要的人员,但是最后再发展下去也就是一个业务专家,业务专家本质上的知识不是自身的知识体系,而是公司的知识体系,而只有技术知识体系才是自己的。于是早在18年我就开始了自己的转型学习之路,前期学习了网络知识、Java并发编程,再要想学习JVM时,命运无情的枷锁打断了我的学习进程,19年就在沉闷和潜伏中度过,终于到了20年了,也即将迈入而立之年,给自己定个目标,今年上半年一定要转型完成进入一家心仪的公司,在职业生涯的黄金时期认认真真的再CODE一下。先说一下转型目标,希望能从事高并发、高性能相关的开发工作,为什么会定这个目标?除了自己对这个领域感兴趣外,确实也能从某些知名大型企业的社会招聘能看到这些领域的需求岗位是比较旺盛的,截图如下:
什么是高并发?
再谈论什么是高并发之前,我觉得非常有必要先搞清楚并发、并行和高并发这几个概念,至少对于非CS科班出身的我,一直就不知道这几个是什么高深的内容。
并发、并行和高并发
网易公开课《清华大学公开课:7.3进程的特点》中是这么定义的并发和并行,并发是指在一个时间段内有多个进程在执行,只不过在人的角度看,因为这个计算机角度的时间实在是太短暂了,人根本就感受不到是多个进程,看起来像是同时进行,这种是并发,而并行指的是在同一时刻有多个进程在同时执行。一个是时间段内发生的,一个是某一时刻发生的,如果是在只有一个CPU的情况下,是无法实现并行的,因为同一时刻只能有一个进程被调度执行,如果此时同时要执行其他进程则必须上下文切换,这种只能称之为并发,而如果是多个CPU的情况下,就可以同时调度多个进程,这种就可以称之为并行。从这里我们可以总结出并发和并行的差异,首先粒度不一样,并发针对的是时间段,并行针对的是时间点,其次行为也不一样,并发的动作侧重于处理行为,并行的动作侧重于执行行为,只不过人的视角和机器的视角差异导致看起来都是同时执行的。
那么什么是高并发呢?其实高并发的意思和前面说的并发的意思不止是差了一个“高”字,而是个宽泛得多的概念。高并发是指可以让软件系统在一段时间内能够处理大量的请求。比如每秒钟可以完成10万个请求。这是互联网系统的一个重要的特征。不像并发说的是“处理”,并行说的是“执行”,高并发说的是最终效果。只要能达到效果,不管怎么实现都行。因此,极端一点高并发甚至并不一定需要并行,只要处理速度快的足够满足要求就可以。如启动一个nginx的OS进程,它只能用到一个CPU核心,也就不可能并行。但是他如果能每秒能处理10万个请求,而业务需求只要求8万个请求就可以了,那么这个单进程的nginx本身就算高并发了。通过这段话,我们能明白高并发是最终的结果,为了实现这个结果,技术上可能会用到非常多的技术方案,这些技术方案大量应用各种并发的集中人类智慧的各种方法,并尽可能的并行。除了并发和并行,高并发还需要:
- 数据表普遍被分库分表,否则单机放不下,或者查询性能不足
- 解决分布式事务
- 因为机器都可能坏,为了保证少数机器坏掉不会影响处理的性能,必须引入HA机制
- 因为系统都有极限,超过极限响应能力就会急剧下降。因此必须引入限流的方案来保护系统
- 这么复杂的系统会涉及到N个service,N个存储,N个队列…… 这些资源的管理又成为了新的问题,这又需要对集群和服务做管理
- 这么多服务,肯定要解决分布式的Tracing和报警问题
- ……
到此我们可以知道,高并发其实是一个技术体系,甚至不同的情况下高并发的指标效果还不一样,为了达到这个效果,我们会使用很多能并发处理的技术,为了能达到这个效果,我们会在应用系统的每一层做不同的处理,为了能达到这个效果,我们还可以根据语言的不同特性来选择实现最终效果的编程语言。
如何应对高并发?
在这一节,我会概括性的总结我所学习的应对高并发的方式方法,之所以说是概括性,是因为高并发这个领域涉及的技术和知识点都不是可以速成的,也不是我这个传统IT行业从业人员实践所得,而是从网络、书本中获取的其他人的经验总结,后续我会从中选取高并发中的连接处理、RPC相关内容做一个较为深入的学习和总结,希望以此作为突破口,实现自己上半年的目标。
从一个完整的HTTP请求说起
高并发意味着单位时间内系统能处理的请求数很高,也就是说系统所能承载的HTTP请求很多,那要应对高并发,就要从HTTP请求处理层面开始,如下是我理解的一个完整的HTTP请求所经历的流程:
1、DNS域名解析
将请求域名解析为IP地址。
2、与IP地址对应的服务器网卡建立连接,TCP的三次握手,连接建立并占用
3、服务器操作系统通过连接读取和处理请求
3.1 从连接中读取字节流(IO密集)
3.2 将读取到的字节流转换成HTTP请求(CPU密集)
4、服务器操作系统将HTTP请求转发给WEB Server或者Application Server
4.1 Application在开发逻辑架构中一般会分层,分为表现层、业务层和持久层
5、Application进行业务逻辑处理并准备响应Response
6、Response准备完成,Response通过网卡回写到用户的浏览器(IO密集)
7、TCP连接三次挥手,断开连接
从这个过程中,我们对一个HTTP的请求能有一个感性的认识,基于这个感性的认识,我们能知道这里面几个关键的点:域名解析、连接处理、系统分层,从这几个关键点其实就可以提取出一些应对方法。
域名解析
域名解析这个环节有一个应对高并发的方法就是CDN,CDN 就是将静态的资源分发到位于多个地理位置机房中的服务器上,因此它能很好地解决数据就近访问的问题,也就加快了静态资源的访问速度。搭建CDN主要有两个关键点,一是如何将用户的请求映射到 CDN 节点上,二是如何根据用户的地理位置信息选择到比较近的节点。
将用户的请求映射到 CDN 节点其实就是域名解析(DNS)的过程,DNS解析的过程大致如下:
首先查看本机的host文件,查看是否有该域名对应的IP地址;
如果没有就请求Local DNS是否有域名解析结果的缓存,如果有就返回标识是否从非权威DNS返回的结果;
如果还是没有就进入DNS迭代查询的过程,先查询根DNS的地址(如.com/.cn/.org), 再请求顶级DNS得到二级域名服务器地址(如baidu.com);再从二级域名服务器中查询到 子域名对应的 IP 地址(如www.baidu.com),返回这个 IP 地址的同时标记这个结果是来自于权威 DNS 的结果,同时写入 Local DNS 的解析结果缓存,这样下一次的解析同一个域名就不需要做 DNS 的迭代查询了。
根据用户的地理位置信息选择到比较近的节点则是GSLB的作用,GSLB(Global Server Load Balance,全局负载均衡)的含义是对于部署在不同地域的服务器之间做负载均衡,下面可能管理了很多的本地负载均衡组件。它有两方面的作用:一方面,它是一种负载均衡服务器,指的是让流量平均分配使得下面管理的服务器的负载更平均;另一方面,它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。
连接处理
这一节,我们关注网络连接处理IO层面的一些应对之策。
Tomcat如何应对
实际工作当中,Tomcat作为容器承担着与用户建立连接、解析请求、转发请求、回写响应的工作,Connector作为Tomcat两个核心组件之一,主要任务是实际负责接收浏览器发来的连接请求,创建一个Request和Response对象分别用于和请求端交换数据,把产生的Request和Response对象传给后续处理这个请求的工作线程。先给个结论,默认情况下Tomcat7.0应对大量连接的能力不如Tomcat8.5,为什么呢?可以来看下Connector中关于protocol的设置:
7.0 protocol- The default value is HTTP/1.1 which uses an auto-switching mechanism to select either a blocking Java based connector or an APR/native based connector.
8.5 protocol- The default value is HTTP/1.1 which uses an auto-switching mechanism to select either a Java NIO based connector or an APR/native based connector.
APR/native全称Apache Portable Runtime,由Apache基金会提供维护支持,非tomcat自带的组件,需要独立安装,能应对大量的连接,但是如果在未安装该组件的情况下,我们可以发现默认情况下7.0使用的是blocking Java而8.5使用的是Java NIO,由此是不是可以猜想是IO处理方式的不同决定了应对高并发的效果不一样。
IO模型
在继续探讨连接处理层面的应对之策前,我们先来回顾一下Unix环境下的网络IO模型。
阻塞式IO模型(blocking I/O):
进程阻塞调用,一旦线程创建过多,线程的创建销毁成本高,线程本身的内存占用大,CPU不断的进行线程的上下文切换。
非阻塞式IO模型(nonblocking I/O):
应用线程在IO数据准备阶段,忙等待。
IO多路复用模型(I/O multiplexing):
相对于同步阻塞IO模型,借由操作系统提供的select/poll功能使用少量线程管理大量连接,用户进程通过select/poll获取当前实际ready的socket,然后在进程中阻塞的将内核态拷贝到用户态。在Linux系统中,有更高效的epoll实现。实际使用中,socket常会被设置成以non-blocking方式访问。
信号驱动式IO模型(signal-driven IO):
异步IO模型(asynchronous I/O):
IO模型区别总览:
Java NIO
在了解完网络IO模型之后,我们再回来看下Tomcat中的Java NIO是如何体现这个IO模型的。
Java NIO中的三个核心概念:Channel、Selector和Buffer。
Channel是可IO操作的硬件、文件、socket、其他程序组件的包装。
Selector是select/poll/epoll包装。
Buffer是数据容器,和Channel成对出现,数据只能从Channel中读取和写入。
JDK1.4引入的NIO,Selector使用Linux poll实现。
JDK6 & JDK 5 update 9支持epoll(Linux Kernel >= 2.6)。
JDK7 支持NIO2,引入异步IO的四个通道:
AsynchronousSocketChannel
AsynchronousServerSocketChannel
AsynchronousFileChannel
AsynchronousDatagramChannel
值得注意的是,这四个通道运行在Linux中仍是通过epoll实现,并非真正意义的异步IO。
至此我们可以知道我们工作中经常使用的Tomcat是如何在IO模型上应对高并发了。
再谈Tomcat 线程模型
Acceptor负责接收Socket并将之封装为NioChannel,通过NioChannel的register注册到Poller中。
Poller实际是基于Java NIO Selector实现的,主要功能为获取当前可读取的NioChannel并将其分发给实际的Worker。
Worker是实际负责请求处理的线程池,将请求转发给应用层处理。
一个新的问题
高并发带来的请求流量都很大,巨大的流量冲击到Tomcat后,如果Worker处理不过来,系统会发生什么呢?
三个重要的tomcat connector参数:acceptCount,maxConnections,maxThreads
若没有空闲的Worker(即到达了maxThreads),新来的请求不得不接受长时间的等待,一直等到有新的Worker为止。
更多的请求到达,Acceptor发现创建的NioChannel抵达maxConnections时,阻塞。
更多的请求到达,OS层面的连接仍会继续进行,抵达acceptCount时,OS拒绝连接,Client端显示Connection refused。
这个时候一个新的应对方式也就产生了-异步化。
基于Servlet API的异步方式
在servlet3.0引入了基于servlet API实现的异步处理方案,流程图如下:
Tomcat的工作线程(Worker)在支持异步的Servlet中通过调用如下命令开启异步处理:
AsyncContext ServletRequest.startAsync(Request request,Response response)
Tomcat的工作线程会将当前的request,response对象的引用放入AsyncContext中,并将AsyncContext交付给一个新的线程处理,保持response的打开状态,然后立即返回Tomcat工作线程池等待处理其他的请求。
新的线程应该是什么?
新的线程是业务线程池产生的,并且不是一个线程池,而是根据业务设计的多级线程池组。例如区分核心业务和非核心业务线程池。通过线程池的隔离,保证非核心业务的抖动不会影响到核心业务,另一个好处就是保留对业务线程池的监控、运维、降级等。新的线程主要做业务处理,通过response回写响应并且关闭response。
AsyncContext.complete()
Spring 3.2开始支持Servlet 3.0,通过异步化,我们将解析和业务处理彻底剥离,同时业务处理间区分不同的线程池保证隔离性。
异步处理不会降低响应的时间,但是会提高吞吐量,从而应对高并发。
Reactive Stack
一个相对于servlet-stack独立的全新的技术栈reactive stack:Spring-WebFlux。
从图中可以看出这个全新的stack是基于Reactive Streams。
最后总结一下,连接处理层面应对高并发的思路就是:
阻塞变非阻塞,同步变异步,核心就是充分利用单机性能,压榨CPU。
系统分层
这一节,我们将从系统分层这个层面来看每一层可以采用的应对之策。
业务层
本节主要关注在业务层,面对高并发场景下对于业务逻辑实现相关的处理方案。
缓存
缓存是一种存储数据的组件,它的作用就是让对数据的请求能更快的返回。高并发的场景下,如果能快速的返回请求所需要的数据,对于系统持久层是一种相当好的保护措施,对于系统来说也能提升吞吐量。常见的缓存技术有静态缓存、分布式缓存和热点本地缓存。
静态缓存在 Web 1.0 时期是非常著名的,它一般通过生成 Velocity 模板或者静态 HTML 文件来实现静态缓存,在 Nginx 上部署静态缓存可以减少对于后台应用服务器的压力。这种缓存只能针对静态数据来缓存,对于动态请求就无能为力了。那么我们如何针对动态请求做缓存呢?这时你就需要分布式缓存了。
分布式缓存性能强劲,通过一些分布式的方案组成集群可以突破单机的限制。所以在整体架构中,分布式缓存承担着非常重要的角色。
对于静态的资源的缓存可以选择静态缓存,对于动态的请求可以选择分布式缓存,那么什么时候要考虑热点本地缓存呢?答案是当我们遇到极端的热点数据查询的时候。热点本地缓存主要部署在应用服务器的代码中,用于阻挡热点查询对于分布式缓存节点或者数据库的压力。
消息队列
系统的初级阶段基本上是读多写少,所以在应对高并发时业务层会先加入缓存组件,希望通过缓存阻挡大量的读请求。随着系统的不断发展,高并发系统开始涌入大量的写请求,此时会使用到消息队列来应对高并发的场景。
异步解耦和削峰填谷是消息队列的主要作用,其中异步处理可以简化业务流程中的步骤,提升系统性能;削峰填谷可以削去到达秒杀系统的峰值流量,让业务逻辑的处理更加缓和;解耦可以将系统和其他系统解耦开,这样一个系统的任何变更都不会影响到另一个系统。
业务拆分
正常情况下,初期为了系统能尽快上线,都会是以单体架构的形式出现,而随着流量和请求的增多,单体架构开始出现一些问题:
数据库连接数可能会成为系统的瓶颈;
内部成员的沟通成本问题;
代码提交,分支管理,项目编译等问题;
模块相互依赖,强耦合,一旦出问题容易牵一发而动全身;
………
这个时候,为了系统能达到支撑高并发的效果,便会对系统做业务拆分,以应对上述众多问题,拿我们保险系统来说,最早期的保险核心系统,其实契约、保全、财务、理赔甚至是各种报表都是集中在一个系统中,虽然保险是一个低频行为,不太具有高并发的特性,但是在系统发展的过程中,也会有类似的拆分,将契约、保全、财务、理赔等从一个系统拆分成多个系统,而高并发系统则是使用了更细粒度的拆分,以提供服务的形式来应对高并发,在流量高峰期可以动态扩容,平稳流量,避免系统崩溃。
RPC
服务拆分之后,原本的单体系统就会变成分布式系统,原来在同一个进程里面两个方法调用就会变成跨进程跨网络的两个方法调用,此时就会引入RPC框架来解决跨网络通信问题。RPC框架封装了网络调用的细节,可以实现像调用本地服务一样调用远程部署的服务。
一个完整的RPC的步骤如下:
在一次 RPC 调用过程中,客户端首先会将调用的类名、方法名、参数名、参数值等信息,序列化成二进制流;
然后客户端将二进制流通过网络发送给服务端;
服务端接收到二进制流之后将它反序列化,得到需要调用的类名、方法名、参数名和参数值,再通过动态代理的方式调用对应的方法得到返回值;
服务端将返回值序列化,再通过网络发送给客户端;
客户端对结果反序列化之后,就可以得到调用的结果了。
从这个过程中,我们可以提取出RPC的核心过程就是网络传输和序列化。其中网络传输可以选择Netty,而序列化则可以选择Thrift或者Protobuf。
RPC能够解决服务之间跨网络通信的问题,但是对于RPC来说,又是如何知道自己该调用谁或者谁会调用自己呢?这个时候就会引入注册中心来解决这个问题,比如ZooKeeper、ETCD、Eureka等。
API网关
API 网关(API Gateway)不是一个开源组件,而是一种架构模式,它是将一些服务共有的功能整合在一起,独立部署为单独的一层,用来解决一些服务治理的问题。你可以把它看作系统的边界,它可以对出入系统的流量做统一的管控。对于高并发系统,API网关的搭建是非常有必要的,因为它可以实现如下作用:
1、提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。在系统中,微服务对外暴露的协议可能不同:有些提供的是 HTTP 服务;有些已经完成 RPC 改造,对外暴露 RPC 服务;有些遗留系统可能还暴露的是 Web Service 服务。API 网关可以对客户端屏蔽这些服务的部署地址以及协议的细节,给客户端的调用带来很大的便捷。
2、在 API 网关中,可以植入一些服务治理的策略,比如服务的熔断、降级、流量控制和分流等等。
3、客户端的认证和授权的实现,也可以放在 API 网关中。不同类型的客户端使用的认证方式是不同的。手机 APP 可以使用 Oauth 协议认证,HTML5 端和 Web 端使用 Cookie 认证,内部服务使用自研的 Token 认证方式。这些认证方式在 API 网关上可以得到统一处理,应用服务不需要了解认证的细节。
4、API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。
5、在 API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志。
持久层
本节主要关注在高并发场景下,数据持久化层所需要关注的内容。
读写分离
持久层最先可能会用到的应对高并发的方法就是读写分离。对于高并发系统来说,一开始都是读多写少,大量的读请求经过业务层到达持久层之后对数据库产生了极大的压力,此时就会准备一个和生产库一致的数据库来单独接收处理读请求,一般这个叫做从库,也会被称为读库,高并发流量中的读请求会被引流到从库,而写请求还是在主库写,主库和从库之间依靠主从复制机制来确保两个库的数据近乎实时一致,这样用户在读请求的时候几乎发现不了数据的不一致。
分库分表
读写分离主要是为了应对读请求,那如果写请求的流量大,此时又会有什么应对的方案呢?常见的一种处理方法就是分库分表。
分库分表是一种常见的将数据分片的方式,它的基本思想是依照某一种策略将数据尽量平均地分配到多个数据库节点或者多个表中。不同于主从复制时数据是全量地被拷贝到多个节点,分库分表后,每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效地提升数据查询的性能。同时,因为数据被分配到多个数据库节点上,那么数据的写入请求也从请求单一主库变成了请求多个数据分片节点,在一定程度上也会提升并发写入的性能。
数据库分库分表的方式有两种:一种是垂直拆分,另一种是水平拆分。垂直拆分的原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。比如前面业务拆分时提到的将保险核心拆成契约、保全、财务和理赔等各大核心系统,这就是一种垂直拆分。和垂直拆分的关注点不同,垂直拆分的关注点在于业务相关性,而水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在数据的特点。拆分的规则有下面这两种:
- 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表,比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分。
- 另一种比较常用的是按照某一个字段的区间来拆分,比较常用的是时间字段。你知道在内容表里面有“创建时间”的字段,而我们也是按照时间来查看一个人发布的内容。我们可能会要看昨天的内容,也可能会看一个月前发布的内容,这时就可以按照创建时间的区间来分库分表,比如说可以把一个月的数据放入一张表中,这样在查询时就可以根据创建时间先定位数据存储在哪个表里面,再按照查询条件来查询。
NoSQL
读写分离和分库分表是改善数据库持久层应对并发能力的利器,但是在高并发的持续不断的积累下,传统关系型数据库已经很难应对数据量上的瓶颈,此时便可以利用NoSQL来帮助解决这些问题,因为它有着天生分布式的能力,能够提供优秀的读写性能,可以很好地补充传统关系型数据库的短板。
本文从宏观上描述了高并发的概念及相关的应对方法,或概括性、或略微详细的描述了自己的理解,后续我会选择里面我感兴趣的NIO、RPC等连接处理和服务相关的领域做较为深入的学习和总结。
参考资料:
https://www.zhihu.com/question/307100151?utm_source=wechat_timeline
http://tomcat.apache.org/tomcat-7.0-doc/config/http.html
http://tomcat.apache.org/tomcat-8.5-doc/config/http.html
极客时间《高并发系统40问》
《UNIX Network Programming.Volume 1.Third Edition.The Sockets Networking API》