前言 为什么要用缓存 随着互联网的普及,内容信息越来越复杂,用户数和访问量越来越大,我们的应用需要支撑更多的并发量,同时我们的应用服务器和数据库服务器所做的计算也越来越多。但是往往我们的应用服务器资源是有限的,数据库每秒能接受的请求次数也是有限的。如何能够有效利用有限的资源来提供尽可能大的吞吐量?一个有效的办法就是引入缓存,打破标准流程,每个环节中请求可以从缓存中直接获取目标数据并返回,从而减少计算量,有效提升响应速度,让有限的资源服务更多的用户。
从硬件介质上来看,无非就是内存和硬盘两种。 内存:将缓存存储于内存中是最快的选择,无需额外的I/O开销,但是内存的缺点是没有持久化落地物理磁盘,一旦应用异常break down而重新启动,数据很难或者无法复原。 硬盘:一般来说,很多缓存框架会结合使用内存和硬盘,在内存分配空间满了或是在异常的情况下,可以被动或主动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的。由于磁盘需要经过寻道,扇区旋转,由DMA加载到内核态内存再到用户态内存,硬盘的读取速度一般是内存的几百分之一。 缓存的分类 缓存有各类特征,而且有不同介质的区别,在目前的应用服务框架中,比较常见的,是根据缓存与应用的藕合度分为本地缓存和分布式缓存。 首先是本地缓存 最简单的方法可以使用静态变量或者局部变量作为缓存,例如定义一个map,数据优先从map中获取把访问到的数据放入到这个 Map中。 目前谷歌已经提供了这种的框架,Guava Cache Guava Cache是Google开源的Java重用工具集库Guava里的一款缓存工具。Guava Cache的架构设计灵感来源于ConcurrentHashMap,简单场景下可以自行编码通过hashmap来做少量数据的缓存,但是,如果结果可能随时间改变或者是希望存储的数据空间可控的话,自己实现这种数据结构还是有必要的。这是 guava cache的结构 Cache由多个Segment组成,这个和1.7版本的concurrentHashMap是一样的,而每个Segment包含一个ReferenceEntry数组,ReferenceEntry就是一个键值对的抽象,它包含了key和值的ValueReference抽象类,每个ReferenceEntry数组项都是一条ReferenceEntry链,且一个ReferenceEntry包含key、hash、valueReference、next字段。除了在ReferenceEntry数组项中组成的链,在一个Segment中,所有ReferenceEntry还组成access队列和write队列。这两个队列都是一个双向链表通过ReferenceEntry中的previousInWriteQueue、nextInWriteQueue和previousInAccessQueue、nextInAccessQueue链接而成,但是以Queue的形式表达,作用是为了实现LRU等淘汰策略。这个和LinkedHashMap实现LRU的算法是一样的,可以先看下用 linkedHashMap是怎么实现的。LinkedHashMap的有序可以按两种顺序排列,一种是按照插入的顺序,一种是按照读取的顺序(构造参数true),而其内部是靠 建立一个双向链表 来维护这个顺序的,在每次插入、删除后,都会调用一个函数来进行 双向链表的维护 ,准确的来说,是有三个函数来做这件事,这三个函数都统称为 回调函数 ,这三个函数分别是:void afterNodeAccess 其作用就是在访问元素之后,将该元素放到双向链表的尾巴处(所以这个函数只有在按照读取的顺序的时候才会执行) void afterNodeRemoval(Node<K,V> p) 其作用就是在删除元素之后,将元素从双向链表中删除。 void afterNodeInsertion(boolean evict) { } 插入新元素之后,需要回调函数判断是否需要移除一直不用的某些元素!因此只要我们重写这个方法就可以实现缓存的一个淘汰。access队列和write队列分别对应linkedHashMap参数为 true 和 false 的情况。
因为Cache支持强引用的Value、SoftReference Value以及WeakReference Value(这个是可以配置的,详见构造方法),因而它对应三个实现类:StrongValueReference、SoftValueReference、WeakValueReference。后两个实现类分别继承了软引用和弱引用。什么时候用后面两种呢,我们知道软引用是gc内存空间不足的时候会被清理掉,弱引用是只要GC就会被清理掉,使用这两种引用可以避免强引用缓存过多将内存占无法回收的情况。为了支持动态加载机制,它还有一个LoadingValueReference,在需要动态加载一个key的值时,先把该值封装在LoadingValueReference中,动态加载就是创建的时候这里的方法,如果get不到这个key会执行这个方法返回并存储,以表达该key对应的值已经在加载了,如果其他线程也要查询该key对应的值,就能得到该引用,并且等待改值加载完成,从而保证该值只被加载一次,在该值加载完成后,将LoadingValueReference替换成其他ValueReference类型,所以在并发请求同一key的情况下只会有一个线程执行加载操作。
Segment中清除策略操作,是在每一次调用操作的开始和结束时触发清理工作,没有额外的线程监控定时清理的机制,所有的过期操作都是懒加载的,可以减少开销,但如果长时间没有调用方法的话,会导致不能及时的清理释放内存空间的问题。evict主要处理四个Queue:1. keyReferenceQueue;2. valueReferenceQueue;3. writeQueue;4. accessQueue。前两个queue是因为WeakReference、SoftReference被垃圾回收时加入的,清理时只需要遍历整个queue,将对应的项从LocalCache中移除即可,这里keyReferenceQueue存放ReferenceEntry,而valueReferenceQueue存放的是ValueReference,要从Cache中移除需要有key,因而ValueReference需要有对ReferenceEntry的引用。而对后面两个Queue,只需要检查是否配置了相应的expire时间,然后从头开始查找已经expire的Entry,将它们移除即可。
Guava Cache提供Builder模式的CacheBuilder生成器来创建缓存的方式,十分方便,并且各个缓存参数的配置设置,类似于函数式编程的写法,可自行设置各类参数选型。 concurrencyLevel这个和 segement 的数量有关 expireAfterAccess 当缓存项在指定的时间段内没有被读或写就会被回收 expireAfterWrite 当缓存项在指定的时间段内没有更新就会被回收 refreshAfterWrite 和expireAfterWrite一样不通电在于多线程同时加载的情况refreshAfterWrite不会去等待而是先返回旧值,所以他的性能比 expireAfterWrite,但是他也有一个缺点,因为到达指定时间后,他不能严格保证所有的查询都获取到了新值 最大容量 初始大小 这四个是设置为弱引用软引用 还移出时的方法,记录状态和动态加载方法 guava cache的三种加载方式 第一种是上边的动态加载方法,剩下两种一个直接 put,另外一个是 get的时候有个重载方法可以设置。
本地缓存实时性问题 GUAVA cache支持设置固定的过期时间,为了解决本地缓存数据的实时性问题,目前大量使用的是结合ZooKeeper或者其他注册中心的自动发现机制,实时变更本地静态变量缓存:
Apollo Client和ConfigService保持长连接,通过一种推拉结合(push & pull)的模式,在实现配置实时更新的同时,保证配置更新不丢失。后来架构中引入了Eureka服务注册中心组件,实现微服务间的服务注册和发现,保障可用性,更新后的架构如下图所示: 美团内部的基础配置组件MtConfig,采用的就是类似原理,使用静态变量缓存,结合ZooKeeper的统一管理,做到自动动态更新缓存(通过消息广播),代码也可以接收zookper的消息广播并通过api来刷新guava缓存。
其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适;同时,它的缺点也是应为缓存跟应用程序耦合,多个应用程序无法直接的共享缓存,每台机器都需要维护自己的单独缓存,对内存是一种浪费。所以为了解耦和节省机器空间可以用分布式缓存 分布式缓存 比较知名的有两个,一个是 memcached memcached是应用较广的开源分布式缓存产品之一,它本身其实不提供分布式解决方案(Magent代理组件)。在服务端,memcached集群环境实际就是一个个memcached服务器的堆积,环境搭建较为简单;cache的分布式主要是在客户端实现的伪集群,通过客户端的路由处理来达到分布式解决方案的目的。客户端做路由的原理非常简单,应用服务器在每次存取某key的value时,通过某种算法把key映射到某台memcached服务器nodeA上,因此这个key所有操作都在nodeA上。 memcached客户端采用一致性hash算法作为路由策略,相对于一般hash(如简单取模)的算法,一致性hash算法除了计算key的hash值外,还会计算每个server对应的hash值,然后将这些hash值映射到一个有限的值域上(比如0~2^32)。通过寻找hash值大于hash(key)的最小server作为存储该key数据的目标server。如果找不到,则直接把具有最小hash值的server作为目标server。同时,一定程度上,解决了扩容问题,增加或删除单个节点,对于整个集群来说,不会有大的影响。 为了避免出现数据倾斜问题,一致性 Hash 算法引入了虚拟节点的机制,也就是每个机器节点会进行多次哈希,最终每个机器节点在哈希环上会有多个比较均匀虚拟节点存在,使用这种方式来大大削弱甚至避免数据倾斜问题。同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到“D1#1”、“D1#2”、“D1#3”三个虚拟节点的数据均定位到 D1 上。这样就解决了服务节点少时数据倾斜的问题。
内存结构 在memcached内存结构中有两个非常重要的概念:slab和chunk slab是一个内存块,它是memcached一次申请内存的最小单位。在启动memcached的时候一般会使用参数-m指定其可用内存,但是并不是在启动的那一刻所有的内存就全部分配出去了,只有在需要的时候才会去申请,而且每次申请一定是一个slab。Slab的大小固定为1M(1048576 Byte),一个slab由若干个大小相等的chunk组成。每个chunk中都保存了一个item结构体、一对key和value。 虽然在同一个slab中chunk的大小相等的,但是在不同的slab中chunk的大小并不一定相等,在memcached中按照chunk的大小不同,可以把slab分为很多种类(class),默认情况下memcached把slab分为40类(class1~class40),在class 1中,chunk的大小为80字节,由于一个slab的大小是固定的1048576字节(1M),因此在class1中最多可以有13107个chunk(也就是这个slab能存最多13107个小于80字节的key-value数据)。 memcached内存管理采取预分配、分组管理的方式,分组管理就是我们上面提到的slab class,按照chunk的大小slab被分为很多种类。向memcached添加一个item时候,memcached首先会根据item的大小,来选择最合适的slab class:例如item的大小为190字节,默认情况下class 4的chunk大小为160字节显然不合适,class 5的chunk大小为200字节,大于190字节,因此该item将放在class 5中(显然这里会有10字节的浪费是不可避免的),计算好所要放入的chunk之后,memcached会去检查该类大小的chunk还有没有空闲的,如果没有,将会申请1M(1个slab)的空间并划分为该种类chunk。例如我们第一次向memcached中放入一个190字节的item时,memcached会产生一个slab class 2(也叫一个page),并会用去一个chunk,剩余5241个chunk供下次有适合大小item时使用,当我们用完这所有的5242个chunk之后,下次再有一个在160~200字节之间的item添加进来时,memcached会再次产生一个class 5的slab(这样就存在了2个pages)。如果用户数据大于1m,则memcached会将其切割,放到多个chunk内。 redis Redis是一个远程内存数据库(非关系型数据库),性能强劲,具有复制特性以及解决问题而生的独一无二的数据模型。它可以存储键值对与5种不同类型的值之间的映射,可以将存储在内存的键值对数据持久化到硬盘,可以使用复制特性来扩展读性能,还可以使用客户端分片来扩展写性能。
字符串可以被编码为raw(一般字符串)或Rint(为了节约内存,Redis会将字符串表示的64位有符号整数编码为整数来进行储存);列表可以被编码为ziplist或linkedlist,ziplist是为节约大小较小的列表空间而作的特殊表示;集合可以被编码为intset或者hashtable,intset是只储存数字的小集合的特殊表示;hash表可以编码为zipmap或者hashtable,zipmap是小hash表的特殊表示;有序集合可以被编码为ziplist或者skiplist格式,ziplist用于表示小的有序集合,而skiplist则用于表示任何大小的有序集合。 从网络I/O模型上看,Redis使用单线程的I/O复用模型,自己封装了一个简单的AeEvent事件处理框架,主要实现了epoll、kqueue和select。对于单纯只有I/O操作来说,单线程可以将速度优势发挥到最大,但是Redis也提供了一些简单的计算功能,比如排序、聚合等,对于这些操作,单线程模型实际会严重影响整体吞吐量,CPU计算过程中,整个I/O调度都是被阻塞住的,在这些特殊场景的使用中,需要额外的考虑。相较于memcached的预分配内存管理,Redis使用现场申请内存的方式来存储数据,并且很少使用free-list等方式来优化内存分配,会在一定程度上存在内存碎片。Redis跟据存储命令参数,会把带过期时间的数据单独存放在一起,并把它们称为临时数据,非临时数据是永远不会被剔除的,即便物理内存不够,导致swap也不会剔除任何非临时数据(但会尝试剔除部分临时数据)。
数据持久化
- 定时快照方式(snapshot): 该持久化方式实际是在Redis内部一个定时器事件,每隔固定时间去检查当前数据发生的改变次数与时间是否满足配置的持久化触发的条件,如果满足则通过操作系统fork调用来创建出一个子进程,这个子进程默认会与父进程共享相同的地址空间,这时就可以通过子进程来遍历整个内存来进行存储操作,而主进程则仍然可以提供服务,当有写入时由操作系统按照内存页(page)为单位来进行copy-on-write保证父子进程之间不会互相影响。它的缺点是快照只是代表一段时间内的内存映像,所以系统重启会丢失上次快照与重启之间所有的数据。
- 基于语句追加文件的方式(aof): aof方式实际类似MySQl的基于语句的binlog方式,即每条会使Redis内存数据发生改变的命令都会追加到一个log文件中,也就是说这个log文件就是Redis的持久化数据。aof的方式的主要缺点是追加log文件可能导致体积过大,当系统重启恢复数据时如果是aof的方式则加载数据会非常慢,几十G的数据可能需要几小时才能加载完,当然这个耗时并不是因为磁盘文件读取速度慢,而是由于读取的所有命令都要在内存中执行一遍。另外由于每条命令都要写log,所以使用aof的方式,Redis的读写性能也会有所下降。 目前 bdrp 采用的是 rdb 的备份形式.
集群架构 与memchaced不同redis官方提供了两种分布式集群模式 redis sentinel模式
redis cluster模式
可以看到,通过集群+主从结合的设计,Redis在扩展和稳定高可用性能方面都是比较成熟的。
淘汰策略 在失效策略上,Redis支持多大6种的数据淘汰策略(memcached只支持lru):
- volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰;
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰;
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰 ;
- allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰;
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰;
- no-enviction(驱逐):禁止驱逐数据。
bdrp 的架构
使用缓存的常见问题 本地缓存和分布式缓存的选型考虑
- 如果数据量小,并且不会频繁地增长又清空(这会导致频繁地垃圾回收),或者少量的热点数据,那么可以选择本地缓存。具体的话,如果需要一些策略的支持(比如缓存满的逐出策略),可以考虑Ehcache;如不需要,可以考虑HashMap;如需要考虑多线程并发的场景,可以考虑ConcurentHashMap。- 其他情况,可以考虑缓存服务。目前从资源的投入度、可运维性、是否能动态扩容以及配套设施来考虑。(比如分布式锁、Hash类型的value)考虑用Redis。
如何保障更新的可靠性和实时性? 更新缓存的策略,需要具体问题具体分析。这里以门店POI的缓存数据为例,具体的缓存更新策略有两个,接收门店变更的消息(binlog-kafka-消费刷新缓存),准实时更新。 给每一个POI缓存数据设置5分钟的过期时间,过期后从DB加载再回设到DB。这个策略是对第一个策略的有力补充,解决了手动变更DB不发消息、接消息更新程序临时出错等问题导致的第一个策略失效的问题。通过这种双保险机制,有效地保证了POI缓存数据的可靠性和实时性。
击穿 对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑另外一个问题:缓存被 “击穿” 的问题。
- 概念:缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
- 如何解决:业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。类似下面的代码(简单来说就是对从db拉取数据的操作进行加锁,本地缓存用 java 的锁,分布式缓存用分布式锁):
缓存穿透是指用户请求的数据在缓存中不存在即没有命中,同时在数据库中也不存在,导致用户每次请求该数据都要去数据库中查询一遍,然后返回空。
如果有恶意攻击者不断请求系统中不存在的数据,会导致短时间大量请求落在数据库上,造成数据库压力过大,甚至击垮数据库系统。 解决方式是通过布隆过滤器或者缓存空对象
什么是缓存雪崩? 缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,请求直接落到数据库上,引起数据库压力过大甚至宕机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。 均匀过期 设置不同的过期时间,让缓存失效的时间点尽量均匀。通常可以为有效期增加随机值或者统一规划有效期 使用主备两层缓存: 主缓存:有效期按照经验值设置,设置为主读取的缓存,主缓存失效后从数据库加载最新值。 备份缓存:有效期长,获取锁失败时读取的缓存,主缓存更新时需要同步更新备份缓存。 设置数据库限流,通过sentinel或者其他限流框架限制到数据库的流量。