关于缓存(上)

标签: 性能优化 | 发表时间:2013-04-24 15:51 | 作者:liaoran
出处:http://www.searchtb.com

缓存概述

商业世界中常说的一句话是“现金为王”。在技术世界里,与之相近的一个说法是“缓存为王”。

缓存在构建高性能web站点中有着举足轻重的作用, sql优化, 算法优化所带来的效果可能远远不如缓存带来的优化效果。但是缓存的使用并不是零成本的,首先的一个问题是,任何缓存的增加,都会带来两大问题:

  • 数据不一致。
  • 系统复杂度大幅度增加。

解决这两个问题需要以下一些方法,首先是去掉缓存。不要为了用缓存而用缓存,缓存不必要时,应该果断去掉,从而降低系统出错的可能性,降低系统复杂度。有些对数据实时性,准确性要求极高的系统,不能使用缓存。其次是分析需求,不同的业务会有不同的缓存策略,仔细分析变化与不变的数据,将不变的数据长时间缓存,变化的数据根据数据的业务意义和实时性要求动态调整缓存时间和存储方式。最后就是增加开发人员自身的能力,后面会详细提及各种问题的处理方法。

关于缓存的设计其实也脱离不了计算机基本的设计思想。数据结构与算法是计算机软件设计永恒的主题,算法的优劣需要考虑算法的时间和空间复杂度。多数优秀的算法都采用空间换时间的方式。涉及到缓存也不例外,缓存的设计需要考虑缓存的占用空间和命中率。我们当然希望缓存占用空间小,命中率高。命中率高是缓存设计的重要考察因素, 是提高系统性能的关键。占用空间越小,需要的成本越低。低成本,高效能的缓存设计是我们追求的目标。这没有固定的设计方法和公式,需要根据不同的业务灵活调整,但是,关于缓存在业务开发中的设计方法,有一些比较常用的思路与模式,借鉴这些模式,我们可以复用或创新,解决新的业务中所出现的问题,下面我就简单总结一些常用的缓存设计方法和应用场景,抛砖引玉,希望能对以后的开发有所帮助。

缓存不可变对象的复杂计算

第一个简单的缓存方式叫做缓存不可变对象的复杂计算。这是一个很常见的缓存设计方法。不可变对象有两个的好处和一个坏处。这个坏处就是不可变对象不能共享内存空间,从而导致内存不可复用,每次修改不可变对象时,并不能修改原有内存空间的值,而是从新创造一个新的对象,将原有的指针指向新的对象。两个好处包括:

  • 不可变对象不会出现线程安全问题,这也是scala,erlang等语言的全新线程模型的基础(这个话题以后探讨)。
  • 不可变对象可以使用缓存提高某些复杂算法的计算速度。

当我们设计不可变对象时,某些复杂计算就可以在第一次调用时计算一次,之后将结果缓存起来,第二次调用时就可以直接返回缓存中的值,从而提高效率。这里面的经典实例是java中String对象计算hashCode的方法。该方法的大致实现如下:

/** Cache the hash code for the string */
private int hash; // Default to 0public int hashCode() {int h = hash;int len = count;
if (h == 0 && len > 0) {
int off = offset;

char val[] = value;

for (int i = 0; i < len; i++) {

h = 31*h + val[off++];
}

hash = h;

}

return h;

}

从这段代码可以看出, String对象缓存了一个hash的字段,第一次调用hashCode方法时,由于hash == 0 ,那么就做一次复杂的hashCode计算,之后的任何一次对该对象hashCode方法调用,都不需要进行计算,直接返回hash字段的值即可。这种方法只限于不可变对象。关于如何实现不可变对象,就不再这里讨论了。

数据库的缓存

把关系数据库类比成文件是我们通常的一种思考方式,但是数据库本身的内容远比一个文件库丰富的多。当我们访问数据库时,我们面对的不是数据库文件本身,而是一个叫做RDBMS的系统,RDBMS将很多访问操作透明化,但是因此产生的访问效率提高远远超过于直接访问文件。数据库缓存就是其中的一种方式,以mysql数据库为例,默认情况下,mysql没有开启查询缓存,我们可以通过在MySQL安装目录中的my.ini文件设置查询缓冲。设置也非常简单, 有以下3个参数:

  • query_cache_size:  总查询缓冲区大小
  • query_cache_type: 可以设置为0(OFF),1(ON)或者2(DEMOND),分别表示完全不使用query cache,除显式要求不使用query cache(使用sql_no_cache)之外的所有的select都使用query cache,只有显示要求才使用query cache(使用sql_cache)
  • query_cache_limit:  单个查询的缓冲区大小

这样,每一次的select语句的查询结果就可以被缓存起来,下次如果数据库没有DML语句(DML语句就是基本的insert update delete语句)操作时,同样的select语句就会从缓存中直接返回结果,一旦有DML语句执行,那么所有的与该表相关的查询缓存全部失效,这样可以有效的保持数据一致性。由此可见,对于查询操作远远多于DML操作时,查询缓存的开放可以有效提高查询效率,但是如果查询操作与DML操作交替进行较多时,由于频繁的缓存失效,每一次的查询都不能从缓存中获取,不仅如此,还要每次写缓存,因此反而会引起效率的低下,这就是由于缓存频繁失效导致的缓存命中率极低。因此,灵活的根据业务设计缓存容量和策略是提高mysql性能的有效手段。对于这种调优策略由于我们无法触碰线上真实的数据库环境,暂时无法实施。与此类似,java开发中与数据库交互的ibatis框架也提供了相应的缓存方法。Ibatis配置可以配置一个CacheModel模块儿,用来缓存查询,基本配置如下:(注意,这是一个示意性的配置,并不是真实的配置文件,真是配置方法,参考ibatis文档)

<cacheModel>

<flushInterval minutes="5" />

<flushOnExecute statement="DML操作" />

<property name="reference-type" value="SOFT" />

</cacheModel>

这段配置中,可以通过cacheID标识一个缓存策略,type可以配置多种缓存存储方式,flushInterval标识定时刷新缓存策略,flushOnExecute标识DML动作刷新策略,reference-type标识缓存的配置为软引用(关于强引用,软引用,弱引用,虚引用的区别可以看看这篇文章 http://www.shangxueba.com/jingyan/88126.html),这样java虚拟机会在内存不足时将缓存的内容垃圾回收。使用时,就可以在select配置中增加cacheModel=”XXXcacheID”来设置额缓存策略。从而达到同样的数据库缓存需求。但是对于分布式系统的环境,这种使用本地内存的策略并不适合,因为当DML语句执行时,只会更新一台机器的本地内存,其他机器的内存并不更新,这时的查询操作如果落在另一台机器上一定会出现数据不一致现象。

分布式环境下的本地缓存

利用本地缓存来存储变化率极低的数据是一个不错的方法。在分布式系统中,本地缓存效率最高,直接存储于服务器本地内存中,但是由于分布式扩展的特征,每台服务器都会存储一份数据在内存中,这样的结果导致如果是集群多服务器共享数据,那么存储于本地内存一定造成数据不一致,比如如果一个论坛系统部署于集群环境,如果将帖子的浏览数存储于本地内存,那么产生的数据都是错误的,因为当一台机器的内存浏览数增加时,另一台机器并不会增加。因此,存放于本地缓存的数据需要符合两个条件之一。变化率极低的数据,或者,对准确性没有要求的数据。这两种都有分别的应用场景。

对于第一种情况比如调用forest接口获得类目数据时,可以考虑使用本地缓存。Forest类目数据是一个变化缓慢的数据,这个数据可以每天更新一次,正适合使用缓存的场景。更新缓存时,有以下两种更新策略。第一种是写一个定时任务每天执行,调用forest接口,更新缓存,这样做的好处是如果更新失败,可以继续使用旧的数据。另一种方法是在获取forest数据时,内置更新定时器,如果没有到更新时间就返回缓存的值,到了更新时间,自动调用forest接口,更新缓存,更新计时器。

代码如下:

public Map<Integer, ForestItem> getForestFromCache() {

Map<Integer, ForestItem> map = null;

long currentTime = System.currentTimeMillis();

if (forestMap.getMap() == null || forestMap.getExpireTime() < currentTime) {

try {

map = getCategoryPairs(); // 调用forest接口获得最新数据

} catch (Exception e) {

logger.error("获取Frorest类目错误", e);

}

forestMap.setExpireTime(currentTime + 12 * 60 * 60 * 1000);

forestMap.setMap(map);

} else {

map = forestMap.getMap();

}

return map;

}

// 带内置定时器的map

static class ForestMap {

private Map<Integer, ForestItem> map;

private long expireTime = System.currentTimeMillis();

// ---getter/setter---

}

这段代码内置的更新计时器,可以自己定制更新时间,也是比较好的实现方式。

第二种情况是对准确率要求不高的数据。这种应用场景将放到 分布式缓存计数器一节中实现秒杀部分来介绍。

缓存写操作

另一个缓存的实现场景被称为缓存写入,web系统中80%的操作是读取,20%的操作是写入,但是某些场景中,读取即伴随着写入,比如浏览帖子记录浏览数。这种操作会把写入操作的频度极大增加。那么,如何处理这种问题?首先是分析需求,浏览数展现给用户,表现的是一个帖子的热度,用户关心的是帖子的浏览量级而不是具体的浏览数字,那么浏览数只要能够体现出浏览量级就可满足需求。那么就不需要每次浏览帖子都更新数据库。只需按照量级,将一定量级的浏览数缓存起来,到一定量级更新一次数据库,比如,100浏览以内的,可以每10条更新一次,1000浏览以后,可以每100更新1次,后面如果量级再高,可以每1000更新一次,这样就极大的减少了写入数据库的次数,同时保证了浏览数的相对正确性。极大减小了系统压力。代码如下:

// user 浏览

currviewCount = Cache.incrViewCount(1);

If (currviewCount < 100 && viewCount % 10 == 0) {

updateViewCountToDB();

} else if (currviewCount >= 100 && currviewCount < 1000 && viewCount % 100 == 0) {

updateViewCountToDB();

} else if ……

读取浏览数时,只需每次从缓存中取出数据即可。如果缓存系统异常了,那么可以考虑优雅的降级,将浏览数隐藏起来,保证主贴和评论内容仍然可以显示,不受影响。后面缓存系统恢复后,可以再次根据数据库的浏览数字段通过后台任务初始化缓存中的浏览数。(浏览数是一个对准确性要求不高的数值,只是反映帖子的相对热度,所以无需准确初始化)

分布式缓存

分布式对象缓存是开发中常见的缓存技术,将某些对象存入缓存,防止每次读取时都需要动态的从数据库取出数据,虽然数据库本身的RDBMS中也有缓存(后面讨论),但是大量的请求压到数据库会是整个系统不可用的风险加大,合理使用缓存,可以极大减少数据库压力。这种使用方式有一个经典的缓存操作流程:

var data = null;

try {

data = getDataFromCache()

} catch (CacheException e) {

data = getDataFromDB() ? // 1 异常时使用数据库继续支撑服务

data = null ? // 2 异常时,直接放弃该数据

}

if (data == null) {

data = getDataFromDB()

putDataToCache(data, expiredTime)

}

首先从缓存中取出数据,如果数据不存在,就从数据库中取出数据,放回缓存中,注意这里要设置一个缓存的过期时间,这样后面就可以动态的更新缓存了。正常的情况下,这样使用无可非议,但是如果缓存出现了异常,见上面的catch块。这一经典的使用方法引发了一个讨论,如果缓存异常了,我们应不应该用数据库去支撑服务? 这里有两种说法:

第一种:如果缓存异常应该访问数据库,当缓存异常时,用数据库继续完成服务支撑可以确保对外的7*24小时不间断服务,如果缓存异常系统的服务就不能提供,这就不能满足7*24小时不间断服务的承诺,况且缓存失效的时刻,不一定是高流量的时间段。数据库可能完全可以顶住压力,完成服务支撑,因此,当缓存异常时,应该使用数据库提供服务。

第二种: 之所以增加缓存,就是因为数据库无法支撑系统的服务能力,如果缓存异常,大量请求压到数据库层面,势必在很短的时间内,服务依然会不可用,因此,当缓存异常时,应该直接返回一个默认值,停止对数据库的查询。

这两种说法哪种正确?我认为都正确,因为对于不同的业务场景,缓存的使用策略也不同。当系统面临缓存异常的危险时,有些系统可以采用备份方案继续支撑服务。有些系统则会优雅降级,将某些依赖缓存的功能直接去除,保证主服务的正确性。所以这两种策略的选择需要根据实际的业务场景考虑并实施。还有一种缓存没有异常的风险,就是缓存数据的失效过快或者缓存数据更新过快,这样每次从缓存获取数据都是不存在的,缓存命中率极低,当命中率为0%时,称为缓存穿透,所有请求直接穿过缓存层调用下一层的服务,导致缓存的存在形同虚设,完全没有起到作用。因此,有效的监控缓存命中率是减小缓存穿透风险的好方法。

分布式缓存计数器

分布式缓存的另一个应用场景是缓存计数器。对于多服务器的系统,分布式缓存提供了统一的存储和原子操作,便于集群环境下的使用。库存计数器是分布式缓存的一个典型应用场景, 对于集群中的每一台机器,库存都应该是一个统一的值,因此使用本地缓存记录库存,数据肯定是不准确的(下面会陈述例外情况)。因此,统一的存储空间是必要的条件。由于库存数据被多台机器共享,因此,必须使用锁机制控制多个请求的并行并发问题。基于这样的机制就可以实行库存技术器的作用,防止货物超卖。最近的集分商城超值兑换就是使用的这种机制。基于这种机制,需要注意操作的逻辑顺序,错误的顺序会导致错的意想不到的结果。兑换的业务流程描述为用户看到要抢兑的商品,如果库存大于0,那么用户可以点击抢兑操作,这时用户会获得兑换该商品的权限,从而优惠购买,这时库存商品应该减一。如果完全按照业务流程,就会在实现中完成下面的三步操作:

  • 验证库存是否大于0
  • 给用户打标,使其获得优惠购买资格
  • 获得资格后,原子减库存,记录用户购买记录。

看起来这样的逻辑是非常正常的,但是考虑一下异常情况,就会发现这样的逻辑无法防住超卖。如果库存只有一件,那么多个用户并发验证库存时,都大于0。这样并发的多个用户都会获得资格,超卖发生。

正确的逻辑为:

  • 验证库存是否大于0,小于0直接返回。
  • 原子减库存,返回的结果如果 小于0说明已经没有库存,直接返回。
  • 如果返回的当前库存大于等于0,为用户打标,如果打标成功, 记录用户购买记录;如果打标失败,回补原子库存。

这样的方法,无法保证缓存中的值一定大于等于0,因为并发的发生会把缓存减为负数,但是,真正能够优惠购买的用户一定是小于等于库存数的。因为,每次原子减操作后,只有返回的库存值大于等于零的用户才能够获得购买资格。无论并发量有多大,原子操作都会成功的防止超卖的发生。

对于上述的逻辑,可以应对绝大多数的情况。但是随着量的增加,这种方式也有风险。当用户量极大,货物的库存极少时,就变成了秒杀。这个时候,大量的用户涌入分不是缓存减库存,对分布式缓存有极大冲击,一旦分布式缓存挂掉,秒杀活动也就宣告失败。使用分布式缓存,目的是为了让用户准确的看到剩余库存数目,秒杀活动非常快,用户还没有看清楚库存,活动就结束了。其实用户关心的只是有没有抢到商品,并不关心库存的剩余数量,因此,库存是减的准不准确并不是主要的问题,这时就可以放弃分布式缓存的设计,转而使用本地缓存存储库存数,这也就是本地缓存使用的第二个场景。(见分布式环境下的本地缓存,第二个场景),比如,一共有10个商品,2台机器,可以设置每台机器的本地内存中库存等于10,那么对于外网的千万个用户,就可以有20个人抢到商品,剩下的人都被挡在库存之外,当这20个人抢到后,就可以实现另一个处理逻辑,从20个人中选出10个真正中标的人,获得10个商品的购买权限,这个选择的逻辑非常灵活,可随意定制。但是从20选10的操作,无论如何也比从千千万万个人中选10要好的多。这样可以确保秒杀的安全完成。如果秒杀的人继续增多,那么也可以通过客户端(即javascript)设置格挡率的方法,使少量的用户可以发出请求到服务器,绝大多数的用户都被挡在浏览器上。如果有人使用秒杀器,那么可以通过监控封锁IP等方式拒绝其访问,天猫的抢红包活动应该有类似的逻辑。

总结

缓存无处不在, 目前的总结的使用场景还相差的很远(本次总结暂定为上集,后面的一些其他使用场景将在下集中继续总结)。缓存的使用是一门艺术,易于上手,难于精通。灵活的使用缓存是进阶高级程序员的必经之路,有经验的程序员可以根据不同的业务场景给出合适的缓存策略,实现不同的需求。后面在新的项目实践中,我仍需不断学习,将灵活使用缓存的能力不断提高。

参考资料

《高扩展性网站的50条原则》

《构建高性能web站点》

《Web应用的缓存设计模式》

《mysql的查询缓存》

《mysql性能优化》

相关 [缓存] 推荐:

缓存算法

- lostsnow - 小彰
没有人能说清哪种缓存算法由于其他的缓存算法. (以下的几种缓存算法,有的我也理解不好,如果感兴趣,你可以Google一下  ). 大家好,我是 LFU,我会计算为每个缓存对象计算他们被使用的频率. 我是LRU缓存算法,我把最近最少使用的缓存对象给踢走. 我总是需要去了解在什么时候,用了哪个缓存对象.

Hibernate 缓存

- - ITeye博客
1数据缓存:(date caching) 是一种将数据暂时存于内存缓存去中的技术,缓存通常是影响系统性能的关键因素. 2.ORM的数据缓存策略有3中.   1.事务级缓存:  分为 数据库事务和 应用级事务,是基于Session的生命周期的实现,每个session都会在内部维持一个数据缓存, 随session的创建和消亡.

hibernate缓存,一级缓存,二级缓存,查询缓存

- - CSDN博客推荐文章
1、缓存是数据库数据在内存中的临时容器,它包含了库表数据在内存中的临时拷贝,位于数据库和访问层之间. 2、ORM在进行数据读取时,会根据缓存管理策略,首先在缓冲中查询,如果发现,则直接使用,避免数据库调用的开销. 事务级缓存:当前事务范围内的数据缓存. 应用级缓存:某个应用中的数据缓存. 分布式缓存:多个应用,多个JVM之间共享缓存.

缓存相关——缓存穿透、缓存并发、缓存失效、缓存预热、缓存雪崩、缓存算法

- - 编程语言 - ITeye博客
我们在项目中使用缓存通常都是先检查缓存中是否存在,如果存在直接返回缓存内容,如果不存在就直接查询数据库然后再缓存查询结果返回. 这个时候如果我们查询的某一个数据在缓存中一直不存在,就会造成每一次请求都查询DB,这样缓存就失去了意义,在流量大时,可能DB就挂掉了. 要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞.

Hibernate 二级缓存

- - CSDN博客推荐文章
很多人对二级缓存都不太了解,或者是有错误的认识,我一直想写一篇文章介绍一下hibernate的二级缓存的,今天终于忍不住了. 我的经验主要来自hibernate2.1版本,基本原理和3.0、3.1是一样的,请原谅我的顽固不化. hibernate的session提供了一级缓存,每个session,对同一个id进行两次load,不会发送两条sql给数据库,但是session关闭的时候,一级缓存就失效了.

App缓存管理

- - ITeye博客
无论大型或小型应用,灵活的缓存可以说不仅大大减轻了服务器的压力,而且因为更快速的用户体验而方便了用户. Android的apk可以说是作为小型应用,其中99%的应用并不是需要实时更新的,而且诟病于蜗牛般的移动网速,与服务器的数据交互是能少则少,这样用户体验才更好,这也是我们有时舍弃webview而采用json传输数据的原因之一.

关于缓存(上)

- - 搜索技术博客-淘宝
商业世界中常说的一句话是“现金为王”. 在技术世界里,与之相近的一个说法是“缓存为王”. 缓存在构建高性能web站点中有着举足轻重的作用, sql优化, 算法优化所带来的效果可能远远不如缓存带来的优化效果. 但是缓存的使用并不是零成本的,首先的一个问题是,任何缓存的增加,都会带来两大问题:. 解决这两个问题需要以下一些方法,首先是去掉缓存.

HTTP缓存算法

- - PHP源码阅读,PHP设计模式,PHP学习笔记,项目管理-胖胖的空间
HTTP协议缓存的目标是去除许多情况下对于发送请求的需求和去除许多情况下发送完整请求的需求. 以不发送请求或减少请求传输的数据量来优化整个HTTP架构,此目标的实现可以产生如下好处:. 降低对原始服务器的请求量. 减少了传送距离,降低了因为距离而产生的时延. 缓存基本处理过程包括七个步骤. 接收 – 缓存从网络中读取抵达的请求报文.

Solr之缓存篇

- - 淘宝网综合业务平台团队博客
Solr在Lucene之上开发了很多Cache功能,从目前提供的Cache类型有:. 而每种Cache针对具体的查询请求进行对应的Cache. 本文将从几个方面来阐述上述几种Cache在Solr的运用,具体如下:. (1)Cache的生命周期. (2)Cache的使用场景. (3)Cache的配置介绍.