参考
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 map = new ConcurrentHashMap();
/**
* 缓存的元素个数计数器,为了避免多次调用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> list = new ArrayList>(threshold * 2);
Set> entries = map.entrySet();
// 为了方便排序处理,先存进列表
for (Entry entry : entries) {
list.add(entry);
}
// 按照最近访问时间排序(这个也可以进一步抽象,让用户自定义清理策略)
Collections.sort(list, new Comparator>() {
@Override
public int compare(Entry o1, Entry 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点整数据依然是准时更新的。
使用了这种缓存机制,既能保证页面数据基本上实时更新,又能保证高并发情况下,缓存的高命中率。