Memcached作为高性能的分布式内存对象缓存系统,在web服务里应用较广,和高性能的异步非阻塞服务器Tornado搭配起来可以大幅提高服务端的性能。

##应用Memcached缓存热点请求结果
我们给客户端提供api,通过json来返回http请求的结果,一般Web服务都是如此。由于用的是Tornado,所以逻辑上大概长这样:

一个请求对应一个RequestHandler对象,RequestHandler类通过定义get/post方法来处理http请求,结果会通过write方法写到一个write_buffer里,最终有flush方法负责将write_buffer里的结果返回给客户端。

缓存的话,我们一般是这样:

从Tornado的RequestHandler继承一个类作为需要缓存数据的handler的基类,比如取名RequestHandlerCached,该基类里会优先走缓存去结果(write_buffer),取不到结果时才走实际的get/post方法,然后把实际请求的结果(write_buffer)重新set回Memcached里,这样只要继承这个类,你的请求就被缓存了,当请求过来的时候可以先从缓存里取结果。

##关键问题: 如何手动批量更新指定的Memcached缓存(强制更新指定的缓存,不能强制flush_all())
我们服务里有很多请求的处理大概是从CMS的接口里拿一些数据,处理后返回。CMS是用来控制内容的一些展示、推荐策略的,所以会有一定程度的周期性更新,CMS主要提供给编辑操作,编辑可以在事先约定好的框架下填入一些内容,比如某个主题、某些刚上线的节目等。当给这些含有CMS的东西的handler加上缓存之后,现在有一个问题:

当编辑更新了上游CMS系统api的内容之后,所有调用了该CMS服务的下游服务又该怎么立即更新缓存,并且,这种立即更新绝对不能给当前服务带来太大的性能波动?
本质上就是个上、下游服务协同的问题,或者说是调用者和被调用者之间的协同问题。
因为Memcached并不像Redis那样支持键的模糊匹配,怎样才能针对一批key做到手动、批量、安全、及时的数据更新呢?

之前的代码是直接提供一个接口,编辑需要更新缓存的时候点个按钮就好了, 清缓存方法是直接flush_all,比较粗暴,好几次编辑在高峰期清缓存的时候,直接把服务搞挂了。

后来我决定研究一下这个缓存策略,一开始想作服务隔离,凡是这些会手动清的缓存一律用单独的一个Memcached服务来缓存,但这样还是会出现更新某个服务的缓存的时候把其他的也一块儿干掉了!因为Memcached不支持键的模糊匹配。

对于Memcached不支持键的模糊匹配这个问题(准确说是键的命名空间),网上倒是有一个解决方案:

给你的键加前缀,比如某个handler的键都有前缀A(类似一个版本号),这样缓存的数据的键可以附在A之后,形成了一个命名空间,当你需要清理这个handler的缓存的时候,直接A=A+1,然后在把数据的键放在后面,这样你拿这个键去Memcached里取数据的时候肯定取不到数据,就会走实际请求,这样相当于更新了缓存,而之前的旧数据还在Memcached里,只是你取不到而已,由于这些数据都有过期时间,让他们到时候过期就行了。

这倒是看起来挺美的一个方案,但是细细想一下,这种方案对于那种偶尔来那么一两下的清缓存需求倒是可以,但是对于这种清缓存的操作都是有编辑控制的情况,有以下问题:

1.一旦有编辑在一个时间段内执行多次更新缓存的操作的话,Memcached内部会有大量我们认为过期但是Memcached不认为过期的数据。内存使用有一定风险。

2.版本号的过期时间怎么控制,因为你的数据完全依赖于这个版本号,一旦这个版本号取不到,你的数据也就拿不到了,那这个时候你走正常请求之后更不更新缓存,可是更新缓存的时候你怎么知道新的版本号是谁?初始化一个,可是假如你这次没有取到版本号是由于Memcached没命中,并不是因为没有版本号怎么办?

3.存你版本号的缓存重启的时候怎么办?

4.第一次上线的时候,所有采取这种方案的线上缓存可就全都废了。这代价可太大了点儿。

这种完全用键来控制数据更新的方案让你的数据对于这个版本号过于敏感,对于频繁操作的手动更新缓存的需求来讲,风险较大。那么我想:既然用键不行,是否可以用缓存的value做点改进?

Memcached是一个key-value数据库,但这不意味着我们只能有这样的思维定势

缓存数据data,序列化成一个字符串,搞个key,set到Memcached里,取的时候,拿key去Memcached里get出来,然后反序列化。

在set数据的时候,完全可以赋予数据一个简单的结构,构造出一个稍微结构化点儿的数据类型,类似一个dict:

{
    data: ..., #待缓存的数据
    some_key: ..., #用于数据做控制的一个键值对
}

对于以上的结构,我们在set数据的时候,将以上的数据序列化;取数据的时候,get出来,然后反序列化成一个dict,则dict.get('data')就是我们的原始数据。这样的结构有什么用呢?

回到我们的问题:

如何手动定向更新一批缓存数据而不影响其他的缓存数据?

最好是更新缓存的时候服务的整体性能不要受到太大影响,避免出现短时间内大量数据失效导致请求全部走实际get/post方法而打爆CMS的服务器。我提出了以下的解决方案,首先,缓存数据时构造简单的结构,就像上文给出的一样,我构造如下的结构:

{
    api.raw_data: ..., #原始数据
    #以下是一个UNIX时间戳, 作为数据的版本号
    api.update_token: 14398700873,
}

具体策略如下:

1.从RequestHandler继承一个新的handler基类:CachedRequestHandler,这个基类会为继承自他的子类生成各自唯一的update_token,使用key()在手动更新缓存时候set到Memcached里;

2.每一个继承自CachedRequestHandler基类的子类都有自己的专属update_token,对于缓存的数据,也有一个update_token,和原始数据一起,构造简单的缓存数据结构dict,序列化后set到Memcached里;

3.手动更新某个handler的缓存时,操作很简单:实际是调接口往Memcached里set一个这个handler的专属update_token,key就是上文提到的key,值就用当前时间戳。

4.对于某一个具体的请求,取缓存时,先取对应handler的专属update_token(同一个handler的所有请求共享一个),再取该请求的缓存数据(序列化的dict),把取出的数据反序列化,拿出update_token,和专属的update_token对比,若不一致则强制更新缓存,并把新的缓存数据的update_token设为专属update_token;

5.问题:绑定到handler的专属update_token的过期时间怎么设?答案是和数据的生存期一致就可以了,因为Memcached里的缓存活得最长也就自己生存期的时间。到时候,你不清他,他也过期了;

6.问题:既然只有手动更新缓存的时候会设update_token,那么平时怎么办,平时数据取不到handler的专属update_token?答案是取不到handler专属update_token就不用care,让数据通过过期时间自己刷新。可是缓存数据set到Memcached里的时候也需要自己的update_token啊,很简单,随便一个默认小数字,因为我们平时并不care这个update_token,只有在手动更新缓存时,才会比较handler的update_token和缓存数据的update_token,所以平时他是什么并不重要,平时数据只要care过期时间就可以了;

7.为什么update_token用unix时间戳?时间总是向未来演进的,某一个时刻的时间戳总是比之前的说有时间戳都大,时刻具有先天的唯一性,保证我们在手动更新缓存的时候,可以通过和数据的update_token比较来通知数据:不一致,你赶紧更新自己吧;即使我们在项目中对这个时间戳做了取整处理,也无妨,除非你两次操作的间隙小于1秒,这个误差完全可以接受;

##互斥锁:不要让所有请求瞬间失效
根据之前的描述,当你调用清缓存的接口时,Memcached里会出现一个绑定到某个handler的update_token(相当于数据的最新版本号),此时,请求过来后发现取到了handler的专属update_token,知道该check一下自己的update_token看是否是最新的update_token了,现在的问题是:高并发的情况下,此handler的所有的请求都会失效,都会去请求后端,非常粗暴,系统的性能会有颠簸。

我们希望的是:平滑的更新我们的缓存,我们不需要大量数据失效,全都过来重新走get/post,重新set到Memcached,根本没必要,因为更新一次缓存的时间很短,我们可以牺牲一点实时性,仅仅是很小的牺牲,因为缓存更新是很短暂的时间,一旦更新成功,后续的请求又可以都走缓存了。所以我有做了一下优化:

利用Memcached的add命令模拟一下互斥锁。Memcached的单个命令是原子的,即不存在任何中间状态,很符合获取锁/释放锁这样的严格的原子性操作。add命令会在Memcached里不存在某个key时,以指定的key存储一个值到Memcached里,返回True;而当存在key时,直接返回False。

使用方法是:

当发现数据的当前版本号(api.update_token)和handler的专属版本号(update_token)不一致的时候,开始试图更新Memcached。当多个有共同缓存key的数据想更新缓存时,通过add命令抢一把互斥锁,只允许抢到锁的请求更新缓存,其余未抢到锁的请求直接返回刚才取到的老数据。

由于更新缓存是很快就可以完成的事情,所以仅仅损失一点点实时性。但却带来了很平滑的更新:不用担心编辑手抖了,不停的刷新缓存,我们每次都只有一个请求会实际走get/post,然后负责更新缓存,其他的都直接返回老数据,服务器的压力很小很小。

##总结
我们的确牺牲了一点点空间和时间,但却换来了更好的可控性,代码里也仅仅增加一点点比较大小之类的逻辑,对于我们这种web服务(或者说绝大多数web服务类型):

后端性能瓶颈基本是I/O(数据库,文件,第三方api)

增加的一点响应时间跟IO瓶颈来讲基本不是一个量级的,实践证明我的方案还是能解决问题的。

文章目录