共计 5180 个字符,预计需要花费 13 分钟才能阅读完成。
导读 | 随着互联网的不断发展,日常生活中越来越多的需求通过网络来实现,从衣食住行到金融教育,从口袋到身份,人们无时无刻不依赖着网络,而且越来越多的人通过网络来完成自己的需求。 |
作为直接面对来自客户请求的 Web 服务端,无疑要同时承受更多的请求,并为用户提供更好的体验。这个时候 Web 端的性能常常会成为业务发展的瓶颈,提升性能刻不容缓。本文作者在开发过程中总结了一些提升 Web 服务端性能的经验,与大家分享。
对于 Web 服务端性能,首先我们分析一下相关指标。从用户角度讲,用户调用 Web 服务时,请求返回时间越短,用户体验越好。从服务端角度讲,同一时间能承载用户请求量越大,服务端性能就越强。综合两方面,我们总结性能优化的两个方向:
1. 增加服务端所能支撑并发请求的最大数量;
2. 提高每个请求处理速度。
明确了优化方向,首先介绍一种服务端通常的架构模式,即来自浏览器或者 App 的 Web 一个请求,在服务端经过哪几层结构被处理并返回的。
架构模式:IP 负载均衡 -> 缓存服务器 -> 反向代理 -> 应用服务器 -> 数据库
如图 1 所示,为了说明方便,我们来举个实际的例子: LVS(Keepalived)->Squid->nginx->Go->MySQL
图 1:服务端架构
我们对请求在每层做分发处理,这样可以使下一级结构有多个分支同时工作,来提高总体的最大并发数。
结合架构,我们来分析通常有哪些问题在拖了性能的后腿,以及找出对应的解决方法。
正常情况下,IP 负载均衡,缓存服务器和 nginx 代理这几层主要是集群稳定性问题。容易出现性能瓶颈的地方往往是应用服务器层和数据库层,我们下面来列举几个例子:
(1)问题:
大部分 Web 请求都是阻塞性质的,当一个请求被处理时,进程就会被挂起(占用 CPU)直至请求完成。在大多数情况下,Web 请求完成的足够快,所以这个问题并不被关注。然而,对于那些响应时间来完成的请求(像返回数据量大的请求或外部 API),这意味着应用程序被锁定直至处理结束,这期间,其他的请求不会被处理,很明显,这些无效的等待时间浪费掉了,并且占用系统资源,严重的影响了我们可以负担的并发请求的数量。
(2)解决办法:
Web 服务端在等待上一个请求处理的过程中,我们可以让 I / O 循环打开以便处理其他应用请求,直到处理完成时启动一个请求并给予反馈,而不再是等待请求完成的过程中挂起进程。这样,我们可以节省一些没有必要的等待时间,用这些时间去处理更多的请求,这样我们就可以大大增加请求的吞吐量,也就是在宏观上提高了我们可处理的并发请求数。
(3)例子
这里我们用 Python 的一款 Web 框架 Tornado 来具体说明改变阻塞方式提高并发性能。
场景:我们构建一个向远端(某个十分稳定的网站)发送 HTTP 请求的简单 Web 应用。这期间,网络传输稳定,我们不考虑网络来带的影响。
在这个例子中,我们使用 Siege(一款压力测试软件)对服务端在 10 秒内执行大约 10 个并发请求。
如图 2 所示,我们可以很容易看出,这里的问题是无论每个请求自身返回多么快,服务器对远端的访问请求往返都会产生足够大的滞后,因为进程直到请求完成并且数据被处理前都一直处于强制挂起状态。当一两个请求时这还不是一个问题,但达到 100 个(甚至 10 个)用户时,这意味着整体变慢。如图,不到 10 秒时间 10 个相似用户的平均响应时间达到了 1.99 秒,共计 29 次。这个例子只展示了非常简单的逻辑。如果你要添加其他业务逻辑或数据库的调用的话,结果会更糟糕。增加更多的用户请求时,同时可被处理的请求就会增长缓慢,甚至有些请求会发生超时或失败。
图 2:阻塞式响应
下面我们用 Tornado 执行非阻塞的 HTTP 请求。
如图 3 所示,我们从每秒 3.20 个事务提升到了 12.59,在相同的时间内总共提供了 118 次请求。这真是一个非常大的改善!正如你所想象的,随着用户请求增多和测试时间增长时,它将能够提供更多连接,并且不会遇到上面版本遭受的变慢的问题。从而稳定的提高了可负载的并发请求数。
Web 服务端性能提升实践
图 3:非阻塞式响应
先来介绍一下基础知识:一个应用程序是运行在机器上的一个进程;进程是一个运行在自己内存地址空间里的独立执行体。一个进程由一个或多个操作系统线程组成,这些线程其实是共享同一个内存地址空间的一起工作的执行体。
(1)问题
传统计算方式单线程运行,效率低,计算能力弱。
(2)解决办法
一种解决办法就是完全避免使用线程。例如,可以使用多个进程将重担交给操作系统来处理。但是,有个劣势就是,我们必须处理所有进程间通信,通常这比共享内存的并发模型有更多的开销。
另一种办法是用多线程工作,不过,公认的,使用多线程的应用难以做到准确,同步不同的线程,对数据加锁,这样同时就只有一个线程可以变更数据。不过过去的软件开发经验告诉我们这会带来更高的复杂度,更容易使代码出错以及更低的性能。
其中最主要的问题是内存中的数据共享,它们会被多线程以无法预知的方式进行操作,导致一些无法重现或者随机的结果(称作“竞态”)。所以这个经典的方法明显不再适合现代多核 / 多处理器编程:thread-per-connection 模型不够有效。在诸多比较合适的范式中,有个被称作 Communicating Sequential Processes(顺序通信处理)(CSP, C. Hoare 发明的)还有一个叫做 message-passing-model(消息传递)(已经运用在了其他语言中,比如 Erlang)。
我们这里使用办法是利用并行的架构来处理任务,一个并发程序可以在一个处理器或者内核上使用多个线程来执行任务,但是只有同一个程序在某个时间点同时运行在多核或者多处理器上才是真正的并行。
并行是一种通过使用多处理器以提高速度的能力。所以并发程序可以是并行的,也可以不是。
并行模式可以同时使用多线程、多核、多处理器,甚至多计算机,这无疑可以调动更多资源,从而压缩响应时间,提升运算效率,极大地增强了服务端的性能。
(3)例子
这里用 Go 语言中的 Goroutine 来具体说明。
在 Go 语言中,应用程序并发处理的部分被称作 goroutines(协程),它可以进行更有效的并发运算。在协程和操作系统线程之间并无一对一的关系:协程是根据一个或多个线程的可用性,映射(多路复用,执行于)在他们之上的;协程调度器在 Go 运行时很好的完成了这个工作。协程是轻量的,比线程更轻。它们痕迹非常不明显(使用少量的内存和资源):使用 4K 的栈内存就可以在堆中创建它们。因为创建非常廉价,必要的时候可以轻松创建并运行大量的协程(在同一个地址空间中 100,000 个连续的协程)。并且它们对栈进行了分割,从而动态的增加(或缩减)内存的使用;栈的管理是自动的,但不是由垃圾回收器管理的,而是在协程退出后自动释放。协程可以运行在多个操作系统线程之间,也可以运行在线程之内,让你可以很小的内存占用就可以处理大量的任务。由于操作系统线程上的协程时间片,你可以使用少量的操作系统线程就能拥有任意多个提供服务的协程,而且 Go 运行时可以聪明地意识到哪些协程被阻塞了,暂时搁置它们并处理其他协程。甚至,程序可以在不同的处理器和计算机上同时执行不同的代码段。
我们通常想将一个长计算过程切分成几块,然后让每个 goroutine 各自负责一块工作,这样对于单一请求的响应时间有成倍的提升。
举个例子,有一个任务分 3 个阶段,a 阶段去数据库 a 中取数据,b 阶段去数据库 b 中取数据,c 阶段合并数据返回。我们启动 goroutine 以后 a、b 阶段可以一起进行,极大地缩短了响应时间。
说白了就是部分计算过程由串行转换为并行,一个任务不需要等待其他无关的任务执行完在执行,实际计算中程序的并行执行会更有用处。
关于这部分佐证的数据就不在这边过多叙述了,感兴趣的同学可以自己看一下这方面的资料。比如 Web 服务端由 Ruby 切换为 Go 性能提升 15 倍的老故事(Ruby 使用的是绿色线程,即只有一个 CPU 得到利用)。虽然这个故事可能有点夸大,但是并行带来的性能提升是毫无疑问的。(Ruby 切换为 Go:http://www.vaikan.com/how-we-went-from-30-servers-to-2-go/)。
(1) 问题
磁盘读取数据靠的是机械运动,每次读取数据花费的时间可以分为寻道时间、旋转延迟、传输时间三个部分,寻道时间指的是磁臂移动到指定磁道所需要的时间,主流磁盘一般在 5ms 以下;旋转延迟就是我们经常听说的磁盘转速,比如一个磁盘 7200 转,表示每分钟能转 7200 次,也就是说 1 秒钟能转 120 次,旋转延迟就是 1 /120/2 = 4.17ms;传输时间指的是从磁盘读出或将数据写入磁盘的时间,一般在零点几毫秒,相对于前两个时间可以忽略不计。那么访问一次磁盘的时间,即一次磁盘 I / O 的时间约等于 9ms(5ms+4.17ms)左右,听起来还挺不错的,但要知道一台 500 -MIPS 的机器每秒可以执行 5 亿条指令,因为指令依靠的是电的性质,换句话说执行一次 I / O 的时间可以执行 40 万条指令,数据库动辄十万百万乃至千万级数据,每次 9 毫秒的时间,显然是个灾难。
(2) 解决办法
磁盘 I / O 对服务器性能的影响没有根本的解决办法,除非你把磁盘扔掉,换成别的东西。我们能在网上搜到各种存储介质的响应速度与价格,如果你有钱,你就可以任性的更换存储介质。
在不更换存储介质的条件下,我们可以减少应用程序对磁盘的访问次数,比如设置缓存,还可以把部分磁盘 I / O 放到请求周期外,比如用队列和栈来处理数据的 I / O 等。
随着业务开发模式的变化,敏捷式开发被越来越多的团队采用,周期越来越短,很多数据库查询语句都是按照业务逻辑来写,时间久了常常就忽略了 SQL 查询的格式问题,造成数据库压力的增加,使数据库查询的响应变慢。这里简单介绍 MySQL 数据库中,几条被我们忽略的常见问题和优化方式:
最左前缀匹配原则,非常重要的原则,MySQL 会一直向右匹配直到遇到范围查询 (>、<、between、like) 就停止匹配,比如 a = 1 and b = 2 and c > 3 and d = 4 如果建立 (a,b,c,d) 顺序的索引,d 是用不到索引的,如果建立 (a,b,d,c) 的索引则都可以用到,a,b,d 的顺序可以任意调整。
尽量选择区分度高的列作为索引,区分度的公式是 count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是 1,而一些状态、性别字段可能在大数据面前区分度就是 0,那可能有人会问,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要 join 的字段我们都要求是 0.1 以上,即平均 1 条扫描 10 条记录。
尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。这是因为引擎在处理查询和连接时会逐个比较字符串中每一个字符,而对于数字型而言只需要比较一次就够了。
索引列不能参与计算,保持列“干净”,比如 from_unixtime(create_time) =’2014-05-29’就不能使用到索引,原因很简单,b+ 树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成 create_time = unix_timestamp(’2014-05-29’); 应尽量避免在 where 子句中对字段进行 null 值判断,否则将导致引擎放弃使用。
索引而进行全表扫描,如:
select id from t where num is null
可以在 num 上设置默认值 0,确保表中 num 列没有 null 值,然后这样查询:
select id from t where num=0
应尽量避免在 where 子句中使用 or 来链接条件,否则将导致引擎放弃使用索引而进行全表扫描,如:
select id from t where num=10 or num=20
可以这样查询:
select id from t where num=10 union all select id from t where num=20
下面的查询也将导致全表扫描(不能前置百分号):
select id from t where name like‘%abc%’
若要提高效率,可以考虑全文检索。
in 和 not in 也要慎用,否则会导致全表扫描,如:
select id from t where num in(1,2,3)
对于连续的数值,能用 between 就不要用 in 了:
select id from t where num between 1 and 3