Home Redis 常见面试题
Post
Cancel

Redis 常见面试题

Redis 为什么这么快?

  1. 纯内存操作

    不论读写操作都是在内存上完成的,跟传统的磁盘文件数据存储相比,避免了通过磁盘 IO 读取到内存这部分的开销。

  2. 单线程模型

    避免了频繁的上下文切换和竞争锁机制,也不会出现频繁切换线程导致CPU消耗,不会存在多线程的死锁等一系列问题。

    单线程指的是 Redis 键值对读写请求的执行是单线程。Redis 服务在执行一些其他命令时就会使用多线程,对于 Redis 的持久化、集群数据同步、异步删除的指令如 UNLINKFLUSHALL ASYNCFLUSHDB ASYNC 等非阻塞的删除操作。

  3. I/O 多路复用模型

    Redis 采用 I/O 多路复用技术,并发处理连接。 Redis 作为一个内存服务器,它需要处理很多来自外部的网络请求,它使用 I/O 多路复用机制同时监听多个文件描述符的可读和可写状态,一旦受到网络请求就会在内存中快速处理,由于绝大多数的操作都是纯内存的,所以处理的速度会非常地快。

  4. 高效的数据结构

    redis 共有 string、list、hash、set、sortedset 五种数据机构

    SDS 简单动态字符串

    1 .SDS 中 len 保存这字符串的长度,O(1) 时间复杂度查询字符串长度信息。
    2 .空间预分配:SDS 被修改后,程序不仅会为 SDS 分配所需要的必须空间,还会分配额外的未使用空间。
    3 .惰性空间释放:当对 SDS 进行缩短操作时,程序并不会回收多余的内存空间,而是使用 free 字段将这些字节数量记录下来不释放,后面如果需要 append 操作,则直接使用 free 中未使用的空间,减少了内存的分配。

    zipList 压缩列表

    压缩列表是 List 、hash、 sorted Set 三种数据类型底层实现之一。
    当一个列表只有少量数据的时候,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现。

    quicklist

    首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也就是压缩列表
    它将所有的元素紧挨着一起存储,分配的是一块连续的内存。当数据量比较多的时候才会改成 quicklist。 因为普通的链表需要的附加指针空间太大,会比较浪费空间,而且会加重内存的碎片化。

    skipList 跳跃表

    sorted set 类型的排序功能便是通过「跳跃列表」数据结构来实现。
    跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
    跳表在链表的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位

    IntSet 小整数集合

    set 集合容纳的元素都是整数并且元素个数较小时,Redis 会使用 intset 来存储结合元素。intset 是紧凑的数组结构,同时支持 16 位、32 位和 64 位整数。

  5. 简单的 RESP 通信协议

    RESP 是 Redis 序列化协议的简写。它是一种直观的文本协议,优势在于实现异常简单,解析性能极好。

    Redis 协议将传输的结构数据分为 5 种最小单元类型,单元结束时统一加上回车换行符号 \r\n

    1.单行字符串以 + 符号开头。
    2.多行字符串 以 $ 符号开头,后跟字符串长度。
    3.整数值以 : 符号开头,后跟整数的字符串形式。
    4.错误消息以 - 符号开头。
    5.数组以 * 号开头,后跟数组的长度。

为什么选择单线程?

  1. 使用单线程模型能带来更好的可维护性,方便开发和调试
  2. 使用单线程模型也能并发的处理客户端的请求
  3. Redis 服务中运行的绝大多数操作的性能瓶颈都不是 CPU

redis 与 memcache 比有什么优势?

  1. 数据类型:memcached 只支持以 key-value 形式访问存取数据,在内存中维护一张巨大的哈希表,从而保证所有查询的时间复杂度是 O(1);redis 则支持除 key-value 之外的其他数据类型,比如 list、set、hash、zset 等,用来实现队列、有序集合等更复杂的功能;
  2. 性能:memcached 支持多线程,可以利用多核优势,不过也引入了锁,redis 是单线程,在操作大数据方面,memcached 更有优势,处理小数据的时候,redis 更优;
  3. 数据持久化:redis 支持数据同步和持久化存储,memcached 不支持,意味着一旦机器重启所有存储数据将会丢失;
  4. 数据一致性:memcached 提供了 cas 命令来保证,而 redis 提供了事务的功能,可以保证一串命令的原子性,中间不会被任何操作打断 。
  5. 分布式:memcached 本身不支持分布式存储,只能在客户端通过一致性哈希算法实现,属于客户端实现;redis 更倾向于在服务端构建分布式存储,并且 redis 本身就对此提供了支持,即redis cluster。

Redis 基础数据类型和结构

所有的 Redis 对象都有下面这个对象头结构:

1
2
3
4
5
6
7
struct RedisObject {
    int4 type; // 4bits
    int4 encoding; // 4bits
    int24 lru; // 24bits
    int32 refcount; // 4bytes
    void *ptr; // 8bytes,64-bit system
} robj;

不同的对象具有不同的类型 type(4bit),同一个类型的 type 会有不同的存储形式 encoding(4bit),为了记录对象的 LRU 信息,使用了 24 个 bit 来记录 LRU 信息。每个对象都有个引用计数,当引用计数为零时,对象就会被销毁,内存被回收。ptr 指针将指向对象内容 (body) 的具体存储位置。这样一个 RedisObject 对象头需要占据 16 字节的存储空间。

String 字符串类型

基础介绍

字符串结构使用非常广泛,一个常见的用途就是缓存用户信息、锁、计数器和限速器等。

如果 value 值是一个整数,还可以对它进行自增操作。 自增是有范围的,它的范围是 signed long 的最大最小值,超过了这个值,Redis 会报错

Redis 规定字符串的长度不得超过 512M 字节

常用命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
> set name Jugg 
OK
> exists name # 是否存在
(integer) 1
> del name # 删除
(integer) 1
> get name
(nil)

# 批量操作
> mset name1 boy name2 girl name3 unknown
> mget name1 name2 name3 # 返回一个列表
1) "Jugg"
2) "holycoder"
3) (nil)

// 过期
> expire name 5  # 5s 后过期
> get name # wait for 5s
(nil)
> ttl name
-1

> set age 30
OK
> incr age
(integer) 31
> incrby age 5
(integer) 36
> incrby age -5
(integer) 31
> set codehole 9223372036854775807  # Long.Max
OK
> incr codehole
(error) ERR increment or decrement would overflow

> setex name 5 Jugg  # 5s 后过期,等价于 set+expire

> setnx name Jugg  # 如果 name 不存在就执行 set 创建,如果已经存在,set 创建不成功
(integer) 1 / 0
> get name
"Jugg"

// set 指令扩展
> set lock:Jugg true ex 5 nx
OK
底层结构
1
2
3
4
5
6
struct SDS<T> {
  T capacity; // 数组容量
  T len; // 数组长度
  byte flags; // 特殊标识位,不理睬它
  byte[] content; // 数组内容
}

Redis 的字符串是动态字符串,是可以修改的字符串,采用预分配冗余空间的方式来减少内存的频繁分配,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。但是 Redis 创建字符串时 lencapacity 一样长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 append 操作来修改字符串。

上面的 SDS 结构使用了范型 T,为什么不直接用 int 呢,这是因为当字符串比较短时,len 和 capacity 可以使用 byteshort 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示

embstr vs raw

Redis 的字符串有两种存储方式,在长度特别短时,使用 emb 形式存储 (embeded),当长度超过 44 时,使用 raw 形式存储。

在字符串比较小时,SDS 对象头的大小是 capacity+3,至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)。RedisObject 占用 16 字节,SDS 占用 3 字节。

  • embstr 存储形式: 它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。
  • raw 存储形式: 它需要两次 malloc,两个对象头在内存地址上一般是不连续的。

字符串是由多个字节组成,每个字节又是由 8 个 bit 组成,如此便可以将一个字符串看成很多 bit 的组合,这便是 bitmap「位图」数据结构。

String 扩容

当字符串长度小于 1M 时,扩容都是加倍现有的空间,也就是保留 100% 的冗余空间。如果超过 1M,扩容时一次只会多扩 1M 的空间。字符串最大长度为 512M。

List 列表

List 基础介绍

Redis 的列表结构常用来做异步队列使用。比如秒杀场景将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。

Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组
这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n),这点让人非常意外

List 常用命令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# 对列 右进左出
> rpush books python java golang
(integer) 3
> llen books
(integer) 3
> lpop books
"python"
...
> lpop books
(nil)

# 栈 - 右进右出
> rpush books python java golang
(integer) 3
> rpop books
"golang"
...
> rpop books
(nil)

> lindex books 1  # O(n) 慎用
"java"
> lrange books 0 -1  # 获取所有元素,O(n) 慎用
1) "python"
2) "java"
3) "golang"
> ltrim books 1 -1 # O(n) 慎用
OK
> lrange books 0 -1
1) "java"
2) "golang"
> ltrim books 1 0 # 这其实是清空了整个列表,因为区间范围长度为负
OK
> llen books
(integer) 0
慢操作

lindex 方法,它需要对链表进行遍历,性能随着参数 index 增大而变差。

ltrim 和字面上的含义不太一样,个人觉得它叫 lretain(保留) 更合适一些,因为 ltrim 跟的两个参数 start_indexend_index 定义了一个区间,在这个区间内的值,ltrim 要保留,区间之外统统砍掉。我们可以通过 ltrim 来实现一个定长的链表,这一点非常有用。

index 可以为负数,index=-1 表示倒数第一个元素,同样 index=-2 表示倒数第二个元素。

list 底层结构

Redis 底层存储的还不是一个简单的 linkedlist,而是称之为 快速链表 quicklist 的一个结构。

首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist,也就是压缩列表它将所有的元素紧挨着一起存储,分配的是一块连续的内存
当数据量比较多的时候才会改成 quicklist

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。
后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist

快速列表
1
2
3
4
5
6
7
8
9
10
11
12
// 链表的节点
struct listNode<T> {
    listNode* prev;
    listNode* next;
    T value;
}
// 链表
struct list {
    listNode *head;
    listNode *tail;
    long length;
}

quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来

压缩列表

Redis 为了节约内存空间使用,zsethash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储
压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙

1
2
3
4
5
6
7
8
9
10
11
12
13
struct ziplist<T> {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

struct entry {
    int<var> prevlen; // 前一个 entry 的字节长度
    int<var> encoding; // 元素类型编码
    optional byte[] content; // 元素内容
}

压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位到最后一个元素,然后倒着遍历。entryprevlen 字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。

增加元素

因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。

如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多

级联更新

每个 entry 都会有一个 prevlen 字段存储前一个 entry 的长度。如果内容小于 254 字节,prevlen 用 1 字节存储,否则就是 5 字节。

如果 ziplist 里面每个 entry 恰好都存储了 253 字节的内容,那么第一个 entry 内容的修改就会导致后续所有 entry 的级联更新,这就是一个比较耗费计算资源的操作。

压缩深度

image quicklist 默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数 list-compress-depth 决定。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

每个 ziplist 存多少元素?

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个 ziplist。ziplist 的长度由配置参数 list-max-ziplist-size 决定。

hash 字典

hash 基础介绍

hash 可以记录结构体信息,如帖子的标题、摘要、作者和封面信息、点赞数、评论数和点击数;缓存近期热帖内容

Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构是数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来image

Redis 为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略image

过期策略

  1. 定时删除
    redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个字典来删除到期的 key。

  2. 惰性删除
    惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期了就立即删除。 定时删除是集中处理,惰性删除是零散处理。

  3. 定期删除
    Redis 默认会每 100ms 进行一次过期扫描,过期扫描不会遍历过期字典中所有的 key,而是采用了一种简单的贪心策略

    1. 从过期字典中随机 20 个 key
    2. 删除这 20 个 key 中已经过期的 key
    3. 如果过期的 key 比率超过 1/4,那就重复步骤 1

    同时,为了保证过期扫描不会出现循环过度,导致线程卡死现象,算法还增加了扫描时间的上限,默认不会超过 25ms。

对比

  1. 定时删除对内存友好,能够在 key 过期后立即从内存中删除,但是对 CPU 不友好,如果过期键较多会占用 CPU 对一些时间

  2. 惰性删除对 CPU 友好,只有在键用到的时候才会进行检查,对于很多用不到的 key 不用浪费时间进行检查,但是对内存不友好,过期 key 如果一直没用到就会一直在内存中,内存就得不到释放,从而造成内存泄漏。

  3. 定期删除可以通过限制操作时长和频率减少删除对 CPU 的影响,同时也能释放过期 key 占用的内存;但是频率和时长不太好控制,执行频繁了和定时一样占用 CPU,执行太少和惰性删除又一样对内存不好。

一般会使用组合策略 惰性删除定期删除 组合使用。

从库的过期策略

从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。

设想一个大型的 Redis 实例中所有的 key 在同一时间过期了,会出现怎样的结果

毫无疑问,Redis 会持续扫描过期字典 (循环多次),直到过期字典中过期的 key 变得稀疏,才会停止 (循环次数明显下降)。这就会导致线上读写请求出现明显的卡顿现象。导致这种卡顿的另外一种原因是内存管理器需要频繁回收内存页,这也会产生一定的 CPU 消耗。

当客户端请求到来时,服务器如果正好进入过期扫描状态,客户端的请求将会等待至少 25ms 后才会进行处理,如果客户端将超时时间设置的比较短,比如 10ms,那么就会出现大量的链接因为超时而关闭,业务端就会出现很多异常。而且这时你还无法从 Redis 的 slowlog 中看到慢查询记录,因为慢查询指的是逻辑处理过程慢,不包含等待时间。

缓存雪崩

造成缓存雪崩的关键在于在同一时间大规模的key失效。为什么会出现这个问题呢,

第一种可能是Redis宕机,第二种可能是采用了相同的过期时间。

解决办法

  1. 所以业务开发人员一定要注意过期时间,如果有大批量的 key 过期,要给过期时间设置一个随机范围,而不宜全部在同一时间过期,分散过期处理的压力。避免缓存雪崩的发生。
  2. 双缓存。我们有两个缓存,缓存A和缓存B。缓存A的失效时间为20分钟,缓存B不设失效时间。自己做缓存预热操作。
  3. 设置热点数据永不过期。
  4. 提高数据库的容灾能力,可以使用分库分表,读写分离的策略
  5. 使用熔断机制。当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上。

缓存穿透

请求传进来的 key 是不存在 Redis 中的,那么就查不到缓存,查不到缓存就会去数据库查询。假如有大量这样的请求,这些请求像“穿透”了缓存一样直接打在数据库上,这种现象就叫做缓存穿透。

这和缓存击穿有根本的区别,区别在于缓存穿透的情况是传进来的 key 在 Redis 中是不存在的。

解决办法

  1. 利用互斥锁,缓存失效的时候,先去获得锁,得到锁了,再去请求数据库。没得到锁,则休眠一段时间重试
  2. 采用异步更新策略,无论key是否取到值,都直接返回。value 值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。
  3. 判断请求是否有效的拦截机制,接口层做校验。比如,利用布隆过滤器,内部维护一系列合法有效的key。迅速判断出,请求所携带的Key是否合法有效。如果不合法,则直接返回,又如用户id基础校验,id<=0的直接过滤。

缓存击穿

缓存击穿是一个热点的 Key,有大并发集中对其进行访问,突然间这个 Key 失效了,导致大并发全部打在数据库上,导致数据库压力剧增。这种现象就叫做缓存击穿。

解决办法

  1. 如果业务允许的话,对于热点的key可以设置永不过期的key。
  2. 缓存预热,项目启动前,先加载缓存
  3. 使用互斥锁。如果缓存失效的情况,只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,防止数据库打死。当然这样会导致系统的性能变差。

内存淘汰策略

LRU:latest recent used.

为了限制最大使用内存,Redis 提供了配置参数 maxmemory 来限制内存超出期望大小。 Redis 提供了几种可选策略 (maxmemory-policy) 来让用户自己决定该如何腾出新的空间以继续提供读写服务

  1. noeviction 不删除策略
    不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。

  2. volatile-lru 尝试淘汰设置了过期时间的 key
    最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。

  3. volatile-ttl
    淘汰设置了过期时间 key 中寿命小的key。而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。

  4. volatile-random
    淘汰的 key 是过期 key 集合中随机的 key。

  5. allkeys-lru
    区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。

  6. allkeys-random
    跟上面一样,不过淘汰的策略是随机的 key。

LRU 算法

实现 LRU 算法除了需要 key/value 字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问时,它在链表中的位置会被移动到表头。所以链表的元素排列顺序就是元素最近被访问的时间顺序。

位于链表尾部的元素就是不被重用的元素,所以会被踢掉。位于表头的元素就是最近刚刚被人用过的元素,所以暂时不会被踢。

近似 LRU 算法

Redis 使用的是一种近似 LRU 算法,它跟 LRU 算法还不太一样。之所以不使用 LRU 算法,是因为需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。

近似 LRU 算法则很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和 LRU 算法非常近似的效果。Redis 为实现近似 LRU 算法,它给每个 key 增加了一个额外的小字段,这个字段的长度是 24 个 bit,也就是最后一次被访问的时间戳。

当 Redis 执行写操作时,发现内存超出 maxmemory,就会执行一次 LRU 淘汰算法。这个算法也很简单,就是随机采样出 5(可以配置) 个 key,然后淘汰掉最旧的 key,如果淘汰后内存还是超出 maxmemory,那就继续随机采样淘汰,直到内存低于 maxmemory 为止。

采样按照 maxmemory-policy 的配置,如果是 allkeys 就是从所有的 key 字典中随机,如果是 volatile 就从带过期时间的 key 字典中随机。每次采样多少个 key 看的是 maxmemory_samples 的配置,默认为 5。

LFU

Redis 4.0 里引入了一个新的淘汰策略 —— LFU 模式,全称是 Least Frequently Used,表示按最近的访问频率进行淘汰,它比 LRU 更加精准地表示了一个 key 被访问的热度。

Redis 持久化

Redis 的持久化机制有两种,第一种是快照,第二种是 AOF 日志

快照是一次全量备份,AOF 日志是连续的增量备份

快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录 的是内存数据修改的指令记录文本

AOF 日志在长期的运行过程中会变的无比庞大,数据库重启时需要加载 AOF 日志进行指令重放,这个时间就会无比漫长。所以需要定期进行 AOF 重写,给 AOF 日志进行瘦身。

RDB 快照原理

Redis 使用操作系统的多进程 COW(Copy On Write) 机制来实现快照持久化

Redis 在持久化时会调用 glibc 的函数 fork 产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这是 Linux 操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。

子进程做数据持久化,它不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。但是父进程不一样,它必须持续服务客户端请求,然后对内存数据结构进行不间断的修改

随着父进程修改操作的持续进行,越来越多的共享页面被分离出来,内存就会持续增长。但是也不会超过原有数据内存的 2 倍大小。另外一个 Redis 实例里冷数据占的比例往往是比较高的,所以很少会出现所有的页面都会被分离,被分离的往往只有其中一部分页面。每个页面的大小只有 4K,一个 Redis 实例里面一般都会有成千上万的页面。

子进程因为数据没有变化,它能看到的内存里的数据在进程产生的一瞬间就凝固了,再也不会改变,这也是为什么 Redis 的持久化叫「快照」的原因。接下来子进程就可以非常安心的遍历数据了进行序列化写磁盘了。

AOF 原理

AOF 日志存储的是 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的指令记录

假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内存数据结构的状态。

Redis 会在收到客户端修改指令后,进行参数校验进行逻辑处理后,如果没问题,就立即将该指令文本存储到 AOF 日志中,也就是先执行指令才将日志存盘。这点不同于 leveldb、hbase 等存储引擎,它们都是先存储日志再做逻辑处理。

Redis 提供的 AOF 配置项 appendfsync 写回策略直接决定 AOF 持久化功能的效率和安全性。

  • always:同步写回,写指令执行完毕立马将aof_buf缓冲区中的内容刷写到 AOF 文件。
  • everysec:每秒写回,写指令执行完,日志只会写到 AOF 文件缓冲区,每隔一秒就把缓冲区内容同步到磁盘。
  • no:操作系统控制,写执行执行完毕,把日志写到 AOF 文件内存缓冲区,由操作系统决定何时刷写到磁盘。

Redis 在长期运行的过程中,AOF 的日志会越变越长。如果实例宕机重启,重放整个 AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要对 AOF 日志瘦身。

AOF 重写

Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。其原理就是开辟一个子进程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。

通常 Redis 的主节点是不会进行持久化操作,持久化操作主要在从节点进行。从节点是备份节点,没有来自客户端请求的压力,它的操作系统资源往往比较充沛。

Redis 4.0 混合持久化

重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。

将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。 image

于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升

主从同步怎么实现的

CAP 原理

  • C - Consistent ,一致性
  • A - Availability ,可用性
  • P - Partition tolerance ,分区容忍性

分布式系统的节点往往都是分布在不同的机器上进行网络隔离开的,这意味着必然会有网络断开的风险,这个网络断开的场景的专业词汇叫着「网络分区」。 网络分区发生时,一致性和可用性两难全

最终一致

Redis 的主从数据是异步同步的,所以分布式的 Redis 系统并不满足「一致性」要求。当客户端在 Redis 的主节点修改了数据后,立即返回,即使在主从网络断开的情况下,主节点依旧可以正常对外提供修改服务,所以 Redis 满足「可用性」。

Redis 保证「最终一致性」,从节点会努力追赶主节点,最终从节点的状态会和主节点的状态将保持一致。如果网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢复,从节点会采用多种策略努力追赶上落后的数据,继续尽力保持和主节点一致。

Redis 同步支持主从同步和从从同步,从从同步功能是 Redis 后续版本增加的功能,为了减轻主库的同步负担。

增量同步

Redis 同步的是指令流,主节点会将那些对自己的状态产生修改性影响的指令记录在本地的内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一样的状态,一边向主节点反馈自己同步到哪里了 (偏移量)。

因为内存的 buffer 是有限的,所以 Redis 主库不能将所有的指令都记录在内存 buffer 中。Redis 的复制内存 buffer 是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容。

如果因为网络状况不好,从节点在短时间内无法和主节点进行同步,那么当网络状况恢复时,Redis 的主节点中那些没有同步的指令在 buffer 中有可能已经被后续的指令覆盖掉了,从节点将无法直接通过指令流来进行同步,这个时候就需要用到更加复杂的同步机制 —— 快照同步

快照同步

快照同步是一个非常耗费资源的操作,它首先需要在主库上进行一次 bgsave 将当前内存的数据全部快照到磁盘文件中,然后再将快照文件的内容全部传送到从节点。从节点将快照文件接受完毕后,立即执行一次全量加载,加载之前先要将当前内存的数据清空。加载完毕后通知主节点继续进行增量同步。

在整个快照同步进行的过程中,主节点的复制 buffer 还在不停的往前移动,如果快照同步的时间过长或者复制 buffer 太小,都会导致同步期间的增量指令在复制 buffer 中被覆盖,这样就会导致快照同步完成后无法进行增量复制,然后会再次发起快照同步,如此极有可能会陷入快照同步的死循环。 image

增加从节点

当从节点刚刚加入到集群时,它必须先要进行一次快照同步,同步完成后再继续进行增量同步。

无盘复制

主节点在进行快照同步时,会进行很重的文件 IO 操作,特别是对于非 SSD 磁盘存储时,快照会对系统的负载产生较大影响。特别是当系统正在进行 AOF 的 fsync 操作时如果发生快照,fsync 将会被推迟执行,这就会严重影响主节点的服务效率。

所谓无盘复制是指主服务器直接通过套接字将快照内容发送到从节点,生成快照是一个遍历的过程,主节点会一边遍历内存,一边将序列化的内容发送到从节点,从节点还是跟之前一样,先将接收到的内容存储到磁盘文件中,再进行一次性加载

Wait 指令

Redis 的复制是异步进行的,wait 指令可以让异步复制变身同步复制,确保系统的强一致性 (不严格)。wait 指令是 Redis3.0 版本以后才出现的。

1
2
3
4
> set key value
OK
> wait 1 0
(integer) 1

wait 提供两个参数,第一个参数是从库的数量 N,第二个参数是时间 t,以毫秒为单位。它表示等待 wait 指令之前的所有写操作同步到 N 个从库 (也就是确保 N 个从库的同步没有滞后),最多等待时间 t。
如果时间 t=0,表示无限等待直到 N 个从库同步完成达成一致。

假设此时出现了网络分区,wait 指令第二个参数时间 t=0,主从同步无法继续进行,wait 指令会永远阻塞,Redis 服务器将丧失可用性。

This post is licensed under CC BY 4.0 by the author.

MySQL - InnoDB 和 MyISAM 的区别

Go 常见面试题