Redis

宋正兵 on 2021-06-22

简介

Redis (远程字典服务器)是一种数据库,能够存储数据、管理数据的一种软件。它是一个用 C 语言编写的、开源的、基于内存运行并支持持久化的、高性能的 NoSQL 数据库。

分布式缓存

分布式缓存主要解决的是单机缓存的容量受服务器限制并且无法保存通用的信息。因为本地缓存只在当前服务里有效,如果部署了两个相同的服务,两者之间的缓存数据是无法互通的。

常见的分布式缓存技术选型方案有 Memcached 和 Redis。

Redis特点

  • 基于内存的数据库,一般用来当作缓存使用,性能高
  • 有过期策略
  • 支持更丰富的数据类型,除了 k/v 类型,还支持 list、set、hash 等数据结构的存储
  • 支持数据持久化
  • 支持灾难恢复机制

为什么要用 Redis(为什么要用缓存)

简单讲,使用缓存主要是为了提升用户体验以应对更多的用户。从“高性能”和“高并发”两个角度看待这个问题。

高性能

假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢的,因为是从硬盘中读取数据的。但是如果用户访问的数据属于高频数据并且不会经常改变的话,那么我们可以很放心地将该数据存在缓存中。这样做的好处是,保证用户下次再访问这些数据的时候就可以直接从缓存中获取,操作缓存就是直接操作内存,所以速度会很快。

高并发

一般来讲,使用 Redis 缓存之后的 QPS 能够达到 MySQL 数据库的10倍以上。所以直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,如果把数据库中的部分数据转移到缓存中,这样用户的一部分请求会直接到缓存而不请求数据库,这样就提高了系统整体的并发度。

QPS(Query Per Second):服务器每秒可以执行的查询次数。

Redis常见数据结构以及使用场景分析

Redis 官网提供的在线 redis 环境 Try Redis

string

string 数据结构是简单的 key-value 类型。可以保存文本数据或者是二进制数据,并且获取字符串长度复杂度为 $O(1)$。

命令: set、get、strlen、exists、dect、incr、setex 等等

应用场景: 一般常用在需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量等等。

普通字符串的基本操作

1
2
3
4
5
6
7
8
9
10
11
12
> set key value # 设置 key-value 类型的值
OK
> get key # 根据 key 获得对应的 value
"value"
> exists key # 判断某个 key 是否存在
(integer) 1
> strlen key # 返回 key 所储存的字符串值的长度。
(integer) 5
> del key # 删除某个 key 对应的值
(integer) 1
> get key
(nil)

批量设置

1
2
3
4
5
> mset k1 v1 k2 v2 # 批量设置 key-value 类型的值
OK
> mget k1 k2 # 批量获取多个 key 对应的 value
1) "value1"
2) "value2"

计数器(字符串的内容为整数的时候可以使用)

1
2
3
4
5
6
7
8
9
10
> set num 1
OK
> incr num # 将 key 中储存的数字值增一
(integer) 2
> get num
"2"
> decr num # 将 key 中储存的数字值减一
(integer) 1
> get num
"1"

过期设置

1
2
3
4
5
6
> expire key 60 # 数据在 60s 后过期
(integer) 1
> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
> ttl key # 查看数据还有多久过期
(integer) 56

list

list 即是链表。Redis 的 list 的实现为一个双向链表。

命令: rpush、lpop、lpush、rpop、rpush、llen

应用场景: 发布与订阅或者说消息队列、慢查询

通过 rpush/lopo 实现队列

1
2
3
4
5
6
7
8
9
10
11
12
> rpush myList value1 # 向 list 的头部(右边)添加元素
(integer) 1
> rpush myList value2 value3 # 向list的头部(最右边)添加多个元素
(integer) 3
> lpop myList # 将 list的尾部(最左边)元素取出
"value1"
> lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end
1) "value2"
2) "value3"
> lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一
1) "value2"
2) "value3"

通过 rpush/rpop 实现栈

1
2
3
4
> rpush myList2 value1 value2 value3
(integer) 3
> rpop myList2 # 将 list的头部(最右边)元素取出
"value3"

通过 lrange 查看对应下标范围的列表元素

1
2
3
4
5
6
7
8
9
> rpush myList value1 value2 value3
(integer) 3
> lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end
1) "value1"
2) "value2"
> lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一
1) "value1"
2) "value2"
3) "value3"

通过 lrange 命令,可以基于 list 实现分页查询,性能非常高!

通过 llen 查看链表长度

1
2
> llen myList
(integer) 3

hash

hash 类似于 JDK 1.8 以前的 HashMap,内部实现也差不多(数组+链表)。hash 是一个 string 类型的变量和 value 的映射表,特别适合用于存储对象,后续操作的时候可以直接仅仅修改这个对象中的某个字段的值。比如可以用 hash 数据结构来存储用户信息、商品信息等等。

常用命令: hset、hmset、hexists、hget、hgetall、hkeys、hvals

应用场景: 系统中对象数据的存储

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
> hmset userInfoKey name "zbsong" description "dev" age "24"
OK
> hexists userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。
(integer) 1
> hget userInfoKey name # 获取存储在哈希表中指定字段的值。
"zbsong"
> hget userInfoKey age
"24"
> hgetall userInfoKey # 获取在哈希表中指定 key 的所有字段和值
1) "name"
2) "zbsong"
3) "description"
4) "dev"
5) "age"
6) "24"
> hkeys userInfoKey # 获取 key 列表
1) "name"
2) "description"
3) "age"
> hvals userInfoKey # 获取 value 列表
1) "zbsong"
2) "dev"
3) "24"
> hset userInfoKey name "zbsongGieGie" # 修改某个字段对应的值
> hget userInfoKey name
"zbsongGieGie"

set

set 类似于 Java 中的 HashSet。set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 锁不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合中。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。

常用命令: sadd、spop、smembers、sismember、scard、sinterstore、sunion

应用场景: 需要存放的数据不能重复以及需要获取多个数据源交集和并集等场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
> sadd mySet value1 value2 # 添加元素进去
(integer) 2
> sadd mySet value1 # 不允许有重复元素
(integer) 0
> smembers mySet # 查看 set 中所有的元素
1) "value1"
2) "value2"
> scard mySet # 查看 set 的长度
(integer) 2
> sismember mySet value1 # 检查某个元素是否存在set 中,只能接收单个元素
(integer) 1
> sadd mySet2 value2 value3
(integer) 2
> sinterstore mySet3 mySet mySet2 # 获取 mySet 和 mySet2 的交集并存放在 mySet3 中
(integer) 1
> smembers mySet3
1) "value2"

sorted set

和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。

常用命令: zadd、zcard、zscore、zrange、zrevrange、zrem

应用场景: 需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
> zadd myZset 3.0 value1 # 添加元素到 sorted set 中 3.0 为权重
(integer) 1
> zadd myZset 2.0 value2 1.0 value3 # 一次添加多个元素
(integer) 2
> zcard myZset # 查看 sorted set 中的元素数量
(integer) 3
> zscore myZset value1 # 查看某个 value 的权重
"3"
> zrange myZset 0 -1 # 顺序输出某个范围区间的元素,0 -1 表示输出所有元素
1) "value3"
2) "value2"
3) "value1"
> zrange myZset 0 1 # 顺序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value3"
2) "value2"
> zrevrange myZset 0 1 # 逆序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value1"
2) "value2"

bitmap

bitmap 存储的是连续的二进制数字(0 和 1),通过 bitmap,只需要一个 bit 位来表示某个元素对应的值或状态,key 就是对应元素本身。我们知道 8 个 bit 可以组成一个 byte,所以 bitmap 本身会极大的节省存储空间。

常用命令: setbit、getbit、bitcount、bitop

应用场景: 适合需要保存状态信息(比如是否签到、是否登录)并需要进一步对这些信息进行分析的场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# setbit 会返回之前位的值(默认是 0)这里会把第 7 位设置为 1
> setbit mykey 7 1
(integer) 0
> setbit mykey 7 0
(integer) 1
> getbit mykey 7
(integer) 0
> setbit mykey 6 1
(integer) 0
> setbit mykey 8 1
(integer) 0
# 通过 bitcount 统计被被设置为 1 的位的数量。
> bitcount mykey
(integer) 2

针对上述提到的一些场景,进一步地进行说明。

场景一:用户行为分析 分析用户的喜好,需要研究你点赞过的内容。

1
2
# 记录你喜欢过 001 号小姐姐【uid=001】
> setbit beauty_girl uid 1

场景二:统计活跃用户

使用时间作为 key,然后用户 id 为 offset,如果当日活跃过就设置为 1。

如果想计算某几天/月/年的活跃用户,可以使用 bitop 命令。

1
2
3
# 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。
# bitop 命令支持 and、or、not、xor 这四种操作中的任意一种参数
bitop operation destkey key [key ...]

初始化数据:

1
2
3
4
5
6
> setbit 20210308 1 1
(integer) 0
> setbit 20210308 2 1
(integer) 0
> setbit 20210309 1 1
(integer) 0

统计 20210308~20210309 总活跃用户数:1

1
2
3
4
> bitop and desk1 20210308 20210309
(integer) 1
> bitcount desk1
(integer) 1

统计 20210308~20210309 在线活跃用户数: 2

1
2
3
4
> bitop or desk2 20210308 20210309
(integer) 1
> bitcount desk2
(integer) 2

场景三:用户在线状态

对于获取或者统计用户在线状态,使用 bitmap 是一个节约空间效率又高的一种方法。

只需要一个 key,然后用户 id 为 offset,如果在线就设置为 1,不在线就设置为 0。

分布式锁

通过命令 setexpire 组合在一起的原子指令 set <key> <value> ex <time> nx 来进行分布式锁的实现。

1
2
3
4
> set lock_key true ex 5 nx
OK
...do something critical...
> del lock_key

保证 setnx(set if not exists)指令和设置超时 expire 指令的原子性。

分布式锁不适合较长时间的任务,因为超出了锁的超时限制后,第二个线程可能会提前持有了锁,导致线程安全问题。

如果要实现可重入的话,可以基于 ThreadLocal 存储当前持有锁的计数来实现。

异步消息队列

利用阻塞读命令来实现 blpoprlpop。消息从固定的一侧进入 Redis 的 list 中,读的时候通过阻塞读来获取,这样在队列没有数据的时候,会立即进入休眠状态,一旦数据到来又立刻醒过来。

Redis持久化机制【备份机制】

基于快照的全量备份 RDB

Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。

手动备份

  • save 命令:同步阻塞的
  • bgsave 命令:非阻塞的(阻塞实际发生在 fork 的子进程中)

自动备份

在 Redis.conf 配置文件中默认有如下配置:

1
2
3
save 900 1           #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

持久化存储实现原理

Redis 进程 fork 出一个子进程,子进程拥有父进程中的所有数据,父进程在接收客户端请求试图修改某个物理页的时候,会先复制一份页出来,然后再在复制出来的物理页上进行修改。

基于指令日志的增量备份 AOF

与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF 方式的持久化,可通过 appendonly 参数开启:

1
appendonly yes

开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,就会将该命令写入硬盘中的 AOF 文件。AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名都是 appendonly.aof。

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:

1
2
3
appendfsync always    #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘
appendfsync no #让操作系统决定何时进行同步

为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没收到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。

AOF重写

AOF 策略会将每条写入命令都写入到 AOF 文件当中,随着时间的逐步推移,AOF 文件就会变得很臃肿。为了解决这个问题,Redis 提供了 AOF 重写功能。

Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。最后服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

RDB和AOF的比较

RDB 的优点

  • 创建的是数据的快照,全量备份,便于数据的传输(比如把 A 服务器上的备份文件传输到 B 服务器,直接将 RDB 文件拷贝即可)
  • 文件采用压缩的二进制文件,重启服务时加载数据文件更快

RDB 的缺点

  • 容易丢失数据,因为全量备份比较耗时,如果在全量备份的过程中宕机,会丢失很多数据。

AOF 的优点

  • 提供多种文件写入(fsync)策略
  • 数据实时保存,数据完整性强

AOF 的缺点

  • 随着时间的推移文件体积会很大,加载的速度会很慢

如何选择两种机制呢?

  • 针对不同的情况来选择,建议使用两种方式相结合
  • 针对数据安全性、完整性要求高的采用 AOF 方式
  • 针对不太重要的数据可以使用 RDB 方式
  • 对于数据进行全量备份,便于数据备份的采用 RDB 方式

redis 备份导出rdb_Redis持久化知识点—RDB+AOF ,你了解多少_weixin_39765697的博客-CSDN博客

混合策略

AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头,在重写过程中执行的写命令以 AOF 持久化的方式追加到 AOF 文件的末尾。这样做的好处是可以结合 RDB 和 AOF 的优点,快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

过期的数据的删除策略

Redis 会把每个设置了过期时间的 key 都放到一个独立的字典里,之后会按照指定的策略来删除到期的 key。

惰性删除:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期的 key 没有被删除。

定期删除: 每隔一段时间抽取一批 key 执行删除过期的 key 的操作。这样对内存最友好,通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

Redis 采用的是 定期删除+惰性删除。默认每秒进行 10 次过期扫描,先从字典里随机挑 20 个 key,删除其中过期的,然后计算字典中过期键的比例,如果比例还是比较大,那就重复步骤。

【主从模式下】从节点不会进行过期扫描,从节点对过期的处理是被动的,主节点在 key 到期后,会在 AOF 文件里增加 del 指令,同步到从节点,从节点执行删除操作。

内存淘汰策略

定期删除和惰性删除还是可能会漏掉很多过期 key 的情况,所以内存淘汰策略就是用来解决这个问题的。

Redis 提供了 6 种数据淘汰策略:

  1. volatile-lru(last recently used):从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
  4. allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key
  5. allkeys-random:从数据集中任意选择数据淘汰
  6. no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错

Redis给缓存数据设置过期时间有啥用?

因为内存是有限的,如果缓存中的所有数据都是一直保存的话,很容易造成 OOM。所以一般情况下,我们设置保存的缓存数据都会设置一个过期时间。

1
2
3
4
5
6
> exp key  60 # 数据在 60s 后过期
(integer) 1
> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
> ttl key # 查看数据还有多久过期
(integer) 56

Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他类型都需要依靠 expire 命令来设置过期时间。persist 命令可以溢出一个键的过期时间。

比如短信验证码可能只在一分钟内有效,用户登录的 token 可能只在一天内有效,用传统的数据库来处理,一般都是自己判断是否过期,这样做很麻烦并且性能差很多,用 Redis 的话直接设置一个过期时间就搞定了。

Redis如何判断数据是否过期

Redis 通过一个过期字典来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key,过期字典的值是一个 long long 类型的整数,这个整数保存了键所指向的数据库 key 的过期时间(毫秒精度的 UNIX 时间戳)。结构大概是这样的

1
2
3
4
5
6
typedef struct redisDb {
...
dict *dict; //数据库键空间,保存着数据库中所有键值对 key-value
dict *expires // 过期字典,保存着键的过期时间 key-过期时间
...
} redisDb;

Redis单线程模型

Redis 利用 I/O 多路复用程序(aeMain() 方法干的事),去监听客户端的请求(每个客户端对应一个 socket 文件描述符),每个客户端对应的文件描述符都被添加到了 aeFileEvent 链表当中,该链表中第一个文件描述符是 Redis 启动时添加用于监听客户端新连接用的。

当监听到有客户端数据传来时,具体执行什么函数需要根据客户端文件描述符所绑定的事务处理器来决定。

由于只有一个线程在监听这些描述符,并做处理,所以即使客户端并发地发送命令,服务器仍然是依次取出命令,顺序执行。

这也就是我们常说地,redis 是单线程地,命令与命令之间是顺序执行,无需考虑线程安全地问题。

《Redis 设计与实现》中介绍:

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器。文件事件处理器使用 I/O 多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。

当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这是文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模式,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

单线程是怎么监听大量的客户端连接呢?

Redis 中的文件事件处理器使用 I/O 多路复用程序来同时监听文件描述符,并根据文件描述符目前绑定的事件处理器来执行相应的任务。

Redis 6.0之后为何引入了多线程?

Redis 6.0 引入多线程主要是为了提高网络 I/O 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。

引入多线程只是在网络数据的读写这类耗时操作上使用,执行命令仍然是单线程顺序执行,因此不需要担心线程安全问题。

Redis 启动流程

1
./redis-server

命令执行后,shell 程序把 Redis 程序加载到了内存,开始执行 Redis 的 main 方法。主要有三大步:

1
2
3
4
5
6
7
8
9
10
11
12
int main(int argc, char **argv) {
...
// 初始化创建 TCP 连接,监听客户端新连接
initServer();
...
// 将创建 TCP 连接返回的文件描述符绑定 accpetHandler() 函数,并加入到 aeFileEvent 链表中
aeCreateFileEvent(fd, acceptHandler, ...);
...
// I/O 多路复用,监听客户端的请求
aeMain();
...
}

在 acCreateFileEvent() 函数的作用是把相应的事件处理器绑定到文件描述符上

  • acceptHandler:连接应答处理器
  • readQueryFromClient:命令请求处理器
  • sendReplyToClient:命令回复处理器

第一步,创建 TCP 连接

通过 listenToPort() 方法创建一个 TCP 连接,返回一个文件描述符 fd。

第二步,将文件描述符加入到 aeFileEvent 链表中

通过 acCreateFileEvent() 方法,将上面创建了 TCP 连接返回的文件描述符 fd,加入到一个叫做 aeFileEvent 的链表中。

同时将这个文件描述符绑定一个函数 acceptHandler,这样当有客户端连接进来时,便会执行这个函数。

其实启动之后就是开启了一个 TCP 监听,然后如果有客户端进来的话,让它执行 acceptHandler 函数。

第三步,I/O多路复用

通过 aeMain() 方法,将上面的 aeFileEvent 链表中的文件描述符,统统作为 select 的入参,这是 I/O 多路复用模式。

1
2
3
4
5
6
void aeMain(aeEventLoop *eventLoop)
{
eventLoop->stop = 0;
while (!eventLoop->stop)
aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}

可以看到它就是在中间一直循环死等,用 select 的方式监听多个文件描述符。

Redis客户端连接服务器的流程

Redis 启动后,假设有一个 redis-client 连接到了该服务器。那么监听客户端新连接的文件描述符感知到,就会执行 acceptHandler 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void acceptHandler(...) {
...
cfd = anetAccept(...);
...
c = createClient(cfd))
...
}

static redisClient *createClient(int fd) {
...
aeCreateFileEvent(c->fd, readQueryFromClient, ...);
...
}

可以看到,当有新客户端连接进来时,便会调用 createClient 创建一个专属的 client 为其服务。所谓的专属服务,其实仍然是这个 aeCreateFileEvent() 函数。

上面讲了,这个函数的功能就是把文件描述符挂在链表上,然后分配一个处理函数。

此处的处理函数不再是处理新客户端连接的 acceptHandler,而是处理具体客户端传来的 redis 命令的函数 readQueryFromClient。

如果再来一个客户端,又来一个客户端,那么不断地将新客户端的文件描述符挂上去即可。而监听新客户端连接的,始终是最上面的那个文件描述符。

总结

当有新客户端建立连接时,会触发 acceptHandler 函数执行,创建一个等待数据的描述符并且绑定 readQueryFromClient 函数。

当有客户端数据传来时,会触发 readQueryFromClient 函数执行,完成这个命令的操作。

由于只有一个线程在监听这些描述符,并做处理。所以即使客户端并发地发送命令,服务器仍然是一次取出命令,顺序执行。这也就是我们常常讲的redis 是单线程的,命令与命令之间是顺序执行,无需考虑线程安全的问题。

Redis命令执行的流程

已经建立好连接的客户端发送一个 redis 命令,此时 readQueryFromClient 命令请求处理器会被执行,它会到一张表中去寻找对应的函数来执行命令。

处理完命令之后,需要发送响应给客户端。它会调用 acCreateFileEvent 函数,将 sendReplyToClient 命令回复处理器 挂在需要响应的客户端连接的文件描述符上。

IO多路复用

由 epoll 是实现的,在内核中开辟一块缓冲区,首先调用 epoll_create 创建红黑树空间,然后执行 epoll_ctl 将红黑树空间的文件描述符和监听 socket 的文件描述符关联,当有 IO 发生时,记录 IO 的编号并存在一个链表中,当发生 epoll_wait 系统调用的时候,会将记录有事件 IO 的链表返回,用户程序只需要根据这个链表上记录的信息读写产生事件的 IO 描述符即可。

你管这破玩意叫 IO 多路复用? (qq.com)

read 函数在客户端一直不发送数据的情况下,服务端会造成阻塞,且无法处理其他客户端的请求,所以它是阻塞 IO

为了解决这个问题,程序员在用户态可以通过多线程来防止主线程卡死。【每次都创建一个新的进程或者线程去调用 read 函数,并做业务处理】

但是多线程的做法存在一个问题,为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。操作系统层面提供了非阻塞的 read 函数【非阻塞 IO,数据没有到达时(到达网卡并拷贝到了内核缓冲区之前)立刻返回一个错误值(-1),但是当数据到达内核缓冲区,这个 read 函数仍然时阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回数据】,利用这个非阻塞的 read 函数,程序员可以在一个线程内完成多个文件描述符的读取。

当用户态遍历的文件描述符也会越来越多时,相当于在 while 循环里进行了越来越多的系统调用,这样做时不划算的,好在操作系统提供了提供了 IO 多路复用机制 ,能够在内核态去遍历文件描述符。

最开始是 select 函数,通过它可以把一个文件描述符的数组发给操作系统,让操作系统去遍历,确定哪个文件描述符可以读写并做上标识。用户需要通过遍历刚刚传入的数组,知道哪些是可读的。它存在三个细节:

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源很大(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,只不过没有系统调用切换上下文的开销(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

poll 函数解决了select 只能监听 1024 个文件描述符的限制。

epoll 提供了三个函数:

  1. int epoll_create(int size):创建一个 epoll 句柄
  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):向内核添加、修改或删除要监控的文件描述符
  3. int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout):类似发起了 select() 调用

epoll 解决了 select 的三个不足。

Redis事务

Redis 事务提供了一种将多个命令请求打包的功能。然后再按照顺序执行打包的所有命令,并且不会被中途打断。【即 Redis 不支持回滚操作】

Redis 命令

  • MULTI:事务开启指令
  • EXEC:事务执行指令
  • DISCARD:丢弃事务,清空事务队列中保存的所有命令
  • WATCH:监听一个或者多个键的状态,持续到 EXEC 命令位置。如果至少有一个被监听的键在 EXEC 前被修改了,那么整个事务会被取消。【可以基于这个机制去实现 CAS 乐观锁,还能实现原子操作】

Redis 事务的执行流程

  1. MULTI 命令开启一个事务
  2. 输入多条命令,这些命令会被放到一个队列中,等待被执行
  3. EXEC 命令执行事务,所有队列中的命令会被执行
  4. 可以调用 DISCARD 命令清空事务队列,放弃执行事务

Redis 的事务不保证原子性,如果有一条命令执行出错了,后面的命令仍然会得到执行。

事务进行到一般断电了怎么办

如果事务进行到一半的时候 Redis 掉电了,那这个事务的行为肯定还没有执行完,日志页肯定还没持久化完毕,所以在重启后,用 AOF 重放恢复 Redis 服务器时,会因为事务的不完整导致报错并重启失败。此时可以用 redis-check-aof 程序对 AOF 文件进行修复,删除不完整的事务信息,然后就能成功重放恢复了。

Redis 不支持回滚

Redis 事务的失败是由于 Redis 命令执行错误导致的,这些错误本应在程序编写的过程中就得到解决,而不是放到 Redis 服务端解决。Redis 的设计初衷就是简洁高效,如果决定支持事务回滚,那么势必需要添加很多逻辑(比如 MVCC ),那就违背了设计原则。

杂记

Redis 可以通过 MULTI、EXEC、DISCARD、WATCH 等命令来实现事务功能。

1
2
3
4
5
6
7
8
9
> MULTI # 开启事务
OK
> SET USER "zbsong"
QUEUED
> GET USER
QUEUED
> EXEC # 事务执行
1) OK
2) "zbsong"

使用 MULTI 命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令将执行所有命令。

这个过程是这样的:

  1. 开始事务(MULTI
  2. 命令入队
  3. 执行事务(EXEC

也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。

1
2
3
4
5
6
7
8
> MULTI
OK
> SET USER "zbsong"
QUEUED
> GET USER
QUEUED
> DISCARD
OK

WATCH 命令用于监听指定的键,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。【类似于 CAS,被监听的键被在事务之外的其他人更改了,那么事务会失败】

霸道的程序猿的博客 (cnblogs.com)

1
2
3
4
5
6
7
8
9
> WATCH USER
OK
> MULTI
> SET USER "zbsong"
OK
> GET USER
zbsong
> EXEC
ERR EXEC without MULTI

缓存穿透

缓存穿透就是大量请求的 key 根本不存在于缓存种,导致请求直接到了数据库上,根本没有经过缓存这一层。比如某个黑客故意制造缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。

缓存穿透情况的处理流程

用户请求 -> 缓存中是否存在对应的数据 —不存在—> 数据库中是否存在对应的数据 —不存在—> 返回空数据

缓存穿透的解决办法

最基本的是首先做好参数校验,比如查询数据库的 id 不能小于 0,传入的邮箱格式不对时直接返回错误信息给客户端等。

1)缓存无效key

如果缓存和数据库都查不到某个 key 的数据,就写一个 key-null 的数据并设置一个较短的过期时间。这种做法可以防止攻击者反复使用同一个 key 来暴力攻击。

2)布隆过滤器

将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免将请求打到数据库。

布隆过滤器判断某个元素存在,小概率会误判;布隆过滤器判断某个元素不在,那么这个元素一定不在。

为什么会出现误判?

当一个元素加入布隆过滤器时:

  1. 使用布隆过滤器的哈希函数对元素值进行计算,得到哈希值
  2. 根据得到的哈希值,在 bitmap 中把对应下标的值置为 1

判断一个元素是否存在于布隆过滤器时:

  1. 对给定元素进行哈希计算得到哈希值
  2. 得到哈希值之后判断 bitmap 中对应的元素是否为 1,如果为 1 说明这个值存在,如果不为 1 说明不存在

然后可能会出现这样的情况:不同的字符串可能哈希出来同样的结果,这样就会导致一个不存在的元素但是哈希值和某个存在的元素的哈希值相同的情况,会出现误判。【通过适当的增加 bitmap 的大小或者调整哈希函数来降低误判的概率】

缓存雪崩

缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。比如系统的缓存模块除了问题(如宕机导致不可用),造成系统的所有访问都要走数据库。又比如一些被大量访问的数据(热点缓存)在某一时刻大面积失效,导致对应的请求直接落到了数据库上。

解决办法

针对 Redis 服务不可用的情况

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用
  2. 限流,避免同时处理大量的请求

针对热点缓存失效的情况

  1. 设置不同的失效时间比如随即设置缓存的失效时间
  2. 缓存永不失效

缓存常用的读写策略

Cache Aside Pattern 旁路缓存模式

Cache Aside Pattern 是使用较多的一个缓存读写模式,比较适合读请求比较多的场景。

Cache Aside Pattern 中服务端需要同时维护 DB 和 cache,并且是以 DB 的结果为准。

写步骤

  • 先更新 DB
  • 然后直接删除 cache

1.可以先删除 cache,然后更新 DB 吗?

先删除 cache 然后更新 DB的话,容易产生脏数据。假设有两个并发操作分别是更新和查询:

  1. 更新操作先将 cache 删除数据 A
  2. 查询操作在 cache 中没有找到数据 A,转头到 DB 中查询,并将查询到的数据 A 写入 cache
  3. 更新操作因为属于写操作比较慢,此时刚完成 DB 中数据 A 的更新,于是 cache 中的数据 A 就变成了旧数据

如果后续数据 A 不再更新的话,这个数据将一直存在于缓存中。【可以通过设置过期时间来优化这个问题】

2.先更新 DB,后删除 cache 就没问题了吗?

先更新 DB 后删除 cache 产生脏数据的概率较小,但是会出现一致性问题:

  1. 更新操作先到 DB 中更新数据 A
  2. 查询操作先从 cache 中查询数据 A,此时查询到的数据 A 是脏数据
  3. 更新操作在 DB 中操作完之后删除 cache,下一轮查询操作就直接走的是 DB,然后将数据 A 放入缓存

可以看到脏数据只产生了一次,代价较小。

读步骤

  • 从 cache 中读取数据,读取到就直接返回
  • cache 中读取不到的话,就从 DB 中读取数据返回
  • 再把数据放到 cache 中

缺陷

1)首次请求的数据一定不在 cache 中

可以将热点数据提前放入 cache 中。

2)写操作比较频繁的话导致 cache 中的数据会被频繁删除,影响缓存命中率

  • 数据库和缓存数据强一致场景:更新 DB 的时候同样更新 cache,不过需要加锁/分布式锁来保证更新 cache 的时候不存在线程安全问题
  • 可以短暂地允许数据库和缓存数据不一致地场景:更新 DB 的时候同样更新 cache,但是给缓存加一个较短的过期时间,这样的话就可以保证即使数据不一致影响也比较小。

Read/Write Through Pattern 读写穿透

Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。【很少使用,抛开性能不谈,Redis 也并没有提供 cache 将数据写入 DB 的功能】

写步骤

  • 先查 cache,cache 中不存在,直接更新 DB
  • cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB

读步骤

  • 从 cache 中读取数据,读取到就直接返回
  • 读取不到,就从 DB 加载,然后写入到 cache 后返回

Write Behind Pattern 异步缓存写入

和读写穿透相似,都是由 cache 服务来负责 cache 和 DB 的读写。

但是 读写穿透是同步更新 cache 和 DB,异步缓存写入则是只更新缓存,通过异步批量的方式来更新 DB。

很显然这种方式对数据一致性带来了很大的挑战,比如 cache 数据可能还没异步更新 DB,cache 服务可能就挂掉了。

但是这种策略的写性能非常高,适用于数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量等。

保证缓存和数据库的一致性

更新操作先删除缓存后更新DB,还是先更新DB后删除缓存问题???_Hello什么来着?-CSDN博客

拿旁路缓存模式来讲,如果更新数据库成功,而删除缓存这一步失败的话,有两种解决方案:

方案一

  1. 更新数据库数据
  2. 缓存因为种种原因删除失败
  3. 将需要删除的 key 发送至消息队列
  4. 自己消费消息,获得需要删除的 key
  5. 继续重试删除操作,直到成功

该方案的缺点是对业务代码造成大量的侵入,于是有了方案二,启动一个订阅程序去订阅数据库的 binlog,获取需要操作的数据。在应用程序中另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

方案二

  1. 更新数据库数据
  2. 数据库会将操作信息写入 binlog 日志当中
  3. 订阅程序提取出所需的数据以及 key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试操作

订阅 binlog 的程序在 MySQL 中有现成的中间件叫 canal,可以完成订阅 binlog 日志的功能。重试机制如果对一致性要求不高,可以直接在程序中另起一个线程,每隔一段时间去重试即可。

Redis主从复制原理

一文让你明白Redis主从同步 - 知乎 (zhihu.com)

虽然 Redis 服务重启后会将硬盘上持久化的数据恢复到内存中,但是当 Redis 服务器的硬盘损坏后可能会导致数据丢失,通过 Redis 主从复制机制可以解决这种单点故障问题。

主从同步分为 2 个步骤:同步和命令传播

  • 同步:将服务器的数据库状态更新成主服务器当前的数据库状态
  • 命令传播:当主服务器数据库状态被修改后,导致主从服务器数据库状态不一致,此时需要让主从数据同步到一致的过程。

同步

从服务器对主服务器的同步操作,需要通过 sync 命令来实现,sync 命令的执行步骤:

  1. 从服务器向主服务器发送 sync 命令
  2. 收到 sync 命令后,主服务器执行 bgsave 命令,用来生成 RDB 文件,并在一个缓冲区中记录从现在开始执行的写命令
  3. bgsave 执行完成后,将生成的 RDB 文件发送给从服务器,用来给从服务器更新数据
  4. 主服务器再将缓冲区记录的写命令发送给从服务器,从服务器执行完这些写命令后,此时的数据库状态便和主服务器一致了

命令传播

经过同步操作,此时主从的数据库状态其实已经一致了,但这种一致的状态并不是一成不变的。

在完成同步之后,也许主服务器马上就接收到了新的写命令,执行完该命令后,主从的数据库状态又不一致了。

于是为了再次让主从数据库状态一致,主服务器就需要向从服务器执行命令传播操作,即把刚才造成不一致的写命令,发送给从服务器去执行。从服务器执行完成之后,主从数据库状态就又恢复一致了。

优化的同步操作

从 Redis 2.8 版本开始,进行主从同步可能只需要执行命令传播即可。是为了优化 sync 命令比较消耗资源的问题。

主从同步分两种情况:

  1. 初次复制:从服务器第一次复制当前主服务器
  2. 断线后重复制:处于命令传播阶段的主从服务器,因为网络问题而中断复制,从服务器通过自动重连,重新连接上主服务器并继续复制

针对于短线后重复制的情况,Redis 2.8 版本之前会再次执行同步(sync 命令)和命令传播。

但是如果在短线期间,主服务器(已有上万的键值对)只执行了几个写命令,为了让从服务器弥补这几个命令却需要重新执行 sync 来生成新的 RDB 文件,效率非常的低。

为了解决这个问题,Redis 2.8 开始就使用 psync 命令来代替 sync 命令去执行同步操作。

psync 具有两种模式:

  • 完整重同步:用于初次复制情况,执行过程同 sync
  • 部分重同步:用于断线后重复制情况,如果满足一定条件,主服务器只需要将短线期间执行的写命令发送给从服务器即可

部分重同步的实现

部分重同步分为三部分:

  1. 主从服务器的复制偏移量
  2. 主服务器的复制积压缓冲区
  3. 服务器的运行 id(run id)

1)复制偏移量

执行复制的主从服务器都会分别维护各自的复制偏移量:

  • 主服务器每次向从服务器传播 n 个字节数据时,都会将自己的复制偏移量加 n
  • 从服务接受主服务器传来的数据时,也会将自己的复制偏移量加 n

2)复制积压缓冲区

复制积压缓冲区是固定长度的先进先出队列,默认 1MB。

当主服务器进行命令传播时,不仅会将命令发送给从服务器,还会发送给这个缓冲区。

因此复制积压缓冲区的构造是这样的:

当从服务器向主服务器发送 psync 命令时,还需要将自己的复制偏移量带上,主服务器就可以通过这个复制偏移量和复制积压缓冲区的偏移量进行对比。

若复制积压缓冲区存在从服务器的复制偏移量 +1 后的数据,则进行部分重同步,否则进行完整重同步。

3)run id

运行 id 是在进行初次复制时,主服务器将自己的运行 id 发送给从服务器,让其保存起来。

当从服务器断线重连后,从服务器会将这个运行 id 发送给刚连接上的主服务器。

若当前服务器的运行 id 与之相同,说明从服务器断线前复制的服务器就是当前服务器,主服务器可以尝试执行部分同步;若不同则说明从服务器断线前复制的服务器不是当前服务器,主服务器直接执行完成重同步。

心跳检测

当完成了同步之后,主从服务器就会进入命令传播阶段,此时从服务器会以每秒 1 次的频率,向主服务器发送命令:REPLCONF ACK <replicaiton_offset> 其中 replication_offset 是从服务器当前的复制偏移量。

发送该命令的主要有三个作用:

  • 检测从服务器的网络状态
  • 辅助实现 min-slaves 选项【不懂】
  • 检测命令丢失(若丢失,主服务器会将丢失的写命令重写发给从服务器)

总结

  • 发送 SLAVEOF 命令可以进行主从同步,比如:SLAVEOF 127.0.0.6379,将当前服务器编程 127.0.0.6379 的从服务器。

  • 主从同步有同步和命令传播 2 个步骤。

    • 同步:将从服务器的数据库状态更新成主服务器当前的数据库状态(一个消耗资源的操作)
    • 命令传播:当主服务器数据库状态被修改后,导致主从服务器数据库状态不一致,此时需要让主从数据同步到一致的过程

Redis集群

主从模式

主从模式比较简单,即一台 Master 服务器,若干台 Slave 服务器,Master 提供读写,Slave 提供读。Master 和 Slave 之间进行状态同步。

如何同步

参考上一节 “Redis 主从复制原理”。

Slave 在启动时会向 Master 发送 SYNC 命令,Master 收到后会执行 bgsave,进行 RDB 备份,并在内存缓冲区 buffer 中记录开始备份之后的写操作。Master 把创建好的 RDB 文件发送给 Slave,Slave 加载快照。随后 Master 会把 buffer 中记录的写命令发送给 Slave,以保证数据一致性。往后 Master 每执行一次写操作,都会同步发送给 Slave。

存在的问题

Master 宕机后,如果数据未同步完成,新的 Master 选出来后会存在数据丢失问题

哨兵模式(Sentinel模式)

哨兵模式基于主从模式引入了哨兵来监控和自动处理故障。该模式着眼于高可用,在 Master 宕机时会自动将 Slave 提升为 Master,继续提供服务。

哨兵程序单独用一台服务器部署,称为哨兵节点,为了防止哨兵节点挂掉影响哨兵模式的运行,部署多个哨兵,采用哨兵集群

如果某个哨兵发现 Master 服务器不可用了(Ping 超时),那么这个哨兵主观地认为 Master 已经挂掉了,称作为主观下线。此时还不能认为 Master 真的挂掉了,还需要询问其他哨兵节点,Master 是否已经下线。当主观下线的数量达到一定值,就可以认为 Master 客观下线。一旦 Master 客观下线了,那么领头的哨兵节点就会执行故障处理流程,即选择一个 Slave 变成 Master。

故障处理流程

  1. 按照规则挑选出一个从节点变成主节点
  2. 修改其他从节点的附属主节点
  3. 将挂掉的主节点变为从节点【等它一复活就发给它】

如何从所有的 Slave 中选出来一个作为 Master?

首先按照优先级排序,优先级一样则按复制偏移量排【选偏移量大的】,如果都一样,则选 run Id 最小的。

领头哨兵的选举 Raft 协议

全面理解Raft协议 - 知乎 (zhihu.com)

Raft 是分布式一致性的实现协议。

一个节点有 3 种状态:Follower、Candidate、Leader。

一开始所有节点都是 Follower 状态,当 Follower 在心跳超时时间内没有接收到 Leader 周期性发送的 heartbeat 就会开启选举过程。Follower 会等待一个随机的选举超时时间(等待随机时间的作用是尽量避免产生多个 Candidate 给选举过程造成麻烦)后,将自己转化为 Candidate,并开始发起第一轮选举。Candidate 节点首先给自己投一票,然后向其他节点发送投票信息,此时有三种情况:

  1. 获得了多数选票,该节点称为 Leader
  2. 收到了 Leader 的消息,有其他节点抢先成为了 Leader
  3. 没有获得多数选票,此次选举失败,等待一段时间后发起下一次选举

缺点

难以扩容,而且还需要额外的资源来启动哨兵,但可用性高

Cluster模式

哨兵模式解决了主从模式不能自动处理故障的问题,实现了高可用,但是没法方便的扩容。Cluster 模式实现了 Redis 的分布式存储,即每个节点存储不同的内容,来解决在线扩容的问题。

Cluster 模式采用无中心架构,它把所有的数据划分到多个槽位中,当我们存取 key 的时候,Redis 会根据 CRC16 算法算出一个结果,然后对槽位数量取余,这样每个 key 都会对应一个槽位编号,然后用这个编号去对应的节点里存取 key。

当客户端连接集群的时候,会得到一份集群槽位的配置信息,这样就可以直接根据配置信息去操作相应的槽位。

Cluster 模式可以实现动态扩容,支持自动故障转移,节点间用 gossip 协议交换状态信息。

Gossip 协议

Gossip 过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。