随着网站规模的变大,访问量提升,网站服务器越来越不堪重负,浏览者也会对页面打开的速度怨声载道。这时候最简单的解决方案就是增加缓存。网站服务器的缓存有很多中,可以放在数据库和Web应用程序之间,也可以放在Web应用程序和Web服务器之间,还可以放在Web服务器和用户浏览器之间,甚至可以直接放在浏览器端。
其中最简单,需要配置最少的莫过于数据库和Web应用程序之间了,并且见效也最快,因为对于现代计算机系统来说IO是最大的瓶颈。常见的方法就是使用MemCache或者K-V型NoSQL数据库做缓存。
这么安逸了一段时间之后,网站的响应速度还是会降下来,如果你不想增加服务器的话,这时需要做的就是页面输出缓存了。
因为数据库和Web应用程序端的缓存只是将数据库中的键和值缓存下来,访问量大的话有一个命中率的问题,并且一个页面往往包含有大量的需要从数据库查询的值,就算数据库缓存全部命中,这其中也需要一个查询的过程,即时它很快。还有一个问题,就是对于Web应用程序来说,从获取数据到输出页面,中间还需要一些逻辑性运算和模板的渲染过程,这个过程也会消耗一段时间。
如果将一个页面响应的结果,也就是HTML页面,整体缓存下来,Web服务器直接输出缓存结果,这样的速度基本上相当于直接输出静态页面,对于服务器端的负载将会大大减轻。
对于页面输出缓存,或者有人称之为页面静态化,主要有以下几个问题:
页面个性化部分的处理。现在绝大部分网站都有登录给功能,对于同一个页面来说登录前后、不同用户登录后都会有不同的显示,比如说未登录时显示“登录或注册”,登录之后显示“欢迎用户XXX”,这样就会造成网站大部分页面都不能直接缓存。
其实这个问题是在页面个性化部分与非个性化部分合成的时间问题上。传统网站这个个性化部分也非个性化部分是直接放在Web应用程序上的,这样就导致不同用户输出页面的不同,缓存不能进行。如果把这个过程放在页面缓存程序之后,使缓存的结果相同就没有问题了。
常见的方式就是使用SSI、ESI和CSI。
SSI(Server Side Include)这个现在大部分的Web服务器都支持,可以在页面中使用来替代原来个性化的部分,然后在Web服务器上就会去寻找这个.shtml文件,将它与页面合成。不过这有一个问题,ssi只能将静态文件与页面合成,这样对于做页面输出缓存意义并不大。
ESI(Edge Side Include)正是为了应付这种场合而出现的,它可以使用类似XML语法,在页面合成过程中调用一个动态页面,将动态页面的内容与现有网页合成。这样一来就可以将某个用户的个性化信息与缓存的页面文件合成了。ESI最大的问题就是现有Web服务器对此支持度不高。Nginx需要将第三方模块编译进来来支持ESI,代理缓存服务器中Varnish原生支持ESI。
CSI(Client Side Include)或者叫做Browser Side Include,就是在用户浏览器端将个性化信息与非个性化信息合成。说白了就是用ajax异步获取个性化信息,或者直接使用iframe显示个性化信息。实际上这个合成的过程越靠近用户浏览器端越好,这样就可以一直从Web服务器到代理服务器的缓存到CDN都可以用到这些完全相同的页面做缓存。但是使用ajax等方式有一些问题,就是对于不支持或者不开启的JavaScript的浏览器来说就不能正常访问页面了,现在很多网站都要考虑到手机客户端,其中不支持JavaScript的占大多数。并且大量使用ajax的话对用户浏览器来说也是一种负担,并且会造成页面加载很“卡顿”的感觉。
具体的做法还是依赖实际环境来决定。
剩下需要解决的方式是如何来缓存页面。
使用代理缓存服务器是一个比较不错的主意,比如Squid还有比较新的Varnish,它们都可以架设在Web服务器前端作为代理缓存服务器,将页面缓存下来。当页面需要更新的时候可以给它发送一个Purge请求加具体页面URL,就可以使代理缓存服务器去访问Web服务器,重新生成一个新的页面,然后将现有的页面缓存失效。这个过程中当新的用户访问请求达到时,代理缓存服务器还是使用之前的缓存,新的缓存页面生成结束后才使过期的页面失效。这样就不用担心更新缓存过程中所有用户请求都去直接访问Web服务器造成大量的压力了。这其中有一个问题,就是触发这个更新动作的用户,比如说他发布了一条回复造成页面更新,如果页面更新请求与页面缓存更新两个动作是异步的话,假如他的网速足够快就会看到自己刚才发布的帖子没有显示出来,造成重复发帖等动作。这个问题接下来再来想办法处理。
如果不想另外搞一个代理服务器的话,Nginx的fastcgi cache是一个好东西,可以直接将fastcgi的页面缓存下来。配合一个第三方的模块cache purge,可以通过访问/purge/url的方式来时缓存过期。另外Nginx+proxy cache+cache purge也可以达到上面Squid和Varnish的作用。
剩下还有一种方式,使用Nginx的rewrite,将缓存保存为一个静态文件,先检查静态页面是否存在,存在的话rewrite到此,不存在则访问Web应用程序。这样还需要另外配合一个独立的进程,来生成缓存页面。当Web应用程序触发更新时给此进程发送一个信号,缓存进程收到此信号后去Web服务器端或者Web应用程序端请求页面(为了做到松耦合和以后的服务器分离最好直接从Web服务器端),将请求生成的页面文件直接覆盖远缓存的静态文件,这样就可以更新缓存,并且在缓存更新过程中访问者依然可以使用之前的缓存文件。
还有一个问题,当很多用户在同一个时间点内同时触发一个页面的更新动作,这样就会导致此页面频繁更新,并且这些更新是不必要的,这就是所谓的惊群问题。
防止此事件发生,一个解决方案是使用队列。
一般页面发生内容变化是在提交POST请求之后,在此时给缓存更新系统发送一条消息,记录发生更新动作的类型、ID(比如说帖子更新,更新的ID)。缓存更新系统在接受到此消息时将消息进行预处理,根据配置文件计算出影响更新的页面(此帖子的页面,帖子列表的页面,等等)。
将此页面的URL插入进队列,更新程序在队列另一端取URL,每取一次URL就过滤一遍整个队列,将相同的URL队列消息删除出队列。这样就可以保证更新依次页面,就可以相应所有更新事件。取出后依据使用的缓存系统发送相应的Purge请求或者自主生成新的缓存页面覆盖老的野蛮来更新缓存。
这种去重复的队列实现可以用现有的模块,比如Python的一个Queue模块,继承此模块中的类并重写几个方法即可实现这种去重的队列。还可以使用现有的队列系统,甚至直接使用数据库来实现队列。使用数据库有一个好处,就是当整个系统Down掉的时候,系统重启后队列中的消息还存在,可以继续更新这些页面的缓存。如果直接使用内存中的队列的话系统Down掉重启就会导致一些未处理的队列消息丢失,导致这些页面的缓存都没有更新。这种情况出现的几率还有出现后对系统的影响需要根据实际情况斟酌,论坛系统如果发帖不是十分频繁,偶尔某个帖子或者回复没有出现并不是非常严重的问题。
一个好的系统应该是针对发生故障的情况而设计的。假如缓存系统出现问题,导致没有向缓存服务器发送更新请求,这时所有更新动作都不会得到相应,导致用户重复提交数据,可能会发生一些问题。这时如果能停掉缓存服务器,让用户直接访问Web应用程序或许是一个好主意(或者如果没有缓存的话服务器能被直接大量直接访问干掉),如果去做是一个问题。
Some Tips:
上面说的如果异步处理更新动作与缓存更新的话,可能会使更新触发者看不到自己更新的数据。这时可以使用一些小花招,比如现在大部分的论坛系统在发帖后跳转到一个“3秒后跳转到帖子页面”,就可以给缓存系统提供充分的更新缓存时间,对于用户体验来说也不会大打折扣。
有些页面不能使用缓存,比如说帖子列表页面。因为帖子列表页面是会随着最后回复时间来动态更新排列顺序的,一个新帖或者回复可能会导致所有帖子列表分页内容发生变化,而帖子列表分页可能有几千甚至几万页,全部都进行更新的话显然是不可能也没有必要的。因此这些页面不适用页面输出缓存,但我们可以只缓存帖子列表的第一页或者前几页,因为绝大部分的访问请求都是来自与这几个页面,每次更新动作都触发这几个页面进行缓存更新也是没有问题的。
假如网站每日发新帖的数量非常高,导致帖子列表更新十分频繁,这时可以使用另外一种更新触发机制,就是按时间来触发更新。这样定时更新的频率比之前按动作进行更新会少一些,并且也不会过分加重队列的负担。
网站首页的信息可能会比较复杂,触发其更新的来源可能会很多,更新一次的成本也较高,而首页对于事实性的要求也没有那么高(比如说在某个板块发布新贴,这时在首页的最新帖子列表中没有看到此贴,对于绝大部分用户来说并不是什么问题),因此也试用于按时间进行更新的机制,并且这个更新时间的间隔可以适当的拉长一些。
类似的还有帖子回复页,此页面也是一个列表排序页面,但是是顺序时间排列的(帖子列表为倒序时间排列的),因此也十分适用与使用缓存。如果网站当初设计的功能为显示所有楼层,删除某一楼层的话此位置消失,这样的话如果删除一个楼层将会导致此页面和接下来所有页面的内容发生变化。因此在网站设计时最好设计成这样:删除一条回复,此回复还在,只不过内容改为了“评论已删除”,这样就不会触发所有后来页面进行缓存更新了。