高性能Web缓存浅析

December 9, 2014
作者:星爷
出处:http://lxWei.github.io/posts/%E9%AB%98%E6%80%A7%E8%83%BDWeb%E7%BC%93%E5%AD%98%E6%B5%85%E6%9E%90.html
声明:转载请注明作者及出处。

目录

本文标题叫“高性能Web缓存浅析”,首先,必须声明,“浅析”并非自己谦虚,而是真的是“浅”析,作为一枚刚毕业应届生,在提笔写这篇文章前一周刚上线了自己作为程序员的第一个正式系统,文中所有内容均来源于阅读和自己的一些思考,与实际生产环境可能会有出入,还请不吝赐教。

首先,简单说下缓存,缓存的思想由来已久,将需要花费大量时间开销的计算结果保存起来,在以后需要的时候直接使用,避免重复计算。在计算机系统中,缓存的应用不胜枚举,比如计算机的三级存储结构、Web 服务中的缓存,本文主要讨论缓存在 Web 环境中的使用,包括浏览器缓存、服务器缓存、反向代理缓存以及分布式缓存等方面。

1. 动态内容缓存

现代 Web 站点更多的提供动态内容,如动态网页、动态图片、Web服务等,它们通常在 Web 服务器端进行计算,生成 HTML 并返回。在生成 HTML 页面的过程中,涉及到大量的 CPU 计算和 I/O 操作,比如数据库服务器的 CPU 计算和磁盘 I/O,以及与数据库服务器通信的网络I/O。这些操作会花费大量时间,然而,大多数情况下,动态网页在多次请求时的生成结果几乎一样,那么,就可以考虑通过缓存去掉这部分时间开销。

1.1 页面缓存

对于动态网页来说,我们将生成的 HTML 缓存起来,称为页面缓存(Page Cache),对于其它动态内容如动态图片、动态 XML 数据,我们也可以相同策略将它们的结果整体进行缓存。

对于页面缓存具体方法,有很多实现方法,如类似 Smarty 的模板引擎或者类似 Zend、Diango 的 MVC 框架,控制器和视图分离,控制器很方便的拥有自己的缓存控制权。

1.1.1 存储方式

通常,我们将动态内容的缓存存储在磁盘上,磁盘提供了廉价的、存储大量文件的方式,不用担心由于空间问题而淘汰缓存,这是一种简单且容易部署的方法。但是,还是可能造成 cache 目录下存在大量缓存文件的可能,从而使 CPU 在遍历目录时花费大量时间,针对这个问题,可以使用缓存目录分级来解决这一问题,从而将每个目录下的子目录或文件数量控制在少量范围内。这样,在存储大量缓存文件的情况下,可以减少 CPU 遍历目录的时间消耗。

当将缓存数据存储在磁盘文件中时,每次缓存加载和过期检查都存在磁盘 I/O 开销,同时也受磁盘负载影响,如果磁盘 I/O 负载大,则缓存文件的 I/O 操作会存在一定的延迟。

另外,可以将缓存放在本机内存中,借助 PHP 的 APC 模块或 PHP 缓存扩展 XCache 可以很方便的实现,这样,加载缓存时就没有任何磁盘 I/O 操作。

最后,还可以将缓存存储在独立的缓存服务器中,利用 memcached 可以很方便的通过 TCP 将缓存存储在其他服务器。使用 memcached,速度会比使用本机内存稍慢,但相比将缓存存放到本机内存,使用 memcached 来实现缓存有两个优势:

  1. Web 服务器内存宝贵,无法提供大量空间做 HTML 缓存。
  2. 使用独立的缓存服务器可以提供良好的可扩展性。

1.1.2 过期检查

既然谈到缓存,就不得不谈过期检查,缓存过期检查主要根据缓存有效期机制来检查,主要两种机制:

  1. 根据缓存创建时间、缓存有效期设置的时间长度以及当前时间,来判断是否过期,也就是说如果当前时间距缓存创建的时间长度超过有效期长度,则认为缓存过期。
  2. 根据缓存的过期时间和当前时间来判断。

对于缓存有效期的设置并不是件容易的事,如果太长,虽然缓存命中率高了,但动态内容更新不及时;如果太短,虽然动态内容更新及时,但缓存命中率降低了。所以,设置合理的有效期十分重要,但更重要的是,我们需要具备能意识到有效期何时需要变换的能力,然后在任何时候找到合适的取值。

除了缓存有效期,缓存还提供了随时强行清除缓存的控制方法。

1.1.3 局部无缓存

在有些情况下,需要页面中某块区域的内容及时更新,如果因为一块区域需要及时更新而重建整个页面缓存的话,会显得不值得。在流行的模板框架中,都提供了局部无缓存的支持,如 Smary。

1.2 静态化内容

前面的方法需动态地控制是否使用缓存,静态化方法将动态内容生成静态内容,然后让用户直接请求静态内容,大幅度提高吞吐率。

同样的,对于静态化内容,同样需要更新,一般有两种方法:

  1. 数据更新时重新生成静态化内容。
  2. 定时重新生成静态化内容。

与前面提到的动态缓存一样,静态页面也可以不更新整个页面,可以通过服务器包含(SSI)技术实现各个局部页面的独立更新,从而大大减少重建整个页面的计算开销和磁盘 I/O 开销,以及分发时的网络 I/O 开销。现在主流的 Web 服务器都支持 SSI 技术,比如 Apache、lighttpd等。

2. 浏览器缓存

串通角度来看,人们习惯将浏览器仅仅看着 PC 上的一个软件,但实际上,浏览器是 Web 站点的重要组成部分。如果将内容缓存在浏览器上,不仅可以减少服务器计算开销,还能避免不必要的传输和带宽浪费。为了在浏览器端存储缓存内容,一般是在用户的文件系统中创建一个目录用来存储缓存文件,并给每个缓存文件打上一些必要标签,如过期时间等。另外,不同浏览器在存储缓存文件时会有细微差别。

2.1 实现

浏览器缓存内容存储在浏览器端,而内容由 Web 服务器生成,要利用浏览器缓存,浏览器和 Web 服务器之间必须沟通,这就是 HTPP 中的“缓存协商”。

2.1.1 缓存协商

当 Web 服务器接收到浏览器请求后,Web 服务器需要告知浏览器哪些内容可以缓存,一旦浏览器知道哪些内容可以缓存后,下次当浏览器需要请求这个内容时,浏览器便不会直接向服务器请求完整内容,二是询问服务器是否可以使用本地缓存,服务器在收到浏览的询问后回应是使用浏览器本地缓存还是将最新内容传回给浏览器。

Last-Modified

Last-Modified 是一种协商方式。通过动态程序为 HTTP 相应添加最后修改时间的标记

header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");

此时,Web 服务器的响应头部会多出一条:

Last-Modified: Fri, 9 Dec 2014 23:23:23 GMT

这代表 Web 服务器对浏览器的暗示,告诉浏览器当前请求内容的最后修改时间。收到 Web 服务器响应后,再次刷新页面,注意到发出的 HTTP 请求头部多了一段标记:

If-Modified-Since: Fri, 9 Dec 2014 23:23:23 GMT

这表示浏览器询问 Web 服务器在该时间后是否有更新过请求的内容,此时,Web 服务器需要检查请求的内容在该时间后是否有过更新并反馈给浏览器,这其实就是缓存过期检查。

如果这段时间里请求的内容没有发生变化,服务器做出回应,此时,Web 服务器响应头部:

HTTP/1.1 304 Not Modified

注意到此时的状态码是304,意味着 Web 服务器告诉浏览器这个内容没有更新,浏览器使用本地缓存。如下图所示: Last-Modified

ETag

HTTP/1.1 还支持ETag缓存协商方法,与最后过期时间不同的是,ETag不再采用内容的最后修改时间,而是采用一串编码来标记内容,称为ETag,如果一个内容的 ETag 没有变化,那么这个内容就一定没有更新

ETag 由 Web 服务器生成,浏览器在获得内容的 ETag 后,会在下次请求该内容时,在 HTTP 请求头中附加上相应标记来询问服务器该内容是否发生了变化:

If-None-Match: "87665-c-090f0adfadf"

这时,服务器需要重新计算这个内容的 ETag,并与 HTTP 请求中的 ETag 进行对比,如果相同,便返回 304,若不同,则返回最新内容。如下图所示,服务器发现请求的 ETag 与重新计算的 ETag 不同,返回最新内容,状态码为200。 ETag

Last-Modified VS ETag

基于最后修改时间的缓存协商存在一些缺点,如有时候文件需频繁更新,但内容并没有发生变化,这种情况下,每次文件修改时间变化后,无论内容是否发生变化,都会重新获取全部内容。另外,在采用多台 Web 服务器时,用户请求可能在多台服务器间变化,而不同服务器上同一文件最后修改时间很难保证完全一样,便会导致重新获取所有内容。采用 ETag 方法就可以避免这些问题。

2.1.2 性能

首先,原本使用浏览器缓存的动态内容,在使用浏览器缓存后,能否获得大的吞吐率提升,关键在于是否能避免一些额外的计算开销,同事,还取决于 HTTP 响应正文的长度,若 HTTP 响应较长,如较长的视频,则能带来大的吞吐率提到。

但使用浏览器缓存的最大价值并不在此,而在于减少带宽消耗。使用浏览器缓存后,如果 Web 服务器计算后发现可以使用浏览器端缓存,则返回的响应长度将大大减少,从而,大大减少带宽消耗。

2.2 彻底消灭请求

The goal of caching in HTTP/1.1 is to eliminate the need to send requests in many cases.

2.2.1 Expires 标记

在上面两图中,有个Expires标记,告诉浏览器该内容何时过期,在内容过期前不需要再询问服务器,直接使用本地缓存即可

2.2.2 请求页面方式

对于主流浏览器,有三种请求页面方式:

  1. Ctrl + F5:强制刷新,使网页以及所有组件都直接向 Web 浏览器发送请求,并且不适用缓存协商,从而获取所有内容的最新版本。等价于按住 Ctrl 键后点击浏览器刷新按钮。
  2. F5:允许浏览器在请求中附加必要的缓存协商,但不允许直接使用本地缓存,即让Last-Modified生效、Expires无效。等价于单击浏览器刷新按钮。
  3. 单击浏览器地址栏“转到”按钮或通过超链接跳转:浏览器对于所有没过期的内容直接使用本地缓存,Expires只对这种方式生效。等价于在地址栏输入 URL 后回车。
Last-ModifiedExpires
Ctrl + F5 无效无效
F5 有效无效
转到 有效有效

2.2.3 适应过期时间

Expires指定的过期时间来源于 Web 服务器的系统时间,如果与用户本地时间不一致,就会影响到本地缓存的有效期检查。

一般情况下,操作系统都使用基于 GMT 的标准时间,然后通过时区来进行偏移计算,HTTP 中也使用 GMT 时间,所以,一般不会因为时区导致本地与服务器相差数个小时,但没人能保证本地时间与服务器一直,甚至有时服务器时间也是错误的。

针对这个问题,HTTP/1.1 添加了标记 Cache-Control,如上图1所示,max-age 指定缓存过期的相对时间,单位是秒,相对时间指相对浏览器本地时间。目前,当 HTTP 响应中同时含有 Expires 和 Cache-Control 时,浏览器会优先使用 Cache-Control。

2.3 总结

HTTP 是浏览器与 Web 服务器沟通的语言,且是它们唯一的沟通方式,好好学学 HTTP 吧!

3. Web 服务器缓存

前面提到的动态内容缓存和静态化基本都是通过动态程序来实现的,下面讨论 Web 服务器自己实现缓存机制。

Web 服务器接收到 HTTP 请求后,需要解析 URL,然后将 URL 映射到实际内容或资源,这里的“映射”指服务器处理请求并生成响应内容的过程。很多时候,在一段时间内,一个 URL 对应一个唯一的响应内容,比如静态内容或更新不频繁的动态内容,如果将最终内容缓存起来,下次 Web 服务器接收到请求后可以直接将响应内容返回给浏览器,从而节省大量开销。现在,主流 Web 服务器都提供了对这种类型缓存的支持。

3.1 简介

当使用 Web 服务器缓存时,如果直接命中,那么将省略后面的一系列操作,如 CPU 计算、数据库查询等,所以,Web 服务器缓存能带来较大性能提升,但对于普通 HTML 也,带来的性能提升较有限。

那么,缓存内容存储在什么位置呢?一般来说,本机内存和磁盘是主要选择,也可以采用分布式设计,存储到其它服务器的内存或磁盘中,这点跟前面提到的动态内容缓存类似,Apache、lighttpd 和 Nginx 都提供了支持,但配置上略有差别。

提到缓存,就不得不提有效期控制。与浏览器缓存相似,Web 服务器缓存过期检查仍然建立在 HTTP/1.1 协议上,要指定缓存有效期,仍然是在 HTTP 响应头中加入 Expires 标记,如果希望不缓存某个动态内容,那么最简单的办法就是使用:

header("Expires: 0");

这样一来,Web服务器就不会将这个动态内容缓存起来,当然,也有其它方法实现这个功能。

如果动态内容没有输出 Expires 标记,也可以采用 Last-Modified来实现,具体方法不再叙述。

3.2 取代动态内容缓存

那么,是否可以使用 Web 服务器缓存取代动态程序自身的缓存机制呢?当然可以,但有些注意:

  1. 让动态程序依赖特定 Web 服务器,降低应用的可移植性。
  2. Web 服务器缓存机制实质上是以 URL 为键的 key-value 结构缓存,所以,必须保证所有希望缓存的动态内容有唯一的 URL。
  3. 编写面向 HTTP 缓存友好的动态程序是唯一需要考虑的事。

3.3 缓存文件描述符

对静态内容,特别是大量小文件站点, Web 服务器很大一部分开销花在了打开文件上,所以,可以考虑将打开后的文件描述符直接缓存到 Web 服务器的内存中,从而减少开销。但是,缓存文件描述符仅仅适用于静态内容,而且仅适用于小文件,对大文件,处理它们的开销主要在传送数据上,打开文件开销小,缓存文件描述符带来的收益小。

4. 反向代理缓存

4.1 反向代理简介

代理(Proxy),也称网络代理,是一种特殊的网络服务,允许一个网络终端(一般为客户端)通过这个服务与另一个网络终端(一般为服务器)进行非直接的连接。提供代理服务的电脑系统或其它类型的网络终端称为代理服务器(Proxy Server)。

上面是维基百科对代理的定义,在这种情况下,用户隐藏在代理服务器后,那么,反向代理服务器便刚好与此相反,Web 服务器隐藏在代理服务器后,这种机制即反向代理(Reverse Proxy),同样的,实现这种机制的服务器,便称为反向代理服务器(Reverse Proxy Server)。我们通常称反向代理服务器后的 Web 服务器为后端服务器(Back-end Server),相应的,反向代理服务器便称为前端服务器(Front-end Server),通常,反向代理服务器暴露在互联网中,后端的 Web 服务器通过内部网络与它相连,用户将通过反向代理服务器来间接访问 Web 服务器,这既带来一定的安全性,也可以实现基于缓存的加速。

4.2 反向代理缓存

有很多方式可以实现反向代理,如最常见的 Nginx 服务器就可以作为反向代理服务器。

4.2.1 修改缓存规则

用户浏览器、Web 服务器要想正常工作,都需要经过反向代理服务器,所以,反向代理服务器拥有很大的控制权,可以通过任何手段重写经过它的 HTTP 头信息,也可以通过其他自定义机制来直接干预缓存策略。从前面的内容知道,HTTP 头信息决定内容是否可以被缓存,所以,反向代理服务器本着提高性能的原则可以修改经过它的数据的 HTTP 头信息,决定哪些内容可以被缓存,哪些不能被缓存。

4.2.2 清除缓存

反向代理服务器也提供了清除缓存的功能,但是,与动态内容缓存不同的是,在动态内容缓存中,我们可以通过主动删除缓存的方法实现缓存到期之前的更新,而基于 HTTP 的反向代理缓存机制则不容易做到,后端的动态程序无法做到主动删除某个缓存内容,除非清空反向代理服务器上的缓存区。

5. 分布式缓存

5.1 memcached

现在已经有很多成熟的分布式缓存系统,如 memcached。为了实现高速缓存,我们不会将缓存内容放在磁盘上,基于这个原则,memcached 使用物理内存作为缓存区,使用 key-value 的方式存储数据,这是一种单索引的结构和数据组织形式,我们将每个 key 以及对应 value 合起来称为数据项,所有数据项之间彼此独立,每个数据项以 key 作为唯一索引,用户可以通过 key 来读取或更新这个数据项,memcached 使用基于 key 的hash 算法来设计存储数据结构,使用精心设计的内存分配器,使得数据项查询时间复杂度达到O(1)。

memcached 使用基于 LRU(Lease Recently Used) 算法的淘汰机制淘汰数据,同时,也可以为数据项设置过期时间,同样的,过期时间的设置在前面已经讨论过了。

作为分布式缓存系统,memcached 可以运行在独立服务器上,动态内容通过 TCP Socket 来访问,这种情况下,memcached 本身的网络并发处理模型就显得十分重要。memcached 使用 libevent 函数库来实现网络并发模型,可以在较大并发用户数环境下使用 memcached。

5.2 读写缓存

在使用缓存系统实现读操作时,相当于使用了数据库的“前置读缓存”,可以较大的提高吞吐率。

对于写操作,缓存系统也能带来巨大好处。通常的数据写操作包括插入、更新、删除,这些写操作往往同时伴随着查找和索引更新,往往带来巨大开销。在使用分布式缓存时,我们可以暂时将数据存储在缓存中,然后进行批量写操作。

5.3 缓存监控

memcached 作为一个分布式缓存系统,可以出色的完成任务,同时,memcached 也提供了协议,让我们可以获取它的实时状态,其中有几个重要信息:

  1. 空间使用率:关注缓存空间使用率,可以让我们知道何时需要为缓存系统扩容,以避免由于缓存空间已满造成的数据被动淘汰。
  2. 缓存命中率
  3. I/O 流量:反映了缓存系统的工作量,可以从中得知 memcached 是空闲还是繁忙。

5.4 缓存扩展

并发处理能力、缓存空间容量等都可能到达极限,扩展在所难免。

当存在多台缓存服务器后,我们面临的问题是,如何将缓存数据均衡的分布在多台缓存服务器上?在这种情况下,可以选择一种基于 key 的划分方法,将所有数据项的 key 均衡分布在不同服务器上,比如采取取余的方法。这种情况下,当我们扩展系统后,由于分区算法的改变,会需要将缓存数据从一台缓存服务器迁移到另一台缓存服务器,实际上,根本不需要考虑迁移,因为是缓存数据,重建缓存即可。

6.总结

缓存如何使用还得具体问题具体分析。举例来讲,当年在百度实习时,仅仅是一次搜索的结果,使用动态内容缓存都分了好多层,总之是能用缓存就用缓存,能尽早用缓存就尽早用缓存;而在我现在工作的创业公司有赞,现在也就使用了 redis 做动态内容缓存,随着业务量的增大,正在一步步完善,哦,差点忘了,还有浏览器缓存。