Redis 面试题

130 阅读21分钟

redis 持久化有哪几种方式,怎么选?

AOF

AOF(Append Only File) 称为'写后日志',语句在redis正确执行完成后被记录到AOF中

AOF 有3种策略:

  • Always: 同步写回,每个命令写完后,立马同步将日志写回磁盘

  • Evenysec: 每秒把AOF文件刷新回磁盘中

  • NO: 由操作系统控制写回

AOF重写机制: 为了防止AOF文件过大

  • 重写过程由bgrewriteraof子进程完成

  • 通过 fork bgrewriteraof 子进程,会拷贝一个aof的副本

  • bgrewriteraof 会在 aof的副本上对命令进行合并,然后写原本的aof文件中

触发AOF重写的时机,有4个

  • 执行bgrewriteaof 命令

  • 手动打开AOF开关(config set appendonly yes)

  • 从库加载完主库RDB后(AOF 被启动的前提下)

  • 定时触发: AOF文件大小比例超出阀值、AOF文件大小绝对值超出阀值(AOF被启动的前提下)

RDB

RDB 称为 内存快照,就是指内存中的数据在某一个时刻的状态记录

save: 在主线程中执行,会导致阻塞

bgsave: 创建一个子进程,专门用于写入RDB文件,避免了主线程的阻塞

  • save 60 10000. 如果60s内至少10000个键值对的修改,就会自动触发bgsave

但是频繁fork 子进程存储RDB,会导致主线程阻塞

AOF 和 RDB 混合

内存快照以一定的频率执行,在两次快照之间,使用AOF日志记录这期间的所有命令操作

为什么Redis源码中有RDB子进程运行时,不会启动AOF重写子进程?

无论是生成RDB还是AOF重写,都需要创建子进程,然后把实例中的所有数据写到磁盘上,这个过程涉及两块

  • CPU: 写盘之前需要先迭代实例中的所有数据,在这期间会耗费比较多的CPU资源,两者同时进行,CPU资源消耗大

  • 硬盘:同样的,RDB和AOF重写,都是把内存数据落盘,在这期间Redis会持续写磁盘,如果同时进行,磁盘IO压力也会较大

所以为了资源考虑,不能让它们同时进行

redis 主从同步是怎样的过程?

图例

image.png

第一阶段:建立连接,协商同步

从库给主库发送psync命令,表示要进行数据同步,主库根据这个命令的参数来启动复制

psync命令包含了主库的runID 和 复制进度offset 两个参数

  • runID,是每个Redis实例启动时都会启动时都会自动生成的一个随机ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库runID,所以设置为'?'

  • offset,此时设为-1,表示第一次复制

当第一次复制采用全量复制,把主库的所有数据复制给从库

第二阶段: 主库同步数据给从库

主库给从库发送RDB文件,从库清空现有的数据,加载RDB

第三阶段: 主库发送新的写命令给从库

主库把新的命令,发送给从库

主从库间网络断了怎么办?

主库会把断连期间收到写操作命令,写入repl_backlog_buffer

repl_backlog_buffer是一个环形缓冲区,主库会记录自己写到的位置(master_repl_Offset),从库则会记录自己已经读到的位置(slave_repl_offset)

如果断连时间过久,就会导致repl_backlog_buffer被覆盖,需要重新做全量复制

redis key 的过期策略

惰性删除

当一个数据过期时间到了,并不会立即删除数据,而是等到再有请求来读写这个数据时,对数据进行检查,如果发现数据已经过期了,再删除这个数据

这个策略的好处是减少删除操作对CPU资源的使用

定时删除

Redis 每隔一段时间(100ms),就会随机选出一定数量的数据,检查是否过期,并把过期的数据删除

主动清理

当Redis实例的内存超过设置的maxmemory时,会根据配置的策略maxmemory-policy来对key进行淘汰,可选的淘汰策略有如下几种

  • noeviction:当内存使用达到阈值的时候,会堵塞所有申请内存的命令,可读,可执行del命令

  • volatile-lru:当内存使用达到阈值的时候,会优先淘汰设置了过期时间且最近最少使用的key,未设置过期时间的key不会被淘汰

  • volatile-ttl:当内存使用达到阈值的时候,会优先淘汰设置了过期时间且剩余过期时间ttl越小的key

  • volatile-random:当内存使用达到阈值的时候,会随机淘汰设置了过期时间key

  • allkeys-lru:当内存使用达到阈值的时候,针对所有的key按照最近最少使用原则进行淘汰

  • allkeys-random:当内存使用达到阈值的时候,针对所有的key随机进行淘汰

线上的所有Reids实例默认的淘汰策略为volatile-lru

参考资料

redis 哨兵和集群

哨兵的作用

监控

监控是指哨兵周期性地给所有主从库发送PING命令,检测是否在线。如果从库没有在规定的时间响应哨兵的PING命令,被视为下线

主观下线

  • 基于主/从库对PING命令的响应时间做判断

  • 如果是从库,可以直接打上“下线状态”

  • 如果是主库,为了避免误判,需要多个哨兵的PING判断,才能设置为下线状态

客观下线

  • Redis 主从集群有一个主库、三个从库,还有三个哨兵实例

  • 如果有2个以上的哨兵,认定主库下线的情况,主库才会被打上“下线状态”

  • 客观下线的标准是,当有N个哨兵实例,最好要有 N / 2 + 1个实例判断主库为“主观下线”

选主

筛选

选主时,要检查从库的当前在线状态,还要判断它们的网络状态

down-after-miliseconds * 10

  • down-after-milliseconds 是我们认定主从库断连的最大连接超时时间

  • 如果在down-after-milliseconds毫秒内,主从节点都没有连上,那就意味着主从节点断了

  • 如果断连次数超过10次,就是说明这个从库的网络状态不好,不适合做主库

打分

优先级最高的从库得分高

  • 通过slave-priority 可以设置优先级

和旧主库同步程度最接近的从库得分高

  • 检测 各个从库得repl_backlog_buffer的slave_repl_offset。取最高的那个

ID号最小的从库得分高

  • 最小的说明最早连接上,数据也就最全,能活到现在也就最稳定,毕竟久经考验

通知

在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制

同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上

哨兵集群

基于 pub/sub 机制的哨兵集群组成

主从集群中,主库上有一个名为“sentinel:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的

哨兵还需要与从库建立连接,在监控任务中,需要对主从库进行心跳判断,主从库切换完成后,还需要通知从库和新主库进行同步

哨兵执行主从切换

任何一个实例只要自身判断主库主观下线后,就会给其他实例发送 is-master-down-by-addr命令

其他实例会根据自己和主库的连接情况,作出Y或N的响应

该哨兵实例获得了仲裁所需的赞成票数后【哨兵配置文件中的quorum配置项】,就可以标记主库为客观下线

该哨兵实例可以再给其他哨兵发送命令,表明希望由自己执行主从切换,并让其他哨兵进行投票(Leader选举)

  • 在投票过程中,任何一个想成为Leader的哨兵,要拿到半数以上的赞成票

  • 拿到的票数同时还需要大于等于哨兵配置文件中quorum

如果这轮没有产生Leader,哨兵集群会等待一段时间(哨兵故障转移超时时间的2倍),再重新选举

redis 数据结构有哪些?分别怎么实现的?

image.png

dict

dict 是一个用于维护key和value映射关系的数据结构

Redis 使用一个哈希表保存所有键值对,一个哈希表实则是一个数组,数组的每个元素称为哈希桶

哈希冲突解决

  • Redis的hash是全局的,所有当大量写入数据后,将会带来哈希冲突

  • Redis解决冲突的方法的是,链式法:同一个哈希桶中的多个元素用一个链表来保存

  • 当哈希冲突链过长时,Reids会对hash表进行rehash操作。rehash就是增加现有的hash桶数量,分散entry元素

rehash机制

  • 为了使rehash操作更高效,Redis使用了两个全局哈希表:哈希表1和哈希表2,起始时hash2没有分配空间

  • 随着数据增多,Reids分3步执行rehash

  1. 给hash2分配更大的内存空间,是hash1的两倍

  2. 把hash1中的数据重新映射并拷贝到哈希表2中

  3. 释放hash1的空间

rehash 的负载因子的计数

  • 负载因子 = ht[0].used(已经使用的容量) / ht[0].size(总容量)

  • dict 在负载因子超过 1 时(used: bucket size >= 1),会触发 rehash

  • 但如果 Redis 正在 RDB 或 AOF rewrite,为避免父进程大量写时复制,会暂时关闭触发 rehash

  • 但这里有个例外,如果负载因子超过了 5(哈希冲突已非常严重),依旧会强制做 rehash(重点)

rehash触发时机

  • ht[0]的大小为 0

  • ht[0]承载的元素个数已经超过了 ht[0]的大小,同时 Hash 表可以进行扩容

  • ht[0]承载的元素个数,是 ht[0]的大小的 dict_force_resize_ratio 倍,其中,dict_force_resize_ratio 的默认值是 5

渐进式rehash

  • 每处理一个请求,就从hash1表的第一个索引位置,顺便将这个索引位置上的所有entries搬运到hash2中

  • 同时启用定时器,把剩余还被处理的哈希表1的数据搬运到哈希表2

  • 那主线程会默认每间隔 100ms 执行一次迁移操作

  • 这里一次会以 100 个桶为基本单位迁移数据,并限制如果一次操作耗时超时 1ms 就结束本次任务,待下次再次触发迁移

sds

sds全称为 Simple Dynamic String

  • 可动态扩展内存。sds表示的字符串其内容可以修改,也可以追加

  • 二进制安全(Binary Safe)。sds能存储任意二进制数据,而不仅仅是可打印字符

  • 与传统的C语言字符串类型兼容

sds类型

  • sdshdr5: 长度在[0, 2 ^ 5 - 1]之间

  • sdshdr8: 长度在[2 ^ 5, 2 ^ 8 - 1]

  • sdshdr16: 长度在[2 ^ 8, 2 ^ 16 - 1]

  • sdshdr32: 长度在 [2 ^ 16, 2 ^ 32 - 1]

  • sdshdr64: 长度大于 2 ^ 32

robj


robj 结构体(

元数据: 8字节

指针: 8字节,指向具体数据类型的实际数据

)

当保存的是字符串数据,并且字符串小于等于44字节时,obj中的元数据、指针和SDS是一块连续的内存区域(embstr编码)

为什么是44字节?

  • sdshdr8 的最大字节为 8 * 8 = 64字节

  • len,alloc,flags都是int8类型,只占用1个字节

  • 所以,就得到 64 - 16(robj 头部) - 3(sds头部) -1(buf末尾\0) = 44字节

当字符串大于44字节,SDS的数据量就开始变多了,Redis就不再把SDS和robj布局在一起,而是给sds分配独立空间,指针指向sds结构(raw编码)

ziplist

ziplist 是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率

ziplist 可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码,而不是编码成字符串序列

它能以O(1)的时间复杂度在表的两端pushpop操作


// 共11个字节

ziplist(压缩列表) (

zlbytes: 32bit,表示ziplist占用字节总数

zltail: 32bit,表示ziplist最后一个entry在ziplist中的偏移位置

zllen: 16bit,表示ziplist中数据项(entry)的个数

entry: 表示真正存放数据的数据项目,长度不定

zlend: ziplist最后1个字节,是一个结束标记,值固定等于255

)

entry (

prevrawlen: 表示前一个数据项占用的总字节数

len: 表示当前数据项的数据长度

data: 表示真正的数据

)

连锁更新

  • 当对压缩列表进行添加节点或删除节点时有可能会引发连锁更新,由于每个节点的 entry prevrawlen 存在两种长度1字节或5字节

  • 当所有节点 entry prevlensize 都为1个字节时,有新节点的长度大于254个字节

  • 那么新的节点的后一个节点的 entry prevlensize 原来为1个字节,无法保存新节点的长度,这是就需要进行空间扩展 entry prevlensize 属性由原来的1个字节增加4个字节变为5个字节

  • 如果增加后原节点的长度超过了254个字节则后续节点也要空间扩展,以此类推,最极端的情况是一直扩展到最后一个节点完成

hash底层的这个ziplist就可能会转成dict


hash-max-ziplist-entries 512

hash-max-ziplist-value 64

  • 当hash中的数据项(即field-value对)的数目超过512的时候,也就是ziplist数据项超过1024的时候

  • 当hash中插入的任意一个value的长度超过了64的时候

quicklist

一个ziplist的双向链表。quicklist的每个节点都是一个ziplist

quicklist为什么这么设计?空间和时间的折中

  • 双向链表便于在表两端进行push和pop操作,但是它的开销比较大,除了保存节点,还要额外保存两个指针

  • 双向链表的各个节点是单独的内存快,地址不连续,节点多了容易产生内存碎片

  • ziplist由于是一块连续内存,所有存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的realloc

  • 特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能

quicklist 中 ziplist的长度


list-max-ziplist-size -2

  • -2: 每个quicklist节点上的ziplist大小不能超过8 Kb

压缩参数


list-compress-depth 0

  • 0: 是个特殊值,表示都不压缩。这是Redis的默认值

  • 1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩

  • 2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩

  • 3: 表示quicklist两端各有3个节点不压缩,中间的节点压缩

skiplist

skiplist本质上也是一种查找结构,用于解决算法中的查找问题,即给定Key,快速查到它所在的位置

相当于链表加了索引

假如,每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是 n/2,第二级索引的结点个数大约就是 n/4,第三级索引的结点个数大约就是 n/8

依次类推,也就是说,第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)

代码例子


/**

* 1. 逐层检查结点:是为了让插入的值插入到合适的位置,比如前面的值比插入的小

* 2. 更新最大层数:新生成的level 和 旧level比较。如果新level比旧level大,就必须初始化旧level后面的数据

* 3. 逐层更新节点的指针:update[i]->next[i] 指向一个新的空地址。然后与tmp的内存地址交换

* 4. 例子

* 1. 插入的数据 1, 2, 3 . 对应的level 4, 3, 4

* 2. 步骤

* 1. 插入1, level 4

* 1. node(0) -> 1 -> null

* 2. node(1) -> 1 -> null

* 3. node(2) -> 1 -> null

* 4. node(3) -> 1 -> null

* 2. 插入2, level 3

* 1. node(0) -> 1 -> 2 -> null

* 2. node(1) -> 1 -> 2 -> null

* 3. node(2) -> 1 -> 2 -> null

* 4. node(3) -> 1 -> null

* 3. 插入3, level 4

* 1. node(0) -> 1 -> 2 -> 3 -> null

* 2. node(1) -> 1 -> 2 -> 3 -> null

* 3. node(2) -> 1 -> 2 -> 3 -> null

* 4. node(3) -> 1 -> 3 -> null

*

*/

intset

intset是一个由整数组成的有序集合,从而便于在上面进行二分查找,用于快速地判断一个元素是否属于这个集合

intset与ziplist相比

  • ziplist可以存储任意二进制串,而intset只能存储整数

  • ziplist是无序的,而intset是从小到大有序的。因此,在ziplist上查找只能遍历,而在intset上可以进行二分查找,性能更高

  • ziplist可以对每个数据项进行不同的变长编码(每个数据项前面都有数据长度字段len),而intset只能整体使用一个统一的编码(encoding)

压缩列表(ziplist)与其他列表内存使用情况

压缩列表(ziplist)VS 双端链表(linkedlist)

假设有一个队列需要存储以下5个元素:"a", "b", "c", "d", "e"

压缩列表:

  • 压缩列表使用连续的entry来存储数据,每个entry包括一个长度字段、编码字段和实际数据

  • 假设每个元素的长度都是相同的,且使用1字节的长度字段和1字节的编码字段

  • 此时,每个entry的总长度为3字节(1字节长度字段 + 1字节编码字段 + 1字节数据)

  • 所以,5个元素占用的总长度为5 * 3 = 15字节

双端链表:

  • 双端链表需要为每个节点保存指向前一个节点和后一个节点的指针。假设每个指针占用4字节

  • 此时,5个元素需要5个节点,并且每个节点需要两个指针

  • 所以,5个元素占用的总长度为5 * (2 * 4) = 40字节

压缩列表(ziplist)VS 跳表(skiplist)

举例来说,假设我们有一个包含100个元素的列表,每个元素占用16字节的空间

如果使用ziplist来存储这个列表,它只需要占用100 * 16 = 1600字节的内存空间

而如果使用skiplist来存储,除了存储元素本身的空间外,还需要为每个节点分配额外的指针空间

  • 假设每个指针占用8字节的空间,那么skiplist需要至少占用100 * (16 + 8) = 2400字节的内存空间

使用ziplist相对于skiplist可以节省2400 - 1600 = 800字节的内存空间

参考资料

用redis实现的分布式锁不适合高并发的情况,如何优化

分布式锁

通过 SETNX 对不存在的键值,会先创建再设置值

然后设置过期值

优化方法

数据不一致的情况(没有并发的情况)

相当于把Redis 当成读写缓存使用,删改操作同时操作数据库和缓存

1. 先更新数据库,再更新缓存

如果更新数据库成功,但缓存更新失败,此时数据库中是最新值,但缓存中是旧值,后续的请求回直接命中缓存,得到的是旧值

2. 先更新缓存,再更新数据库

如果更新缓存成功,但数据库更新失败,此时缓存中的是最新值,数据库的是旧值

后期读请求回直接命中缓存,但得到的是最新值,短期对业务影响不大

但是,一旦缓存过期或者满后被淘汰,读请求就会从数据库中重新加载旧值到缓存中,之后的读请求会从缓存中得到旧值,对业务产生影响

解决方法

可以使用重试机制解决,把第二步操作放入消息队列中,消费者从消息队列取出消息,再更新缓存或数据库,成功后把消息从消息队列中删除

否则进行重试,以此达到数据和缓存的一致

数据不一致的情况(有并发的情况)

1. 先更新数据库,再更新缓存, 写 + 读并发

线程A先更新数据库,之后线程B读取数据,此时线程B会命中缓存,读取到旧值

之后线程A更新缓存成功,后续的读请求回命中缓存得到最新值

这种场景下,线程A未更新完缓存之前,在这期间的读请求回短暂读到旧值,对业务短暂影响

2. 先更新缓存,再更新数据库,写 + 读并发

线程A先更新缓存成功,之后线程B读取数据,此时线程B命中缓存,得到最新值返沪,之后线程A更新数据库成功

这种场景下,虽然线程A还未更新完数据库,数据库会与缓存存在短暂不一致,但在这之前进行的读请求都会直接命中缓存,获取到最新值,所以对业务没有影响

3. 先更新数据,再更新缓存, 写 + 写并发

线程A和线程B同时更新同一条数据,更新数据的顺序是先A后B,但更新缓存时的顺序是先B后A,这会导致缓存不一致

4. 先更新缓存,再更新数据库,写 + 写并发

与场景3类似,线程A和线程B同时更新同一条数据,更新缓存的顺序是先A后B,但是更新数据的顺序是先B后A

场景3和4的解决方案是

对于写请求,需要配合分布式锁使用

写请求进来时,针对同一资源的修改操作,先加分布式锁

这样同一时间只允许一个线程去更新数据库和缓存,没有拿到锁的线程把操作放入到队列中,延时处理

用这种方式保证多个线程操作同一资源的顺序性,以此保证一致性

Redis 如何保证数据一定写入

握手成功,证明网络是通畅的

本地机器

本地机器基本上是没有问题

远程机器

Redis 事务

Redis的事务可以将多个命令打包成一个原子操作,要么全部执行成功,要么全部执行失败

通过使用事务,可以确保队列数据的写入操作是原子的,要么全部写入成功,要么全部失败

Redis的持久化功能

Redis提供了RDB和AOF两种持久化方式,可以将数据写入到磁盘中,以确保数据的持久化

当Redis重启后,可以从磁盘加载数据并恢复到之前的状态

如何实现ACK功能

创建一个Redis连接,并订阅一个或多个频道


import redis

# 创建Redis连接

r = redis.Redis(host='localhost', port=6379, db=0)

# 订阅频道

r.subscribe('channel1', 'channel2')

在另一个地方,通过另一个Redis连接发布消息到指定频道


import redis

# 创建Redis连接

r = redis.Redis(host='localhost', port=6379, db=0)

# 发布消息到频道

r.publish('channel1', '消息内容')

订阅者接收到消息后,可以发送ACK确认消息回复给发布者


import redis

# 创建Redis连接

r = redis.Redis(host='localhost', port=6379, db=0)

# 接收消息

pubsub = r.pubsub()

pubsub.subscribe('channel1')

for message in pubsub.listen():

# 处理接收到的消息

print(message)

# 发送ACK确认消息

r.publish('ack_channel', 'ACK消息')