Loading

阿里P8面试官:如何设计一个扛住千万级并发的架构?

大家先思考一个问题,这也是在面试过程中经常遇到的问题。

如果你们公司现在的产品能够支持10W用户访问,你们老板突然和你说,融到钱了,会大量投放广告,预计在1个月后用户量会达到1000W,如果这个任务交给你,你应该怎么做?

1000W用户的问题分解

如何支撑1000W用户其实是一个非常抽象的问题,对于技术开发来说,我们需要一个非常明确的对于执行关键业务上的性能指标数据,比如,高峰时段下对于事务的响应时间、并发用户数、QPS、成功率、以及基本指标要求等,这些都 必须要非常明确,只有这样才能够指导整个架构的改造和优化。所以,如果大家接到这样一个问题,首先需要去定位到问题的本质,也就是首先得知道一些可量化的数据指标。

  • 如果有过往的相似业务交易历史数据经验,你需要尽量参考,处理这些收集到的原始数据(日志),从而分析出高峰时段,以及该时段下的交易行为,交易规模等,得到你想要看清楚的需求细节

  • 另外一种情况,就是没有相关的数据指标作为参考,这个时候就需要经验来分析。比如可以参考一些类似行业的比较成熟的业务交易模型(比如银行业的日常交易活动或交通行业售检票交易活动)或者干脆遵循“2/8”原则和“2/5/8”原则来直接下手实践。

    • 当用户能够在2秒以内得到响应时,会感觉系统的响应很快;
    • 当用户在2-5秒之间得到响应时,会感觉系统的响应速度还可以;
    • 当用户在5-8秒以内得到响应时,会感觉系统的响应速度很慢,但是还可以接受;
    • 而当用户在超过8秒后仍然无法得到响应时,会感觉系统糟透了,或者认为系统已经失去响应,而选择离开这个Web站点,或者发起第二次请求。

在估算响应时间、并发用户数、TPS、成功率这些关键指标的同时,你仍需要关心具体的业务功能维度上的需求,每个业务功能都有各自的特点,比如有些场景可以不需要同步返回明确执行结果,有些业务场景可以接受返回“系统忙,请等待!”这样暴力的消息,以避免过大的处理流量所导致的大规模瘫痪,因此,学会平衡这些指标之间的关系是必要的,大多数情况下最好为这些指标做一个优先级排序,并且尽量只考察几个优先级高的指标要求。(SLA服务等级)

SLA:Service-Level Agreement的缩写,意思是服务等级协议。服务的SLA是服务提供者对服务消费者的正式承诺,是衡量服务能力等级的关键项。服务SLA中定义的项必须是可测量的,有明确的测量方法。

image-20210623165109183

并发中相关概念的解释

在分析上述问题之前,先给大家普及一下,系统相关的一些关键衡量指标。

TPS

TPS(Transaction Per Second)每秒处理的事务数。

站在宏观角度来说,一个事务是指客户端向服务端发起一个请求,并且等到请求返回之后的整个过程。从客户端发起请求开始计时,等到收到服务器端响应结果后结束计时,在计算这个时间段内总共完成的事务个数,我们称为TPS。

站在微观角度来说,一个数据库的事务操作,从开始事务到事务提交完成,表示一个完整事务,这个是数据库层面的TPS。

QPS

QPS(Queries Per Second)每秒查询数,表示服务器端每秒能够响应的查询次数。这里的查询是指用户发出请求到服务器做出响应成功的次数,可以简单认为每秒钟的Request数量。

针对单个接口而言,TPS和QPS是相等的。如果从宏观层面来说,用户打开一个页面到页面渲染结束代表一个TPS,那这个页面中会调用服务器很多次,比如加载静态资源、查询服务器端的渲染数据等,就会产生两个QPS,因此,一个TPS中可能会包含多个QPS。

QPS=并发数/平均响应时间

image-20210622180649041

RT

RT(Response Time),表示客户端发起请求到服务端返回的时间间隔,一般表示平均响应时间。

并发数

并发数是指系统同时能处理的请求数量。

需要注意,并发数和QPS不要搞混了,QPS表示每秒的请求数量,而并发数是系统同时处理的请求数量,并发数量会大于QPS,因为服务端的一个连接需要有一个处理时长,在这个请求处理结束之前,这个连接一直占用。

举个例子,如果QPS=1000,表示每秒钟客户端会发起1000个请求到服务端,而如果一个请求的处理耗时是3s,那么意味着总的并发=1000*3=3000,也就是服务端会同时有3000个并发。

计算方法

上面说的这些指标,怎么计算呢?举个例子。

假设在10点到11点这一个小时内,有200W个用户访问我们的系统,假设平均每个用户请求的耗时是3秒,那么计算的结果如下:

  • QPS=2000000/60*60 = 556 (表示每秒钟会有556个请求发送到服务端)
  • RT=3s(每个请求的平均响应时间是3秒)
  • 并发数=556*3=1668

从这个计算过程中发现,随着RT的值越大,那么并发数就越多,而并发数代表着服务器端同时处理的连接请求数量,也就意味服务端占用的连接数越多,这些链接会消耗内存资源以及CPU资源等。所以RT值越大系统资源占用越大,同时也意味着服务端的请求处理耗时较长。

但实际情况是,RT值越小越好,比如在游戏中,至少做到100ms左右的响应才能达到最好的体验,对于电商系统来说,3s左右的时间是能接受的,那么如何缩短RT的值呢?

按照2/8法则来推算1000w用户的访问量

继续回到最开始的问题,假设没有历史数据供我们参考,我们可以使用2/8法则来进行预估。

  • 1000W用户,每天来访问这个网站的用户占到20%,也就是每天有200W用户来访问。

  • 假设平均每个用户过来点击50次,那么总共的PV=1亿。

  • 一天是24小时,根据2/8法则,每天大部分用户活跃的时间点集中在(24*0.2) 约等于5个小时以内,而大部分用户指的是(1亿点击 * 80%)约等于8000W(PV), 意味着在5个小时以内,大概会有8000W点击进来,也就是每秒大约有4500(8000W/5小时)个请求。

  • 4500只是一个平均数字。在这5个小时中,不可能请求是非常平均的,有可能会存在大量的用户集中访问(比如像淘宝这样的网站,日访问峰值的时间点集中在下午14:00、以及晚上21:00,其中21:00是一天中活跃的峰值),一般情况下访问峰值是平均访问请求的3倍到4倍左右(这个是经验值),我们按照4倍来计算。那么在这5个小时内有可能会出现每秒18000个请求的情况。也就是说,问题由原本的支撑1000W用户,变成了一个具体的问题,就是服务器端需要能够支撑每秒18000个请求(QPS=18000)

image-20210622160313561

image-20210622160320454

服务器压力预估

大概预估出了后端服务器需要支撑的最高并发的峰值之后,就需要从整个系统架构层面进行压力预估,然后配置合理的服务器数量和架构。既然是这样,那么首先需要知道一台服务器能够扛做多少的并发,那这个问题怎么去分析呢?我们的应用是部署在Tomcat上,所以需要从Tomcat本身的性能下手。

下面这个图表示Tomcat的工作原理,该图的说明如下。

  • LimitLatch是连接控制器,它负责控制Tomcat能够同时处理的最大连接数,在NIO/NIO2的模式中,默认是10000,如果是APR/native,默认是8192

  • Acceptor是一个独立的线程,在run方法中,在while循环中调用socket.accept方法中接收客户端的连接请求,一旦有新的请求过来,accept会返回一个Channel对象,接着把这个Channel对象交给Poller去处理。

    Poller 的本质是一个 Selector ,它同样也实现了线程,Poller 在内部维护一个 Channel 数组,它在一个死循环里不断检测 Channel 的数据就绪状态,一旦有 Channel 可读,就生成一个 SocketProcessor 任务对象扔给 Executor 去处理

  • SocketProcessor 实现了 Runnable 接口,当线程池在执行SocketProcessor这个任务时,会通过Http11Processor去处理当前这个请求,Http11Processor 读取 Channel 的数据来生成 ServletRequest 对象。

  • Executor 就是线程池,负责运行 SocketProcessor 任务类, SocketProcessor 的 run 方法会调用 Http11Processor 来读取和解析请求数据。我们知道, Http11Processor 是应用层协议的封装,它会调用容器获得响应,再把响应通过 Channel 写出。

image-20210622154519229

从这个图中可以得出,限制Tomcat请求数量的因素四个方面。

当前服务器系统资源

我想可能大家遇到过类似“Socket/File:Can't open so many files”的异常,这个就是表示Linux系统中的文件句柄限制。

在Linux中,每一个TCP连接会占用一个文件描述符(fd),一旦文件描述符超过Linux系统当前的限制,就会提示这个错误。

我们可以通过下面这条命令来查看一个进程可以打开的文件数量

ulimit -a 或者 ulimit -n

open files (-n) 1024 是linux操作系统对一个进程打开的文件句柄数量的限制(也包含打开的套接字数量)

这里只是对用户级别的限制,其实还有个是对系统的总限制,查看系统总线制:

cat /proc/sys/fs/file-max

file-max是设置系统所有进程一共可以打开的文件数量 。同时一些程序可以通过setrlimit调用,设置每个进程的限制。如果得到大量使用完文件句柄的错误信息,是应该增加这个值。

当出现上述异常时,我们可以通过下面的方式来进行修改(针对单个进程的打开数量限制)

vi /etc/security/limits.conf
  root soft nofile 65535
  root hard nofile 65535
  * soft nofile 65535
  * hard nofile 65535
  • *代表所有用户、root表示root用户。
  • noproc 表示最大进程数量
  • nofile代表最大文件打开数量。
  • soft/hard,前者当达到阈值时,制作警告,后者会报错。

另外还要注意,要确保针对进程级别的文件打开数量反问是小于或者等于系统的总限制,否则,我们需要修改系统的总限制。

vi /proc/sys/fs/file-max

TCP连接对于系统资源最大的开销就是内存。

因为tcp连接归根结底需要双方接收和发送数据,那么就需要一个读缓冲区和写缓冲区,这两个buffer在linux下最小为4096字节,可通过cat /proc/sys/net/ipv4/tcp_rmem和cat /proc/sys/net/ipv4/tcp_wmem来查看。

所以,一个tcp连接最小占用内存为4096+4096 = 8k,那么对于一个8G内存的机器,在不考虑其他限制下,最多支持的并发量为:810241024/8 约等于100万。此数字为纯理论上限数值,在实际中,由于linux kernel对一些资源的限制,加上程序的业务处理,所以,8G内存是很难达到100万连接的,当然,我们也可以通过增加内存的方式增加并发量。

Tomcat依赖的JVM的配置

我们知道Tomcat是Java程序,运行在JVM上,因此我们还需要对JVM做优化,才能更好的提升Tomcat的性能,简单带大家了解一下JVM,如下图所示。

image-20210623204411021

在JVM中,内存划分为堆、程序计数器、本地方发栈、方法区(元空间)、虚拟机栈。

堆空间说明

其中,堆内存是JVM内存中最大的一块区域,几乎所有的对象和数组都会被分配到堆内存中,它被所有线程共享。 堆空间被划分为新生代和老年代,新生代进一步划分为Eden和Surivor区,如下图所示。

image-20210623205840226

新生代和老年代的比例是1:2,也就是新生代会占1/3的堆空间,老年代会占2/3的堆空间。 另外,在新生代中,空间占比为Eden:Surivor0:Surivor1=8:1:1 。 举个例子来说,如果eden区内存大小是40M,那么两个Survivor区分别是占5M,整个新生代就是50M,然后计算出老年代的内存大小是100M,也就是说堆空间的总内存大小是150M。

可以通过 java -XX:PrintFlagsFinal -version查看默认参数

uintx InitialSurvivorRatio                      = 8
uintx NewRatio                                  = 2

InitialSurvivorRatio: 新生代Eden/Survivor空间的初始比例

NewRatio : Old区/Young区的内存比例

堆内存的具体工作原理是:

  • 绝大部分的对象被创建之后,会保存在Eden区,当Eden区满了的时候,就会触发YGC(Young GC),大部分对象会被回收掉,如果还有活着的对象,就拷贝到Survivor0,这时Eden区被清空。
  • 如果后续再次触发YGC,活着的对象Eden+Survivor0中的对象拷贝到Survivor1区, 这时Eden和Survivor0都会被清空
  • 接着再触发YGC,Eden+Survivor1中的对象会被拷贝到Survivor0区,一直这么循环,直到对象的年龄达到阈值,则放入到老年代。(之所以这么设计,是因为Eden区的大部分对象会被回收)
  • Survivor区装不下的对象会直接进入到老年代
  • 老年代满了,会触发Full GC。

GC标记-清除算法 在执行过程中暂停其他线程??

image-20210623214030533

程序计数器

程序计数器是用来记录各个线程执行的字节码地址等,当线程发生上下文切换时,需要依靠这个来记住当前执行的位置,当下次恢复执行后要沿着上一次执行的位置继续执行。

方法区

方法区是逻辑上的概念,在HotSpot虚拟机的1.8版本中,它的具体实现就是元空间。

方法区主要用来存放已经被虚拟机加载的类相关信息,包括类元信息、运行时常量池、字符串常量池,类信息又包括类的版本、字段、方法、接口和父类信息等。

方法区和堆空间类似,它是一个共享内存区域,所以方法区是属于线程共享的。

本地方发栈和虚拟机栈

Java虚拟机栈是线程私有的内存空间,当创建一个线程时,会在虚拟机中申请一个线程栈,用来保存方法的局部变量、操作数栈、动态链接方法等信息。每一个方法的调用都伴随这栈帧的入栈操作,当一个方法返回之后,就是栈帧的出栈操作。

本地方法栈和虚拟机栈类似,本地方法栈是用来管理本地方法的调用,也就是native方法。

JVM内存应该怎么设置

了解了上述基本信息之后,那么JVM中内存应该如何设置呢?有哪些参数来设置?

而在JVM中,要配置的几个核心参数无非是。

  • -Xms,Java堆内存大小

  • -Xmx,Java最大堆内存大小

  • -Xmn,Java堆内存中的新生代大小,扣除新生代剩下的就是老年代内存

    新生代内存设置过小会频繁触发Minor GC,频繁触发GC会影响系统的稳定性

  • -XX:MetaspaceSize,元空间大小, 128M

  • -XX:MaxMetaspaceSize,最大云空间大小 (如果没有指定这两个参数,元空间会在运行时根据需要动态调整。) 256M

    一个新系统的元空间,基本上没办法有一个测算的方法,一般设置几百兆就够用,因为这里面主要存放一些类信息。

  • -Xss,线程栈内存大小,这个基本上不需要预估,设置512KB到1M就行,因为值越小,能够分配的线程数越多。

JVM内存的大小,取决于机器的配置,比如一个2核4G的服务器,能够分配给JVM进程也就2G左右,因为机器本身也需要内存,而且机器上还运行了其他的进程也需要占内存。而这2G还得分配给栈内存、堆内存、元空间,那堆内存能够得到的也就1G左右,然后堆内存还要分新生代、老年代。

Tomcat本身的配置

http://tomcat.apache.org/tomcat-8.0-doc/config/http.html

The maximum number of request processing threads to be created by this Connector, which therefore determines the maximum number of simultaneous requests that can be handled. If not specified, this attribute is set to 200. If an executor is associated with this connector, this attribute is ignored as the connector will execute tasks using the executor rather than an internal thread pool. Note that if an executor is configured any value set for this attribute will be recorded correctly but it will be reported (e.g. via JMX) as -1 to make clear that it is not used.

server:
  tomcat:
    uri-encoding: UTF-8
    #最大工作线程数,默认200, 4核8g内存,线程数经验值800
    #操作系统做线程之间的切换调度是有系统开销的,所以不是越多越好。
    max-threads: 1000
    # 等待队列长度,默认100,
    accept-count: 1000
    max-connections: 20000
    # 最小工作空闲线程数,默认10, 适当增大一些,以便应对突然增长的访问量
    min-spare-threads: 100
  • accept-count: 最大等待数,当调用HTTP请求数达到tomcat的最大线程数时,还有新的HTTP请求到来,这时tomcat会将该请求放在等待队列中,这个acceptCount就是指能够接受的最大等待数,默认100。如果等待队列也被放满了,这个时候再来新的请求就会被tomcat拒绝(connection refused)

  • maxThreads:最大线程数,每一次HTTP请求到达Web服务,tomcat都会创建一个线程来处理该请求,那么最大线程数决定了Web服务容器可以同时处理多少个请求。maxThreads默认200,肯定建议增加。但是,增加线程是有成本的,更多的线程,不仅仅会带来更多的线程上下文切换成本,而且意味着带来更多的内存消耗。JVM中默认情况下在创建新线程时会分配大小为1M的线程栈,所以,更多的线程异味着需要更多的内存。线程数的经验值为:1核2g内存为200,线程数经验值200;4核8g内存,线程数经验值800。

  • maxConnections,最大连接数,这个参数是指在同一时间,tomcat能够接受的最大连接数。对于Java的阻塞式BIO,默认值是maxthreads的值;如果在BIO模式使用定制的Executor执行器,默认值将是执行器中maxthreads的值。对于Java 新的NIO模式,maxConnections 默认值是10000。对于windows上APR/native IO模式,maxConnections默认值为8192

    如果设置为-1,则禁用maxconnections功能,表示不限制tomcat容器的连接数。
    maxConnections和accept-count的关系为:当连接数达到最大值maxConnections后,系统会继续接收连接,但不会超过acceptCount的值。

1.3.4 应用带来的压力

前面我们分析过,NIOEndPoint接收到客户端请求连接后,会生成一个SocketProcessor任务给到线程池去处理,SocketProcessor中的run方法会调用HttpProcessor组件去解析应用层的协议,并生成Request对象。最后调用Adapter的Service方法,将请求传递到容器中。

容器主要负责内部的处理工作,也就是当前置的连接器通过Socket获取到信息之后,得到一个Servlet请求,而容器就是负责处理Servlet请求。

Tomcat使用Mapper组件将用户请求的URL定位到一个具体的Serlvet,然后Spring中的DispatcherServlet拦截到该Servlet请求后,基于Spring本身的Mapper映射定位到我们具体的Controller中。

到了Controller之后,对于我们的业务来说,才是一个请求真正的开始,Controller调用Service、Service调用dao,完成数据库操作之后,讲请求原路返回给到客户端,完成一次整体的会话。也就是说,Controller中的业务逻辑处理耗时,对于整个容器的并发来说也会受到影响。

image-20210622151107514

服务器数量评估

通过上述分析,我们假设一个tomcat节点的QPS=500,如果要支撑到高峰时期的QPS=18000,那么需要40台服务器,这四台服务器需要通过Nginx软件负载均衡,进行请求分发,Nginx的性能很好,官方给的说明是Nginx处理静态文件的并发能够达到5W/s。另外Nginx由于不能单点,我们可以采用LVS对Nginx做负载均衡,LVS(Linux VirtualServer),它是采用IP负载均衡技术实现负载均衡。

image-20210622220213652

通过这样的一组架构,我们当前服务端是能够同时承接QPS=18000,但是还不够,再回到前面我们说的两个公式。

  • QPS=并发量/平均响应时间

  • 并发量=QPS*平均响应时间

假设我们的RT是3s,那么意味着服务器端的并发数=18000*3=54000,也就是同时有54000个连接打到服务器端,所以服务端需要同时支持的连接数为54000,这个我们在前文说过如何进行配置。如果RT越大,那么意味着堆积的链接越多,而这些连接会占用内存资源/CPU资源等,容易造成系统崩溃的现象。同时,当链接数超过阈值时,后续的请求无法进来,用户会得到一个请求超时的结果,这显然不是我们所希望看到的,所以我们必须要缩短RT的值。
关注[跟着Mic学架构]公众号,获取更多精品原创

posted @ 2021-10-17 14:12  跟着Mic学架构  阅读(6406)  评论(10编辑  收藏  举报