
本文介绍缓存相关的知识点以及Memcache和Redis这两个最常使用的缓存。重点学习以下三个方面的内容:
- 使用缓存时常遇到的典型问题;
- Memcache的内存结构;
- Redis相关的知识点以及Redis常用结构的实现。
缓存知识点

类型
缓存是高并发场景下提高热点数据访问性能的一个有效手段,在开发项目时会经常使用到。缓存的类型分为:本地缓存、分布式缓存和多级缓存。
本地缓存就是在进程的内存中进行缓存,比如我们的JVM堆中,可以用LRUMap来实现,也可以使用Ehcache这样的工具来实现。本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。
分布式缓存可以很好得解决这个问题。分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。
为了平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。
淘汰策略
不管是本地缓存还是分布式缓存,为了保证较高性能,都是使用内存来保存数据,由于成本和内存限制,当存储的数据超过缓存容量时,需要对缓存的数据进行剔除。一般的剔除策略有FIFO淘汰最早数据、LRU 剔除最近最少使用、和LFU剔除最近使用频率最低的数据几种策略。
Memcache
注意后面会把Memcache简称为MC。
先来看看MC 的特点:
- MC处理请求时使用多线程异步IO的方式,可以合理利用CPU多核的优势,性能非常优秀;
- MC功能简单,使用内存存储数据,只支持K-V结构,不提供持久化和主从同步功能;
- MC的内存结构以及钙化问题后面会详细介绍;
- MC对缓存的数据可以设置失效期,过期后的数据会被清除;
- 失效的策略采用延迟失效,就是当再次使用数据时检查是否失效;
- 当容量存满时,会对缓存中的数据进行剔除,剔除时除了会对过期key进行清理,还会按LRU策略对数据进行剔除。
另外,使用MC 有一些限制:
- key不能超过250个字节
- value不能超过1M字节;
- key的最大失效时间是30天。
Redis
先简单说一下Redis 的特点,方便和MC比较。
与MC不同的是,Redis采用单线程模式处理请求。这样做的原因有2个:一个是因为采用了非阻塞的异步事件处理机制;另一个是缓存数据都是内存操作IO时间不会太长,单线程可以避免线程上下文切换产生的代价。
Redis支持持久化,所以Redis不仅仅可以用作缓存,也可以用作NoSQL数据库。
相比MC,Redis还有一个非常大的优势,就是除了K-V之外,还支持多种数据格式,例如list、set、sorted set、hash等。
Redis提供主从同步机制,以及Cluster集群部署能力,能够提供高可用服务
详解 Memcache(MC)
内存结构
首先来看MC的内存结构。MC默认是通过 SlabAllocator来管理内存,如下图所示。Slab机制主要是用来解决频繁malloc/free会产生内存碎片的问题。
如图左侧,MC会把内存分为许多不同类型的Slab,每种类型Slab用来保存不同大小的对象。每个Slab由若干的Page组成,如图中浅绿色的模块。不同Slab的Page,默认大小是一样的,都是1M,这也是默认MC存储对象不能超过1M的原因。每个Page内又划分为许多的Chunk,Chunk就是实际用来保存对象的空间,就是图中橘色的。不同类型的Slab中Chunk的大小是不同的,当保存一个对象时,MC会根据对象的大小来选择最合适的Chunk来存储,减少空间浪费。
Slab Allocator创建Slab时的参数有三个,分别是Chunk大小的增长因子,Chunk大小的初始值以及Page的大小。在运行时会根据要保存的对象大小来逐渐创建Slab
钙化问题
来考虑这样一个场景,使用MC来保存用户信息,假设单个对象大约300字节。这时会产生大量的384字节大小的Slab。运行一段时间后,用户信息增加了一个属性,单个对象的大小变成了500字节,这时再保存对象需要使用768字节的Slab,而MC 中的容量大部分创建了384字节的Slab,所以768的Slab非常少。这时虽然384Slab的内存大量空闲,但768Slab还是会根据LRU算法频繁剔除缓存,导致MC的剔除率升高,命中率降低。这就是所谓的MC钙化问题。
解决钙化问题可以开启MC的Automove机制,每10s调整Slab。也可以分批重启MC缓存,不过要注意重启时要进行一定时间的预热,防止雪崩问题。另外,在使用Memcached时,最好计算一下数据的预期平均长度,调整growth factor, 以获得最恰当的设置,避免内存的大量浪费。
详解 Redis
Redis的知识点结构如下图所示。
功能
来看Redis提供的功能。
Bitmap位图是支持按bit位来存储信息,可以用来实现BloomFilter;HyperLogLog提供不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计UV;Geospatial可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。这三个其实也可以算作一种数据结构。
pub/sub功能是订阅发布功能,可以用作简单的消息队列。
Pipeline可以批量执行一组指令,一次性返回全部结果,可以减少频繁的请求应答。
Redis支持提交Lua脚本来执行一系列的功能。
最后一个功能是事务,但Redis提供的不是严格的事务,Redis只保证串行执行命令,并且能保证全部执行,但是执行命令失败时并不会回滚,而是会继续执行下去。
持久化
Redis提供了RDB和AOF两种持久化方式,RDB是把内存中的数据集以快照形式写入磁盘,实际操作是通过fork子进程执行,采用二进制压缩存储;AOF是以文本日志的形式记录Redis处理的每一个写入或删除操作。
RDB把整个Redis的数据保存在单一文件中,比较适合用来做灾备,但缺点是快照保存完成之前如果宕机,这段时间的数据将会丢失,另外保存快照时可能导致服务短时间不可用。
AOF对日志文件的写入操作使用的追加模式,有灵活的同步策略,支持每秒同步、每次修改同步和不同步,缺点就是相同规模的数据集,AOF要大于RDB,AOF在运行效率上往往会慢于RDB。
高可用
来看Redis的高可用。Redis支持主从同步,提供Cluster集群部署模式,通过Sentinel哨兵来监控Redis主服务器的状态。当主挂掉时,在从节点中根据一定策略选出新主,并调整其他从slaveof到新主。
选主的策略简单来说有三个:
slave的priority设置的越低,优先级越高;
同等情况下,slave复制的数据越多优先级越高;
相同的条件下runid越小越容易被选中。
在Redis集群中,sentinel也会进行多实例部署,sentinel之间通过Raft协议来保证自身的高可用。
RedisCluster使用分片机制,在内部分为16384个slot插槽,分布在所有master节点上,每个master节点负责一部分slot。数据操作时按key做CRC16来计算在哪个slot,由哪个master进行处理。数据的冗余是通过slave节点来保障。
key 失效机制
Redis的key可以设置过期时间,过期后Redis采用主动和被动结合的失效机制,一个是和MC一样在访问时触发被动删除,另一种是定期的主动删除。
淘汰策略
Redis提供了6种淘汰策略,一类是只针对设置了失效期的key做LRU、最小生存时间和随机剔除;另一类是针对所有key做LRU、随机剔除。当然,也可以设置不剔除,容量满时再存储对象会返回异常,但是已存在的key还可以继续读取
新特性
可以了解一下Redis4.0和5.0的新特性,例如5.0的Stream,是一个可以支持多播,也就是一写多读的消息队列。还可以了解一下4.0的模块机制等。
数据结构
Redis内部使用字典来存储不同类型的数据,如下图中的dictht,字典由一组dictEntry组成,其中包括了指向key和value的指针以及指向下一个dictEntry的指针。
在Redis中,所有的对象都被封装成了redisObject,如图中浅绿的模块。redisObject包括了对象的类型,就是Redis支持的string、hash、list、set和sortedset5种类型。另外redisObject还包括了具体对象的存储方式,如图最右边的虚线标出的模块内的几种类型。
下面结合类型来介绍具体的数据存储方式。
- string类型是Redis中最常使用的类型,内部的实现是通过SDS(Simple Dynamic String )来存储的。SDS类似于Java中的ArrayList,可以通过预分配冗余空间的方式来减少内存的频繁分配。
- list类型,有ziplist压缩列表和linkedlist双链表实现。ziplist是存储在一段连续的内存上,存储效率高,但是它不利于修改操作,适用于数据较少的情况;linkedlist在插入节点上复杂度很低,但它的内存开销很大,每个节点的地址不连续,容易产生内存碎片。此外在3.2版本后增加了quicklist,结合了两者的优点,quicklist本身是一个双向无环链表,它的每一个节点都是一个ziplist。
- hash类型在Redis中有ziplist和hashtable两种实现。当Hash表中所有的key和value字符串长度都小于64字节且键值对数量小于512个时,使用压缩表来节省空间;超过时,转为使用hashtable。
- set类型的内部实现可以是intset或者hashtable,当集合中元素小于512且所有的数据都是数值类型时,才会使用intset,否则会使用hashtable。
- sorted set是有序集合,有序集合的实现可以是ziplist或者是skiplist跳表。有序集合的编码转换条件与hash和list有些不同,当有序集合中元素数量小于128个并且所有元素长度都小于64字节时会使用ziplist,否则会转换成skiplist。
提示:Redis的内存分配是使用jemalloc进行分配。jemalloc将内存空间划分为小、大、巨大三个范围,并在范围中划分了小的内存块,当存储数据时,选择大小最合适的内存块进行分配,有利于减小内存碎片。
缓存常见问题
对使用缓存时常遇到几个问题,整理出一个表格,如下图所示。
缓存更新方式
第一个问题是缓存更新方式,这是决定在使用缓存时就该考虑的问题。
缓存的数据在数据源发生变更时需要对缓存进行更新,数据源可能是DB,也可能是远程服务。更新的方式可以是主动更新。数据源是DB时,可以在更新完DB后就直接更新缓存。
当数据源不是DB而是其他远程服务,可能无法及时主动感知数据变更,这种情况下一般会选择对缓存数据设置失效期,也就是数据不一致的最大容忍时间
这种场景下,可以选择失效更新,key不存在或失效时先请求数据源获取最新数据,然后再次缓存,并更新失效期。
但这样做有个问题,如果依赖的远程服务在更新时出现异常,则会导致数据不可用。改进的办法是异步更新,就是当失效时先不清除数据,继续使用旧的数据,然后由异步线程去执行更新任务。这样就避免了失效瞬间的空窗期。另外还有一种纯异步更新方式,定时对数据进行分批更新。实际使用时可以根据业务场景选择更新方式。
数据不一致
第二个问题是数据不一致的问题,可以说只要使用缓存,就要考虑如何面对这个问题。缓存不一致产生的原因一般是主动更新失败,例如更新DB后,更新Redis因为网络原因请求超时;或者是异步更新失败导致。
解决的办法是,如果服务对耗时不是特别敏感可以增加重试;如果服务对耗时敏感可以通过异步补偿任务来处理失败的更新,或者短期的数据不一致不会影响业务,那么只要下次更新时可以成功,能保证最终一致性就可以
缓存穿透
第三个问题是缓存穿透。产生这个问题的原因可能是外部的恶意攻击,例如,对用户信息进行了缓存,但恶意攻击者使用不存在的用户id频繁请求接口,导致查询缓存不命中,然后穿透DB查询依然不命中。这时会有大量请求穿透缓存访问到DB。
解决的办法如下。
- 对不存在的用户,在缓存中保存一个空对象进行标记,防止相同ID再次访问DB。不过有时这个方法并不能很好解决问题,可能导致缓存中存储大量无用数据。
- 使用BloomFilter过滤器,BloomFilter的特点是存在性检测,如果BloomFilter中不存在,那么数据一定不存在;如果BloomFilter中存在,实际数据也有可能会不存在。非常适合解决这类的问题。
缓存击穿
第四个问题是缓存击穿,就是某个热点数据失效时,大量针对这个数据的请求会穿透到数据源
解决这个问题有如下办法。
- 可以使用互斥锁更新,保证同一个进程中针对同一个数据不会并发请求到DB,减小DB压力。
- 使用随机退避方式,失效时随机sleep一个很短的时间,再次查询,如果失败再执行更新
- 针对多个热点key同时失效的问题,可以在缓存时使用固定时间加上一个小的随机数,避免大量热点key同一时刻失效。
缓存雪崩
第五个问题是缓存雪崩。产生的原因是缓存挂掉,这时所有的请求都会穿透到DB。
解决方法:
- 使用快速失败的熔断策略,减少DB瞬间压力;
- 使用主从模式和集群模式来尽量保证缓存服务的高可用。
实际场景中,这两种方法会结合使用。
考察点
这一块内容的主要面试考察点是对缓存特性的理解,对MC、Redis的特点和使用方式的掌握。
要知道缓存的使用场景,不同类型缓存的使用方式,例如:
- 对DB热点数据进行缓存减少DB压力;对依赖的服务进行缓存,提高并发性能;
- 单纯K-V缓存的场景可以使用MC,而需要缓存list、set等特殊数据格式,可以使用Redis;
- 需要缓存一个用户最近播放视频的列表可以使用Redis的list来保存、需要计算排行榜数据时,可以使用Redis的zset结构来保存。
要了解MC和Redis的常用命令,例如原子增减、对不同数据结构进行操作的命令等。
了解MC 和Redis在内存中的存储结构,这对评估使用容量会很有帮助
了解MC 和Redis的数据失效方式和剔除策略,比如主动触发的定期剔除和被动触发延期剔除
要理解Redis的持久化、主从同步与Cluster部署的原理,比如RDB和AOF的实现方式与区别。
加分项
如果想要在面试中获得更好的表现,还应了解下面这些加分项。
第一,是要结合实际应用场景来介绍缓存的使用。例如调用后端服务接口获取信息时,可以使用本地+远程的多级缓存;对于动态排行榜类的场景可以考虑通过Redis的sorted set来实现等等
第二,最好你有过分布式缓存设计和使用经验,例如项目中在什么场景使用过Redis,使用了什么数据结构,解决哪类的问题;使用MC时根据预估值大小调整McSlab分配参数等等。
第三,最好可以了解缓存使用中可能产生的问题。比如Redis是单线程处理请求,应尽量避免耗时较高的单个请求任务,防止相互影响;Redis服务应避免和其他CPU密集型的进程部署在同一机器;或者禁用Swap内存交换,防止Redis的缓存数据交换到硬盘上,影响性能。再比如前面提到的MC钙化问题等等。
第四,要了解Redis的典型应用场景,例如,使用Redis来实现分布式锁;使用Bitmap来实现BloomFilter,使用HyperLogLog来进行UV统计等等。
最后,知道Redis4.0、5.0中的新特性,例如支持多播的可持久化消息队列Stream;通过Module系统来进行定制功能扩展等等。
真题汇总
第1~4题前面都有提到,不再赘述
第5题,可以从主从读写分离、多从库、多端口实例,以及Cluster集群部署来支持水平扩展等几方面回答,高可用可以回答用Sentinel来保证主挂掉时重新选主并完成从库变更。
第6题,可以使用Redis的sorted set来实现延时队列,使用时间戳做Score,消费方使用zrangbyscore来获取指定延迟时间之前的数据。
- 简单场景下分布式锁可以使用setnx实现,使用setnx设置key,如果返回1表示设置成功,即获取锁成功,如果返回0则获取锁失败。setnx需要同时使用px参数设置超时时间,防止获取锁的实例宕机后产生死锁。
- 严格场景下,可以考虑使用RedLock方案。但是实现比较复杂。
- 本文标题:缓存中间件总结
- 本文作者:形而上
- 创建时间:2021-10-22 13:18:00
- 本文链接:https://deepter.gitee.io/2021_10_22_cache/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!