本课时介绍缓存相关的知识点以及 Memcache 和 Redis 这两个最常使用的缓存。重点学习以下三个方面的内容:
1.使用缓存时常遇到的典型问题;
2.Memcache 的内存结构;
3.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 默认是通过 Slab Allocator 来管理内存,如下图所示。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 非常少。这时虽然 384 Slab 的内存大量空闲,但 768 Slab 还是会根据 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 集群部署模式,通过 Sentine l哨兵来监控 Redis 主服务器的状态。当主挂掉时,在从节点中根据一定策略选出新主,并调整其他从 slaveof 到新主。
选主的策略简单来说有三个:
& slave 的 priority 设置的越低,优先级越高;
& 同等情况下,slave 复制的数据越多优先级越高;
& 相同的条件下 runid 越小越容易被选中。
在 Redis 集群中,sentinel 也会进行多实例部署,sentinel 之间通过 Raft 协议来保证自身的高可用。
Redis Cluster 使用分片机制,在内部分为 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 和 sorted set 5种类型。另外 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。
解决的办法如下。
1.对不存在的用户,在缓存中保存一个空对象进行标记,防止相同 ID 再次访问 DB。不过有时这个方法并不能很好解决问题,可能导致缓存中存储大量无用数据。
2.使用 BloomFilter 过滤器,BloomFilter 的特点是存在性检测,如果 BloomFilter 中不存在,那么数据一定不存在;如果 BloomFilter 中存在,实际数据也有可能会不存在。非常适合解决这类的问题。
缓存击穿
第四个问题是缓存击穿,就是某个热点数据失效时,大量针对这个数据的请求会穿透到数据源。
解决这个问题有如下办法。
1.可以使用互斥锁更新,保证同一个进程中针对同一个数据不会并发请求到 DB,减小 DB 压力。
2.使用随机退避方式,失效时随机 sleep 一个很短的时间,再次查询,如果失败再执行更新。
3.针对多个热点 key 同时失效的问题,可以在缓存时使用固定时间加上一个小的随机数,避免大量热点 key 同一时刻失效。
缓存雪崩
第五个问题是缓存雪崩。产生的原因是缓存挂掉,这时所有的请求都会穿透到 DB。
解决方法:
1.使用快速失败的熔断策略,减少 DB 瞬间压力;
2.使用主从模式和集群模式来尽量保证缓存服务的高可用。
实际场景中,这两种方法会结合使用。
考察点与加分项
考察点
本课时内容的主要面试考察点是对缓存特性的理解,对 MC、Redis 的特点和使用方式的掌握。
1.要知道缓存的使用场景,不同类型缓存的使用方式,例如:
a.对 DB 热点数据进行缓存减少 DB 压力;对依赖的服务进行缓存,提高并发性能;
b.单纯 K-V 缓存的场景可以使用 MC,而需要缓存 list、set 等特殊数据格式,可以使用 Redis;
c.需要缓存一个用户最近播放视频的列表可以使用 Redis 的 list 来保存、需要计算排行榜数据时,可以使用 Redis 的 zset 结构来保存。
2.要了解 MC 和 Redis 的常用命令,例如原子增减、对不同数据结构进行操作的命令等。
3.了解 MC 和 Redis 在内存中的存储结构,这对评估使用容量会很有帮助。
4.了解 MC 和 Redis 的数据失效方式和剔除策略,比如主动触发的定期剔除和被动触发延期剔除
5.要理解 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 方案。但是实现比较复杂。