2019独角兽企业重金招聘Python工程师标准>>>
一、使用场景
什么情况适合用缓存?考虑以下两种场景:
- 短时间内相同数据重复查询多次且数据更新不频繁,这个时候可以选择先从缓存查询,查询不到再从数据库加载并回设到缓存的方式
- 高并发查询和更新热点数据,后端数据库不堪重负,可以用缓存来扛。
二、缓存利弊
利
(1) 加速读写:通常来说加速是明显的,因为缓存通常都是全内存的系统,而后端(可能是mysql、甚至是别人的HTTP, RPC接口)都有速度慢和抗压能力差的特性,通过缓存的使用可以有效的提高用户的访问速度同时优化了用户的体验。
(2) 降低后端负载:通过缓存的添加,如果程序没有什么问题,在命中率还可以的情况下,可以帮助后端减少访问量和复杂计算(join、或者无法在优化的sql等),在很大程度降低了后端的负载。
弊(代价)
(1) 数据不一致性:无论你的设计做的多么好,缓存数据与权威数据源(可以理解成真实或者后端数据源)一定存在着一定时间窗口的数据不一致性,这个时间窗口的大小可大可小,具体多大还要看一下你的业务允许多大时间窗口的不一致性。
(2) 代码维护成本:加入缓存后,代码就会在原数据源基础上加入缓存的相关代码,例如原来只是一些sql, 现在要加入k-v缓存,必然增加了代码的维护成本。
(3) 架构复杂度:加入缓存后,例如加入了redis-cluster,一般来说缓存不会像Mysql有专门的DBA,很有可能没有专职的管理人员,所以也增加了架构的复杂度和维护成本。
如果要加入选择了缓存,一定要能给出足够的理由,不是为了简单的show技术和想当然,最好的方法就是用数据说话:加速比有多少、后端负载降低了多少。
三、缓存分类
1. 本地缓存
(1) 缓存和应用在一个JVM中,请求缓存快速,没有网络传输的开销。
(2) 缓存间是不通信的、独立的,应用程序和缓存耦合,多个应用程序无法直接共享缓存,缓存单独维护,对内存是一种浪费。
(3) 弱一致性。
常见本地缓存
(1)本地编程直接实现
成员变量或者局部变量实现,以局部变量map结构缓存部分业务数据,减少频繁的重复数据库I/O操作。缺点仅限于类的自身作用域内,类间无法共享缓存;
静态变量实现,实现类间共享。那么如何解决本地缓存的实时性问题,实现自动更新缓存?目前大量使用的是结合ZooKeeper的自动发现机制,实时变更本地静态变量缓存。
(上图来自美团点评技术中心博客)
这类缓存实现,优点是能直接在heap区内读写,最快也最方便;缺点同样是受heap区域影响,缓存的数据量非常有限,同时缓存时间受GC影响(JVM在进行垃圾回收时,会导致所有的工作线程暂停(stop the world),GC成为影响Java程序性能的重要因素)。主要满足单机场景下的小数据量缓存需求,同时对缓存数据的变更无需太敏感感知,如上一般配置管理、基础静态数据等场景。
(2) Ehcache
Ehcache是现在最流行的纯Java开源缓存框架,配置简单、结构清晰、功能强大,是一个非常轻量级的缓存实现,我们常用的Hibernate(Hibernate二级缓存)里面就集成了相关缓存功能。
需要注意的是,虽然Ehcache支持磁盘的持久化,但是由于存在两级缓存介质,在一级内存中的缓存,如果没有主动的刷入磁盘持久化的话,在应用异常down机等情形下,依然会出现缓存数据丢失,为此可以根据需要将缓存刷到磁盘,将缓存条目刷到磁盘的操作可以通过cache.flush()方法来执行,需要注意的是,对于对象的磁盘写入,前提是要将对象进行序列化。
(3)Guava Cache
继承了ConcurrentHashMap的思路,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。Cache类似于Map,它是存储键值对的集合,不同的是它还需要处理evict、expire、dynamic load等算法逻辑,需要一些额外信息来实现这些操作。
2. Standalone(单机)
(1) 缓存和应用是独立部署的。
(2) 缓存可以是单台。(例如memcache/redis单机等等)
(3) 强一致性
(4) 无高可用、无分布式。
(5) 跨进程、跨网络
3. Distributed(分布式)
例如Redis-Cluster, memcache集群等等
(1) 缓存和应用是独立部署的。
(2) 多个实例。(例如memcache/redis等等)
(3) 强一致性或者最终一致性
(4) 支持Scale Out、高可用。
(5) 跨进程、跨网络
memcache集群
memcached是应用较广的开源分布式缓存产品之一,它本身其实不提供分布式解决方案。在服务端,memcached集群环境实际就是一个个memcached服务器的堆积,环境搭建较为简单;cache的分布式主要是在客户端实现,通过客户端的路由处理来达到分布式解决方案的目的。
redis集群
与memcached客户端支持分布式方案不同,Redis更倾向于在服务端构建分布式存储。
Redis Cluster是一个实现了分布式且允许单点故障的Redis高级版本,它没有中心节点,具有线性可伸缩的功能。
四、选型考虑
- 如果数据量小,并且不会频繁地增长又清空(这会导致频繁地垃圾回收),那么可以选择本地缓存。具体的话,如果需要一些策略的支持(比如缓存满的逐出策略),可以考虑Ehcache;如不需要,可以考虑HashMap;如需要考虑多线程并发的场景,可以考虑ConcurentHashMap。
- 其他情况,可以考虑缓存服务。目前从资源的投入度、可运维性、是否能动态扩容以及配套设施来考虑,我们优先考虑Tair。除非目前Tair还不能支持的场合(比如分布式锁、Hash类型的value),我们考虑用Redis。
五、设计关键点
什么时候更新缓存?如何保障更新的可靠性和实时性?
- (被动)接收变更消息,准实时的更新。
- (主动)设置过期时间,过期之后从DB捞数据并且回设到缓存,这个策略是对第一个策略的有力补充,解决了手动变更DB不发消息、接消息更新程序临时出错等问题导致的第一个策略失效的问题。通过这种双保险机制,有效地保证了缓存数据的可靠性和实时性。
缓存是否会满,缓存满了怎么办?
对于一个缓存服务,理论上来说,随着缓存数据的日益增多,在容量有限的情况下,缓存肯定有一天会满的。如何应对?
① 给缓存服务,选择合适的缓存逐出算法,比如最常见的LRU。
② 针对当前设置的容量,设置适当的警戒值,比如10G的缓存,当缓存数据达到8G的时候,就开始发出报警,提前排查问题或者扩容。
③ 给一些没有必要长期保存的key,尽量设置过期时间。
缓存是否允许丢失?丢失了怎么办?
根据业务场景判断,是否允许丢失。如果不允许,就需要带持久化功能的缓存服务来支持,比如Redis或者Tair。更细节的话,可以根据业务对丢失时间的容忍度,还可以选择更具体的持久化策略,比如Redis的RDB或者AOF。
简单理解:
RDB持久化,把当前进程数据生成快照保存到硬盘的过程。
AOF持久化,以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中的命令达到恢复数据的目的。
六、缓存算法
缓存容量超过预设,如何踢掉“无用”的数据。
FIFO(first in first out)
先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。
LFU(less frequently used)
最少使用策略,无论是否过期,根据元素的被使用次数判断,清除使用次数较少的元素释放空间。策略算法主要比较元素的hitCount(命中次数)。在保证高频数据有效性场景下,可选择这类策略。
LRU(least recently used)
最近最少使用策略,无论是否过期,根据元素最后一次被使用的时间戳,清除最远使用时间戳的元素释放空间。策略算法主要比较元素最近一次被get使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。与LFU的存在一定区别。
图-LRU示意图
可以想象,要清理哪些数据,不是由开发者决定(只能决定大致方向:以上策略算法),数据的一致性是最差的。
一般来说我们都需要配置超过最大缓存后的更新策略(例如:LRU)以及最大内存,这样可以保证系统可以继续运行(例如redis可能存在OOM问题)(极端情况下除外,数据一致性要求极高)
超时剔除
一般来说业务可以容忍一段时间内(例如一个小时),缓存数据和真实数据(例如:mysql, hbase等等)数据不一致(一般来说,缓存可以提高访问速度降低后端负载),那么我们可以对一个数据设置一定时间的过期时间,在数据过期后,再从真实数据源获取数据,重新放到缓存中,继续设置过期时间。一段时间内(取决于过期时间)存在数据一致性问题,即缓存数据和真实数据源数据不一致。
主动更新
具有强一致性,维护成本高。业务对于数据的一致性要求很高,需要在真实数据更新后,立即更新缓存数据。具体做法:例如可以利用消息系统或者其他方式(比如数据库触发器,或者其他数据源的listener机制来完成)通知缓存更新。
存在的问题:如果主动更新发生了问题,那么这条数据很可能很长时间不会更新了。
一般来说我们需要把超时剔除和主动更新组合使用,那样即使主动更新出了问题,也能保证过期时间后,缓存就被清除了(不至于永远都是脏数据)。
七、缓存使用中的坑与对策
缓存粒度
假如我现在需要对视频的信息做一个缓存,也就是需要对select * from video where id=?的每个id在redis里做一份缓存,这样cache层就可以帮助我抗住很多的访问量(注:这里不讨论一致性和架构等等问题,只讨论缓存的粒度问题)。
我们假设视频表有100个属性(这个真有,有些人可能难以想象),那么问题来了,需要缓存什么维度呢,也就是有两种选择吧:
(1)cache(id)=select * from video where id=#id
(2)cache(id)=select importantColumn1, importantColumn2 .. importantColumnN from video where id=#id
以上这两种方式在通用性、空间占用和代码维护方面均存在较大差异。
缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很多无用空间的浪费,可能会造成网络带宽的浪费,可能会造成代码通用性较差等情况,必须学会综合数据通用性、空间占用比、代码维护性 三点评估取舍因素权衡使用。
缓存穿透
缓存穿透是指查询一个一定不存在的数据,由于缓存不命中,并且出于容错考虑, 如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。查一个压根就不存在的值, 如果不做兼容,永远会查询storage。
如何解决?
方案一
(1) 如上图所示,当第②步MISS后,仍然将空对象保留到Cache中(可能是保留几分钟或者一段时间,具体问题具体分析),下次新的Request(同一个key)将会从Cache中获取到数据,保护了后端的Storage。
(2) 适用场景:数据命中不高,数据频繁变化实时性高(一些乱转业务)
(3) 维护成本:代码比较简单,但是有两个问题:
第一是空值做了缓存,意味着缓存系统中存了更多的key-value,也就是需要更多空间(有人说空值没多少,但是架不住多啊),解决方法是我们可以设置一个较短的过期时间。
第二是数据会有一段时间窗口的不一致,假如,Cache设置了5分钟过期,此时Storage确实有了这个数据的值,那此段时间就会出现数据不一致,解决方法是我们可以利用消息或者其他方式,清除掉Cache中的数据。
方案二
bloomfilter或者压缩filter(bitmap等等)提前拦截。
图-布隆过滤器解决缓存穿透示意图
方案三(技术分享)
存在问题的策略
解决后的策略
缓存雪崩
如果Cache层由于某些原因(宕机、cache服务挂了或者不响应了)整体crash掉了,也就意味着所有的请求都会达到Storage层,所有Storage的调用量会暴增,所以它有点扛不住了,甚至也会挂掉。
如何解决?
方案一
保证Cache服务高可用性,和飞机都有多个引擎一样,如果我们的cache也是高可用的,即使个别实例挂掉了,影响不会很大(主从切换或者可能会有部分流量到了后端),实现自动化运维。一致性hash算法可以很好地解决因为cache集群节点宕机时数据存取变化问题,具有良好的可扩展性。
方案二
其实无论是cache或者是mysql, hbase, 甚至别人的API,都会出现问题,我们可以将这些视同为资源,作为并发量较大的系统,在服务不可用或者并发量过大会对系统造成影响时,设置一定的降级、限流、隔离等策略。
无底洞问题
键值数据库或者缓存系统,由于通常采用hash函数将key映射到对应的实例,造成key的分布与业务无关,但是由于数据量、访问量的需求,需要使用分布式后(无论是客户端一致性哈性、redis-cluster、codis),批量操作比如批量获取多个key(例如redis的mget操作),通常需要从不同实例获取key值,相比于单机批量操作只涉及到一次网络操作,分布式批量操作会涉及到多次网络io。
无底洞问题带来的危害
(1) 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着实例的增多,耗时会不断增大。
(2) 服务端网络连接次数变多,对实例的性能也有一定影响。
用一句通俗的话总结:更多的机器不代表更多的性能,所谓“无底洞”就是说投入越多不一定产出越多。分布式又是不可以避免的,因为我们的网站访问量和数据量越来越大,一个实例根本坑不住,所以如何高效的在分布式缓存和存储批量获取数据是一个难点。
热点key问题
在缓存失效的瞬间,有大量线程来构建缓存(缓存的构建是需要一定时间的。(可能是一个复杂计算,例如复杂的sql、多次IO、多个依赖(各种接口)等等)),造成后端负载加大,甚至可能会让系统崩溃。
如何解决?
方案一
使用互斥锁(mutex key): 这种解决方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了。
如果是单机,可以用synchronized或者lock来处理,如果是分布式环境可以用分布式锁就可以了(分布式锁,可以用memcache的add, redis的setnx, zookeeper的添加节点操作)
图-互斥锁解决热点key问题分析
方案二
"提前"使用互斥锁(mutex key),在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。
方案三
“永远不过期”
(1) 从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。
从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
方案四
hystrix资源保护
方案五
使用mutex
如何解决:业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。类似下面的代码:
public String get(key) { String value = redis.get(key); if (value == null) { //代表缓存值过期 //设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功 value = db.get(key); redis.set(key, value, expire_secs); redis.del(key_mutex); } else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可 sleep(50); get(key); //重试 } } else { return value; } }
八、缓存更新的模式
缓存更新模式指的是如何更新数据库和缓存,特别是在并发环境下避免脏数据等错误,以下提供了一些可借鉴的更新模式(或者说是缓存更新的常用套路)。
Cache Aside更新模式
图-Cache Aside更新模式
这种方式属于比较标准的缓存更新模式,即先更新数据库,再删除缓存,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略,在实际的系统中也推荐使用这种方式。但是这种方式理论上还是可能存在问题。如下图(以Redis和Mysql为例),查询操作没有命中缓存,然后查询出数据库的老数据。此时有一个并发的更新操作,更新操作在读操作之后更新了数据库中的数据并且删除了缓存中的数据。然而读操作将从数据库中读取出的老数据更新回了缓存。这样就会造成数据库和缓存中的数据不一致,应用程序中读取的都是原来的数据(脏数据)。但是这种并发的概率极低,因为这个条件需要发生在读缓存时缓存失效而且有一个并发的写操作。实际上数据库的写操作会比读操作慢得多,而且还要加锁,而读操作必需在写操作前进入数据库操作,又要晚于写操作更新缓存,所有这些条件都具备的概率并不大。但是为了避免这种极端情况造成脏数据所产生的影响,我们还是要为缓存设置过期时间。
图-Cache Aside更新模式潜在问题分析(低概率事件)
常见的错误做法及原因分析如下:
先更新数据库,再更新缓存。这种做法最大的问题就是两个并发的写操作导致脏数据。两个并发更新操作,数据库先更新的反而后更新缓存,数据库后更新的反而先更新缓存。这样就会造成数据库和缓存中的数据不一致,应用程序中读取的都是脏数据。
图-先更新数据库再更新缓存错误分析
先删除缓存,再更新数据库。这个逻辑是错误的,因为两个并发的读和写操作导致脏数据。如下图(以Redis和Mysql为例)。假设更新操作先删除了缓存,此时正好有一个并发的读操作,没有命中缓存后从数据库中取出老数据并且更新回缓存,这个时候更新操作也完成了数据库更新。此时,数据库和缓存中的数据不一致,应用程序中读取的都是原来的数据(脏数据)。
图-先删除缓存再更新数据库错误分析
Read/Write Through更新模式
我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。
Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。
Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)
Write Behind Caching更新模式
在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存),异步还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。但是,其带来的问题是数据不是强一致性的,而且可能会丢失。
和Read/Write Through更新模式类似,区别是Write Behind Caching更新模式的数据持久化操作是异步的,但是Read/Write Through更新模式的数据持久化操作是同步的。
九、二级缓存
十、典型使用场景举例
场景一:同一热卖商品高并发读/写请求
读请求:本地缓存结合分布式缓存集群,为缩短本地缓存和分布式缓存之间的数据不一致窗口期,可以引入消息队列。
如何进行缓存的更新?
更新时主动发消息,借助缓存更新程序进行更新;
设置key过期时间(防止消息丢失或者缓存更新程序更新失败);
借助DataBus实时更新(解决缓存和DB的不一致性)
写请求:可直接在缓存中进行库存信息操作,如何防止超卖?引入用ZK实现的分布式锁
防止超卖的核心在于:不允许同一商品的库存记录在同一时刻被不同的两个数据库事务修改。
如何做到?利用数据库的事务锁机制;分布式锁(基于ZK或者Redis)
场景二:小/大库存商品秒杀典型架构
小库存:数据库乐观锁实现库存信息修改;
大库存:直接在缓存中修改库存,使用分布式锁防止超卖;
场景三:缓存业务接口查询数据避免重复的数据库查询
具体可以采取切面的形式以无侵入方式实现,对接口整体返回结果进行缓存。
参考链接
http://carlosfu.iteye.com/blog/2269678(感谢大神的系列文章)
https://tech.meituan.com/cache_about.html