关于缓存(上)
缓存概述
商业世界中常说的一句话是“现金为王”。在技术世界里,与之相近的一个说法是“缓存为王”。
缓存在构建高性能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站点》