不同颜色的处理框表示不同的请求。异步流程需要使用方的两次请求才能获取到数据。像图中“用服务端数据更新缓存”(update cache)、“服务端数据与缓存数据汇总”(merge data)步骤在异步流程里是在第二次请求中完成的,区别于同步流程第一次请求就完成所有步骤。将数据流程拆分为这些子步骤,同步与异步只是这些步骤的不同顺序的组合。因此读写缓存(search cache、update cache)这两个步骤可以抽象出来,与其余逻辑解耦。
数据存储——时间先于空间,客户端与服务端分离
客户端之于服务端,犹如服务端之于数据库,其实数据存储压缩的思路是完全一样的。具体的数据压缩与存储策略在上文数据压缩章节已经做了详细介绍,这里主要想说明两点问题:
客户端压缩与服务端压缩由于应用场景的不同,其目标是有差异的。服务端压缩使用场景是一次性高吞吐写入,逐条高并发低延迟读取,它主要关注的是读取时的解压时间和数据存储时的压缩比。而客户端缓存属于数据存储分层中最顶端的部分,由于读写的场景都是高并发低延迟的本地内存操作,因此对压缩速度、解压速度、数据量大小都有很高要求,它要做的权衡更多。
其次,客户端与服务端是两个完全独立的模块,说白了,虽然我们会编写客户端代码,但它不属于服务的一部分,而是调用方服务的一部分。客户端的数据压缩应该尽量与服务端解耦,切不可为了贪图实现方便,将两者的数据格式耦合在一起,与服务端的数据通信格式应该理解为一种独立的协议,正如服务端与数据库的通信一样,数据通信格式与数据库的存储格式没有任何关系。
内存管理——缓存与分代回收的矛盾
缓存的目标是让热数据(频繁被访问的数据)能够留在内存,以便提高缓存命中率。而JVM垃圾回收(GC)的目标是释放失去引用的对象的内存空间。两者目标看上去相似,但细微的差异让两者在高并发的情景下很难共存。缓存的淘汰会产生大量的内存垃圾,使Full GC变得非常频繁。这种矛盾其实不限于客户端,而是所有JVM堆内缓存共同面临的问题。下面我们仔细分析一个场景:
随着请求产生的数据会不断加入缓存,QPS较高的情形下,Young GC频繁发生,会不断促使缓存所占用的内存从新生代移向老年代。缓存被填满后开始采用Least Recently Used(LRU)算法淘汰,冷数据被踢出缓存,成为垃圾内存。然而不幸的是,由于频繁的Young GC,有很多冷数据进入了老年代,淘汰老年代的缓存,就会产生老年代的垃圾,从而引发Full GC。
可以看到,正是由于缓存的淘汰机制与新生代的GC策略目标不一致,导致了缓存淘汰会产生很多老年代的内存垃圾,而且产生垃圾的速度与缓存大小没有太多关系,而与新生代的GC频率以及堆缓存的淘汰速度相关。而这两个指标均与QPS正相关。因此堆内缓存仿佛成了一个通向老年代的垃圾管道,QPS越高,垃圾产生越快!
因此,对于高并发的缓存应用,应该避免采用JVM的分带管理内存,或者可以说,GC内存回收机制的开销和效率并不能满足高并发情形下的内存管理的需求。由于JVM虚拟机的强制管理内存的限制,此时我们可以将对象序列化存储到堆外(Off Heap),来达到绕开JVM管理内存的目的,例如Ehcache,BigMemory等第三方技术便是如此。或者改动JVM底层实现(类似之前淘宝的做法),做到堆内存储,免于GC。
三、结束语
本文主要介绍了一些在线特征系统的技术点,从系统的高并发、高吞吐、大数据、低延迟的需求出发,并以一些实际特征系统为原型,提出在线特征系统的一些设计思路。正如上文所说,特征系统的边界并不限于数据的存储与读取。像数据导入作业调度、实时特征、特征计算与生产、数据备份、容灾恢复等等,都可看作为特征系统的一部分。本文是在线特征系统系列文章的第一篇,我们的特征系统也在需求与挑战中不断演进,后续会有更多实践的经验与大家分享。一家之言,难免有遗漏和偏颇之处,但是他山之石可以攻玉,若能为各位架构师在面向自己业务时提供一些思路,善莫大焉。