Redis 从入门到精通

445 阅读41分钟

数据库是软件中最重要的一个组件。本质上是需要提供读写能力。然而,细节是魔鬼。针对不同的数据模型,可以分为关系数据库、KV 数据库、文档数据库、向量数据库等。根据存储介质不同又分为内存数据和非内存数据库。为了保证数据库的可用性,往往需要引入分布式技术,还需要考虑一致性、分布式事务、节点故障、节点迁移等难点。

之所以会有这么多类型的数据库,本质上都是为了解决某些特定问题。并没有一个数据库可以解决所有的问题,在搭建应用时往往会选择不同类型的数据库进行组合,提升服务的性能。

在内存数据库中,Redis 就是其中的佼佼者,其核心特性就是快。本文将介绍一下 Redis 的数据结构、高级特性、线程模型、存储实现、通信协议、对事务的支持、分布式场景中的集群搭建等信息。

Redis 是什么

Redis 是一种基于内存存储的数据结构,可以被用作数据库,缓存和消息生产者。Redis 提供灵活的数据结构,例如:string、set、list、z_set、hash。Redis 内置副本,Lua 脚本,淘汰策略,事务,和不同级别的数据持久化。使用哨兵机制和集群的自动分区策略保证高可用。

常用命令

全部命令参考: Commands

全局命令

使用 help @generic 查看具体使用命令。

# 根据pattern获取键,线上禁止使用KEYS *,数据量较大情况下会严重阻塞主线程
help KEYS
  KEYS pattern
  summary: Returns all key names that match a pattern.
  since: 1.0.0
  group: generic

# 渐进式遍历,替换KEYS命令,不会阻塞主线程
help SCAN
  SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
  summary: Iterates over the key names in the database.
  since: 2.8.0
  group: generic

# 获取键的个数
help DBSIZE
  DBSIZE (null)
  summary: Returns the number of keys in the database.
  since: 1.0.0
  group: server

# 测试键是否存在
help EXISTS
  EXISTS key [key ...]
  summary: Determines whether one or more keys exist.
  since: 1.0.0
  group: generic

# 删除键
help DEL
  DEL key [key ...]
  summary: Deletes one or more keys.
  since: 1.0.0
  group: generic

# 设置键过期时间
help EXPIRE
  EXPIRE key seconds [NX|XX|GT|LT]
  summary: Sets the expiration time of a key in seconds.
  since: 1.0.0
  group: generic
  
help PEXPIRE
  PEXPIRE key milliseconds [NX|XX|GT|LT]
  summary: Sets the expiration time of a key in milliseconds.
  since: 2.6.0
  group: generic
  
help EXPIREAT
  EXPIREAT key unix-time-seconds [NX|XX|GT|LT]
  summary: Sets the expiration time of a key to a Unix timestamp.
  since: 1.2.0
  group: generic

# 返回键剩余过期时间,单位为s; 返回-1:没设过期时间,-2:键不存在
help TTL
  TTL key
  summary: Returns the expiration time in seconds of a key.
  since: 1.0.0
  group: generic

# 键数据结构
help TYPE
  TYPE key
  summary: Determines the type of value stored at a key.
  since: 1.0.0
  group: generic

string

字符串相关的命令是最常用的。通常会在应用程序中对内存中的数据进行序列化,存储到 Redis 中。在存储时可以设置过期时间。例如:设置 token 的有效期。此外,还可以使用 INCR、SETNX 执行原子增加操作。

使用 help @string 查看具体使用命令。

help set
  SET key value [EX seconds|PX milliseconds|KEEPTTL] [NX|XX]
  summary: Set the string value of a key // 设置 key 的值为 value, EX 过期时间(s), PX 过期时间(ms), NX 只设置不存在的 key, XX 只设置存在的 key
  since: 1.0.0
  group: string

help get
  GET key
  summary: Get the value of a key // 获取指定 key 的值
  since: 1.0.0
  group: string

# 常见于分布式锁,setnx 设置成功,意味着加锁成功
help setnx
  SETNX key value
  summary: Set the value of a key, only if the key does not exist // 当 key 不存在的时候设置 key 的值,常用于获取分布式锁
  since: 1.0.0
  group: string

help INCR
  INCR key
  summary: Increment the integer value of a key by one // 整型数值加 1,线程安全, 底层是获取存储的 string, 转换为 int 后加 1,在转 string 存储
  since: 1.0.0
  group: string

对于 Redis 中 vlaue, 在 redis 中都是以字节的形式存储,这样有效规避了编码问题,即对于 client 发送的字节原样存储, 具体可以看下面的例子:

127.0.0.1:6379> set kye 00
OK
127.0.0.1:6379> STRLEN kye  // 实际存储是两个字节
2
127.0.0.1:6379> BITCOUNT kye  // 00110000 00110000 对应的二进制位个数位 4
4

在 stirng 中还提供了关于 bitmap 的操作,方便用户快速的进行二进制操作。以统计用户在近一年任意时间段的登陆天数为例子:

127.0.0.1:6379> SETBIT user:888 0 1  // user_id: 888 第 0 天登陆系统
0
127.0.0.1:6379> SETBIT user:888 20 1 // user_id: 888 第 20 天登陆系统
0
127.0.0.1:6379> BITCOUNT user:888 // user_id: 888 共登陆了 2 次
2

list

列表。可以当作队列处理。例如:入队和出队。也可以当作栈来处理。例如:入栈和出栈。

使用 help @list 查看具体使用命令

127.0.0.1:6379> LPUSH l_k 1 2 3 // list 存入 1 2 3
(integer) 3
127.0.0.1:6379> LRANGE l_k 0 -1 // 获取列表,从 0 到最后一位
5) "3"
6) "2"
7) "1"
127.0.0.1:6379> LINDEX l_k -1 // 获取堆底元素
"1"
127.0.0.1:6379> LPOP l_k // 出栈
"3"
127.0.0.1:6379> RPUSH l_k 0 0 -1  // 栈底加入元素 3
(integer) 3
127.0.0.1:6379> LPOS l_k 2 RANK 1  // 获取第一个 2 所在的位置
(integer) 0
127.0.0.1:6379> LPOS l_k 2 COUNT 2 // 列表中前两个 2 所在的位置
1) (integer) 0
2) (integer) 1
127.0.0.1:6379> LTRIM l_k 0 2 // 删除栈底的两个元素
OK

hash

在 Redis 中, value 可以为 hash 类型。可以应用于 value 为 json 类型的数据, 例如:u_789 : name zzl, age 20。相较于将对象序列化存储到 Redis 中,使用 hash 可以快捷的操作其中的一个属性;缺点就是增加了应用程序存储和读取全部字段的复杂度。如果是小对象,还是优先使用序列化的形式。

使用 help @hash 查看具体使用命令

127.0.0.1:6379> HSET h_k name zzl age 18  // 设置 h_k 的值为 {name: zzl}, {age: 18}
(integer) 2
127.0.0.1:6379> HGET h_k name // 获取 h_k 值中 key 为 name 的数据
"zzl"
127.0.0.1:6379> HDEL h_k name // 删除 
(integer) 1
127.0.0.1:6379> HGETALL h_k // 获取所有 value
1) "age"
2) "18"
127.0.0.1:6379> HVALS h_k // 获取 value 中的所有 value
1) "18"

set

集合。在 redis 中, value 可以为 set 类型, 相较于 lsit, set 能够去除重复的元素, 使用 help @set 查看具体使用命令。

127.0.0.1:6379> SADD s_k a b c d e f  // 添加元素
(integer) 6
127.0.0.1:6379> SCARD s_k // 元素个数
(integer) 6
127.0.0.1:6379> SADD s_k_1 a a // 添加元素
(integer) 1
127.0.0.1:6379> SDIFF s_k s_k_1 // 求差集
1) "e"
2) "b"
3) "d"
4) "c"
5) "f"
127.0.0.1:6379> SMEMBERS s_k // 获取全部的值
1) "c"
2) "a"
3) "b"
4) "e"
5) "f"
6) "d"

z_set

有序集合。在 Redis 中可以存储有序的 set, 添加元素时需要指定 member 的 socre, set 按照 score 从大到小的顺序排序, 如果 score 相同则按照字典序, 可以用于需要排名的问题。

127.0.0.1:6379> ZADD z_l CH 1 a  // 添加元素 a, score 1
(integer) 1
127.0.0.1:6379> ZADD z_l CH 1 a
(integer) 0
127.0.0.1:6379> ZADD z_l CH 2 b  // 添加元素 b,score 2
(integer) 1
127.0.0.1:6379> ZRANGE z_l 0 -1 // 获取所有元素
1) "a"
2) "b"
127.0.0.1:6379> ZRANGE z_l 0 -1 WITHSCORES // 获取所有元素和评分
1) "a"
2) "1"
3) "b"
4) "2"
127.0.0.1:6379> ZREM z_l a  // 删除
(integer) 1
127.0.0.1:6379> ZRANGE z_l 0 -1
1) "b"
127.0.0.1:6379> ZPOPMAX z_l  // 获取评分最大的元素
1) "b"
2) "2"

HyperLogLog

HyperLogLog 是一种概率型的数据结构,适用于处理海量数据的基数统计,常用于统计在线用户数、IP访问频率等场景。HyperLogLog 占用空间固定、时间复杂度低,但是会有一定的误差。

# 添加元素
PFADD key element1 [element2 ...]

# 计算独立元素个数
PFCOUNT key1 [key2 ...]

高级特性

pipeline

Redis 是一种 c/s 架构,即 client 和 server 架构,使用 tcp 协议进行数据传输。当 client 需要执行多个连续的命令时,需要向 server 发送多个命令,即经过多个 RTT(往返时间) + processTime(server处理的时间),才能拿到最终的结果。这时可以使用 pipelining「client 不需要获得上个请求的结果就可以处理新的请求」 来解决这个问题,即将多个命令一起发送给 server 执行,这样只需经过一个 RTT。同时,使用 pipeline 可以减少因 socket io 而导致的用户内存和系统内存的数据交换,提升服务端执行命令的时间。

对于 pipeline 命令,Redis 的 server 端需要在内存中使用一个队列来存储这些命令,然后逐个处理。因此,client 一次不宜发送过多的命令,防止内存溢出,可分批发送处理。

func main() {
    ctx := context.Background()
    rdb := redis.NewClient(&redis.Options{
       Addr:     "localhost:6380",
       Password: "", // 没有密码,默认值
       DB:       0,  // 默认DB 0
    })
    p := rdb.Pipeline()
    p.Set(ctx, "key", "value", 0)
    p.Set(ctx, "key2", "value2", 0)
    rs, err := p.Exec(ctx)
    if err != nil {
       panic(err)
    }
    for _, r := range rs {
       fmt.Println(r.Name(), r.Err())
    }
}

订阅和发布

订阅发布(pub/sub)是一种信息交互的模式,发送消息的一方称为发布者(publisher), 接收消息的一方称为订阅者(subscribers), publisher 和 subscriber 无需感知对方的存在, 他们只需发布或订阅自己关注的管道(channels)即可。

在 Redis 中使用 subscribe channel [channel ...] 订阅消息;使用 pulish channel message 发布消息;psubscribe channel [cahnner ...] 按照指定模式订阅消息,例如 psubscribe test_*,可以订阅 test_1, test_a 等消息。订阅和发布命令返回的结果是一个三元组,第一个元素标示消息的类型:

  1. suscribe 订阅消息成功,第二个元素为频道的名称,第三个元素为当前订阅的频道数
  2. unsubscribe 表示我们已成功取消订阅作为回复中第二个元素的频道。 第三个元素表示我们当前订阅的频道数
  3. message 收到发布者发送的消息, 第二个元素为频道名称, 第三个参数为消息体
[127.0.0.1:6379> SUBSCRIBE test_a  // 订阅 test_a
Reading messages... (press Ctrl-C to quit)
1) "subscribe"  // 订阅成功
2) "test_a"     // 频道名称
3) (integer) 1  // 频道数
[127.0.0.1:6379> PUBLISH test_a zjlzjl  // 发布数据
(integer) 3     // 三个客户端订阅
[127.0.0.1:6379>  // 收到订阅消息
1) "message"
2) "test_a"
3) "zjlzjl"

从使用中可以发现 Redis 实现了简单的发布/订阅模式。相比于消息队列,redis 仍有许多不足,不能进行消息回拨、不能进行重复订阅、无法订阅历史数据; 当然 redis 也有一定的优势,那就是快,可以快速发送给频道的订阅者。所以在真实的业务场景中还是不建议使用 redis 进行订阅和发布操作。

大批量插入

在某些场景下,需要尽可能快的向 redis 插入大批量的 key「百万级别」。当然,你也可以使用 pipeline。 但是 pipeline 受到缓冲区大小的限制,对于量级特别大的插入则会分多次来处理。因此,redis 支持一种通过文件方式来插入大量的 key, 同时 redis 会返回插入结果

cat data.txt | redis-cli --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 2

事务支持

提到数据库,不得不提的就是对于事务的支持。也就是大家常说的原子性、一致性、隔离性、持久性。那 Redis 对事务的支持程度如何呢?

原子性

即事务中的操作,要么全部成功执行,要么全部失败。Redis 对事务的支持:

  1. 在一个事务操作中,所有的命令会被顺序的执行,并且不会被其他请求所打断。
  2. 所有的命令要么全部执行,要么都不执行,不会结束在中间环节。如果 Redis 设置了 append-only 的进行持久化,将会使用系统调用将事务写入磁盘,如果此时 Redis 服务端宕机或者被管理员杀掉,那么磁盘只会写入部分命令。Redis 在重新启动时将会检测到这种事务并退出。可以使用 redis-check-aof 工具修复磁盘文件,将删除部分事务,以便服务器可以重新启动。

在 Redis 中,使用 MULTI 启动一个事务,EXEC提交事务并返回每个命令的结果,DISCARD 删除事务。如下图所示,如果事务中存在不合法的命令,事务整体并不会回滚。并不能保证事务的原子性。

127.0.0.1:6379> MULTI // 启动一个事务
OK
127.0.0.1:6379> set k 9 // 存储 k : 9
QUEUED
127.0.0.1:6379> INCR k // k 自增
QUEUED
127.0.0.1:6379> EXEC  // 提交
1) OK
2) (integer) 10
127.0.0.1:6379> MULTI // 启动一个事务
OK
127.0.0.1:6379> INCR k // 自增
QUEUED
127.0.0.1:6379> INCR k // 自增
QUEUED
127.0.0.1:6379> DISCARD // 删除并退出事务, k:10
OK
127.0.0.1:6379> MULTI // 启动事务
OK
127.0.0.1:6379> set kkkkkkkk k
QUEUED
127.0.0.1:6379> set kkkkkkkk kd
QUEUED
127.0.0.1:6379> LPUSH kkkkkkkk k  // 错误命令
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
3) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6380> get kkkkkkkk
"kd"

Redis 服务端为什么不支持回滚,而是继续执行命令呢?

  1. 对于操作类型错误,这种情况是不应该在生产环境发生的。
  2. 不支持回滚可以大量节省服务端资源,提升性能。

在 Redis 事务中,可通过 watch 命令来检测指定的 key 在事务执行时是否被修改,如果被修改则事务回滚,所有的命令无效。既然 redis 是单线程,为什么还需要 watch 命令。在事务操作前,我们往往会 get k, 然后 multi set k value+1; exec, 然后在事务执行前 k 可能会被修改,这时候就可以使用 watch 命令。可以理解为 mysql 的 select for update。

127.0.0.1:6379> WATCH k
OK
127.0.0.1:6379> set k 9 // watch 的 k 被修改
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set kkk k
QUEUED
127.0.0.1:6379> EXEC
(nil) // 事务执行失败

对于一些特殊的场景,可能需要先读取结果,更新数据,然后执行相关操作。可以使用 Lua 脚本解决这一问题。将一些列操作写入到 Lua 脚本中,Redis 保证 Lua 脚本中的一组命令的执行是原子的。需要注意的是,如果 Lua 脚本中有错误命令,已经执行的命令并不会回滚。

MULTI 和 Lua 脚本均可以处理一批指令。区别在于,MULTI 开启事务时,会将命令依次发送到 Redis server 的队列中等待执行,只有在事务提交之后才能一次性获取到全部结果。使用 Lua 脚本可以将多个命令和逻辑打包在一起,直接发送给 Redis server 执行。如果有错误发生,Lua 脚本会直接终止。已经执行的命令不会回滚。

一致性

一致性是指数据满足数据库系统约束。如果不符合约束,则会报错。例如:下面的例子,k 是 string,无法作为 list 操作。

127.0.0.1:6380> set k 9
OK
127.0.0.1:6380> LPUSH k 9
(error) WRONGTYPE Operation against a key holding the wrong kind of value

隔离性

隔离性是指在并发操作中,一个事务是否可以感知到另一个事务操作。按照隔离级别的强弱可以分为:串行化、可重复读、读以提交、读未提交。在 Redis 中,所有命令都是串行执行「后面会详细介绍」。因此,事务天然就是串行化的。Redis 事务只是在EXEC时执行一批命令。

持久性

持久性通常是指将数据写入非易失性磁盘,数据库故障时可以通过加载磁盘数据进行恢复。Redis 本身是一个内存型数据库,这就意味着宕机之后数据会丢失。为了解决这个问题,Redis 提供了两种主要的持久化方式。

AOF

AOF「Append Only File」:在单独的日志文件中追加 Redis 接收的写命令,重启时通过指令回放来恢复存储的数据。在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:

appendonly no
# The base name of the append only file.
appendfilename "appendonly.aof"

执行流程

当开启 appendoly 之后,其执行流程如下:

  1. Redis 先执行对应的命令,然后同步将命令写入缓冲区。这么做的好处是如果命令有错,日志不用存储错误的命令,缺点就是如果命令执行成功,但没有写入日志,会造成数据丢失。
  2. 根据配置的写入磁盘策略来写入 AOF 文件。可以在 redis.conf 中进行配置。配置值如下所示。如果强调性能,可以配置 no;如果强调持久性可以配置 always
appendfsync everysec

  1. 随着服务的不断运行,AOF 文件会越来越大,Redis 启动加载时长越来越长。其实在 AOF 文件中,会记录同一个 key 的所有操作。例如: set k 9; set k 10; set k 11。可以对这一操作进行重写,只保留 set k 11 即可。
  2. 当Redis服务器重启时,可以加载AOF文件进行数据恢复。

RDB

RDB「Redis DataBase File」将当前 Redis 进程的快照 dump 到文件中,重启时加载文件。RDB 存储的是某一个时刻的快照,加载效率会高于 AOF。且缺点就是不能像 AOF 一样实时追加文件。

RDB 在保存快照时,有两种方式:

  • 手动触发

    •   bgsave是针对save做的优化,目前save命令已经废弃,新版本为了向下兼容仍带有这个命令,官方已经不推荐使用。

    • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程。
    • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞。
  • 自动触发

    • 使用save相关配置,如“save m n”。表示 m 秒内数据集存在 n 次修改时,自动触发bgsave。 默认配置如下:
    • # 只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:
      # 900 秒之内,对数据库进行了至少 1 次修改;
      # 300 秒之内,对数据库进行了至少 10 次修改;
      # 60 秒之内,对数据库进行了至少 10000 次修改。
      save 900 1
      save 300 10
      save 60 10000
      
    • 如果从节点执行全量复制操作,主节点自动执行 bgsave 生成 RDB 文件并发送给从节点。
    • 执行 debug reload 命令重新加载 Redis 时,也会自动触发 save 操作。
    • 默认情况下执行 shutdown 命令时,如果没有开启 AOF 持久化功能则自动执行 bgsave

RDB 执行流程

  1. 执行 RDB 持久化操作,主进程会判断当前是否存在其它持久化操作,有则直接返回。
  2. 执行 fork 操作,创建一个新的进程。
  3. 主进程继续响应后续到来的命令。
  4. 子进程根据父进程的内存数据创建临时快照文件,完成后替换原有的 RDB 文件。
  5. 通知父进程已完成持久化操作,更新相应的持久化信息。

AOF 和 RDB 混合

AOF 的优势在于实时性高,丢失的数据少;RDB 的优势在于文件体积下,重启恢复快,不影响 Redis 写入性能。Redis 4.0 提出一种混合方法,该方法混合使用 AOF 日志和内存快照,也叫混合持久化。其目标是即保证数据尽可能的不丢失,也要保证日志文件体积小,服务加载速度快。

如果想要开启混合持久化功能,可以在 Redis 配置文件将下面这个配置项设置成 yes:

aof-use-rdb-preamble yes

执行流程

  1. Redis 在进行 AOF 文件重写时,会 fork 一个子进程。
  2. 子进程会将内存中的数据以 RDB 格式写入新的 AOF 文件中。这个 RDB 格式包含了数据的全部文件,方便 Redis 快速启动。
  3. RDB 快照写完之后,子进程会讲 AOF 缓冲区的数据追加到新的 AOF 文件末尾。这些命令是 RDB 文件生成之后写入的数据,从而保证数据的完整性。
  4. 所有命令都追加到 AOF 文件后,子进程会将新的 AOF 文件替换旧的 AOF 文件,然后退出。
  5. 主进程继续接受命令,写入 AOF 文件。当 AOF 文件进行重写时,开始步骤 1

在混合模式中,AOF 文件由两部分组成,RDB 内容和 AOF 内容。可以有效的减少 AOF 文件的体积和服务启动的耗时。混合模式的缺点是实现比较复杂。

底层实现

Redis 最大的特性就是快。那么 Redis 为什么这么快呢?Redis 除了是基于内存的操作之外,还包括使用了性能较高的数据结构体,以及 IO 多路复用模型。

线程模型

在 Redis 中,大家经常说其是单线程模型。其实这一说法并不准确。Redis 启动时创建后台进程执行关闭文件、AOF 刷盘、大 key 异步删除。

在 6.0 以前,「接收客户端请求->解析请求->命令执行->发送数据到客户端」这个过程是由一个线程「主线程」来完成的。从 6.0 版本开始,核心工作「命令执行」仍是由主线程来完成,额外加入一个 IO 线程组,提升网络 IO 的性能。也就是大家常说的单线程模型。

Redis 使用单线程的原因在于纯内存操作,执行速度快,开销不在于 CPU。Redis 核心操作是读写数据,轻计算,瓶颈不在 CPU,使用 CPU 还可以增加 CPU 缓存的命中率。如果使用多行程进行读写操作,意味着需要处理复杂的并发逻辑,造成非必要的开销。

IO 多路复用

在 c/s 模式中,即一个 server 多个 client。不得不说的就是 IO 通信模型,即 server 如何管理多个 client 链接。最流行就是 IO 多路复用模型。即通过一个线程监听多个文件描述符,当某个文件描述准备好数据时,通知用户线程,让其进行数据处理,避免无效等待。本质上就是让用户程序与 IO 相关操作解耦,当数据准备好时主动通知用户线程。避免无效的 cpu 操作。

IO 多路复用是一种模型。在 linux 中有多种实现,select 是最早实现的方案。但性能不是很高,主要是涉及到数据在用户态和内核态的拷贝、需要过多无用的遍历。poll 是对 select 的改进,将 select 的数组换成链表,解决文件描述符数量限制,但其他问题仍然存在。epoll 相比于上面的两种方式做了较大的改进。核心逻辑如下图所示:

  • 用户程序使用 epoll_create 创建一个 epoll 对象。对象是直接分配在内核空间的。
  • 用户程序使用 epoll_ctl 给 epoll 对象添加或删除一个 socket。当有可读或可写的 socket 是会放入 list_head。
  • 用户程序水用 epoll_wait 将准备就绪的文件描述符复制到用户空间。 image.png

Redis 6.0 之前

在 redis 6.0 之前,主任务采用的是单线程的模型。如下图所示,主线程同 epoll_wait 获取可处理的 socket,同步执行命令解析->命令执行->返回结果->写入 socket。

主要流程如下:

  1. 创建一个 epoll 对象。
  2. 服务端创建一个 socket,监听端口。
  3. 将 socket 的可读/可写事件加入到 epoll 对象中。
  4. 主线程通过 epoll_wait 获取操作事件,处理请求,执行命令,返回响应。重复执行 4.

Redis 6.0

在 Redis 6.0 之前,主线程循环执行读取命令解析->命令执行->返回结果。使用单线程的优势在于所有命令都是串行执行的,无需处理并发中的同步逻辑。缺点就是无法充分发挥系统的多核处理能力。

在 6.0 版本中,命令执行仍然使用单线程,因为这块操作的都是内存数据,速度快,无需多核处理。选择在命令解析结果返回两个操作中加入并行处理的能力「IO 多线程」。对于这两个操作,并不存在并发冲突,可以充分利用 CPU,提升处理性能。

默认情况下 I/O 多线程只针对发送响应数据,并不会以多线程的方式处理读请求。要想开启多线程处理客户端读请求,需要配置 redis.conf 文件,同时配置 I/O 线程个数。

//读请求也使用io多线程
io-threads-do-reads
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4 

关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

内部编码

Redis 内部使用了一系列巧妙的数据结构来支持 string、list、hash、set、zset 等操作。具体参考 www.xiaolincoding.com

内存管理

内存数据库的最大瓶颈就是空间有限,一台机器的内部是无法无限增加的。可以从两个方面解决这一问题。一种是尽可能让业务使用较少内存,另一种是使用分布式技术「后面会讨论」。为了尽可能的减少内存的使用量,那就需要业务指导哪些数据不经常使用,及时进行清理。

在 Redis 中,对内存的清理分为两类。一类是应用程序设置了过期时间,一类是通过淘汰策略进行清理。

过期时间

可以通过 TTL key查看当前 key 的过期时间, 通过expire key seconds设置 key 的过期时间,对于过期时间的准确性误差在 1 ms 内。Redis 的过期时间存储的是时间戳,这就意味着服务端的时间必须是准确的,不然就会导致过期时间不准确。

对于过期的 key 常见的删除方式有两种:

  • 懒删除「读取到 key 的时候判断是否过期,如果过期则删除」
  • 及时删除「定时扫描全部的 key 删除, 删除过期的 key」。

对于第一种方式, 如果有 key 一直没有被访问, 那将会造成内存的浪费; 对于第二种方式, 则需要消耗一定的性能来扫描并删除 key。在 redis 种具体是只能处理的呢? 每秒执行 10 次一下操作:

  1. 从设置过期时间的 key 中随机抽取 10 个 key
  2. 删除过期的 key
  3. 如果过期的 key 超过 25% 则继续执行 1 。在样本可信的情况下, 通过这个简单的算法, 可以保证已过期的 key 约占全部 key 的 25%。为防止一致性问题, redis 副本会依赖 master 节点的 del 命令来删除 key。

内存淘汰

过期时间只能删除已经过期的 key,如果内存中已经使用完,且没有过期的 key 可以删除,那么 Redis 就依照内存淘汰策略作出响应。

在 redis.conf 配置文件中,设置使用的内存「默认为空,尽可能使用机器内存」和淘汰策略「默认为 noevication」。

  • noeviction「不允许淘汰」,当内存使用到达 redis 的配置容量时,对于客户端发送过来的写入请求,服务端会报错
  • allkeys-lru「最近最少使用」, 先删除最长时间未使用的 key, 来省出空间存储新的 key
  • volatile-lru「最近最少使用」, 与 allkeys-lru 相比, 会删除设置了有效期的 key
  • allkeys-random 随机删除 key, 来省出空间存储新的 key
  • volatile-random 随机删除设置有效期的 key
  • volatile-ttl 从设置有效期的 key 中, 删除有效期最短的 key
  • allkeys-lfu 删除访问最少的 key
  • volatile-lfu 从设置有效期的 key 中, 删除访问次数最少的的 key

对于 volatitle-lru, volatitle-random, volatitle-ttl, 如果找不到符合设置有效期的 key, 则服务端返回结果和 noevition 相同。需要根据不同的业务场景来考虑是使用 LRU 还是使用 LFU。

在 Redis 中为 key 设置过期时间会额外消耗一些内存, 因此可以使用 allkeys-lru 提高内存利用率, 无需为了防止内存溢出而设置有效期。

在 LRU 算法中, 只是相对意义上的 LRU 算法「可以节省大量的内存, 如果使用十分准确的 LRU 算法, 则需要使用一个链表维护 key 的访问顺序」, redis 会随机采样一些 key「默认 5」, 淘汰其中最近最久访问的 key, 你也可以配置 maxmemory-samples 参数来调整随机采样的个数

使用的是近似的 LFU 算法中, 保证在过去访问频率很高的 key, 其访问次数会随时间增长而减少。使用 24bit 存储相关信息「16bit 上次递减的时间, 8bit 访问次数, 基于概率的对数计数器」, 每次访问会增加访问次数, 随机访问 N 个 key, 如果递减时间距离现在超过 N 分钟则减少访问次数, 能够有效防止过去的热 key 不会被删除。在内存淘汰的时候, 随机采样 5 个 key, 删除访问次数最少的 key 。

分布式

对于 Redis 内存的使用,除了尽可能删除不用的数据外,还可以搭建 Redis 集群来提升空间的瓶颈。使用集群除了可以提升内存瓶颈之外,还可以增加读写性能,解决单点故障的问题。

下面来介绍一下 Redis 高可用的部署方式。

哨兵模式

哨兵模式在 Redis 2.8 之后版本可以生产使用。整体结构如下。本质上就是一个主从复制结构,主库承担写流量,从库分摊读流量。引入哨兵,用于监控、选主、通知。

图中的 sentinel 节点、master 节点和 slave 节点本质上都是 redis-server。只是启动时配置不同。对于 sentinel 节点,需要配置要监听的主节点,以及其他配置:

sentinel monitor master 127.0.0.1 6379 2      # 监控127.0.0.1:6379的主节点,名字为master,quorum为2
sentinel down-after-milliseconds master 30000 # 设置下线时间为30s
sentinel parallel-syncs master 1              # 设置故障转移后同时进行复制新主节点的从节点的个数
sentinel failover-timeout master 180000       # 设置故障转移超时时间为180s 

对于 slave 节点,需要配置追随的节点

slaveof 127.0.0.1 6379        # 作为6379的从节点

对于 master 无需额外的配置。

实现原理

三个定时任务
  1. 每隔 10s。哨兵会向主节点发送 info 命令来获取最新的节点拓扑信息,同时与从节点建立连接。通过这一命令,可以使哨兵只配置主节点的信息下,获取到各个节点的拓扑信息。

  1. 每隔 2s。哨兵会向 sentinel:hello 频道发送自身的哨兵信息以及对主节点故障的判断,哨兵节点集合中的所有节点均会订阅这个频道。当有新的哨兵加入时会订阅这一频道,从而与各个哨兵建立连接,交换信息。

  1. 每隔 1s。 每个哨兵会向主节点、从节点和其他哨兵节点发送 ping 命令做心跳检测,来确认节点是否可达,如果节点不可达,则标记节点下线。这个定时任务是判断节点故障的重要依据。这个定时任务是判断节点故障的重要依据。
主观下线和客观下线
  • 主观下线:利用第三个定时任务,可以对所有节点进行故障检测。当哨兵向其他节点发送 ping 消息后,超过 down-after-milliseconds 配置事件后仍没有收到回复,则会判定为节点故障,并标记这个节点下线,这个行为叫做主管下线。即一个哨兵节点对另一个节点主观方面的故障判断,可能存在误判,不会作为实际下线的判断依据。
  • 客观下线:主观判断存在误判的可能性,所以需要多个哨兵就某个节点是否下线达成共识。具体操作是:哨兵会向其他哨兵发送命令 is-master-down-by-addr 询问其他节点对主节点的故障判断,只有当达到 quorum 配置个哨兵节点作出故障判断时,才确定节点客观下线。
哨兵领导者选举

当主节点发生故障时,需要将一个从节点上升为主节点。为了避免脑裂的情况,即多个从节点均认为自己是主节点,则需要从哨兵集合中选取一位领导者,执行故障切换操作。Redis 使用了 Raft 算法来选举领导者,流程如下:

  1. 只有没被标记为主观下线的哨兵节点才能称为候选人。哨兵节点向其他哨兵节点发送 is-master-down-by-addr 命令,让对方为自己投票。
  2. 收到命令的哨兵节点会判断自己的在本轮的投票中自己的选票是否还存在,如果选票存在,就同意对方称为领导者,反之拒绝。
  3. 如果哨兵节点发现自己获得的选票达到max(quorum, num(sentinels)/2),那么它将成为领导,由于每轮选举每个哨兵节点都只有一张票,因此只有人拿到半数以上的票数,其它人就不可能拿到半数以上的票。
  4. 如果本轮投票没有选出领导者,开启下一轮选举。
主从故障迁移

选出哨兵领导者之后,有领导者快开始进行主从故障转移工作,如下图所示:

  1. 从所有的从节点选出一个节点,将其转为主节点。
  2. 修改其他从节点的复制目标,修改为新主节点。
  3. 将新主节点的 IP 地址和信息,通过发布/订阅机制通知给客户端。
  4. 继续监视旧主节点,如果节点重新上线,则将它设置为新主节点的从节点。

集群模式

哨兵模式本质上就是主从复制的形式,由于每个节点都存储相同的数据,无法提升内存瓶颈。另一个问题就是写性能受阻。由于 Redis 是 KV 类型的数据库,天然就适合分区存储,从而利用多个节点存储同一份数据,同时分散写入负载。

在 Redis 3.0 可以使用官方提供的分布式解决方案,整体拓扑结构如下。集群中有三个主节点提供读写能力,每个主节点会搭配一个从节点,默认情况下从节点不支持任何读写操作,仅作为主节点的一个热备存在。不同节点之间会通过协议交换信息获取集群的整体状体。

分区方式

在集群模式下,各个节点存储不同的数据。即需要一个分区规则确定一条记录存储在那个节点。常见的分区方式包括哈希取模、键值的范围、固定数分区、动态分区。

在 Redis 中使用的是固定数分区,即将整体数据分为固定数量的虚拟分区,每个节点分布不同数量虚拟分区。又叫虚拟槽分区。共有 163483 个槽,数据通过哈希计算确定具体的槽,不同的节点分配不同的槽范围。

节点通信

上面介绍了如何为每一条记录寻找对应的节点。其中涉及一个重要的细节,就是每个节点维护的槽范围需要在哪里维护?常见的方式就是集中式和 P2P 方式。在 Redis 集群中,采用 P2P 的 Gossip 协议,节点间不断同步信息,从而保证每个节点均知道不同节点的状态,负责的槽范围。

Gossip 协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的Gossip消息。常用的 Gossip 消息可分为:ping 消息、pong 消息、meet 消息、fail 消息等。

  • ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息,ping消息中封装了自身节点和部分其他节点的状态数据。
  • meet消息:用于通知新节点加入。发送该消息的节点通知接收节点自己要加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换,从而把自己的加入到集群的信息传播到整个集群。
  • pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部也封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
  • fail消息:当节点判定集群内另一个节点客观下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为客观下线状态,从节点收到fail消息会执行自动故障转移。

扩缩容

采用虚拟槽的方式,可以极大的减少扩缩容导致的数据迁移问题。集群扩缩容底层就是将一些虚拟槽的数据在各个节点之间移动。

  • 扩容

    • 准备新节点,为了避免单点故障,至少准备两个节点。
    • 将节点加入集群,新节点向集群中的任意节点发送 meet 信息,请求加入新节点,集群中节点收到消息后回去 pong 消息,并与集群中的其他节点同步新节点的信息。
    • 执行槽迁移,向新节点迁移数据
    • 执行cluster replication 命令,是新节点中的其中一个称为另一个具有槽节点的从节点。
  • 缩容

    • 如果需要将主从节点一起下线,需要先下线从节点,否则主节点下线后从节点会被分配给从节点最少的主节点并执行复制操作,增加了额外的带宽压力。
    • 下线主节点,将槽迁移至集群中的其它的节点。
    • 对集群中其它的节点执行cluster forget命令来忘记下线节点,忘记节点后,节点就不会向下线节点发送gossip 消息。
    • 停止下线节点进程。

扩容期间如何保证服务可用

  1. 客户端通过 key 计算 slot, 查询本地的 slot 和节点的映射缓存,请求到对应的节点,节点存在 key 则直接返回
  2. 如果节点的 slot 已经迁移,返回 MOVE 命令,客户端更新本地缓存,将请求发送到新的节点
  3. 如果数据正在迁移中,节点会回复 ASK 重定向异常。格式如下: ( error ) ASK { slot } { targetIP } : { targetPort }
  4. 客户端从 ASK 重定向异常提取出目标节点信息,发送 asking 命令到目标节点打开客户端连接标识,再执行键命令

故障迁移

  • 故障发现

在 Redis 集群中,不同节点之间会通过 ping/pong 进行信息交流,从而实现故障检测。

  1. 主观下线:集群中每个节点都会定期地向其它节点发送 ping 消息告诉自身节点和保存的其它节点的信息,收到消息的节点也会回复 pong 信息来告诉对方自己的节点和保存的其它节点的信息。如果在配置 cluster-node-timeout 时间一直没收到 pong 信息,则会认为这个节点存在故障,标识这个节点为主观下线(pfail)并保存在本地。
  2. 客观下线:当某个节点判断另一个节点主观下线后,后续的 ping 消息会将这个主观下线判断传播到集群中的其它节点,其它节点收到这个主观下线报告后,会维护一个链表记录下线节点的报告者,并尝试进行客观下线。当这个下线节点链表中的报告者的数量大于 num(nodes)/2 + 1 时,执行客观下线(fail),并向集群中广播fail信息。
  • 故障恢复

当主节点被客观下线后,需要从从节点中选取一个作为主节点。从节点的内部定时任务发现主节点被标记为客观下线后,将会发起故障恢复流程:

  1. 资格检查:每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果断线时间过短,不会发起投票选举。
  2. 准备选举延迟时间:当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。从节点根据自身复制偏移量设置延迟选举时间,保证复制延迟低的从节点优先发起选举。
  3. 发起选举:递增全局配置 epoch,并赋值给从节点本地 epoch,并广播选举消息。
  4. 选举投票:每个具有槽的主节点在每个 epoch 内有一张选票,在收到从节点的选举消息后,如果选票还在,就会投出这一票。当从节点收到的票数达到 N/2+1,从节点就可以执行替换主节点的操作,其中 N 为具有槽的主节点个数。为了能够达到选举票数,必须保证 N 大于等于3。如果在一个 epoch 内从节点没有获取到足够的选票,会更新 epoch,并发起下一轮投票。
  5. 替换主节点:取消复制主节点,接管主节点的槽并广播 pong 消息通知集群当前节点以晋升为主节点并接管了原主节点的槽。

应用场景

缓存

缓存是 Redis 引用最广泛的一种场景。主要利用的就是 Redis 的快这一特性。在读多写少的场景,可以利用 Redis 来处理大量的读流量,不仅提升了响应的速度,还降低了数据库的压力。

计数器

Redis 的命令执行是单线程的,不用考虑竞争条件,其提供的 incr 命令性能非常高。可以应用于计数器的场景,如网站的浏览量、视频的播放量等。

分布式锁

分布式场景下需要使用分布式锁对资源进行保护。利用 Redis 的 setnx 命令,返回 1 加锁成功,否则加锁失败。

限流器

可以利用 Redis 的 key 具有有效期这一特性实现限流器。例如:设置一个 key 过期时间为 1s,如果 get 有结果返回则允许访问,否则拒绝访问。

排行榜

很多网站都有排行榜应用的,如京东的月度销量榜单、商品按时间的上新排行榜等。Redis提供的有序集合数据类构能实现各种复杂的排行榜应用。

布隆过滤器

布隆过滤器可以判断一个 key 绝对不存在。常见于:

  • 网页爬虫:使用布隆过滤器存储已经爬取过的URL,避免重复爬取。
  • 邮箱或手机号判断:判断某个邮箱或手机号是否已经注册过。
  • 缓存穿透:使用布隆过滤器判断某个数据是否存在,如果布隆过滤器判断不存在,则直接返回,避免对数据库进行查询,从而防止缓存穿透。
  • 反垃圾邮件:判断某封邮件的发件人和内容是否出现在已知的垃圾邮件列表中。

会话管理

利用 Redis key 可以设置有效期这一特性,可以为会话设置过期时间,且读取速度高。

注意事项

大 key

Redis 是 KV 类型的数据库。这里的大 key 并不是特性 K,也包括 V。即如果一个记录数量过高,会导致 Redis 复杂增加,吞吐下降。常见的解决思路:业务评估是否需要存储那么多的数据。对数据进行分片,将一个 key 分为多个子 key。这样就可以将同一条记录分布到不同的节点上,降低单点负载。

命中率

Redis 通常被用来当作缓存,用于抵挡大量的读流量。这时需要关注命中率,尽可能让内存中存储常用的数据,避免空查一次 Redis。

参数配置

在 Redis 中,提供需要可配置化的功能。例如,淘汰策略、持久化策略、多线程策略、超时策略等。需要根据具体的业务场景,进行合理的配置,才能保证将性能发挥到极致。

内存使用率

相比于硬盘,内存资源相比还是比较珍贵。在使用 Redis 需要注意内存使用的监控。如果使用了 noevication 的淘汰策略,写入会直接报错 对于 对于 volatitle-lru, volatitle-random, volatitle-ttl, 如果找不到符合设置有效期的 key, 则写入数据会报错。

总结

Redis 是一个 KV 类型的内存数据库。其特点就是快。除了快之外,Redis 还支持丰富类型的数据结构 string、list、set、hash、zset。同时还可以为 key 设置有效期、支持订阅和发布。丰富的命令 setnx、incr 、decr 等。这就可以将其用作更多的场景。 Redis 最令人津津乐道的就是单线程模型。所谓的单线程其实就是在执行命令时使用单线程。从而可以忽略对数据的并发修改的问题。好处是实现简单,且无耗 CPU 的操作。缺点就是无法使用多核。当然,6.0 开始在 I/O 处理中计入线程组,充分发挥多核能力。

作为内存型数据库,为了尽可能的保证数据的持久化。提供了 AOF、RDB 和混合方式进行持久化。使用 AOF 的形式可以及时的保存执行的命令,但是需要定期对日志压缩。使用 RDB 是对内存快照进行存储,每次都生成一个新的快照,但数据没有那么及时。混合模式是指在对 AOF 文件压缩是进行快照存储,将快照文件和快照执行后AOF 文件存储到一个新的文件中,然后替换旧文件。但是,需要注意的是,仍然不建议将其作为持久性数据库使用,毕竟有丢失数据的风险。

另一个不得不提的话题就是事务。Redis 中可以使用 MULTI 命令和 lua 脚本执行一系列命令,但遗憾的是其并不支持事务的回滚。MULTI 是将一些列指令依次发送到 server,当提交时统一执行,如果有命令失败仍继续执行。而 lua 脚本是一次性将命令发送进行执行,里面可以处理额外逻辑,如果命令失败就终止。

为了保证 Redis 的高可用,目前也提供哨兵模式和集群模式进行部署。在哨兵模式中,使用主从复制的方式,意味着只能分散读压力,避免单点故障。如果想提升存储瓶颈可以使用集群模式,本质上是对数据分区,将数据存储到不同的节点。需要注意的是,使用集群模式,扩缩容需要设计槽的迁移。