Redis
Redis 在当今的计算机行业,可以说是使用的最为广泛的内存数据库,几乎所有的后端技术面试都会涉及到 Redis 相关的知识,正所谓知己知彼,百战百胜。
什么是 Redis
Redis 是用 C 语言开发的一个开源的高性能键值对(key-value)数据库。通常建议在 Linux 上运行,它通过提供多种键值数据类型来适应不同场景下的存储需求,数据存储在内存中,也可持久化到磁盘中,目前为止 Redis 支持的键值数据类型如下:
- 字符串类型
- 散列类型
- 列表类型
- 集合类型
-
Redis 特色
Redis 是用 C 语言写的开源项目,又由于数据都在内存中,所以读写速度非常快
Redis 所有数据保存在内存中,对数据的更新会异步地保存到磁盘上,这样可以做到断电不丢失数据
Redis 主从复制可以实现高可用和分布式
Redis 数据结构
字符串
String 是 Redis 当中最为基本的数据类型,最大512M,是二进制安全的
Redis 的字符串是动态字符串,是可以修改的,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,内存为当前字符串实际分配的空间,一般要高于实际字符串长度。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间
使用场景:记录用户页面访问量、缓存基本数据、分布式 Id 生成器散列
Redis 当中的 Hash 相当于 Java 的 HashMap,无序字典,内部实现是用数组+链表,第一维的数组出现碰撞,则存到链表里面去。Rehash 的时候,为了不阻塞服务,采用的是渐进式的 Rehash,保留2个 Hash,逐渐在指令执行或者定时任务中,将数据从老的 Hash 迁移到新的 Hash
使用场景:购物车、频繁变化的属性列表
Redis 的列表按照插入顺序排序,相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为O(n)。当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收
Redis 的列表结构常用来做异步队列使用,将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理
列表不同进出栈的方式,产生的不同数据结构 LPUSH+LPOP = 栈
- LPUSH+RPOP = 队列
- LPUSH+ LTRIM = 固定数量的列表
- LPUSH +BRPOP = 消息队列
无序集合
set 相当于 Java 的 HashSet,无序的键值对,不过其值都是NULL,键不可以重复。数据量较少且是整数的时候用有序数组,较大的时候采用散列表
应用场景:标签、社交、随机数
有序集合
zset 可能是 Redis 提供的最为特色的数据结构,它类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权重。
它的内部实现用的是一种叫「跳跃列表」的数据结构,之所以「跳跃」,是因为内部的元素可能「身兼数职」
一个元素,同时处于 L0、L1 和 L2 层,可以快速在不同层次之间进行「跳跃」,定位插入点时,先在顶层进行定位,然后下潜到下一级定位,一直下潜到最底层找到合适的位置,将新元素插进去
应用场景:value 为粉丝 ID,score 是关注时间,可以按照关注时间顺序给出粉丝 ID;value 是学生 ID,score 是其分数,可以按照分数排序
Redis 持久化
Redis 提供了两种持久化的方式,分别是RDB(Redis DataBase)和AOF(Append Only File)
RDB,简而言之,就是在不同的时间点,将 Redis 存储的数据生成快照并存储到磁盘等介质上
AOF,则是换了一个角度来实现持久化,那就是将 Redis 执行过的所有写指令记录下来,在下次 Redis 重新启动时,只要把这些写指令从前到后再重复执行一遍,就可以实现数据恢复了
其实 RDB 和 AOF 两种方式也可以同时使用,在这种情况下,如果 Redis 重启的话,则会优先采用 AOF 方式来进行数据恢复,这是因为 AOF 方式的数据恢复完整度更高
AOF 有一个配置属性 sync,就是用来同步命令到磁盘的,如果对于 Redis 的性能要求不高,则可以在每条写指令时都 sync 一下磁盘,这样即使在突然断电的情况下,也能保证数据的最小丢失率
RDB
RDB 是将 Redis 某一时刻的数据持久化到磁盘中,是一种快照式的持久化方法
Redis 在进行数据持久化的过程中,会先将数据写入到一个临时文件中,待持久化过程都结束了,才会用这个临时文件替换上次持久化好的文件。正是这种特性,可以随时来进行备份,因为快照文件总是完整可用的
对于 RDB 方式,Redis 会单独创建(fork)一个子进程来进行持久化,而主进程是不会进行任何 IO 操作的,这样就确保了 Redis 极高的性能,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来
如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那 RDB 方式要比 AOF 方式更加的高效
AOF
AOF,英文是 Append Only File,即只允许追加不允许改写的文件
如前面介绍的,AOF 方式是将执行过的写指令记录下来,在数据恢复时按照从前到后的顺序再将指令都执行一遍,就这么简单
默认的 AOF 持久化策略是每秒钟 fsync 一次(fsync是指把缓存中的写指令记录到磁盘中),因为在这种情况下,Redis 仍然可以保持很好的处理性能,即使 Redis 故障,也只会丢失最近1秒钟的数据
RDB 与 AOF 比较
Redis 主从
Redis 是支持主从同步的,而且也支持一主多从以及多级从结构
主从结构,一是为了纯粹的冗余备份,二是为了提升读性能,比如很消耗性能的 sort 就可以由从服务器来承担
Redis 的主从同步是异步进行的,这意味着主从同步不会影响主逻辑,也不会降低 Redis 的处理性能
主从架构中,可以考虑关闭主服务器的数据持久化功能,只让从服务器进行持久化,这样可以提高主服务器的处理性能
在主从架构中,从服务器通常被设置为只读模式,这样可以避免从服务器的数据被误修改。但是从服务器仍然可以接受 CONFIG 等指令,所以还是不应该将从服务器直接暴露到不安全的网络环境中
旧版复制功能
Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作:
- 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态
命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态
同步过程
当客户端向从服务器发送 SLAVEOF 命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作,也就是将从服务器的数据库状态更新至主服务器当前所处的数据库状态
从服务器对主服务器的同步操作需要通过向主服务器发送 SYNC 命令来完成,以下是 SYNC 命令的执行步骤:1)从服务器向主服务器发送 SYNC 命令
- 2)收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令
- 3)当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收并载入这个 RDB 文件,将自己的数据库状态更新至主服务器执行 BGSAVE 命令时的数据库状态
4)主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态
缺点
这种复制功能,虽然可以很好的完成主备之间的数据同步,但是效率确实非常低的
每次执行 SYNC 命令,主从服务器需要执行以下动作:1)主服务器需要执行 BGSAVE 命令来生成 RDB 文件,这个生成操作会耗费主服务器大量的 CPU、内存和磁盘 I/O 资源
- 2)主服务器需要将自己生成的 RDB 文件发送给从服务器,这个发送操作会耗费主从服务器大量的网络资源(带宽和流量),并对主服务器响应命令请求的时间产生影响
- 3)接收到 RDB 文件的从服务器需要载入主服务器发来的 RDB文件,并且在载入期间,从服务器会因为阻塞而没办法处理命令请求
- 4)BGSAVE 命令产生的 RDB 文件是主服务器锁包含的所有数据,这是一个全量过程
因为 SYNC 命令是一个如此耗费资源的操作,所以 Redis 有必要 保证在真正有需要时才执行 SYNC 命令
新版复制功能
为了解决旧版本复制功能的低效问题,Redis 从2.8版本开始,使用 PSYNC 命令代替 SYNC 命令来执行复制时的同步操作
PSYNC 命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:
- 完整重同步用于处理初次复制情况:完整重同步的执行步骤SYN命令的执行步骤基本一样,它们都是通过让主服务器创建并发RD文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步
- 部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态
PSYNC 命令的部分重同步模式解决了旧版复制功能在处理断线后 重复制时出现的低效情况,即采用增量同步的形式,大大节省了服务器资源
哨兵与集群
Redis Sentinal 主要用于高可用,在 master 宕机时会自动将 slave 提升为 master,继续提供服务
Redis Cluster 则侧重于扩展性,在单个 Redis 内存不足时,使用 Cluster 进行分片存储
Redis Sentinal
Sentinel(哨岗、哨兵)是 Redis 的高可用性(high availability)解决方案:由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求
初始状态下,Server1 为主服务器,其余为从服务器
假设这时,主服务器 Server1 进入下线状态,那么从服务器 Server2、Server3、Server4 对主服务器的复制操作将被中止,并且 Sentinel 系统会察觉到 Server1 已下线
当 Server1 的下线时长超过用户设定的下线时长上限时,Sentinel 系统就会对 Server1 执行故障转移操作:
- 首先,Sentinel 系统会挑选 Server1 属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器
- 之后,Sentinel 系统会向 Server1 属下的所有从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完毕
- 另外,Sentinel 还会继续监视已下线的 Server1,并在它重新上线时,将它设置为新的主服务器的从服务器
Redis Cluster
Redis 集群是 Redis 提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能
Redis Cluser 采用虚拟槽分区,所有的键根据哈希函数映射到0~16383整数槽内,计算公式:slot=CRC16(key)&16383
每一个节点负责维护一部分槽以及槽所映射的键值数据
Redis 虚拟槽分区解耦了数据与节点之间的关系,简化了节点扩容和收缩的难度
当然 Redis 集群也有很多功能上的限制
- 1)key 批量操作支持有限。如 mset、mget,目前只支持具有相同 slot 值的 key 执行批量操作。对于映射为不同 slot 值的 key 由于执行 mget、mget 等操作可能存在于多个节点上因此不被支持
- 2)key 事务操作支持有限。同理只支持多 key 在同一节点上的事务操作,当多个 key 分布在不同的节点上时无法使用事务功能
- 3)key 作为数据分区的最小粒度,因此不能将一个大的键值对象如 hash、list 等映射到不同的节点
- 4)不支持多数据库空间。单机下的 Redis 可以支持16个数据库,集群模式下只能使用一个数据库空间,即db0
5)复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构
更新策略
缓存中的数据通常都是有生命周期的,需要在指定时间后被删除或更 新,这样可以保证缓存空间在一个可控的范围。但是缓存中的数据会和数据源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新。
下面介绍 Redis 常用的三种缓存更新策略LRU/LFU/FIFO 算法剔除
剔除算法通常用于缓存使用量超过了预设的最大值的时候,如何对现有的数据进行剔除,例如使用 maxmemory-policy 这个配置作为内存最大值后对于数据的剔除策略
超时删除
超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如 Redis 提供的 expire 命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间
主动更新
应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新
更新策略对比
从上面的横向对比数据,可以得出如下建议配置低一致性业务建议配置最大内存和淘汰策略的方式
- 高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据
Redis 缓存雪崩、击穿和穿透
这是三个 Redis 最为常见的三个问题,逐一来了解下1、雪崩
什么是雪崩
由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况
预防和解决办法
- 保证缓存层服务高可用性
出现服务不可用的情况,第一时间想到的肯定是高可用,甚至是异地容灾等机制
- 依赖隔离组件为后端限流并降级
无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部阻塞(hang)在这个资源上,造成整个系统不可用。降级机制在高并发系统中是非常普遍的:比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面开天窗
- 数据预热
针对大量缓存同时过期的情况,可以通过缓存 reload 机制,预选去更新缓存,在即将发生大并发访问前手动触发加载缓存不同的 key,设置不同的过期时间,让缓存失效的时间点尽量均匀
2、击穿
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题
击穿其实可以看做是雪崩的一个子集,解决方法一般有两种,设置热点数据永不过期和设置互斥锁
所谓的互斥锁,就是保证同一时间只有一个业务线程更新缓存,对于没有获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
3、穿透
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命 中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,产生穿透问题
缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高 并发性,甚至可能造成后端存储宕掉
解决方案
- 缓存空对象
当存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源
当然缓存空对象会有两个问题:
第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除
第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象
- 布隆过滤器拦截
在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行
这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据 集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少
缓存空对象与布隆过滤器比较