写点什么

阿里 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 中定义的项必须是可测量的,有明确的测量方法。



并发中相关概念的解释

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

TPS

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

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

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

QPS

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

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

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



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)




服务器压力预估

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



从这个图中可以得出,限制 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,如下图所示。



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

堆空间说明

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



新生代和老年代的比例是 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 标记-清除算法 在执行过程中暂停其他线程??



程序计数器

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

方法区

方法区是逻辑上的概念,在 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 中的业务逻辑处理耗时,对于整个容器的并发来说也会受到影响。



服务器数量评估

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



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

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

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

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


来源:https://www.cnblogs.com/mic112/p/15416875.html

用户头像

云流

关注

还未添加个人签名 2020.09.02 加入

还未添加个人简介

评论

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