热数据缓存
这是使用缓存最频繁最直接的方式,即我们把需要频繁访问DB的数据加载到内存里面,以提高响应速度。通常我们的做法是使用一个ConcuccrentHashMap<Request, AtomicInteger>来记录一天当中每个请求的次数,每天凌晨取出昨天访问最频繁的K个请求(K取多少个取决你的可用内存有多少),从DB中读取这些请求的返回结果放到一个ConcuccrentHashMap<Request, Response>容器中,然后把所有请求计数清0,重新开始计数。
LRU缓存
热数据缓存适用于那些热数据比较明显且稳定的业务场景,而对于那些热数据不稳定的应用场景我们需要发明一种动态的热数据识别方式。我们都知道常用的内存换页算法有2种:LFU和LRU。
LFU(Least Frequently Used)是把那些最近最不经常使用的页面置换出去,这跟上面讲的热数据缓存是一个道理,缺点有2个:
- 需要维护一个计数器,记住每个页面的使用次数。
- 上一个时间段频繁使用的,在下一个时间段不一定还频繁。
LRU(Least Recently Used)策略是把最近最长时间未使用的页面置换出去。实现起来很简单,只需要一个链表结构,每次访问一个元素时把它移到链表的尾部,当链表已满需要删除元素时就删除头部的元素,因为头部的元素就是最近最长时间未使用的元素。
1 import java.util.ArrayList; 2 import java.util.Collection; 3 import java.util.LinkedHashMap; 4 import java.util.Map; 5 import java.util.concurrent.locks.ReadWriteLock; 6 import java.util.concurrent.locks.ReentrantReadWriteLock; 7 8 /** 9 * 利用LinkedHashMap实现一个定长容量的,先进先出的队列。当指定按访问顺序排序时,就实际上是一个最近最少使用LRU队列10 * 11 * 根据链表中元素的顺序可以分为:按插入顺序的链表,和按访问顺序(调用get方法)的链表。12 * 默认是按插入顺序排序,如果指定按访问顺序排序,那么调用get方法后,会将这次访问的元素移至链表尾部。13 * 不断访问可以形成按访问顺序排序的链表。14 * 可以重写removeEldestEntry方法返回true值指定插入元素时移除最老的元素。15 * 16 * @Author:zhangchaoyang17 * @Since:2014-9-518 * @Version:1.019 */20 public class LRUCacheextends LinkedHashMap {21 22 private static final long serialVersionUID = -2045058079564141163L;23 24 private final int maxCapacity;25 26 // 本类中设置装载因子实际没有意义,因为容量超过maxCapacity时就会把元素移除掉27 private static final float DEFAULT_LOAD_FACTOR = 1f;28 29 private final ReadWriteLock lock = new ReentrantReadWriteLock();30 31 public LRUCache(int maxCapacity) {32 super(maxCapacity, DEFAULT_LOAD_FACTOR, true);// 第3个参数false表示维持插入顺序,这样最早插入的将最先被移除。true表示维持访问顺序,调用get方法后,会将这次访问的元素移至链表尾部,删除老元素时会删除表头元素。33 this.maxCapacity = maxCapacity;34 }35 36 @Override37 protected boolean removeEldestEntry(java.util.Map.Entry eldest) {38 return size() > maxCapacity;// 到达maxCapacity时就移除老元素,这样实现定长的LinkedHashMap39 }40 41 @Override42 public boolean containsKey(Object key) {43 try {44 lock.readLock().lock();45 return super.containsKey(key);46 } finally {47 lock.readLock().unlock();48 }49 }50 51 @Override52 public V get(Object key) {53 try {54 lock.readLock().lock();55 return super.get(key);56 } finally {57 lock.readLock().unlock();58 }59 }60 61 @Override62 public V put(K key, V value) {63 try {64 lock.writeLock().lock();65 return super.put(key, value);66 } finally {67 lock.writeLock().unlock();68 }69 }70 71 public int size() {72 try {73 lock.readLock().lock();74 return super.size();75 } finally {76 lock.readLock().unlock();77 }78 }79 80 public void clear() {81 try {82 lock.writeLock().lock();83 super.clear();84 } finally {85 lock.writeLock().unlock();86 }87 }88 89 public Collection > getAll() {90 try {91 lock.readLock().lock();92 return new ArrayList >(super.entrySet());93 } finally {94 lock.readLock().unlock();95 }96 }97 }
TimeOut缓存
Timeout缓存常用于那些跟用户关联的请求数据,比如用户在翻页查看一个列表数据时,他第一次看N页的数据时,服务器是从DB中读取的相应数据,当他看第N+1页的数据时应该把第N页的数据放入缓存,因为用户可能呆会儿还会回过头来看第N页的数据,这时候服务器就可以直接从缓存中获取数据。如果用户在5分钟内还没有回过头来看第N页的数据,那么我们认为他再看第N页的概率就非常低了,此时可以把第N页的数据从缓存中移除,实际上相当于我们为缓存设置了一个超时时间。
我想了一种Timeout缓存的实现方法。还是用ConcurrentHashMap来存放key-value,另建一棵,每个节点上存放key以及key的到期时间,建堆时依据到期时间来建。开一个后台线程不停地扫描堆顶元素,拿当前的时间戳去跟堆顶的到期时间比较,如果当前时间晚于堆顶的到期时间则删除堆顶,把堆顶里存放的key从ConcurrentHashMap中删除。删除堆顶的时间复杂度为$O(log_2{N})$,具体步骤如下:
-
用末元素替换堆顶元素root
-
临时保存root节点。从上往下遍历树,用子节点中较小那个替换父节点。最后把root放到叶节点上
下面的代码是直接基于java中的java.util.concurrent.Delayed实现的,Delayed是不是基于上面的小顶堆的思想我也没去深入研究。
TimeoutCache.java
1 import java.io.IOException; 2 import java.util.concurrent.ConcurrentHashMap; 3 import java.util.concurrent.ConcurrentMap; 4 import java.util.concurrent.DelayQueue; 5 import java.util.concurrent.TimeUnit; 6 7 import org.apache.commons.logging.Log; 8 import org.apache.commons.logging.LogFactory; 9 10 /**11 * 可以为每个元素设置存活时间的缓存容器12 * 13 * @Author:orisun14 * @Since:2015-10-915 * @Version:1.016 */17 public class TimeoutCache{18 19 private static final Log logger = LogFactory.getLog(TimeoutCache.class);20 private ConcurrentMap cacheObjMap = new ConcurrentHashMap ();21 private DelayQueue >> queue = new DelayQueue >>();22 private Thread daemonThread;23 24 public TimeoutCache() {25 Runnable daemonTask = new Runnable() {26 public void run() {27 daemonCheck();28 }29 };30 daemonThread = new Thread(daemonTask);31 daemonThread.setDaemon(true);32 daemonThread.setName("TimeoutCache Daemon Check");33 daemonThread.start(); // 启动后台线程,对容器中的元素不停地进行轮循,将过期的元素移除出出去34 }35 36 private void daemonCheck() {37 logger.info("check timeout element of cache started");38 for (;;) {39 try {40 DelayItem > delayItem = queue.take();// 如果所有元素都没有超时,该行代码会阻塞41 if (delayItem != null) {42 Pair pair = delayItem.getItem();43 cacheObjMap.remove(pair.first, pair.second); // 超时对象,从容器中移除44 }45 } catch (InterruptedException e) {46 logger.error("take timeout element from cache failed", e);47 break; // 检测到中断时就退出循环48 }49 }50 logger.info("check timeout element of cache stopped.");51 }52 53 /**54 * 以覆盖的方式向缓存中添加对象,缓存以 的形式存在. 55 * 注意:value如果是List,则它不是由通过List.subList()得来的56 * 。因为List.subList()返回的是一个RandomAccessSubList实例57 * ,在反序列化时ObjectOutputStream.writeObject(RandomAccessSubList)会出错58 * 59 * @param key60 * @param value61 * @param time62 * 对象在缓存中的生存时间63 * @param unit64 * 时间单位65 */66 public void put(K key, V value, long time, TimeUnit unit) {67 V oldValue = cacheObjMap.put(key, value);68 if (oldValue != null)69 queue.remove(key);70 71 long nanoTime = TimeUnit.NANOSECONDS.convert(time, unit);72 queue.put(new DelayItem >(new Pair (key, value),73 nanoTime));74 }75 76 /**77 * 根据key从缓存中取得对应的value,如果key不存在则返回null 78 * 取出的是value的深拷贝79 * 80 * @param key81 * @return82 */83 @SuppressWarnings("unchecked")84 public V get(K key) {85 try {86 return (V) JavaSerializer.deepCopy(cacheObjMap.get(key));87 } catch (ClassNotFoundException | IOException e) {88 e.printStackTrace();89 return null;90 }91 }92 93 }
DelayItem.java
1 import java.util.concurrent.Delayed; 2 import java.util.concurrent.TimeUnit; 3 import java.util.concurrent.atomic.AtomicLong; 4 5 /** 6 * 7 * @Author:orisun 8 * @Since:2015-10-9 9 * @Version:1.010 */11 public class DelayItemimplements Delayed {12 13 private static final long ORIGIN = System.nanoTime();// 记录进入队列的时刻14 private static final AtomicLong sequencer = new AtomicLong(0);15 private final long sequenceNumber;16 private final long time;17 private final T item;18 19 final static long now() {20 return System.nanoTime() - ORIGIN;21 }22 23 /**24 * 25 * @param submit26 * 队列中的元素类型27 * @param timeout28 * 元素在队列中存活的时间,单位:毫秒29 */30 public DelayItem(T submit, long timeout) {31 this.time = now() + timeout;// 出队时刻32 this.item = submit;// 入队元素33 this.sequenceNumber = sequencer.getAndIncrement();// 在队列中的编号34 }35 36 public T getItem() {37 return this.item;38 }39 40 @Override41 public long getDelay(TimeUnit unit) {42 long d = unit.convert(time - now(), TimeUnit.NANOSECONDS);43 return d;44 }45 46 @Override47 public int compareTo(Delayed other) {48 if (other == this)49 return 0;50 if (other instanceof DelayItem) {51 DelayItem x = (DelayItem ) other;52 long diff = time - x.time;53 if (diff < 0)54 return -1;55 else if (diff > 0)56 return 1;57 else if (sequenceNumber < x.sequenceNumber) // 如果是同时进入队列的,则先进者先出58 return -1;59 else60 return 1;61 }62 long d = (getDelay(TimeUnit.NANOSECONDS) - other63 .getDelay(TimeUnit.NANOSECONDS));64 return (d == 0) ? 0 : ((d < 0) ? -1 : 1);65 }66 }
JavaSerializer.java
1 import java.io.ByteArrayInputStream; 2 import java.io.ByteArrayOutputStream; 3 import java.io.IOException; 4 import java.io.ObjectInputStream; 5 import java.io.ObjectOutputStream; 6 7 public class JavaSerializer { 8 9 public static Object deepCopy(Object obj) throws IOException,10 ClassNotFoundException {11 // 将该对象序列化成流,因为写在流里的是对象的一个拷贝,而原对象仍然存在于JVM里面。所以利用这个特性可以实现对象的深拷贝12 ByteArrayOutputStream bos = new ByteArrayOutputStream();13 ObjectOutputStream oos = new ObjectOutputStream(bos);14 oos.writeObject(obj);// 要写入ObjectOutputStream的话必须实现Serializable接口15 // 将流序列化成对象16 ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());17 ObjectInputStream ois = new ObjectInputStream(bis);18 return ois.readObject();19 }20 }
Redis省内存的技巧
redis自带持久化功能,当它决定要把哪些数据换出内存写入磁盘时,使用的也是LRU算法。同时redis也有timeout机制,但它不像上面的TimeoutCache.java类一样开个无限循环的线程去扫描到期的元素,而是每次get元素时判断一个该元素有没有到期,所以redis中一个元素的存活时间远远超出了设置的时间是很正常的。
本节想讲的重点其实是redis省内存的技巧,这也是实践中经常遇到的问题,因为内存总是很昂贵的,运维大哥总是很节约的。在我们的推荐系数中使用Redis来存储信息的索引,没有使用Lucene是因为Lucene不支持分布式,但是省内存的技巧都是从Lucene那儿学来的。
首先,如果你想为redis节省内存那你就不能再用<String,String>类型的key-value结构,必须全部将它们序列化成二进制的形式。我写了一个工具类,实现各种数据类型和byte[]的互相置换。
DataTransform.java
1 import java.nio.ByteBuffer; 2 import java.util.ArrayList; 3 import java.util.List; 4 5 /** 6 * 各种数据类型的相互转换 7 *
- 8 *
- { @code <<} 左移,符号位不动 9 *
- { @code >>} 右移,符号位不动 10 *
- { @code >>>} 循环右移,符号位要跟着移,高位用0填充 11 *
请留意一下上述代码中出现了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的末位上去。
1 import java.nio.ByteBuffer; 2 3 /** 4 * 5 *@Author:orisun 6 *@Since:2016-5-14 7 *@Version:1.0 8 */ 9 public class Info {10 11 private int city;12 private boolean valid;13 private String name;14 15 public byte[] serialize() {16 ByteBuffer buffer = ByteBuffer.allocate(10);17 int cv = (city << 1) + (valid ? 1 : 0);18 byte[] cv_b = DataTransform.intToBytes(cv, false);19 buffer.put(cv_b);20 buffer.put(name.getBytes());21 byte[] rect = new byte[buffer.position()];22 buffer.flip();23 buffer.get(rect);24 return rect;25 }26 27 public static Info deserialize(byte[] value) {28 if (value == null || value.length <= 4) {29 return null;30 }31 Info inst = new Info();32 try {33 int cv = DataTransform.bytesToInt(new byte[] { (byte) value[0],34 value[1], value[2], value[3] }, false);35 inst.setValid(cv % 2 != 0);36 inst.setCity(cv >> 1);37 inst.setName(new String(value, 4, value.length - 4));38 } catch (ArgumentException e) {39 e.printStackTrace();40 }41 return inst;42 }43 44 public int getCity() {45 return city;46 }47 48 public void setCity(int city) {49 this.city = city;50 }51 52 public boolean isValid() {53 return valid;54 }55 56 public void setValid(boolean valid) {57 this.valid = valid;58 }59 60 public String getName() {61 return name;62 }63 64 public void setName(String name) {65 this.name = name;66 }67 68 public static void main(String[] args) {69 Info inst1 = new Info();70 inst1.setCity(100);71 inst1.setValid(true);72 inst1.setName("pc");73 Info inst2 = Info.deserialize(inst1.serialize());74 assert inst1.getCity() == inst2.getCity();75 assert inst1.getName().equals(inst2.getName());76 assert inst1.isValid() ^ inst2.isValid();77 }78 }