1.前言
在高访问量的web系统中,缓存几乎是离不开的;但是一个适当、高效的缓存方案设计却并不容易;所以接下来将讨论一下应用系统缓存的设计方面应该注意哪些东西,包括缓存的选型、常见缓存系统的特点和数据指标、缓存对象结构设计和失效策略以及缓存对象的压缩等等,以期让有需求的同学尤其是初学者能够快速、系统的了解相关知识。
2.数据库的瓶颈
2.1 数据量
关系型数据库的数据量是比较小的,以我们常用的MySQL为例,单表数据条数一般应该控制在2000w以内,如果业务很复杂的话,可能还要低一些。即便是对于Oracle这些大型商业数据库来讲,其能存储的数据量也很难满足一个拥有几千万甚至数亿用户的大型互联网系统。
2.2 TPS
TPS:Transactions Per Second(每秒传输的事物处理个数),即服务器每秒处理的事务数。TPS包括一条消息入和一条消息出,加上一次用户数据库访问。(业务TPS = CAPS × 每个呼叫平均TPS)
在实际开发中我们经常会发现,关系型数据库在TPS上的瓶颈往往会比其他瓶颈更容易暴露出来,尤其对于大型web系统,由于每天大量的并发访问,对数据库的读写性能要求非常高;而传统的关系型数据库的处理能力确实捉襟见肘;以我们常用的MySQL数据库为例,常规情况下的TPS大概只有1500左右(各种极端场景下另当别论);下图是MySQL官方所给出的一份测试数据:
![](https://i-blog.csdnimg.cn/blog_migrate/74c922fec47b1cdf32ddea7200649217.jpeg)
而对于一个日均PV千万的大型网站来讲,每个PV所产生的数据库读写量可能要超出几倍,这种情况下,每天所有的数据读写请求量可能远超出关系型数据的处理能力,更别说在流量峰值的情况下了;所以我们必须要有高效的缓存手段来抵挡住大部分的数据请求!
2.3 响应时间
正常情况下,关系型数据的响应时间是相当不错的,一般在10ms以内甚至更短,尤其是在配置得当的情况下。但是就如前面所言,我们的需求是不一般的:当拥有几亿条数据,1wTPS的时候,响应时间也要在10ms以内,这几乎是任何一款关系型数据都无法做到的。
那么这个问题如何解决呢?最简单有效的办法当然是缓存!
3 缓存的类型
3.1 本地缓存
本地缓存可能是大家用的最多的一种缓存方式了,不管是本地内存还是磁盘,其速度快,成本低,在有些场合非常有效;
但是对于web系统的集群负载均衡结构来说,本地缓存使用起来就比较受限制,因为当数据库数据发生变化时,你没有一个简单有效的方法去更新本地缓存;然而,你如果在不同的服务器之间去同步本地缓存信息,由于缓存的低时效性和高访问量的影响,其成本和性能恐怕都是难以接受的。
3.2 分布式缓存
前面提到过,本地缓存的使用很容易让你的应用服务器带上“状态”,这种情况下,数据同步的开销会比较大;尤其是在集群环境中更是如此!
分布式缓存这种东西存在的目的就是为了提供比RDB更高的TPS和扩展性,同时有帮你承担了数据同步的痛苦;优秀的分布式缓存系统有大家所熟知的Memcached、Redis(当然也许你把它看成是NoSQL,但是我个人更愿意把分布式缓存也看成是NoSQL),还有国内阿里自主开发的Tair等;
对比关系型数据库和缓存存储,其在读和写性能上的差距可谓天壤之别;memcached单节点已经可以做到15w以上的tps、Redis、google的levelDB也有不菲的性能,而实现大规模集群后,性能可能会更高!
所以,在技术和业务都可以接受的情况下,我们可以尽量把读写压力从数据库转移到缓存上,以保护看似强大,其实却很脆弱的关系型数据库。
4 缓存特征
缓存也是一个数据模型对象,那么必然有它的一些特征:
4.1 命中率
命中率=返回正确结果数/请求缓存次数,命中率问题是缓存中的一个非常重要的问题,它是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。
4.2 最大元素(或最大空间)
5 缓存介质
5.1 内存
将缓存存储于内存中是最快的选择,无需额外的I/O开销,但是内存的缺点是没有持久化落地物理磁盘,一旦应用异常break down而重新启动,数据很难或者无法复原。
5.2 硬盘
一般来说,很多缓存框架会结合使用内存和硬盘,在内存分配空间满了或是在异常的情况下,可以被动或主动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的。
5.3 数据库
前面有提到,增加缓存的策略的目的之一就是为了减少数据库的I/O压力。现在使用数据库做缓存介质是不是又回到了老问题上了?其实,数据库也有很多种类型,像那些不支持SQL,只是简单的key-value存储结构的特殊数据库(如BerkeleyDB和Redis),响应速度和吞吐量都远远高于我们常用的关系型数据库等。
6 缓存存在的问题
6.1 缓存穿透
- 当业务系统发起某一个查询请求时,首先判断缓存中是否有该数据;
- 如果缓存中存在,则直接返回数据;
- 如果缓存中不存在,则再查询数据库,然后返回数据。
根据上面的模型,假如查询的数据根本就不存在,根据上面的流程,首先会往缓存中查询,由于缓存中不存在,然后继续往数据库中查询。由于数据根本不存在,所以数据库返回也为空。这就是缓存穿透
6.1.1 缓存穿透危害
如果有大量请求查询根本就不存在的数据,那么这些大量的请求最终都会落到数据库上,导致数据库压力剧增,这时候我们的缓存机制就不起作用了,就会导致各种问题。
6.1.2 为什么会发生缓存穿透
- 恶意攻击。恶意请求大量不存在数据,由于缓存中不存在这些数据,会导致大量请求直接落在数据库上。
- 缓存设计不得当。程序员的锅,依靠良好的逻辑可以避免
6.1.3 缓存穿透解决办法
6.1.3.1 缓存空数据
之所以会发生缓存穿透,是因为缓存中没有存储这些空数据,导致这些请求全部落到数据库上。那么,可以让结果为空的key也存储在缓存中。当后续再次出现该key的查询请求的时候,缓存直接返回null,无需查询数据库。当然这样设计也会有其他问题,后面会讲
6.1.3.2 BloomFilter
这种技术在缓存之前再加一层屏障,里面存储目前数据库中存在的所有key,如下图
当业务系统有查询请求的时候,首先去BloomFilter中查询该key是否存在。若不存在,则说明数据库中也不存在该数据,因此缓存都不要查了,直接返回null。若存在,则继续执行后续的流程,先前往缓存中查询,缓存中没有的话再前往数据库中的查询。
6.1.3.3 两种方案的比较
- 对于一些恶意攻击,查询的key往往各不相同,而且数据贼多。此时,第一种方案就显得提襟见肘了。因为它需要存储所有空数据的key,而这些恶意攻击的key往往各不相同,而且同一个key往往只请求一次。因此即使缓存了这些空数据的key,由于不再使用第二次,因此也起不了保护数据库的作用。这时候推荐使用第二种方案。
- 因此,对于空数据的key各不相同、key重复请求概率低的场景而言,应该选择第二种方案。而对于空数据的key数量有限、key重复请求概率较高的场景而言,应该选择第一种方案。
6.2 缓存雪崩
通过上文可知,缓存其实扮演了一个保护数据库的角色。它帮数据库抵挡大量的查询请求,从而避免脆弱的数据库受到伤害。
6.2.1 缓存雪崩危害
6.2.2 如何避免缓存雪崩?
6.2.2.1 使用缓存集群,保证缓存高可用
也就是在雪崩发生之前,做好预防手段,防止雪崩的发生。 PS:关于分布式高可用问题不是今天讨论的重点,套路就那些,后面会有高可用的相关文章,尽请关注。
6.2.2.2 使用Hystrix
Hystrix是一款开源的“防雪崩工具”,它通过 熔断、降级、限流三个手段来降低雪崩发生后的损失。
Hystrix就是一个Java类库,它采用命令模式,每一项服务处理请求都有各自的处理器。所有的请求都要经过各自的处理器。处理器会记录当前服务的请求失败率。一旦发现当前服务的请求失败率达到预设的值,Hystrix将会拒绝随后该服务的所有请求,直接返回一个预设的结果。这就是所谓的“熔断”。当经过一段时间后,Hystrix会放行该服务的一部分请求,再次统计它的请求失败率。如果此时请求失败率符合预设值,则完全打开限流开关;如果请求失败率仍然很高,那么继续拒绝该服务的所有请求。这就是所谓的“限流”。而Hystrix向那些被拒绝的请求直接返回一个预设结果,被称为“降级”。
6.2 热点数据集中失效
我们一般都会给缓存设定一个失效时间,过了失效时间后,该数据库会被缓存直接删除,从而一定程度上保证数据的实时性。但是,对于一些请求量极高的热点数据而言,一旦过了有效时间,此刻将会有大量请求落在数据库上,从而可能会导致数据库崩溃。其过程如下图所示:
6.2.1 解决方案
6.2.1.1 互斥锁
我们可以使用缓存自带的锁机制,当第一个数据库查询请求发起后,就将缓存中该数据上锁;此时到达缓存的其他查询请求将无法查询该字段,从而被阻塞等待;当第一个请求完成数据库查询,并将数据更新值缓存后,释放锁;此时其他被阻塞的查询请求将可以直接从缓存中查到该数据。当某一个热点数据失效后,只有第一个数据库查询请求发往数据库,其余所有的查询请求均被阻塞,从而保护了数据库。但是,由于采用了互斥锁,其他请求将会阻塞等待,此时系统的吞吐量将会下降。这需要结合实际的业务考虑是否允许这么做。
互斥锁可以避免某一个热点数据失效导致数据库崩溃的问题,而在实际业务中,往往会存在一批热点数据同时失效的场景。那么,对于这种场景该如何防止数据库过载呢?
6.2.1.2 设置不同的失效时间
当我们向缓存中存储这些数据的时候,可以将他们的缓存失效时间错开。这样能够避免同时失效。如:在一个基础时间上加/减一个随机数,从而将这些缓存的失效时间错开。7 缓存更新方法
7.1 更新缓存 VS 淘汰(删除)缓存
什么是更新缓存:数据不但写入数据库,还会把有变化的数据更新到缓存中什么是淘汰缓存:数据只会写入数据库,不会写入缓存,直接把缓存删掉
更新缓存的优点:由于原先的缓存都在,而且已经被更新,所以命中率高
淘汰缓存的优点:操作简单
那到底是选择更新缓存还是淘汰缓存呢,主要取决于“更新缓存的复杂度”。
加入更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率。
但是如果遇到业务逻辑复杂,更新成本较高时,这时候就没必要用更新缓存了。
7.1.1 总结
淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,建议作为通用的处理方式。
7.2 先操作数据库 vs 先操作缓存
- 先写数据库,再淘汰缓存
- 先淘汰缓存,再写数据库
由于写数据库与淘汰缓存不能保证原子性,所以究竟采用哪种时序呢?
![](https://i-blog.csdnimg.cn/blog_migrate/67cb9686e9ce2c6b6f08387bebd5a44b.png)
![](https://i-blog.csdnimg.cn/blog_migrate/e1c729864336e45ec85d9a5633868a4b.png)
假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则只会引发一次Cache miss。
7.2.1总结
为了避免发生数据不一致现象,建议先淘汰缓存再写数据库。
8.缓存更新策略
8.1被动更新
8.1.1FIFO(first in first out)
8.1.2LFU(less frequently used)
8.1.3LRU(least recently used)
8.1.4超时剔除
8.2主动更新
- 使用背景:业务对于数据的一致性要求很高,需要在真实数据更新后,立即更新缓存数据。具体做法:例如可以利用消息系统或者其他方式(比如数据库触发器,或者其他数据源的listener机制来完成)通知缓存更新。
- 一致性:可以想象一致性最高(几乎接近强一致),但是有个问题:如果主动更新发生了问题,那么这条数据很可能很长时间不会更新了(所以可以结合超时剔除一起使用,下面最佳实践会说到)
- 维护成本:相当高,用户需要自己来完成更新(需要一定量的代码,从某种程度上加大了系统的复杂性),需要自己检查数据是否真的更新了之类的工作。
8.3最佳实践
2. 一般来说我们需要把超时剔除和主动更新组合使用,那样即使主动更新出了问题,也能保证过期时间后,缓存就被清除了(不至于永远都是脏数据)。
---------------------------------------------------------------------
参考:
https://juejin.im/post/5aa8d3d9f265da2392360a37?utm_source=gold_browser_extension
https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=404087915&idx=1&sn=075664193f334874a3fc87fd4f712ebc
https://tech.meituan.com/cache_about.html
http://www.cnblogs.com/chenpingzhao/p/5020729.html