MySQL优化系列18-应用层优化

备注:测试数据库版本为MySQL 8.0

一.概述

如果在提高MySQL的性能上花费太多时间,容易使视野局限于MySQL本身,而忽略了用户体验。回过头来看,也许可以意识到,或许MySQL已经足够优化,对于用户看到的响应时间而言,其所占的比重已经非常之小,此时应该关注下其他部分了。这是个很不错的观点,尤其是对DBA而言,这是很值得去做的正确的事。但如果不是MySQL,那又是什么导致了问题呢?如果能顺着应用的逻辑过程从头到尾来剖析,那么找到问题的源头一般来说并不困难。有时,尽管问题在MySQL上,也很容易在系统的另一部分得到解决。

无论问题出在哪里,都至少可以找到一个靠谱的工具来帮助进行分析,而且通常是免费的。例如,如果有JavaScript或者页面渲染的问题,可以使用包括Firefox浏览器的Firebug插件在内的调优工具,或者使用Yahoo!的YSlow工具。

二. 常见问题

我们在应用中反复看到一些相同的问题,经常是因为人们使用了缺乏设计的现成系统或者简单开发的流行框架。虽然有时候可以通过这些框架更快更简单地构建系统,但是如果不清楚这些框架背后做了什么操作,反而会增加系统的风险。

下面是我们经常会碰到的问题清单,通过这些过程可以激发你的思维。

  1. 什么东西在消耗系统中每台主机的CPU、磁盘、网络,以及内存资源?这些值是否合理?如果不合理,对应用程序做基本的检查,看什么占用了资源。配置文件通常是解决问题最简单的方式。例如,如果Apache因为创建1000个需要50MB内存的工作进程而导致内存溢出,就可以配置应用程序少使用一些Apache工作进程。也可以配置每个进程少使用一些内存。

  2. 应用真的需要所有获取到的数据吗?获取1000行数据但只显示10行,而丢弃剩下的990行,这是常见的错误。(如果应用程序缓存了另外的990行备用,这也许是有意的优化。)

  3. 应用在处理本应由数据库处理的事情吗,或者反过来?这里有两个例子,从表中获取所有的行在应用中进行统计计数,或者在数据库中执行复杂的字符串操作。数据库擅长统计计数,而应用擅长正则表达式。要善于使用正确的工具来完成任务。

  4. 应用执行了太多的查询?ORM宣称的把程序员从写SQL中解放出来的语句接口通常是罪魁祸首。数据库服务器为从多个表匹配数据做了很多优化,因此应用程序完全可以删掉多余的嵌套循环,而使用数据库的关联来代替。

  5. 应用执行的查询太少了?好吧,上面只说了执行太多SQL可能成为问题。但是,有时候让应用来做“手工关联”以及类似的操作也可能是个好主意。因为它们允许更细的粒度控制和更有效的使用缓存,以及更少的锁争用,甚至有时应用代码里模拟的哈希关联会更快(MySQL的嵌套循环的关联方法并不总是高效的)。

  6. 应用创建了没必要的MySQL连接吗?如果可以从缓存中获得数据,就不要再连接数据库。

  7. 应用对一个MySQL实例创建连接的次数太多了吗(也许因为应用的不同部分打开了它们自己的连接)?通常来说更好的办法是重用相同的连接。

  8. 应用做了太多的“垃圾”查询?一个常见的例子是发送查询前先发送一个ping命令看数据库是否存活,或者每次执行SQL前选择需要的数据库。总是连接到一个特定的数据库并使用完整的表名也许是更好的方法。(这也使得从日志或者通过SHOW PROCESSLIST看SQL更容易了,因为执行日志中的SQL语句的时候不用再切换到特定的数据库,数据库名已经包含在SQL语句中了。)“预备(Preparing)”连接是另一个常见问题。Java驱动在预备期间会做大量的操作,其中大部分可以禁用。另一个常见的垃圾查询是SET NAMES UTF8,这是一个错误的方法(它不会改变客户端库的字符集,只会影响服务器的设置)。如果应用在大部分情况使用特定的字符集工作,可以修改配置文件把特定字符集设为默认值,而不需要在每次执行时去做修改。

  9. 应用使用了连接池吗?这既可能是好事,也可能是坏事。连接池可以帮助限制总的连接数,有大量SQL执行的时候效果不错(Ajax应用是一个典型的例子)。然而,连接池也可能有一些副作用,比如说应用的事务、临时表、连接相关的配置项,以及用户自定义变量之间相互干扰等。

  10. 应用是否使用长连接?这可能导致太多连接。通常来说长连接不是个好主意,除非网络环境很慢导致创建连接的开销很大,或者连接只被一或两个很快的SQL使用,或者连接频率很高导致客户端本地端口不够用。如果MySQL的配置正确,也许就不需要长连接了。比如使用skip-name-resolve来避免DNS反向查询,确保 thread_cache足够大,并且增加back_log。

  11. 应用是否在不使用的时候还保持连接打开?如果是这样,尤其是连接到很多服务器时,可能会过多地消耗其他进程所需要的连接。例如,假设你连接到10个MySQL服务器。从一个Apache进程中获取10个连接不是问题,但是任意时刻其中只有1个在真正工作。其他9个大部分时间都处于Sleep状态。如果其中一台服务器变慢了,或者有一个很长的网络请求,其他的服务器就可能因为连接数过多受到影响。解决方案是控制应用怎么使用连接。例如,可以将操作批量地依次发送到每个MySQL实例,并且在下一次执行SQL前关闭每个连接。如果执行的是比较消耗时间的操作,例如调用Web服务接口,甚至可以先关闭MySQL连接,执行耗时的工作,再重新打开MySQL连接继续在数据库上工作。

长连接和连接池的区别可能使人困惑。长连接可能跟连接池有同样的副作用,因为重用的连接在这两种情况下都是有状态的。

然而,连接池通常不会导致服务器连接过多,因为它们会在进程间排队和共享连接。另一方面,长连接是在每个进程基础上创建,不会在进程间共享。

连接池也比共享连接的方式对连接策略有更强的控制力。连接池可以配置为自动扩展,但是通常的实践经验是,当遇到连接池完全占满时,应该将连接请求进行排队而不是扩展连接池。这样做可以在应用服务器上进行排队等待,而不是将压力传递到MySQL数据库服务器上导致连接数太多而过载。

有很多方法可以使得查询和连接更快,但是一般的规则是,如果能够直接避免进行查询和连接,肯定比努力提升查询和连接的性能能获得更好的优化结果。

三.web服务器问题

Apache是最流行的Web应用服务器软件。它在许多情况下都运行良好,但如果使用不当也会消耗大量的资源。最常见的问题是保持它的进程的存活(alive)时间过长,或者在各种不同的用途下混合使用,而不是分别对不同类型的工作进行优化。

Apache通常是通过prefork配置来使用mod_php、mod_perl和mod_python模块的。prefork模式会为每个请求预分配进程。因为PHP、Perl和Python脚本是可以定制化的,每个进程使用50MB或100MB内存的情况并不少见。当一个请求完成后,会释放大部分内存给操作系统,但并不是全部。Apache会保持进程处于打开状态以备后来的请求重用。这意味着,如果下一个请求是请求静态文件,比如一个CSS文件或者一张图片,就会出现用一个占用内存很多的进程来为一个很小的请求服务的情况。这就是使用Apache作为通用Web服务器很危险的原因。它的确是为通用目的而设计的,但如果能够有针对性地使用其长处,会获得更好的性能。

另一个主要的问题是,如果开启了Keep-Alive设置,进程可能很长时间处于繁忙状态。当然,即使没有开启Keep-Alive,某些进程也可能存活很久,“填鸭式”地将内容传给客户端可能导致获取数据很慢。

人们常犯的另外一个错误,就是保持那些Apache默认开启的模块不动。最好能够精简Apache的模块,移除掉那些不需要的。这很简单:只需要检查Apache的配置文件,注释掉不想要的模块,然后重启Apache就行。也可以在php.ini文件中删除不使用的PHP模块。

最差情况是,如果用一个通用目的的Apache配置直接用于Web服务,最后很可能产生很多重量级的Apache进程。这将浪费Web服务器的资源。它们还可能保持大量MySQL连接,浪费MySQL的资源。下面是一些可以降低服务器负载的方法。

不要使用Apache来做静态内容服务,或者至少和动态服务使用不同的Apache实
例。流行的替代品有Nginx(http://www.nginx.com)和lighttpd(http://www.lighttpd.net)。

  1. 使用缓存代理服务器,比如Squid或者Varnish,防止所有的请求都到达Web服
    务器。这个层面即使不能缓存所有页面,也可以缓存大部分页面,并且使用像
    ESI(Edge Side Includes,参见http://www.esi.org)这样的技术来将部
    分页面中的小块的动态内容嵌入到静态缓存部分。
  2. 对动态和静态资源都设置过期策略。可以使用Squid这样的缓存代理显式地使内
    容过期。维基百科就使用了这个技术来清理缓存中变更过的文章。

有时也许还需要修改应用程序,以便得到更长的过期时间。例如,如果你告诉浏览器永久缓存CSS和JavaScript文件,然后对站点的HTML做了一个修改,这个页面渲染将会出问题。这种情况可以为文件的每个版本设定唯一的文件名。例如,你可以定制网站的发布脚本,复制CSS文件到/css/123_frontpage.css,这里的123就是版本管理器中的版本号。对图片文件的文件名也可以这么做——永不重用文件名,这样页面就不会在升级时出问题,浏览器缓存多久的文件都没问题。

  1. 不要让Apache填鸭式地服务客户端,这不仅仅会导致慢,也会导致DDoS攻击变
    得简单。硬件负载均衡器通常可以做缓冲,所以Apache可以快速地完成,让负
    载均衡器通过缓存响应客户端的请求,也可以在应用服务器前端使用Nginx、
    Squid或者事件驱动模式下的Apache。
  2. 打开gzip压缩。对于现在的CPU而言这样做的代价很小,但是可以节省大部分
    流量。如果想节省CPU周期,可以使用缓存,或者诸如Nginx这样的轻量级服务
    器保存压缩过的页面版本。
  3. 不要为用于长距离连接的Apache配置启用Keep-Alive选项,因为这会使得重量
    级的Apache进程存活很长时间。可以用服务器端的代理来处理保持连接的工
    作,从而防止Apache被客户端拖垮。配置Apache到代理之间的连接使用Keep-
    Alive是可以的,因为代理只会使用很少的Apache连接去获取数据。图14-1展
    示了这个区别。

这些策略可以使Apache进程存活时间变得很短,所以会有比实际需求更多的进程。无论如何,有些操作依然可能导致Apache进程存活时间太长,并且占用大量资源。举个例子,一个请求查询延时非常大的外部资源,例如远程的Web服务,就会出现Apache进程存活时间太长的问题。这种问题通常是无解的。

3.1 寻找最优并发度

每个Web服务器都有一个最佳并发度——就是说,让进程处理请求尽可能快,并且不超过系统负载的最优的并发连接数。进行一个简单的测量和建模,或者只是反复试验,就可以找到这个“神奇的数”,为此花一些时间是值得的。

对于大流量的网站,Web服务器同一时刻处理上千个连接是很常见的。然而,只有一小部分连接需要进程实时处理。其他的可能是读请求,处理文件上传,填鸭式服务内容,或者只是等待客户端的下一步请求。

随着并发的增加,服务器会逐渐到达它的最大吞吐量。在这之后,吞吐量通常开始降低。更重要的是,响应时间(延迟)也会因为排队而开始增加。

为什么会这样呢?试想,如果服务器只有一个CPU,同时接收到了100个请求,会发生什么事情呢?假设CPU每秒能够处理一个请求。即便理想情况下操作系统没有调度的开销,也没有上下文切换的成本,那100个请求也需要CPU花费整整100s才能
完成。

处理请求的最好方法是什么?可以将其一个个地排到队列中,也可以并行地执行并在不同请求之间切换,每次切换都给每个请求相同的服务时间。在这两种情况下,吞吐量都是每秒处理一个请求。然而,如果使用队列(并发=1),平均延时是50s,如果是并发执行(并发=100)则是100s。在实践中,并发执行会使平均延时更高,主要是因为上下文切换的代价。

对于CPU密集型工作负载,最佳并发度等于CPU数量(或者CPU核数)。然而,进程并不总是处于可运行状态的,因为会有一些阻塞式请求,例如I/O、数据库查询,以及网络请求。因此,最佳并发度通常会比CPU数量高一些。

可以预测最优并发度,但是这需要精确的分析。尝试不同的并发值,看看在不增加响应时间的情况下的最大吞吐量是多少,或者测量真正的工作负载并且进行分析,这通常更容易。Percona Toolkit的pt-tcp-model工具可以帮助从TCP转储中测量和建模分析系统的可扩展性和性能特性。

四.缓存

缓存对高负载应用来说是至关重要的。一个典型的Web应用程序会提供大量的内容,直接生成这些内容的成本比采用缓存要高得多(包含检查和缓存超时的开销),所以采用缓存通常可以获得数量级的性能提升。诀窍是找到正确的粒度和缓存过期策略组合。另外也需要决定哪些内容适合缓存,缓存在哪里。

典型的高负载应用会有很多层缓存。缓存并不仅仅发生在服务器上,而是在每一个环节,甚至包括用户的Web浏览器(这就是内容过期头的用处)。通常,缓存越接近客户端,就越节省资源并且效率更高。从浏览器缓存提供一张图片比从Web服务器的内存获取快得多,而从服务器的内存读取又比从服务器的磁盘上读取好得多。每种类型的缓存有其不一样的特点,例如容量和延时;在后面的章节我们会解释其中的一部分。

可以把缓存分成两大类:被动缓存和主动缓存。被动缓存除了存储和返回数据外不做任何事情。当从被动缓存请求一些内容时,要么可以得到结果,要么得到“结果不存在”。被动缓存的一个典型例子是memcached。

相比之下,主动缓存会在访问未命中时做一些额外的工作。通常会将请求转发送给应用的其他部分来生成请求结果,然后存储该结果并返回给应用。Squid缓存代理服务器就是一个主动缓存。

设计应用程序时,通常希望缓存是主动的(也可以叫做透明的),因为它们对应用隐藏了检查—生成—存储这个逻辑过程。也可以在被动缓存的前面构建一个主动缓存。

4.1 应用层以下的缓存

MySQL服务器有自己的内部缓存,但也可以构建你自己的缓存和汇总表。可以对缓存表量身定制,使它们最有效地过滤、排序、与其他表关联、计数,或者用于其他用途。缓存表也比许多应用层缓存更持久,因为在服务器重启后它们还存在。

4.2 应用层缓存

应用层缓存通常在同一台机器的内存中存储数据,或者通过网络存在另一台机器的内存中。

因为应用可以缓存部分计算结果,所以应用层缓存可能比更低层次的缓存更有效。因此,应用层缓存可以节省两方面的工作:获取数据以及基于这些数据进行计算。一个很好的例子是HTML文本块。应用程序可以生成例如头条新闻的标题这样的HTML片段,并且做好缓存。后续的页面视图就可以简单地插入这个缓存过的文本。一般来说,在缓存数据前对数据做的处理越多,缓冲命中节省的工作就越多。

但应用层缓存也有缺点,那就是缓存命中率可能更低,并且可能使用较多的内存。假设需要50个不同版本的头条新闻标题,以使不同地区生活的用户看到不同的内容,那就需要足够的内存去存储全部50个版本,任何给定版本的标题命中次数都会更少,并且失效策略也会更加复杂。

应用缓存有许多种,下面是其中的一小部分。

  1. 本地缓存
    这种缓存通常很小,只在进程处理请求期间存在于进程内存中。本地缓存可以有效地避免对某些资源的重复请求。这种类型的缓存技术并不复杂:通常只是应用代码中的一个变量或者哈希表。例如,假设需要显示一个用户名,而且已经知道其ID,就可以创建一个get_name_from_id() 函数并且在其中增加缓存。

  2. 本地共享内存缓存
    这种缓存一般是中等大小(几个GB),快速,难以在多台机器间同步。它们对小型的半静态位数据比较合适。例如每个州的城市列表,分片数据存储的分区函数(映射表),或者使用存活时间(TTL)策略进行失效的数据等。共享内存最大的好处是访问非常快——通常比其他任何远程缓存访问都要快不少。

  3. 分布式内存缓存
    最常见的分布式内存缓存的例子是memcached。分布式缓存比本地共享内存缓存要大得多,增长也容易。缓存中创建的数据的每一个比特都只有一份副本,这样既不会浪费内存,也不会因为相同的数据存在不同的地方而引入一致性问题。分布式内存非常适合存储共享对象,例如用户资料、评论,以及HTML片段。
    分布式缓存比本地共享缓存的延时要高得多,所以最高效的使用方法是批量进行多个获取操作(例如,在一次循环中获取多个对象)。分布式缓存还需要考虑怎么增加更多的节点,以及某个节点崩溃了怎么处理。对于这两个场景,应用程序必须决定在节点间怎么分布或重分布缓存对象。当缓存集群增加或减少一台服务器时,一致性缓存对避免性能问题而言是非常重要的。在下面这个网站有一个为memcached做的一致性缓存库:http://www.audioscrobbler.net/development/ketama/。

  4. 磁盘上的缓存
    磁盘是很慢的,所以缓存在磁盘上的最好是持久化对象,很难全部装进内存的对象,或者静态内容(例如预处理的自定义图片)。

4.3 缓存控制策略

缓存也有像反范式化数据库设计一样的问题:重复数据,也就是说有多个地方需要更新数据,所以需要想办法避免读到脏数据。下面是一些最常见的缓存控制策略。

  1. TTL(time to live,存活时间)
    缓存对象存储时设置一个过期时间;可以通过清理进程在达到过期时间后删掉对象,或者先留着直到下次访问时再清理(清理后需要使用新的版本替换)。对于数据很少变更或者没有新数据的情况,这是最好的失效策略。

  2. 显式失效
    如果不能接受脏数据,那么进程在更新原始数据时需要同时使缓存失效。这种策略有两个变种:写—失效和写—更新。写—失效策略很简单:只需要标记缓存数据已经过期(是否清理缓存数据是可选的)。写—更新策略需要多做一些工作,因为在更新数据时就需要替换掉缓存项。无论如何,这都是非常有益的,特别是当生成缓存数据代价很昂贵时(写线程也许已经做了)。如果更新缓存数据,后续的请求将不再需要等待应用来生成。如果在后台做失效处理,例如基于TTL的失效,就可以在一个从用户请求完全分离出来的进程中生成失效数据的新版本。

  3. 读时失效
    在更改旧数据时,为了避免要同时失效派生出来的脏数据,可以在缓存中保存一些信息,当从缓存中读数据时可以利用这些信息判断数据是否已经失效。和显式失效策略相比,这样做有很大的优势:成本固定且可以分散在不同时间内。假设要失效一个有一百万缓存对象依赖的对象,如果采用写时失效,需要一次在缓存中失效一百万个对象,即使有高效的方法来找到这些对象,也可能需要很长的时间才能完成。如果采用读时失效,写操作可以立即完成,但后续这一百万对象的读操作可能会有略微的延迟。这样就把失效一百万对象的开销分散了,并且可以帮助避免出现负载冲高和延迟增大的峰值。

一种最简单的读时失效的办法是采用对象版本控制。使用这种方法,在缓存中存储一个对象时,也可以存储对象所依赖的数据的当前版本号或者时间戳。例如,假设要缓存用户博客日志的统计信息,包括用户发表的博客数。当缓存blog_stats对象时,也可以同时存储用户的当前版本号,因为该统计信息是依赖于用户的。不管什么时候更新依赖于用户的数据,都需要更新用户的版本号,假设用户的版本号初始为0,并且由你来生成和缓存统计信息。当用户发表了一篇博客,就增加用户的版本号到1(当然也要同时存储这篇博客,尽管在这个例子并没有用到博客数据)。然后当需要显示统计数据的时候,可以对缓存中blog_stats对象的版本与缓存的用户版本进行比较。因为用户的版本比对象的版本高,所以可以知道缓存的统计信息已经过期了,需要重新计算。

这是一个非常粗糙的内容失效方式,因为它假设依赖于用户的每一个比特的数据与所有其他数据都有交互。但这个假设并不总是成立的。举个例子,如果一个用户对一篇博客做了编辑,你也需要增加用户的版本号,这就会导致存储的统计信息失效,而实际上统计信息(发表的博客数)并没真的改变。这个取舍是很简单的。一个简单的缓存失效策略不只是更容易创建,也可能更加高效。对象版本控制是一种简单的标记缓存方法,它可以处理更复杂的依赖关系。一个标记的缓存可以识别不同类型的依赖,并且分别跟踪每个依赖的版本。回到第11章中图书俱乐部的例子,你可以通过下面的版本号标记评论,使缓存的评论依赖于用户的版本和书的版本:user_ver=1234和book_ver=5678。任一版本号变了,都应该刷新缓存的评论。

五.MySQL的替代品

MySQL不是万能的,核心的OLTP业务可以放在MySQL里面。
如果涉及全文索引的,可以使用elastic。
如果涉及到一些简单key value的数据,可以直接用redis。
如果设计到json类数据,可以使用mongodb。
如果涉及到大批量数据的查询的,可以使用hadoop架构。

参考:

  1. 《高性能MySQL》
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值