这是使用缓存最频繁最直接的方式,即我们把需要频繁访问DB的数据加载到内存里面,以提高响应速度。通常我们的做法是使用一个ConcuccrentHashMap<Request, AtomicInteger>来记录一天当中每个请求的次数,每天凌晨取出昨天访问最频繁的K个请求(K取多少个取决你的可用内存有多少),从DB中读取这些请求的返回结果放到一个ConcuccrentHashMap<Request, Response>容器中,然后把所有请求计数清0,重新开始计数。

LRU缓存

热数据缓存适用于那些热数据比较明显且稳定的业务场景,而对于那些热数据不稳定的应用场景我们需要发明一种动态的热数据识别方式。我们都知道常用的内存换页算法有2种:LFU和LRU。

LFU(Least Frequently Used)是把那些最近最不经常使用的页面置换出去,这跟上面讲的热数据缓存是一个道理,缺点有2个:

  1. 需要维护一个计数器,记住每个页面的使用次数。
  2. 上一个时间段频繁使用的,在下一个时间段不一定还频繁。

LRU(Least Recently Used)策略是把最近最长时间未使用的页面置换出去。实现起来很简单,只需要一个链表结构,每次访问一个元素时把它移到链表的尾部,当链表已满需要删除元素时就删除头部的元素,因为头部的元素就是最近最长时间未使用的元素。

常用缓存技术_缓存View Code

TimeOut缓存

Timeout缓存常用于那些跟用户关联的请求数据,比如用户在翻页查看一个列表数据时,他第一次看N页的数据时,服务器是从DB中读取的相应数据,当他看第N+1页的数据时应该把第N页的数据放入缓存,因为用户可能呆会儿还会回过头来看第N页的数据,这时候服务器就可以直接从缓存中获取数据。如果用户在5分钟内还没有回过头来看第N页的数据,那么我们认为他再看第N页的概率就非常低了,此时可以把第N页的数据从缓存中移除,实际上相当于我们为缓存设置了一个超时时间。

我想了一种Timeout缓存的实现方法。还是用ConcurrentHashMap来存放key-value,另建一棵小顶堆,每个节点上存放key以及key的到期时间,建堆时依据到期时间来建。开一个后台线程不停地扫描堆顶元素,拿当前的时间戳去跟堆顶的到期时间比较,如果当前时间晚于堆顶的到期时间则删除堆顶,把堆顶里存放的key从ConcurrentHashMap中删除。删除堆顶的时间复杂度为O(log2N)O(log2N),具体步骤如下:

  1. 用末元素替换堆顶元素root
    常用缓存技术_java_02

  2. 临时保存root节点。从上往下遍历树,用子节点中较小那个替换父节点。最后把root放到叶节点上
    常用缓存技术_链表_03

下面的代码是直接基于java中的java.util.concurrent.Delayed实现的,Delayed是不是基于上面的小顶堆的思想我也没去深入研究。

TimeoutCache.java

常用缓存技术_缓存View Code

DelayItem.java

常用缓存技术_缓存View Code

JavaSerializer.java

常用缓存技术_缓存View Code

Redis省内存的技巧

 redis自带持久化功能,当它决定要把哪些数据换出内存写入磁盘时,使用的也是LRU算法。同时redis也有timeout机制,但它不像上面的TimeoutCache.java类一样开个无限循环的线程去扫描到期的元素,而是每次get元素时判断一个该元素有没有到期,所以redis中一个元素的存活时间远远超出了设置的时间是很正常的。

本节想讲的重点其实是redis省内存的技巧,这也是实践中经常遇到的问题,因为内存总是很昂贵的,运维大哥总是很节约的。在我们的推荐系数中使用Redis来存储信息的索引,没有使用Lucene是因为Lucene不支持分布式,但是省内存的技巧都是从Lucene那儿学来的。

首先,如果你想为redis节省内存那你就不能再用<String,String>类型的key-value结构,必须全部将它们序列化成二进制的形式。我写了一个工具类,实现各种数据类型和byte[]的互相置换。

DataTransform.java

常用缓存技术_缓存View Code

请留意一下上述代码中出现了VInt和VLong两种类型,具体看注释。

倒排索引常见的形式为:term -->  [infoid1,infoid2,infoid3...],针对这种形式的索引我们看下如何节省内存。首先value要采用redis中的list结构,而且是list<byte[]>而非list<String>(想省内存就要杜绝使用String,上面已经说过了)。假如infoid是个int,置换成byte[]就要占4个字节,而绝大部分情况下infoid都1000万以内的数字,因此使用VInt只需要3个字节。内存还可以进一步压缩。链表的第1个infoid我们存储它的VInt形式,后面的infoid与infoid1相减,差值也是个1000万以内的数字而且有可能非常小,我们采用VInt存储这个差值最多需要3个字节,有可能只需要1个字节。访问链表中的任意一个元素时都需要先把首元素取出来。

另一种常见的索引形式为:infoid --> infoDetail,infoDetail中包含很多字段,譬如city、valid、name等,通常情况下人们会使用Redis的hash结构来存储实体,而我们现在要做的就是把infoDetail这个实体序列化成尽可能短的字节流。首先city代表城市,本来是个String类型,而city这个东西是可以穷举的,我们事先对所有city进行编号,在redis中只存储city编号即可。valid表示信息是否过期是个bool类型,在java中存储一个bool也需要1个字节,这显然很浪费,本来一个bit就够了嘛,同时city又用不满一个int,所以可以让valid跟city挤一挤,把city左移一位,把valid塞到city的末位上去。

常用缓存技术_缓存View Code