Memcached应用总结
memcached是一款高性能的分布式缓存系统,凭借其简单方便的操作,稳定可靠的性能广泛应用于互联网应用中,网上关于memcached介绍的资料也很多,最经典的资料就是《memcached全面剖析》这个文档,原文链接:http://gihyo.jp/dev/feature/01/memcached/0001,中文翻译网上很多:http://tech.idv2.com/2008/08/17/memcached-pdf/,这个文档写的很好,也很容易读懂。接下来我主要去总结一些常见应用场景问题以及解决办法。
1. 缓存的存储设计
按应用场景的不同一般有以下两种设计方案:
-
方案一:把数据库的SQL查询结果缓存到memcached,读取数据的时候优先从memcached读取,挡住数据库查询请求。
优点
:我们可以在开发框架上做一些统一的缓存处理,对业务开发透明,减少对业务逻辑代码的侵入。这种情况下缓存的预热也比较方便,我们可以借助数据库的存储日志(eg:mysql的binlog)来预热缓存。缺点
:这种方式有个隐患就是如果前端的一次请求需要涉及到多个SQL查询结果,这时候memcached需要取多次数据,在高并发的情况下网络io的开销和memcached的并发压力有可能成为瓶颈。 -
方案二:把业务处理的最终结果进行缓存,客户端来请求时可以直接返回这个缓存的结果。
优点
:可以快速返回数据,只取一次memcache就可以了,减少了网络io消耗和运算处理消耗。缺点
:需要在业务逻辑里显式处理缓存,同时存储的数据结构较复杂,当我们有数据更新时,重新生成缓存会比较麻烦。这种情况比较适用于计算密集型的高并发应用场景。
2. 缓存更新策略
两种常见的方案,也各有优缺点和应用场景:
-
方案一:懒惰式加载,客户端先查询memcached,如果命中,返回结果,如果没命中(没有数据或已经过期),则从数据库中加载最新数据,并写回到memcached中,最后返回结果。
优点
:使用方便,简单;缺点
:高并发的情况下如果缓存失效,将对后端数据库造成瞬时压力。当然,我们可以在应用里加锁来控制并发,但是这样也会对应用程序造成影响。 -
方案二:主动更新策略,缓存里的数据永不失效,当有数据更新的时候,由单独程序来更新这个缓存。
优点
:缓存数据总是可靠的(没有LRU的情况下),前端可以快速响应,后端的数据库也没有并发查询的压力。缺点
:程序结构变复杂了,需要维护单独的程序来完成更新,两套程序要共享一套缓存配置。(ps:其实有一些业务场景本来就是这样的,比如门户网站的内容发布系统和网站系统就需要共享一份数据,一个负责写数据,一个负责展示数据)
3. 批量删除(或更新)问题
在memcached中,我们的绝大部分操作都是基于单个key的add/set/del/get操作,用起来很方便,但是呢,有些时候我们会碰到批量删除(或更新)的问题。比如某手机App应用因为出现了敏感内容,网络监管部门要求删除所有跟这条内容有关的信息,这个时候因为手机机型、版本不同,这个内容在缓存里的key有多种多样。我们不能方便地拿到所有的key,或者可以枚举出所有的key,但是memcached并不支持批量删除操作,这就麻烦了,怎么解决这种问题呢?下面我以某门户网站删除敏感新闻来举例,我们假设每条新闻都有很多维度的内容,新闻以newsid标识,每个维度以prop 来老相识,再加一个通用前缀,这样,完整的key应该是这样的格式:key{newsid}{prop}
-
方案一:
用一个单独的集合(Set)把一类key维护起来。当需要批量删除(或更新)时只需要取出这个集合里的所有key进行相应的操作即可。这样做起来比较简单:
首先,我们往memcached里面添加一个新的k,v时,就往那个set里加一个key,比如一条新闻在memcached里面有下面这些 对:
key_{newsid}_{prop1}:value1 key_{newsid}_{prop2}:value2 key_{newsid}_{prop3}:value3 …… key_{newsid}_{propn}:valuen
在我们的集合里面,就要存放所有跟这条新闻有关key的集合:
keyset_{newsid}:key_{newsid}_{prop1},key_{newsid}_{prop2},……,key_{newsid}_{propn})
这样,当我们要清除这条新闻的缓存时,就可以取出这个key的集合,然后遍历这些key,到memcached里面逐个删除,这样就达到了批量删除的目的。
在这里,我们提到的这个key set具体怎么存放和维护呢?
一种方式是,在memcached里面把所有key用逗号拼接成一个大字符串构成keyset的value或者借助开发语言提供的集合结构(set)来组织数据,系列化到memcached中。
另一种方式是,借助更方便的存储结构来保存这个key,比如redis的set结构,当然了,这种方式并不推荐,会给现有系统带来复杂度。
-
方案二:
通过动态更新key的方式来实现,这种方式是给每一个key都在原来key的基础上加一个版本号来组成,当需要批量删除或更新时只需升级版本号即可,具体怎么做呢?
首先,我们在memcached给这条新闻维护一个版本号,这样:
key_version_{newsid}:v1.0 (版本号可以用时间戳或其它任何有意义的内容代替) // 伪代码 $memcacheClient->setVersion(key_version{newsid}, "v1.0");
然后,当我们要保存或读取这条新闻相关的数据时,先取出这个版本号来生成新的key,如下:
//伪代码 $version = getVersion(key_version_{newsid}); $key = "key_{newsid}_{prop}_" + $version;
再用这个新的key来保存(或读取)真正的内容,这样在memcached里面保存的跟这条新闻有关的 对就是下面这样了:
key_{newsid}_{prop1}_v1.0:value1 key_{newsid}_{prop2}_v1.0:value2 key_{newsid}_{prop3}_v1.0:value3 …… key_{newsid}_{propn}_v1.0:valuen
当我们需要删除(或更新)这条新闻相关的所有key时,只需要升级版本号即可,如下:
//伪代码 $memcacheClient->updateVersion(key_version_{newsid},"v2.0");
这样的话,当我们下次访问这条新闻的缓存时,由于版本号升级,新的key下所有内容都为空,需要从数据库加载新的内容,或者是返回空的结果。而旧的key在过期时间到了以后也就可以回收利用了。这样就达到了我们批量删除或更新的目的。
上面提到的两种方案其实都比较简单和实用,当然也各有缺点,方案一的key set维护需要额外的消耗,方案二的老版本数据不能及时清理,造成缓存垃圾。我们在实际应用场景中可以灵活选择,两者在效果上其实不会有太大区别。
4. 故障转移和扩容的问题
memcached它不是一个分布式的系统,严格来说是个单点系统,所谓的分布式只是借助客户端来实现的。所以它没有那些开源分布式系统那样的高可用性,我们这里来讨论一下memcached怎么去避免单点故障,以及在线扩容的问题。(ps:memcached做得真省事儿,最大的特点就是简单,好多辅助功能都要依赖于客户端自己去实现)。
- 一致性哈希:好吧,这应该算是最简单常见的一种机制了,依赖于一致性哈希的特点,节点故障或扩容加节点时对集群影响较小,基本上可以满足大部分应用场景了。但是要注意:节点调整的最初一段时间内,会有一部分缓存丢失,穿透到后端的数据库上,在高并发的应用里,要做好并发控制,以免对数据库造成压力。
- 双写机制:客户端维护两个集群,每次更新数据的时候同时更新两份,读取的时候随机(或固定)读取一份,这种情况下集群的可用性和稳定性是很高的,可以无痛变更,节点故障或扩容对缓存和后端数据库都没有影响。当然,这样做也是有代价的:一是两份数据的一致性问题,不过对缓存来说,这种极少数的不一致情况是可以容忍的;另一个是内存浪费的问题,通过冗余一份数据来减少故障率,代价还是挺大的,并不适合大型的互联网应用。
- Twemproxy:这是推ter开源的一个代理程序,可以给redis和memcached作代理,有了这个东西可以减少好多维护成本(主要是客户端的)。对于故障转移和在线扩容也很方便。具体可以参考:https://github.com/推ter/twemproxy
5. 与优化有关的一些小细节
-
批量读取(multiget):有些较复杂的业务请求可能一次请求要进行多次memcached操作,其中的网络往返的消耗以及对memcached节点施加的并发压力还是比较可观的,这种情况下我们可以考虑进行批量读取来减少网络io往返的次数,一次把数据返回,同时还能减轻客户端的业务处理逻辑。
这里有一个著名的multiget无底洞问题,在非死book的应用中发现了这个问题,请参考:http://highscalability.com/blog/2009/10/26/非死books-memcached-multiget-hole-more-machines-more-capacit.html,这篇文章中已经提出了解决方案。但其实我们也可以考虑把multiget的key分布到一个节点上,来避免这个问题,这样就需要自己定制memcache 的客户端,按一定的规则(比如:相同的前缀)把一类key分布到同一个节点上,来避免这个问题,同时这样也可以提高性能,不用在多个节点之间等待数据。
-
改变系列化方式:不使用java的对象序列化方式(哈哈,我这里只针对java来说),自己实现序列化,把要缓存的对象序列化成字节数组或者string进行保存。这样在内存节省和网络传输上都有不错的效果。
- 数据预热:一些场景下我们需要为应用预热缓存数据(比如节点扩容需要重新分布数据),在前面说缓存设计的时候提出过,可以借助数据库的更新日志来预热缓存,这主要依赖于缓存的内容是跟数据库存储一致。其它情况下我们可以考虑在现有缓存前面挡一层空内容的集群节点,逐步把旧缓存读取到新缓存集群中来达到数据预热的目的,这样做就是有一点麻烦,需要应用端配合。
- 增长因子:合理调整memcached的增长因子,可以有效控制内存的浪费。
- 空结果的处理:有些场景下我们数据库里没有查到数据,缓存里也是空的,这时候需要在缓存里存放一个短时效的空结果来挡住前端的频繁请求,以免对数据库造成压力。
memcached的使用其实非常简单,性能也很出色,上面这些就是我们在实际业务开发中会碰到的一些场景,根据实际场景去选择合适的解决方案,可以给以后的开发维护带来不少便利。