缓存系统 MemCached 的 Java 客户端优化历程

阅读数:38494 2008 年 9 月 27 日

Memcached 是什么?

Memcached 是一种集中式 Cache,支持分布式横向扩展。这里需要解释说明一下,很多开发者觉得 Memcached 是一种分布式缓存系统,但是其实 Memcached 服务端本身是单实例的,只是在客户端实现过程中可以根据存储的主键做分区存储,而这个区就是 Memcached 服务端的一个或者多个实例,如果将客户端也囊括到 Memcached 中,那么可以部分概念上说是集中式的。其实回顾一下集中式的构架,无非两种情况:一是节点均衡的网状(JBoss Tree Cache),利用 JGroup 的多播通信机制来同步数据;二是 Master-Slaves 模式(分布式文件系统),由 Master 来管理 Slave,比如如何选择 Slave,如何迁移数据等都是由 Master 来完成,但是 Master 本身也存在单点问题。下面再总结几个它的特点来理解一下其优点和限制。

内存存储:不言而喻,速度快,但对于内存的要求高。这种情况对 CPU 要求很低,所以常常采用将 Memcached 服务端和一些 CPU 高消耗内存、低消耗应用部署在一起。(我们的某个产品正好有这样的环境,我们的接口服务器有多台,它们对 CPU 要求很高——原因在于 WS-Security 的使用,但是对于内存要求很低,因此可以用作 Memcached 的服务端部署机器)。

集中式缓存(Cache):避开了分布式缓存的传播问题,但是需要非单点来保证其可靠性,这个就是后面集成中所作的集群(Cluster)工作,可以将多个 Memcached 作为一个虚拟的集群,同时对于集群的读写和普通的 Memcached 的读写性能没有差别。

分布式扩展:Memcached 很突出的一个优点就是采用了可分布式扩展的模式。可以将部署在一台机器上的多个 Memcached 服务端或者部署在多个机器上的 Memcached 服务端组成一个虚拟的服务端,对于调用者来说则是完全屏蔽和透明的。这样做既提高了单机的内存利用率,也提供了向上扩容(Scale Out)的方式。

Socket 通信:这儿需要注意传输内容的大小和序列化的问题,虽然 Memcached 通常会被放置到内网作为缓存,Socket 传输速率应该比较高(当前支持 TCP 和 UDP 两种模式,同时根据客户端的不同可以选择使用 NIO 的同步或者异步调用方式),但是序列化成本和带宽成本还是需要注意。这里也提一下序列化,对于对象序列化的性能往往让大家头痛,但是如果对于同一类的 Class 对象序列化传输,第一次序列化时间比较长,后续就会优化,也就是说序列化最大的消耗不是对象序列化,而是类的序列化。如果穿过去的只是字符串,这种情况是最理想的,省去了序列化的操作,因此在 Memcached 中保存的往往是较小的内容。

特殊的内存分配机制:首先要说明的是 Memcached 支持最大的存储对象为 1M。它的内存分配比较特殊,但是这样的分配方式其实也是基于性能考虑的,简单的分配机制可以更容易回收再分配,节省对 CPU 的使用。这里用一个酒窖做比来说明这种内存分配机制,首先在 Memcached 启动的时候可以通过参数来设置使用的所有内存——酒窖,然后在有酒进入的时候,首先申请(通常是 1M)的空间,用来建酒架,而酒架根据这个酒瓶的大小将自己分割为多个小格子来安放酒瓶,并将同样大小范围内的酒瓶都放置在一类酒架上面。例如 20 厘米半径的酒瓶放置在可以容纳 20-25 厘米的酒架 A 上,30 厘米半径的酒瓶就放置在容纳 25-30 厘米的酒架 B 上。回收机制也很简单,首先新酒入库,看看酒架是否有可以回收的地方,如果有就直接使用,如果没有则申请新的地方,如果申请不到,就采用配置的过期策略。从这个特点来看,如果要放的内容大小十分离散,同时大小比例相差梯度很明显的话,那么可能对于空间使用来说效果不好,因为很可能在酒架 A 上就放了一瓶酒,但却占用掉了一个酒架的位置。

缓存机制简单:有时候很多开源项目做的面面俱到,但到最后因为过于注重一些非必要的功能而拖累了性能,这里提到的就是 Memcached 的简单性。首先它没有什么同步,消息分发,两阶段提交等等,它就是一个很简单的缓存,把东西放进去,然后可以取出来,如果发现所提供的 Key 没有命中,那么就很直白地告诉你,你这个 Key 没有任何对应的东西在缓存里,去数据库或者其他地方取;当你在外部数据源取到的时候,可以直接将内容置入到缓存中,这样下次就可以命中了。这里介绍一下同步这些数据的两种方式:一种是在你修改了以后立刻更新缓存内容,这样就会即时生效;另一种是说容许有失效时间,到了失效时间,自然就会将内容删除,此时再去取的时候就不会命中,然后再次将内容置入缓存,用来更新内容。后者用在一些实时性要求不高,写入不频繁的情况。

客户端的重要性:Memcached 是用 C 写的一个服务端,客户端没有规定,反正是 Socket 传输,只要语言支持 Socket 通信,通过 Command 的简单协议就可以通信。但是客户端设计的合理十分重要,同时也给使用者提供了很大的空间去扩展和设计客户端来满足各种场景的需要,包括容错、权重、效率、特殊的功能性需求和嵌入框架等等。

几个应用点:小对象的缓存(用户的 Token、权限信息、资源信息);小的静态资源缓存;SQL 结果的缓存(这部分如果用的好,性能提高会相当大,同时由于 Memcached 自身提供向上扩容,那么对于数据库向上扩容的老大难问题无疑是一剂好药);ESB 消息缓存。

优化 MemCached 系统 Java 客户端的原因

MemCached 在大型网站被应用得越来越广泛,不同语言的客户端也都在官方网站上有提供,但是 Java 开发者的选择并不多。由于现在的 MemCached 服务端是用 C 写的,因此我这个 C 不太熟悉的人也就没有办法去优化它。当然对于它的内存分配机制等细节还是有所了解,因此在使用的时候也会十分注意,这些文章在网络上有很多。这里我重点介绍一下对于 MemCache 系统的 Java 客户端优化的两个阶段。

第一阶段:封装 Whalin

第一阶段主要是在官方推荐的 Java 客户端之一 whalin 开源实现基础上做再次封装。

  1. 缓存服务接口化:定义了 IMemCache 接口,在应用部分仅仅只是使用接口,为将来替换缓存服务实现提供基础。
  2. 使用配置代替代码初始化客户端:通过配置客户端和 SocketIO Pool 属性,直接交由 CacheManager 来维护 Cache Client Pool 的生命周期,便于单元测试。
  3. KeySet 的实现:对于 MemCached 来说本身是不提供 KeySet 的方法的,在接口封装初期,同事向我提出这个需求的时候,我个人觉得也是没有必要提供,因为缓存轮询是比较低效的,同时这类场景,往往可以去数据源获取 KeySet,而不是从 MemCached 去获取。但是 SIP 的一个场景的出现,让我不得不去实现了 KeySet。

    SIP 在作服务访问频率控制的时候需要记录在控制间隔期内的访问次数和流量,此时由于是集群,因此数据必须放在集中式的存储或者缓存中,数据库肯定撑不住这样大数据量的更新频率,因此考虑使用 Memcached 的很出彩的操作——全局计数器(storeCounter,getCounter,inc,dec),但是在检查计数器的时候如何去获取当前所有的计数器?我曾考虑使用 DB 或者文件,但是效率有问题,同时如果放在一个字段中的话,还会存在并发问题。因此不得不实现了 KeySet,在使用 KeySet 的时候有一个参数,类型是 Boolean,这个字段的存在是因为在 Memcached 中数据的删除并不是直接删除,而是标注一下,这样会导致实现 keySet 的时候取出可能已经删除的数据。如果对于数据严谨性要求低,速度要求高,那么不需要再去验证 Key 是否真的有效,而如果要求 Key 必须正确存在,就需要再多一次的轮询查找。
  4. 集群的实现:Memcached 作为集中式缓存,存在着集中式的致命问题:单点问题。虽然 Memcached 支持多 Instance 分布在多台机器上,但仅仅只是解决了数据全部丢失的问题,当其中一台机器出错以后,还是会导致部分数据的丢失,一个篮子掉在地上还是会把部分的鸡蛋打破。因此就需要实现一个备份机制,能够保证 Memcached 在部分失效以后,数据还能够依然使用,当然大家很多时候都用缓存不命中就去数据源获取的策略。然而在 SIP 的场景中,如果部分信息找不到就去数据库查找,很容易将 SIP 弄垮,因此 SIP 对于 Memcached 中的数据认为是可信的,做集群也是必要的。
  5. LocalCache 结合 Memcached 使用,提高数据获取效率:在第一次压力测试过程中,发现和原先预料的一样,Memcached 并不是完全无损失的,Memcached 是通过 Socket 数据交互来进行通信的,因此机器的带宽,网络 IO,Socket 连接数都是制约 Memcached 发挥其作用的障碍。Memcache 的一个突出优点就是 Timeout 的设置,也就是可以对放进去的数据设置有效期,从而在一定的容忍时间内对那些不敏感的数据就可以不去更新,以提高效率。根据这个思想,其实在集群中的每一个 Memcached 客户端也可以使用本地的缓存,来存储获取过的数据,设置一定的失效时间,来减少对于 Memcached 的访问次数,提高整体性能。

因此,在每一个客户端中都内置了一个有超时机制的本地缓存(采用 Lazy Timeout 机制),在获取数据的时候,首先在本地查询数据是否存在,如果不存在则再向 Memcache 发起请求,获得数据以后,将其缓存在本地,并设置有效时间。方法定义如下:

复制代码
/**

* 降低 memcache 的交互频繁造成的性能损失,因此采用本地 cache 结合 memcache 的方式

* @param key

* @param 本地缓存失效时间单位秒

* @return

*
*/

public Object get(String key,int localTTL);

第二阶段:优化

第一阶段的封装基本上已经可以满足现有的需求,也被自己的项目和其他产品线所使用,但是不经意的一句话,让我开始了第二阶段的优化。有同事告诉我说 Memcached 客户端的 SocketIO 代码里面有太多的 Synchronized(同步),多多少少会影响性能。虽然过去看过这部分代码,但是当时只是关注里面的 Hash 算法。根据同事所说的回去一看,果然有不少的同步,可能是作者当时写客户端的时候 JDK 版本较老的缘故造成的,现在 Concurrent 包被广泛应用,因此优化并不是一件很难的事情。但是由于原有 Whalin 没有提供扩展的接口,因此不得不将 Whalin 除了 SockIO,其余全部纳入到封装过的客户端的设想,然后改造 SockIO 部分。

结果也就有了这个放在 Google 上的开源客户端:http://code.google.com/p/memcache-client-forjava/

  1. 优化 Synchronized:在原有代码中 SockIO 的资源池被分成三个池(普通 Map 实现),——Free(闲)、Busy(忙)和 Dead(死锁),然后根据 SockIO 使用情况来维护这三个资源池。优化方式为首先简化资源池,只有一个资源池,设置一个状态池,在变更资源状态的过程时仅仅变更资源池中的内容。然后用 ConcurrentMap 来替代 Map,同时使用 putIfAbsent 方法来简化 Synchronized,具体的代码可参见 Google 上该软件的源文件。
  2. 原以为这次优化后,效率应该会有很大的提高,但是在初次压力测试后发现,并没有明显的提高,看来有其他地方的耗时远远大于连接池资源维护,因此用 JProfiler 作了性能分析,发现了最大的一个瓶颈:Read 数据部分。原有设计中读取数据是按照单字节读取,然后逐步分析,为的仅仅就是遇到协议中的分割符可以识别。但是循环 Read 单字节和批量分页 Read 性能相差很大,因此我内置了读入缓存页(可设置大小),然后再按照协议的需求去读取和分析数据,结果显示效率得到了很大的提高。具体的数据参见最后部分的压力测试结果。

上面两部分的工作不论是否提升了性能,但是对于客户端本身来说都是有意义的,当然提升性能给应用带来的吸引力更大。这部分细节内容可以参看代码实现部分,对于调用者来说完全没有任何功能影响,仅仅只是性能。

压力测试比较

在这个压力测试之前,其实已经做过很多次压力测试了,测试中的数据本身并没有衡量 Memcached 的意义,因为测试是使用我自己的机器,其中性能、带宽、内存和网络 IO 都不是服务器级别的,这里仅仅是将使用原有的第三方客户端和改造后的客户端作一个比较。场景就是模拟多用户多线程在同一时间发起缓存操作,然后记录下操作的结果。

Client 版本在测试中有两个:2.0 和 2.2。2.0 是封装调用 Whalin Memcached Client 2.0.1 版本的客户端实现。2.2 是使用了新 SockIO 的无第三方依赖的客户端实现。checkAlive 指的是在使用连接资源以前是否需要验证连接资源有效(发送一次请求并接受响应),因此启用该设置对于性能来说会有不少的影响,不过建议还是使用这个检查。

单个缓存服务端实例的各种配置和操作下比较:

缓存配置 用户 操作 客户端 版本 总耗时 (ms) 单线程耗时 (ms) 提高处理能力百分比
checkAlive 100 1000 put simple obj

1000 get simple obj
2.0

2.2
13242565

7772767
132425

77727
+41.3%
No checkAlive 100 1000 put simple obj

1000 put simple obj
2.0

2.2
7200285

4667239
72002

46672
+35.2%
checkAlive 100 1000 put simple obj

2000 get simple obj
2.0

2.2
20385457

11494383
203854

114943
+43.6%
No checkAlive 100 1000 put simple obj

2000 get simple obj
2.0

2.2
11259185

7256594
112591

72565
+35.6%
checkAlive 100 1000 put complex obj

1000 get complex obj
2.0

2.2
15004906

9501571
150049

95015
+36.7%
No checkAlive 100 1000 put complex obj

1000 put complex obj
2.0

2.2
9022578

6775981
90225

67759
+24.9%

从上面的压力测试可以看出这么几点,首先优化 SockIO 提升了不少性能,其次 SockIO 优化的是 get 的性能,对于 put 没有太大的作用。原以为获取数据越大性能效果提升越明显,但结果并不是这样。

单个缓存实例和双缓存实例的测试比较:

缓存配置 用户 操作 客户端 版本 总耗时 (ms) 单线程耗时 (ms) 提高处理能力百分比
One Cache instance

checkAlive
100 1000 put simple obj

1000 get simple obj
2.0

2.2
13242565

7772767
132425

77727
+41.3%
Two Cache instance

checkAlive
100 1000 put simple obj

1000 put simple obj
2.0

2.2
13596841

7696684
135968

76966
+43.4%

结果显示,单个客户端对应多个服务端实例性能提升略高于单客户端对应单服务端实例。

缓存集群的测试比较:

缓存配置 用户 操作 客户端 版本 总耗时 (ms) 单线程耗时 (ms) 提高处理能力百分比
No Cluster

checkAlive
100 1000 put simple obj

1000 get simple obj
2.0

2.2
13242565

7772767
132425

77727
+41.3%
Cluster

checkAlive
100 1000 put simple obj

1000 put simple obj
2.0

2.2
25044268

8404606
250442

84046
+66.5%

这部分和 SocketIO 优化无关。2.0 采用的是向集群中所有客户端更新成功以后才返回的策略,2.2 采用了异步更新,并且是分布式客户端节点获取的方式来分散压力,因此提升效率很多。

开源代码下载

其实封装后的客户端一直在内部使用,现在作了二次优化以后,觉得应该开源出来,一是可以完善自己的客户端代码,二是也可以和更多的开发者交流使用心得。目前我已经在 Google Code 上传了应用的代码、范例和说明等,有兴趣的朋友可以下载下来测试一下,与现在用的 Java Memcached 客户端在易用性和性能方面是否有所提高,也期待更多对于这部分开源内容的反馈,能够将它做的更好。

链接地址:http://code.google.com/p/memcache-client-forjava/


作者介绍:岑文初,就职于阿里软件公司研发中心平台一部,任架构师。当前主要工作涉及阿里软件开发平台服务框架(ASF)设计与实现,服务集成平台(SIP)设计与实现。没有什么擅长或者精通,工作到现在唯一提升的就是学习能力和速度。个人 Blog 为:http://blog.csdn.net/cenwenchu79

志愿参与 InfoQ 中文站内容建设,请邮件至editors@cn.infoq.com。也欢迎大家到InfoQ 中文站用户讨论组参与我们的线上讨论。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论