夏俊:深入网站服务端技术(一)——网站并发的问题

编者按】 本文来自拥有十年IT从业经验、擅长网站架构设计、Web前端技术以及Java企业级开发的夏俊,此文也是《关于大型网站技术演进的思考》系列文章的最新出炉内容,首发于CSDN,各位技术人员不容错过。

以下为正文:

一、 引子

《关于大型网站技术演进的思考》已经连载完了两个系列,它们分别是《存储的瓶颈》和《网站静态化的处理》,这两个系列对应到网站里的组件就是存储端和浏览器端,网站除了这两端外,还有一端那就是服务端了,服务端上接浏览器端,下承存储端,所以当我们想让网站的浏览器端或存储端性能更加优秀的时候,就不得不去考虑服务端的问题,因为服务端和它们永远都是剪不断理还乱的关联性。现在我要开启《关于大型网站技术演进的思考》这个主题下最后一个系列,这个系列就是讨论网站组件里最后一端服务端了,由于服务端和浏览器端以及存储端存在着一种永远都剪不断的关系,所以本系列还会讲解和其他两端相关联的技术,不过本系列的讲述的深度会更高些,希望通过这种深入的研究让我们更加深入的理解那些能作用于浏览器端和存储端的服务端技术,当然服务端除了上接浏览器端,下承存储端作用外,它自身还有自己的技术范畴,那就是如何使用服务端技术实现网站的业务逻辑了,以上这些就是本系列将要讨论的主题了。

本系列大概会按以下思路进行讨论:

  • 首先是从和浏览器端相连的服务端技术讲起,这块知识映射到MVC模式那就是控制层的相关技术,这里主要包含:并发性,并发和集群的问题,HTTP协议的转化问题等等,最后我将以MVC里C层即控制层的作用来总结下这块的技术;
  • 接下来就是讲解服务端实现业务逻辑层的技术,这个映射到MVC模式就是M层即模型层的问题了,这里面我不会去讨论如何使用服务端技术写业务逻辑,而是以模型层和控制层关系的角度、模型层和存储层关系的角度以及模型层的服务治理的角度来讨论模型层架构设计的问题;
  • 然后就是服务端和存储端相关的技术了,这里面主要就是我在存储瓶颈系列里提到的数据访问层,该层的作用是为了迁移存储层的计算功能,从而达到减轻存储层系统压力的目的,上面内容讲完以后我会参照以上的技术总结下大型网站分布式系统和网站SOA应用的特点。

当然上面的知识都是我自己多年经验和自己所掌握知识的总结,现在还没有完整的知识雏形,所以在写的过程里很有可能会根据实际情况进行调整,不管最后结果如何,上面的列举的大方向我都会尽力讲到的。

二、关于网站并发的问题

下面就我开始讲服务端和浏览器端相关部分的技术了,首先从网站的并发性开始讲起吧。

1)《关于大型网站技术演进的思考》前两系列的内容

存储的瓶颈和网站静态化处理参见本人的 博客。

2)网站并发问题概述

什么是网站的并发?这个问题答案很简单,网站的并发就是指网站在同一个时间可以同时处理多个用户请求。谈到网站的并发,很多朋友很自然的会想到多线程技术,多线程可以使得一个应用程序并行处理多个计算任务,这个技术的作用类推到Web应用里那就是多线程技术可以让网站并行处理多个请求,因此多线程技术是可以用来处理网站的并发问题的。所以当我们要去理解网站的并发问题时候,首先要解决的问题就是如何使用多线程技术。当我们学好了多线程技术,是不是就可以解决网站的并发问题了?回答当然是可以的,不过一个网站对并发的要求绝对不是仅仅要求我们会使用多线程技术那么简单了,当网站并发的问题解决后,我们马上就要面临一个同样迫切的问题了,那就是如何提升网站的并发能力,这个问题落到实处就是如何让网站在有限的系统资源下并发能力变得更强,换句话说就是如何让有限的系统资源下,网站的并发数更大,当网站并发数变大以后,我们又要考虑如何让单个请求处理效率更高。这两个内容就是本篇文章的主题了。

本篇文章主要是谈论如何提升单台服务器的并发能力问题,下一篇文章谈论的是当网站处理用户请求的服务端使用了集群技术后,针对并发的处理会发生怎样的变化呢。

3)多线程技术

并发技术对应的是多线程技术,而多线程技术又是建立在线程技术上,那么我们这里首先谈谈线程技术的问题。

第一步我要做的是明确什么是线程,下面是百度百科里对线程的解释,具体如下:

线程,有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。

从这个定义里我们知道线程有如下特点:

  • 特点一:线程是进程的一个实体,也就是说线程的母体是进程,这是线程的范畴问题。
  • 特点二:线程是轻量级的,它自己不拥有系统资源,但是我们知道线程执行时候是可以操作硬盘的文件,也可以操作内存的数据,那么这些被线程操作的资源从哪里来的呢?上面的解释里回答了这个问题,那就是线程可以共享进程的资源,换句话说线程操作的资源是进程的资源。
  • 特点三:线程自己也有寄存器和堆栈,这些东西本身也是可以存储数据的,数据也是要占用系统的部分资源,那么这个解释也就说明,线程本身也是可以占一定系统资源了,只不过这个资源比较少而已,所以这个解释就补充了特点二的场景:线程操作的资源除了它所在进程的资源外还有它自身能控制的资源,这一点比较重要,我会在后面讲线程安全问题里提到线程自有资源的使用问题的。

单个线程就是一个独立的程序执行流,我们在学习计算机语言,写的练习代码都是在一个单线程的场景下进行的,单个线程放到网站并发处理的范畴里,它有个问题是我们一定要注意的,那就是如何提升单个线程的执行效率问题,也就是说我们如何让一个线程运行的更快同时还要让一个线程执行时候所消耗的系统资源更低。为了解决这个问题,我们就要分析下单个线程的运作特点,看看线程运作流程里那些方面会影响到线程的运行效率问题。

线程运作流程很简单,我们使用线程时候首先是创建一个线程,线程创建好以后就是使用它了,线程使用完毕以后就要销毁它了。把这个流程放到请求处理场景里,我们就会发现线程的创建过程和线程的销毁过程其实是和处理请求的逻辑无关,但是一个线程又必须经历这三个阶段,因此线程创建的时间和线程销毁的时间也会被统计到请求处理时间里,那么我们就会想有没有办法可以消除线程创建和销毁所花的时间对请求处理的影响呢?

曾经有人在Linux上做过一个测试,这个测试结果就是一个线程创建和消耗至少要消耗2MB的内存,按照这个结论,如果我们在Linux上创建1000个线程那么系统至少要消耗2G以上的内存,这个消耗是非常惊人,如果我们每次使用一个线程就来个创建销毁,那么请求处理时候就会要新增很多无谓的系统资源消耗,假如碰到服务器系统资源很紧张时候,线程的频繁创建和销毁的过程就会侵占更多系统资源从而影响到线程的执行,这个问题我们又该如何来解决了?

要解决这个问题我们首先要回顾下线程的运作流程,这个结果如下图所示:

夏俊:深入网站服务端技术(一)——网站并发的问题_第1张图片

这张图不是我们希望看到的,我们希望看到下面这样的流程图,如下图所示:

夏俊:深入网站服务端技术(一)——网站并发的问题_第2张图片

我们希望请求处理流程只是作用在线程执行这块,那么在实际的生产实践里我们又是如何来解决这个问题了?解决方案就是使用线程池技术,线程池技术就是基于线程创建和销毁操作影响性能的角度来设计的,不过线程池技术并非简单的事先创建好一批线程,然后统一销毁一批线程那么简单,这里我以Java的JDK里自带的线程池技术为例来讲讲关于线程池技术的使用,内容具体如下:

JDK里的线程池对线程池大小的设定使用了两个参数,一个是核心线程个数,一个是最大线程个数,核心线程在系统启动时候就会被创建,如果用户请求没有超过核心线程处理能力,那么线程池不会再创建新线程,如果核心线程个数已经处理不过来了,线程池就会开启新线程,新线程第一次创建后,使用完毕后也不是立即对其销毁,也是被会收到线程池里,当线程池里的线程总数超过了最大线程个数,线程池将不会再创建新线程,这种做法让线程数量根据实际请求的情况进行调整,这样既达到了充分利用计算机资源的目的,同时也避免了系统资源的浪费,JDK的线程池还有个超时时间,当超出核心线程的线程在一定时间内一直未被使用,那么这些线程将会被销毁,资源就会被释放,这样就让线程池的线程的数量总是处在一个合理的范围里;如果请求实在太多了,线程池里的线程暂时处理不过来了,JDK的线程池还提供一个队列机制,让这些请求排队等待,当某个线程处理完毕,该线程又会从这个队列里取出一个请求进行处理,这样就避免请求的丢失,jdk的线程池对队列的管理有很多策略,有兴趣的童鞋可以查查百度,这里我还要说的是jdk线程池的安全策略做的很好,如果队列的容量超出了计算机的处理能力,队列会抛弃无法处理的请求,这个也叫做线程池的拒绝策略。

由上面对JDK自带线程池的介绍,我们发现使用线程池我们要考虑如下的问题,具体如下:

  • 问题一:线程池初始化的时候我们到底要事先创建多少个线程放在池子里比较合适。
  • 问题二:线程池初始化时候如果创建的线程过多会有什么问题。
  • 问题三:一台服务器由于受限于自身能力的限制例如CPU的能力,内存的能力等等,那么一台服务器可以创建多少个有效线程的数量其实是有个临界值的,那么当业务方要求创建超出临界值的线程时候我们的处理策略是怎样的呢。

问题三的我在介绍JDK里线程池的设计方案时候已经提到了,解决方法就是当业务方要求超出临界值时候,具体就是超出线程池最大线程数的时候,我们用一个队列先缓存这些请求,等线程池里的线程空闲出来后,再去从队列里取出业务请求交付给线程进行处理。

至于问题一和问题二,这个就和多线程的技术相关了,为了更好的解答它,我这里先来介绍下多线程技术。多线程技术的核心就是让多个线程同时被执行,用户的感受就是计算机可以并行执行计算任务,那么我们首先要理解下多个线程是如何进行并发操作的。具体如下:

一个程序包含两个部分,它们就是计算和存储了,好的程序就和一个活生生的人一样,存储相当于人的臭皮囊也就是身体了,而计算就是人的大脑了,而程序的计算是通过CPU来完成的,所以线程的并行执行效果就是看CPU是如何并行处理多个线程的机制了。那么CPU如何做到并行处理呢?其实CPU并不能做到并行处理,CPU只能一次执行一个计算指令,听到这个回答,我们的头是不是一下子变大了,CPU没法做到并行处理,那我们看到活生生的并行操作到底是怎么回事呢?

线程技术里有个概念叫做时间片,要解释时间片我们就要从进程说起了,当一个进程被操作系统运行时候,CPU会给这个进程分配一段执行时间,当进程里面创建了线程以后,操作系统会把进程分配到的执行时间分成片段赋予给线程,假如进程里只有一个线程,那么这个线程的时间片的长度就和进程的时间段基本一致,如果进程里开启的线程有多个,如果这些线程没有设置什么优先级策略,那么每个线程就会平分到相同的时间片,时间片就是线程被CPU执行的单位时间,当CPU按照时间片规定的时间执行了某个线程后,如果线程的CPU计算没有全部执行完毕,那么CPU就会让线程先挂起来等于是让线程处于一个等待状态,CPU的调度机制找到下一个线程,当下一个线程时间片所规定的时间执行完毕后,CPU就会让这个线程挂起来,再去找第三个线程,这个过程依次进行下去,等进程里开启的线程都执行完毕后,第一个线程才会重新开始执行,这个机制就是线程的轮询机制了,也是我们常说的线程切换机制背后的原理了。该机制可以保障在一个固定时间范围内,所有线程都能得到执行,由于线程的执行速度非常快,所以给用户的感觉就是多个线程是可以并行执行的。

由上面的原理我们来看看这个实例,具体如下:

有一个线程被分配到的时间片长度是10毫秒,如果这个线程没有其他干扰,它执行完毕需要花费50毫秒,那就等于要执行5次时间片,假如我们再新增了一个线程,该线程时间片也是10毫秒,也要执行50毫秒才能执行完毕,那么其中一个线程执行完一次时间片,按照轮询机制另外一个线程也要被执行,最后我们就会发现第一个线程执行时间就变成了100毫秒,如果我们再加上CPU的调度所花的时间,该线程的执行时间就会远远大于100毫秒,虽然100多毫秒人的感官是很难觉察到,但是这个做法毕竟让单个线程的执行效率大幅度降低了,如果我们线程开启的更多,那么单个线程执行效率也就会变得更低。

有了这个结论我们再去看看线程池问题一和问题二,如果我们一开始创建了太多线程,而且这些线程大部分都会被闲置,那么这些闲置的线程就会让有效线程的执行效率大大降低,同时闲置的线程还会消耗系统资源,而这些被消耗的系统资源都没有用到业务处理上,所以成熟的线程池方案就会设计核心线程和最大线程的概念,它们可以让线程池根据实际业务需求和系统负载能力做到动态调节,这样就可以减少开启更多线程影响线程执行效率的问题,也可以让计算机的系统资源得到更加有效的利用。

4)网站的并发与多线程技术

多线程技术可以实现并发操作,网站的并发场景非常适合使用多线程技术,那么我们就先来谈谈如何使用多线程技术来实现网站并发,首先我们从单个网站请求处理场景开始说起吧。

单个网络请求是从浏览器端发送,通过网络传输到服务端,服务端接收到数据后进行处理,处理完毕后服务端再把响应通过网络发送给浏览器端。而这个过程都是使用服务端一个线程全程陪同的完成,这个做法似乎没什么问题,这里我先给大家看一个表格,这个表格是Node.js 的作者 Ryan Dahl 为 JSConf 大会所作的演讲里提供的,具体如下所示:

I/O设备

CPU 调用周期

CPU一级缓存

3

CPU二级缓存

14

内存

250

硬盘

41000000

网络

240000000

从这个表格里我们发现,网络IO的处理时间是CPU一级缓存处理时间的一亿倍,假如我们把CPU一级缓存的处理速度等同于CPU的计算速度,这里我假设CPU执行时间是1毫秒,那么当一个线程里有网络操作时候,CPU要等待将近1亿毫秒的时间才被执行,这1亿毫秒时间里CPU不知道可以做多少事情啊。

我们再以Java的网络编程为例进一步说明这个问题,网站的网络传输协议是HTTP协议,HTTP协议使用的是TCP协议进行网络通讯的,java里使用socket技术来编写TCP通讯程序,最基本的socket编程里当客户端有数据传递到服务端后,服务端的ServerSocket就会开启一个线程处理这个请求了,但是服务端的处理需要客户端把全部数据传输完毕后才能做后续处理,所以当客户端数据还没传输完毕,处理线程就要在哪里等待数据传输完毕,我们由上表可以知道线程的等待时间相对于CPU执行时间是何等长了,等待的线程什么都没有做的时候还要参入线程的轮询处理里,因此它还会影响其他线程的执行效率。单个服务器本身所能承载的线程数量是有限的,假如某个线程就这样的被闲置起来就会导致线程的利用率十分低下。这些问题我们到底该如何来解决了?

5)如何提升线程的使用效率?

要解答标题的问题,我们首先要分析下网站请求的特点,网站的请求其实包含两个操作步奏,这两个步奏分别是IO操作和CPU操作,而线程的作用主要是体现在CPU的操作上,如果我们在一个线程里包含IO操作和CPU操作,特别是使用到很慢的网络IO操作时候,那么IO操作的效率就会影响到CPU操作的执行效率,如果我们能把这两个操作分解开来,让线程只去关心CPU操作,这样单个线程就能被更加充分的利用起来,可是IO操作毕竟是请求操作里不可分割的操作,那么我们到底该如何破这个局了?

破这个局的方法就是使用赫赫有名的reactor设计模式,我们来看看reactor模式的设计图,如下图所示:


reactor模式里有一个组件就是reactor组件,它其实是一个单独的线程,客户端的请求首先是发送到服务端的reactor线程进行处理,reactor线程采取轮询的方式轮询客户端的请求,当它发现某一个请求的数据传输完毕后,reactor就以事件通知的方式从线程池里取出一个线程进行后续处理,这样线程池里的每个线程都能被充分的使用。

我们再仔细分析下reactor模式,我们发现啊,reactor模式其实并没有提升一个请求的整体运行效率,而是把请求里效率最低的IO操作和CPU计算操作分成两步进行执行,这个方式就等于是把同步请求操作成了一个异步执行操作,虽然该方式没有提升请求整体的处理效率,但是它能让服务端的线程利用率更高,这也就变相的让网站的并发处理能力增强了,而且该方式可以避免线程被闲置在那里空转的问题,假如我们能有效的控制好线程的合理数量,那么该方式还是有可能提升单个请求的执行效率的。

Reactor模式处理请求的模式和上节里讲到请求处理模式,它们的核心问题都是发生在请求的IO处理问题上,所以上节的IO处理场景在java技术领域里有个专有名词表述那就是BIO,中文解释就是阻塞的IO,这个阻塞的含义就是指当一个IO操作执行时候它会独占IO处理的线程,其他IO操作就被此IO操作阻塞起来,而Reactor模式下的IO操作在java技术里也有个专有名词那就是NIO,NIO最早出现在JDK1.4版,当时官方解释是New IO,通过我们上面的论述我们发现NIO其实是针对BIO的阻塞问题设计的,所以我们习惯把NIO称之为非阻塞IO,非阻塞IO操作不会阻塞线程的操作。

古老的Apache服务器和java里的tomcat容器新版本都采取了NIO技术,但是Apache和它同类型的ngnix服务器相比,所能承载的并发能力实在差太多了,实际场景下Apache能支持5000个并发就要惊为天人了,而ngnix官方文档里就说它可以支持5万并发,而实际场景里支持3万并发那是一点问题都没有的。为什么ngnix可以达到如此高的性能呢?它有什么独门绝技呢?

6)C10K的目标

业界有一个C10K的目标,所谓c10k问题,指的是服务器同时支持成千上万个客户端的问题,也就是concurrent 10 000 connection(这也是c10k这个名字的由来)。

我们想让Apache同时支持上万并发这个在实际场景下是一件基本无法完成的事情,听到我的说法不知道会不会有朋友不服气,我要是把服务器的配置搞得高高的,我就不信Apache不能支持上万并发,如果我们这么做了我们就会发现服务器硬件的提升并不能导致Apache并发能力的线性提升,假如我们设定Apache在5000并发下我们通过增加硬件性能可以近似的提升Apache的并发能力,当Apache并发达到5000后,我们再把硬件性能提升一倍,最终我们一定会发现Apache的并发数并不会变成1万,它能支持到7000以上就谢天谢地了,而且Apache的并发越高,其单个连接的处理性能下降的也非常厉害。而这个场景迁移到ngnix上,结果就大不相同了,ngnix基本可以做到硬件性能提升,其并发性能也能达到线性提升的目的。

由此可见ngnix实在太优秀了,它到底用什么独门秘籍了?大家是不是很急迫的想知道了,下面我就来讲讲ngnix的设计思想了。

首先我们要分析下多线程做并发的问题,线程本身是要消耗系统资源的,如果我们开启线程很多,计算机就得抽取更多资源维护这些线程,所以线程越多浪费掉的系统资源也就更多,这是多线程做并发的第一个问题。多线程做并发第二个问题就是多线程的并行处理机制了,线程的并发要通过线程轮询或者说线程切换来保证,而线程的切换会影响单个线程的执行效率,而且CPU管理线程切换也会消耗CPU的计算能力,所以当我们线程开启的越多,单个线程的执行效率也会随之下降。这也就是Apache容器在高并发下表现出来的问题。

明确了问题,解决方法就出来了,我们如果想让线程执行效率更高我们就不要创建太多线程,这样就可以减少维护线程的开销,同时也能减少线程切换的开销,最理想的方案是我们只使用一个线程来处理所有并发,如果一个线程可以处理好所有并发,那么线程的开销问题就基本可以忽略不计了。按照这个思路我们就要抛弃多线程处理并发的思路了,这样的想法咋一看是不是有点毁人三观了。那么一个线程到底可不可以处理并发了?问题的答案是肯定的。

这里我先不讲解ngnix的设计,我们先讲讲Node.js技术,Node.js作者之所以要创建Node.js,源自于他对如何设计一个高效的Web容器的思考,他认为高效的Web容器要采取异步机制,事件驱动机制和非阻塞的IO处理,看到这个描述我们发现这个和我前面讲到reactor模式是何其的相似,但是Node.js在异步机制和事件驱动做的比上面的reactor模式方案更加优秀,在网络IO处理上,Node.js和reactor模式基本一致,在Node.js有一个专门的模块异步处理IO操作,不过Node.js对IO操作已经完成的请求的后续处理就和reactor模式大不相同了,Node.js只用一个线程完成这个请求的后续处理,这个线程处理请求的方式借鉴了多线程里并行处理的原理,因为请求最耗时的IO操作被抽取出来异步处理,所以处理后续请求的单线程只需要执行请求环节里效率最高的部分,这就让每个请求的处理速度变得非常快了,人眼基本感觉不出这个顺序性操作,不过Node.js单线程处理请求的方式和多线程的轮询机制是不太一样的,首先轮询本身的效率是很低下的,为了轮询,操作系统把线程拆分成若干个步奏执行,这个做法就增加了我们对多线程处理的难度,如果碰到不同线程操作一个共享资源,因为步奏的拆分就很有可能产生错误的操作共享资源的行为,这也就是线程安全问题的源头了,这个问题我后面会将详细讨论,这里就不展开论述了。Node.js吸取了多线程方案的经验教训,它没有采取轮询方式处理请求,而是以队列方式一个个执行请求,这样就可以充分利用CPU的操作时间,同时也规避了线程安全的问题,而且采取这个方式,当一个请求到达服务端后服务端增加的系统资源消耗基本和请求本身的系统资源消耗一致,所以和Node.js机制类似的ngnix服务器当并发上去后,请求处理性能也不会像Apache那样陡降下去。

不过Node.js处理机制里还有一个细节我们要特别注意下,那就是异步IO操作如何和主线程处理协同起来的,也就是当IO处理完毕后我们如何把后续操作插入到主线程下,这个问题看起来很简单,我们直接把新的请求放到请求队列的最后不就行了吗,那么问题来了,这个问题就和Ajax技术的问题类似,Ajax可以异步请求,但是因为请求要通过网络给服务端处理,所以Ajax从开始执行到最后获取响应处理响应结果之间就存在很大的时间差,而这个时间差是很难把控的,也许有时是1秒,有时就变成了3秒,如果我们简单把结果代码放在Ajax处理后面,那么我们只能烧香拜佛希望响应能在代码执行到结果处理位置时候到达,那么Ajax技术是如何解决这个问题呢?Ajax使用回调函数机制来破这个题,当服务端响应完全返回到客户端后,javascript里有相关的事件就会通知回调函数马上执行,这其实就把请求发送和响应处理做成了一个异步模式,这也就是Node.js解释里提到的异步机制和事件驱动了,异步机制其实就是指的是回调函数的使用,这个机制放到Node.js对主线程请求队列的操作里,那就是异步IO处理完毕后,事件机制就会把请求后续操作以回调函数的方式放在请求队列的后面,这样就能有效的保证请求处理的时序性了。

Ngnix的设计思想和Node.js的设计思想类似,不过ngnix使用的不是谷歌的V8引擎完成这个机制的,而是直接使用操作系统的类似机制完成,所以ngnix的并发能力比Node.js会强很多,它和Apache相比那就是质的飞越了。

好了,本篇内容讲完了,下篇文章我开始再补充下单台服务器并发处理的内容,之后我就要讨论并发和集群之间的问题了。

你可能感兴趣的:(夏俊:深入网站服务端技术(一)——网站并发的问题)