抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

redis 概览

Redis全景图包括“:
“两大维度”: 系统维度和应用维度;
“三大主线”也就是指高性能、高可靠和高可扩展(可以简称为“三高”):

  1. 高性能主线,包括线程模型、数据结构、持久化、网络框架;
  2. 高可靠主线,包括主从复制、哨兵机制;
  3. 高可扩展主线,包括数据分片、负载均衡。

Redis 问题画像

从simpleKV到redis

大体来说,一个键值数据库包括了访问框架、索引模块、操作模块和存储模块:

  • 访问框架:函数库调用/socket通讯(I/O 模型设计?)。例如,RocksDB 以动态链接库的形式使用,而 Memcached 和 Redis 则是通过网络框架访问。
  • 索引模块:场景索引类型有哈希表、B+ 树、字典树等。例如,Memcached 和 Redis 采用哈希表作为 key-value 索引,而 RocksDB 则采用跳表作为内存中 key-value 的索引。
  • 操作模块:GET/SCAN,PUT/DELETE会分配和释放内存,由分配器完成。
  • 存储模块:glibc 的 malloc 和 free (处理随机的大小内存块分配时会有内存碎片问题), 将数据持久化落盘。

impleKV 和 Redis 对比:

redis 数据结构

5 大数据类型: String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)

6 种底层数据结构:简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。

它们和数据类型的对应关系如下图所示:

集合常见操作的复杂度:

  • 单元素操作是基础:指每一种集合类型对单个数据实现的增删改查操作,如Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等,复杂度都是O(1);
  • 范围操作非常耗时:指集合类型中的遍历操作,可以返回集合中的所有数据,如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS、List 类型的 LRANGE 和 ZSet 类型的 ZRANGE等,复杂度都是O(N);
  • 统计操作通常高效:指集合类型对集合中所有元素个数的记录,如 LLEN 和 SCARD,复杂度只有 O(1);
  • 例外情况只有几个:指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,复杂度也是O(1)。

键和值用什么结构组织?

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。
一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
哈希表的最大好处就是可以用 O(1) 的时间复杂度来快速查找到键值对——只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素:

随数据增加,当两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中,就可能出现哈希冲突。

Redis使用链式哈希来解决哈希冲突。

链式哈希就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接,但链表查询效率不高。

此时,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表,基本rehash过程:

哈希表1(默认) -> 哈希表2(开启使用,更大容量) -> 哈希表1拷贝到哈希表2 -> 释放哈希表1留作下一次 rehash 扩容备用

但数据量太大,若哈希表一次性拷贝,会造成 Redis 线程阻塞,无法服务其他请求。

所以Redis 采用了渐进式 rehash。即在哈希表拷贝时,Redis 仍然正常处理客户端请求,每处理一个请求时,会从哈希表1依次拷贝一个索引上的元素到哈希表2,避免了一次性大量拷贝的开销。

ps: 在 rehash 被触发后,即使没有收到新请求,Redis 也会定时执行一次 rehash 操作,而且,每次执行时长不会超过 1ms,以免对其他任务造成影响。

redis 线程

Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程,而采用单线程的一个核心原因是避免多线程开发的并发控制问题。同时使用多路复用的 IO 模型,避免网络 IO 操作阻塞。

所谓的IO多路复用指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。
下图就是基于多路复用的 Redis IO 模型,此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。
正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

redis 持久化

目前,Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照。如果 Redis 发生了宕机,可以分别通过回放日志和重新读入 RDB 文件的方式恢复数据,从而保证尽量少丢失数据,提升可靠性。

AOF

AOF(全称Append Only File),即写后日志,先执行指令,把数据写入内存,然后才记录日志。因此每执行一条更改数据指令都会追加到AOF文件中,在一定程度上会降低性能。

AOF 三种回收策略:

配置项 写回机制 优点 缺点
Always 同步写回 可靠性高,数据基本不丢失 每个写指令都要落盘,性能影响较大
EverySec 每秒写回 性能适中 宕机时丢失1秒内的数据
No 操作系统控制的写回 性能好 宕机时丢失数据较多

AOF 重写:

为了避免日志文件过大,Redis 提供了 AOF 重写机制,直接根据数据库里数据的最新状态,生成这些数据的插入命令,作为新日志。
AOF重写过程可概括为:“一个拷贝,两处日志”。

一个拷贝:每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。

两处日志:
第一处日志指正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。
第二处日志指新的 AOF 重写日志,也会被写到重写日志的缓冲区。

AOF非阻塞的重写过程如下图:

PS:
有两个配置项在控制AOF重写的触发时机:

  1. auto-aof-rewrite-min-size: 表示运行AOF重写时文件的最小大小,默认为64MB
  2. auto-aof-rewrite-percentage: 这个值的计算方法是:当前AOF文件大小和上一次重写后AOF文件大小的差值,再除以上一次重写后AOF文件大小。也就是当前AOF文件比上一次重写后AOF文件的增量大小,和上一次重写后AOF文件大小的比值。

AOF文件大小同时超出上面这两个配置项时,会触发AOF重写。

一份简单的AOF配置文件:

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
# appendonly参数开启AOF持久化
appendonly no

# AOF持久化的文件名,默认是appendonly.aof
appendfilename "appendonly.aof"

# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./data

# 同步策略
# appendfsync always
appendfsync everysec
# appendfsync no

# aof重写期间是否同步
no-appendfsync-on-rewrite no

# 重写触发配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 加载aof出错如何处理
aof-load-truncated yes

# 文件重写策略
aof-rewrite-incremental-fsync yes

RDB

RDB(全称Redis DataBase),即内存快照。所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。
对 Redis 来说就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。

Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave

  • save:在主线程中执行,会导致阻塞;
  • bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。

Redis 借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作,原理大致是:
主线程 fork 出 bgsave 子进程后,bgsave 子进程实际是复制了主线程的页表,这些页表保存了在执行 bgsave 命令时,主线程的所有数据块在内存中的物理地址。bgsave 子进程生成 RDB 时,就可以根据页表读取这些数据,再写入磁盘中。如果此时主线程进行新的写操作,会把新数据写到一个新的物理地址中,并修改自己的页表映射,但不影响bgsave保存的原页表的映射关系,即实现写时复制。

一份简单的RDB配置文件:

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
################################ SNAPSHOTTING  #################################

# 配置快照持久化条件,达到则进行持久化
# 900秒(15分钟)内至少1个key值改变
# 300秒(5分钟)内至少10个key值改变
# 60秒(1分钟)内至少10000个key值改变
# 注释或设置为""则代表关闭RDB
save 900 1
save 300 10
save 60 10000

# 持久化失败是否继续,
stop-writes-on-bgsave-error yes

# 是否对rdb文件采用LZF压缩字符串和对象,默认开启
# 开启能节省空间但会吃cpu
rdbcompression yes

# CRC64校验,可以关掉提高性能
# 没有校验的RDB文件会有一个0校验位,来告诉加载代码跳过校验检查
rdbchecksum yes

# 导出数据库的文件名称
dbfilename dump.rdb

# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
# 文件名就是上面'dbfilename'配置项指定的文件名
dir ./data

PS:
关于 AOF 和 RDB 的选择问题的三点建议:

  1. 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择 (Redis 4.0提出混用);
  2. 如果允许分钟级别的数据丢失,可以只使用 RDB;
  3. 如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。

redis 数据同步

Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。

  • 读操作:主库、从库都可以接收;
  • 写操作:首先到主库执行,然后,主库将写操作同步给从库。

主从同步基本有三种模式:全量复制、基于长连接的命令传播,以及增量复制。

全量复制

从库首次复制无法避免需要进行全量复制,一个redis实例在几GB级别较合适。
在主库同步数据给从库时不会被阻塞,仍然可写,为保证数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
这个repl buffer用于每个client专门传播用户写命令到从库,一旦超过buffer大小,主库会强行断开这个client,redis可以通过client-output-buffer-limit参数限制这个buffer的大小。

如果从库过多,会给主库带来压力导致请求过慢,此时可以通过主-从-从模式,将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

基于长连接的命令传播

一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这期间如果遇到了网络断连,就需要使用到增量复制。

增量复制

当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。
repl_backlog_buffer 是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

一般来说这两个位置偏移量基本相等,但如果从库读取较慢,就有可能还没读取就被主库新写覆盖了,导致数据不一致。
为了避免这种情况,可以调整repl_backlog_size参数来控制这个环形缓冲区大小。一般缓冲空间的计算公式是:缓冲空间大小 = 2 * (主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小)。

关于replication buffer 和 repl_backlog_buffer 的区别:

  • replication buffer 是主从库在进行全量复制时,主库上用于和从库连接的客户端的 buffer,每个从库客户端对应一个;
  • repl_backlog_buffer 是为了支持从库增量复制,主库上用于持续保存写操作的一块专用 buffer,所有从库共享。

PS:
如果由于各种原因repl_backlog_buffer被主库写满(覆盖)了,那么从库就需要重新进行全量复制,所以尽量配置大一点,可以降低主从断开后全量同步的概率,避免额外的性能开销。

redis 哨兵机制

当redis主库挂了,按照主从库模式下的读写分离要求,此时不能执行写操作了。
为了提供正常的读写服务,redis需要有一个运行在特殊模式下的进程来负责监听、选主、通知和主从切换,这就是redis的哨兵机制。

监听

所谓监听,就是判断主库的下线状态。哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。

当哨兵发现主库或从库对 PING 命令的响应超时了,那么哨兵就会先把它标记为“主观下线”。但是如果仅仅是一个哨兵,可能由于在集群网络压力较大、网络拥塞,或者是主库本身压力较大的原因出现误判,一旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。

所以通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。

哨兵集群多数实例(这个票数可通过配置项 quorum 来设置)达成共识,主库才会被标记为“客观下线”。

选主

首先判断从库在线状态,然后是网络连接状态。
此时可以通过配置项down-after-milliseconds * 10来判断。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。

最后依次按照优先级、复制进度、ID 号大小再对筛选后的从库进行打分,选出新主库。

通知

通知包括哨兵与哨兵、哨兵与从库、哨兵与客户端之间的通讯。

哨兵之间是通过 Redis 提供的 pub/sub 机制,也就是发布/订阅机制来相互发现的。

哨兵与从库试过通过向主库发送 INFO 命令,主库返回从库列表信息建立连接。

哨兵与客户端也是通过 pub/sub 机制,客户端订阅哨兵一些频道来获取消息。

场景的频道:

主从切换

检查到主观主库下线后,需要通过“Leader选举”来确认哨兵的Leader,执行主从切换。
在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

PS: 要保证所有哨兵实例的配置是一致的,尤其是主观下线的判断值 down-after-milliseconds 要一致。

哨兵实例是不是越多越好呢?如果同时调大 down-after-milliseconds 值,对减少误判是不是也有好处?

哨兵实例越多,误判率会越低,但是在判定主库下线和选举 Leader 时,实例需要拿到的赞成票数也越多,等待所有哨兵投完票的时间可能也会相应增加,主从库切换的时间也会变长,客户端容易堆积较多的请求操作,可能会导致客户端请求溢出,从而造成请求丢失。如果业务层对 Redis 的操作有响应时间要求,就可能会因为新主库一直没有选定,新操作无法执行而发生超时报警。
调大 down-after-milliseconds 后,可能会导致这样的情况:主库实际已经发生故障了,但是哨兵过了很长时间才判断出来,这就会影响到 Redis 对业务的可用性。

redis 切片集群

切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。从 3.0 开始,官方提供了一个名为 Redis Cluster 的方案,用于实现切片集群。Redis Cluster 方案中就规定了数据和实例的对应规则。简单说就是采用哈希槽来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。

那么 哈希槽是怎么被映射到具体的实例上的呢?

  • 使用 cluster create 命令创建集群,均分哈希槽。
  • 使用 cluster meet 手动创建实例连接,再通过cluster addslots命令手动分配哈希槽(需要把槽分满)。

切片集群请求键值的流程:

创建集群 –> 实例间同步哈希槽信息 –> 发给客户端 –> 客户端缓存在本地 –> 请求键值 –> 计算出所在分槽 –> 对应分槽实例发送请求

但集群的实例增减,或者是为了实现负载均衡而进行的数据重新分布,会导致哈希槽和实例的映射关系发生变化。
为了让客户端感知,Redis Cluster 方案提供了一种重定向机制,这个重定向指当客户端发送请求时,如果根据本地缓存算出来的实例请求发现没有数据,则响应一个MOVED命令:

1
2
GET hello:key
(error) MOVED 13320 192.168.1.5:6379

MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 192.168.1.5 这个实例上,即已经迁移到新实例了,客户端就可以直接请求新实例。

但如果在迁移过程请求,客户端就会收到一条 ASK 报错信息:

1
2
GET hello:key
(error) ASK 13320 192.168.1.5:6379

ASK 命令表示客户端请求的键值对所在的哈希槽 13320,在 192.168.1.5 这个实例上,但是这个哈希槽正在迁移。此时,客户端需要给新实例发送 ASKING 命令,然后再发送操作命令。

和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息。

评论