偷天换日术-Web应用中的缓存技术漫谈

广义的来说,我们将凡是位于速度相差较大的两种(硬件或者服务)之间,用于协调两者数据传输速度差异的[结构或者服务],均可称之为缓存。缓存属于典型的用空间换时间的方法。

我们来讲个故事,小白同学做了一个共享书吧的创业项目,并且写出了第一版的程序上线运行,因为还没有拿到任何投资,所以小白同学很苦逼的每月剩下了外出和小伙伴们吃烧烤的200元钱租了XX云的虚拟主机,开始运营项目。由于小伙伴们都很捧场,业务飞速的发展了起来,很快就有小伙伴来抱怨,页面打开越来越慢,有时候点一个按钮要反应好一会儿。

这个时候小白赶紧连上服务器,并且用top命令一看

top命令查看服务器负载

结果发现其实服务器并没有和我们想象的一样忙成狗,其实在大多数时候,我们的程序并没有纯粹的就是在"计算",因为Web应用大多数时候都没有什么好算的,大部分的时间其实都浪费在了IO等到上面。

我们看看一个页面的执行一共经历了那些步骤:

web请求的执行过程

当一个页面从请求到在浏览器上看到的时候,其实就经历了上面的完整步骤,但是,如果刷新页面后,显示的内容没变,但是其实还是把上面的流程走了一遍,这个时候就产生了很多冗余的步骤,那么这个时候我们就需要用缓存来消除重复执行的操作。缓存的第一个作用:

减少重复操作的步骤

减少重复操作能带来多大的性能提升呢,我们来举个例子。抛开复杂的网络和IO问题,我们用纯计算的例子,计算斐波那契数列。我们用python来做例子,如下:

递归计算斐波那契数列

这段代码和斐波那契数列的经典公式几乎是等价的,我们假设 fib_recursion(5) 的话,计算过程如下:

fib_recursion(5)时的计算过程

由上图可见,计算了2次fib(3),3次fib(2),5次fib(1)。那么当参数更大的时候,必然会产生更多的冗余计算。显而易见的,冗余的计算会增加开销,降低运算速度。

那么我们改进算法为迭代的方式:

迭代版的菲波那契数列代码

这个时候我们通过变量b来缓存了中间计算结果,减少了冗余计算。效果我们通过timeit模块来测试,首先是递归的方式:

timeit.timeit(fib_recursion(30),setup=from fib import fib_iter,fib_recursively,number=100),

返回的时间是 48.081s;

再来看迭代的方式:

timeit.timeit(fib(30),setup=from fib import fib_iter,fib_recursively,number=100),

返回的时间是 0.s;

可见在减少冗余操作带来的效率提升能有多大了。

缓存的第二点就是将常用数据放在更快的存储设备中,下面图是不同的存储设备的访问时间量级的表格:

存储设备访问时间量级

L1 L2 L3 Cache这类不是用户级别代码能操作的,所以Pass。我们主要讨论的是剩下的几中方式。由图可知,数据库和文件访问是比较耗时的,而内存的访问速度则是千倍于磁盘和数据库。而由于摩尔定律的影响,现在的内存价格一降再降,服务器上的内存也从十几年前的几个G疯涨到了好几百G,而大部分时候服务器的内存空闲量都是比较大的,那么为什么不利用内存来加速IO的访问呢。所以内存缓存就兴起了,比如memcache,Redis等现在基本成为了网络应用(网站,App后端)的标配了。

Web应用所用缓存的种类

我们从客户端开始到服务端由近及远的来说说我们能用到那些种类的缓存。

客户端缓存

离客户最近的就是最快的,最快的缓存方式莫过于客户端缓存,因为客户端缓存抛开了所有不必要的步骤,不经过网络,不用服务器处理,直接能返回访问过的内容。原理很简单,就是把每个url对应的内容存在本地磁盘上,下一次访问的时候直接从磁盘读出来显示。但是这种方式有个弊端,就是如果服务器上的内容发生了变更的话,客户端是不会知道的,所以,万一你把内容发布出去了后发现写错了,要修改,那么修改前访问过的用户看到的还是错的内容。针对这个情况,HTTP协议有定义相应的应对办法,就是响应的expire信息,web服务器或者web应用都可以指定返回的内容的expire,也就是描述内容的过期时间,可以是几秒,几分钟,几小时,几天,几个月,几年,甚至是永久,客户端收到URL响应的expire就会根据这个来设置缓存内容的过期时间。

但是这种静态的设置方式对静态内容是适用的,比如静态的网页,图片,css,js等,但是动态响应的内容就不行了,比如商城系统里看到的货物剩余数量什么的,客户端缓存因为当内容改变后,服务端无法通知对应的缓存失效,而只能采用固定的失效时间,所以一般只应用到发布的静态内容。

CDN

CDN的全称是内容分发网络(content delivery network),作用是把内容从你的服务器搬运到里用户最近的服务器,当你访问内容的时候,CDN会自动的把请求发送到离你最近的缓存服务器。原理如下图:

CDN原理

由于网络的复杂性(国内国外互通的带宽和延迟问题,国内南北互通的问题),我们没法保证服务器到任何地方的网络都能一样的快,所以,CDN将缓存服务器分布到很多地方的数据中心,并且通过高效的调度协议,在一个资源在一个地方被访问后就会缓存在最近最快的缓存服务器上,以后里这个服务器最进最快的用户客户端在请求这个资源的时候都会直接从这个服务器返回,访问的用户越多,缓存命中的机率就会越大,加速的效果也会越明显。

CDN明显不适合小规模的应用,因为第一,CDN服务是收费的,按流量计费,很贵。第二,因为小规模应用访问的人少,所以命中率不高(失效期端短的时候)。

早期的CDN大多用来缓存静态内容, 比如视频,图片,CSS,JS等,但是最近技术发展后新的CDN技术也可用来缓存动态内容,比如提供了接口可以让服务端通知CDN某个资源过期。

HTTP服务器缓存

不管是Nginx,还是IIS亦或者是Apache都提供了缓存,工作的方式基本上都是类似的,访问过的资源直接返回,这里就不赘述了。

应用页面缓存

在应用服务中的控制器输出的时候,部分Web框架会提供页面缓存机制,这个级别的缓存是用户代码可以控制的,所以对于动态应用来说,可以精确的在内容更新后立即让缓存失效,从而不会出现内容更新了而客户端获取的内容不更新的情况。

应用缓存

在应用程序的执行逻辑中通过缓存API去访问的缓存服务,有可能是Web框架提供的,也有可能是诸如memcache或者Redis这样子的外部服务,通过对应的API访问,甚至可以是你自己实现的。

建议直接使用成熟的memcache或者Redis,因为如果直接用进程内的数据结构直接缓存数据,虽然访问速度是最快的,但是如果应用程序在多进程环境下执行的话就会存在数据同步的问题,并且还会在多个进程中存在缓存数据的多份COPY。如果是访问本地的磁盘来实现缓存的话,因为磁盘的效率和通过网络访问内存缓存服务的效率大致相仿,但是还是存在不能跨服务器的问题,所以在将来扩容的时候还是会扯到蛋,不如直接一次性到位了。

如果上文的套路太深的不好实施的话,有个比较小白的办法就是,在涉及到更新的操作,不要使用缓存的对象,重新从数据库读一个出来会比较靠谱。

数据库缓存

数据库缓存是现代关系型数据库的标准配置了,除了运维修改数据库设置能够影响数据库缓存的话,用户代码基本上不会碰到这部分,不过我们在实际应用的时候还是需要考虑到的数据库缓存对访问速度的影响。比如MySQL对缓存的敏感度就很高,缓存越大,跑得越快。并且大多数数据库针对查询,排序,结构维护都有独立的缓存,如果缓存设置和实际情况不符的话,不但不能加速,反而会适得其反。

在项目中应用缓存

在掌握了上述一堆缓存技术后,小白就开始着手将他们应用在系统中。

首先是客户端缓存的设置,针对所有的静态页面,比如关于,登录,注册,首页,都设置较短时间,比如1小时或者1天左右的缓存。

CDN可以采用XX云的服务,大部分都有免费额度,可以把图片和CSS,JS都放上面,这样所有的静态内容都从自己的服务上剥离出去了。

在系统内部加入Redis或者memcache作为应用缓存在各个服务之间作为数据管道和缓存中间计算结果。

所以加入缓存后的系统结构:

修改后系统结构

然后小白的网站又重新快了起来,而且没有增加一分钱的开销(因为用了XX云的免费额度),然后用户量又能蹭蹭的涨起来了。

然而天有不测风云,某日风和日丽,但是网上暗潮汹涌,服务器到豆瓣的网络连接突然断掉了,这个时候很多用户又在通过ISBN查书籍信息,然后小白的服务也跟着挂掉了。

所以下一章我们来聊聊 后台服务与异步任务

to be continue。。。