参考
http://www.infoq.com/cn/articles/memcached-java
http://www.cnblogs.com/redcreen/archive/2011/02/15/1955248.html
http://raychase.iteye.com/blog/1545906
http://rdc.taobao.com/team/jm/archives/689
业务场景:一个页面,包含多个item列表,每个列表的抽取规则都不一样,每个item上面都有一个实时的字段(可能每秒都会变),并且要求在每天10点准时刷新(大量的用户会在10点不停F5直到看到新列表)。要求单机qps是1200.
完全无缓存的方案:不同的列表拼装不同的查询对象,通过搜索引擎查询。拿到结果列表,再根据列表中item的主键走一次远程调用实时读取每个item的库存。系统开销:假设有2个列表,走两次搜索引擎估计30ms,再从列表中拼接item主键批量去查询库存30ms,页面渲染20ms,整个过程得80ms左右。qps基本上没有可能达到要求。
使用分布式缓存:由于要在10点准时刷新,并且库存基本上每秒都会变化,所以缓存时间不能太长,必须在3秒左右。而用户访问特点是会不停刷新页面,直到缓存失效了,数据更新了才罢手。这样一来,在高峰访问瞬间,缓存的命中率是很低的。无法保证在高峰访问瞬间能有很高的qps。
也就是说我们必须做到缓存可以很快的更新,3秒更新一次,并且能够保证瞬间高访问情况下有很高的缓存命中率。
于是我们采用了有限失效的缓存策略,为了减少远程调用的网络io开销,对象序列化的cpu开销,我们基于本地缓存来实现有限失效的缓存方案。
有限失效策略的核心是对缓存对象的包装,由CacheObject实现。
/** * 有限失效次数的缓存对象 */ public class CacheObject implements Serializable { private static final long serialVersionUID = -5101398256697770240L; // 以下单位均是ms // 创建时间 long createTime; // 缓存有效时间 long lifeTime; // 最后一次访问时间,用于LRU策略 long lastVisitTime; // 真正的缓存值 Object value; // 失效次数,cacheObject的核心概念 AtomicInteger invalidTimes = new AtomicInteger(); // 最大失效次数,这边设为一次。一般来说一次已经够了 int maxInvalidTimes = 1; public CacheObject(long lifeT, Object v, int maxInvalid) { this.createTime = System.currentTimeMillis(); this.lifeTime = lifeT; this.lastVisitTime = this.createTime; this.value = v; this.maxInvalidTimes = maxInvalid; } public CacheObject(long lifeT, Object v) { this.createTime = System.currentTimeMillis(); this.lifeTime = lifeT; this.lastVisitTime = this.createTime; this.value = v; } /** * 防止瞬间大量访问失效,对系统造成过载,对数据过期进行特殊处理,有限失效次数策略 * * @return */ public Object getValue() { // 判断是否过期 if (isExpired()) { // 如果已经过期,并且已经有一个访问触发失效 if (invalidTimes.incrementAndGet() > maxInvalidTimes) { // 如果已经有一个访问触发过失效,5s后对象没有更新(一般一个请求缓存没命中,会去读取数据源,并在很短的时间内更新该缓存,正常不会超过5s) // 那么可能触发失效的那个请求出现问题,为了防止缓存永不更新,再度触发失效 if (isSoExpired()) { return null; } // 正常情况,第(maxInvalidTimes+1)次及以后的过期,不触发失效,直接返回值 return value; } else { return null; } } // 如果不过期,直接返回值 return value; } public boolean isExpired() { return (System.currentTimeMillis() - createTime) > lifeTime; } /** * 判断失效时间是否超过5s * * @return */ private boolean isSoExpired() { return (System.currentTimeMillis() - createTime - lifeTime) > 5000; } public long getLastVisitTime() { return lastVisitTime; } public void setLastVisitTime(long lastVisitTime) { this.lastVisitTime = lastVisitTime; } public long getCreateTime() { return createTime; } public long getLifeTime() { return lifeTime; } }
缓存接口
/** * 缓存接口,按k-v存取,和一般缓存无异 * * @author Administrator * */ public interface Cache { /** * 按key读 * * @param key * @return */ Object get(String key); /** * 按key存 * * @param key * @param value * @param lifeTime * 有效时间,单位ms * @return */ boolean put(String key, Object value, long lifeTime); /** * 返回缓存的元素个数 * * @return */ int size(); /** * 触发缓存的垃圾回收 * * @return */ boolean gc(); }
本地缓存的实现:
为了保证线程安全和高并发能力,我们用了ConcurrentHashMap和异步触发清理的机制。
异步触发清理,可以让本地缓存的并发性能接近ConcurrentHashMap本身的并发读写能力,同时由能够按照LRU有效的清理缓存数据。
public class LocalCache implements Cache { /** * 缓存的存储实现,线程安全 */ private Map<String, CacheObject> map = new ConcurrentHashMap<String, CacheObject>(); /** * 缓存的元素个数计数器,为了避免多次调用ConcurrentHashMap.size()产生的系统开销。 * ConcurrentHashMap.size()的近似值 */ private AtomicInteger count = new AtomicInteger(); /** * 缓存元素的个数阀值。本地内存有限,需要做限制。(具体多少由业务场景定) */ private int threshold = 100; /** * gc线程数量,用于控制并发的gc线程数 */ private AtomicInteger gcThreadCount = new AtomicInteger(); public LocalCache(int threshold) { this.threshold = threshold; } @Override public Object get(String key) { CacheObject cacheObject = map.get(key); if (cacheObject == null) { return null; } // 维护最后访问时间 cacheObject.setLastVisitTime(System.currentTimeMillis()); return cacheObject.getValue(); } @Override public boolean put(String key, Object value, long lifeTime) { CacheObject cacheObject = new CacheObject(lifeTime, value); // 如果count大于2倍阀值,那么说明瞬间插入过多新值,gc未及时处理 // 直接返回,不插入,防止内存爆掉 if (count.get() > 2 * threshold) { gc(); return false; } // 如果先前map不包含该k-v,那么表示新增,维护计数器 if (map.put(key, cacheObject) == null) { count.incrementAndGet(); } // 如果元素个数大于阀值,触发缓存GC if (count.get() > threshold) { gc(); } return true; } @Override public int size() { // 也可以直接返回ConcurrentHashMap.size(),视调用量而定 return count.get(); } @Override public boolean gc() { // 防止阻塞,另起一个线程,并保证同时只会有一个gc线程 if (gcThreadCount.incrementAndGet() == 1) { Thread gc = new GCThread(); gc.start(); return true; } else { return false; } } /** * 按照LRU进行清理 * * @author Administrator * */ private class GCThread extends Thread { public void run() { System.out.println("GC start"); long start = System.currentTimeMillis(); List<Entry<String, CacheObject>> list = new ArrayList<Map.Entry<String, CacheObject>>(threshold * 2); Set<Entry<String, CacheObject>> entries = map.entrySet(); // 为了方便排序处理,先存进列表 for (Entry<String, CacheObject> entry : entries) { list.add(entry); } // 按照最近访问时间排序(这个也可以进一步抽象,让用户自定义清理策略) Collections.sort(list, new Comparator<Entry<String, CacheObject>>() { @Override public int compare(Entry<String, CacheObject> o1, Entry<String, CacheObject> o2) { if (o1.getValue().getLastVisitTime() > o2.getValue().getLastVisitTime()) { return 1; } else if (o1.getValue().getLastVisitTime() < o2.getValue().getLastVisitTime()) { return -1; } else { return 0; } } }); System.out.println("---------------"); System.out.println("map size before" + map.size()); // 移除最近最少访问的元素,移除多少个可以视业务场景(这边为了防止频繁触发gc,所以移除较多元素) for (int i = 0; i < list.size() - threshold / 2; i++) { map.remove(list.get(i).getKey()); } System.out.println("map size after" + map.size()); // 对计数器进行修正 count.set(map.size()); // 线程基本退出,可以重置运行gc线程数 gcThreadCount.set(0); System.out.println("gc cost" + (System.currentTimeMillis() - start)); } } }
当系统是一个集群的时候,使用本地缓存涉及到一个缓存同步的问题,鉴于缓存同步产生的系统开销,和缓存同步机制引入的系统复杂度带来的维护弊端。本方案不进行缓存同步,该机制适用于缓存时间很短的,或者对数据一致性不敏感的数据场景。并且是瞬间高并发的场景,这样才能充分发挥有限失效机制的优势。
结合上述本地缓存机制,再回到刚才的业务场景,问题就可以迎刃而解了。
我们对整个页面的渲染结果进行缓存,这样只要本地缓存命中了,那么页面的性能就和业务的处理无关了。
并且我们对只对页面缓存3s,如果同一个用户访问了同一台服务器,连续刷新两次页面基本上3秒已过,用户察觉不到有缓存。对于不同的用户访问到不同的服务器,最大不一致时间窗口也就是6秒,再加上页面也不一定是每秒都有大幅度的更新,也是察觉不到。
做了以上的优化后,基本上一个请求过来的处理过程就是到ConcurrentHashMap读一下,key为访问url,value可以是html字符串,也可以是html byte流(这个更佳)。
尽管缓存的是3s,但是在瞬间高并发访问下,qps1000的时候,缓存的命中率可以是99%以上,平均响应时间可以控制在10ms内(具体取决于页面大小)。
由于缓存的时间很短,所以对用户来说,10点整数据依然是准时更新的。
使用了这种缓存机制,既能保证页面数据基本上实时更新,又能保证高并发情况下,缓存的高命中率。