Redis 进阶

安全策略

数据安全

给 Redis 配置密码可以保护存储在 Redis 中的数据,因为 Redis 默认是不需要任何口令,只要客户端成功连接上服务器便可以对数据进行操作,这是非常不安全的,所以我们可以给 Redis 配置密码,让无知的客户端们束手无策。配置密码有两种方式,下面一一来介绍。

命令行配置

  1. 首先,我们可以查看 Redis 是否配置了密码

    127.0.0.1:6379> config get requirepass

    1. "requirepass"
    2. ""

返回第二个字段为空字符串,表示没有配置密码,于是我们可以进入第二步给 Redis 设置密码。

  1. 给 Redis 配置密码

    127.0.0.1:6379> config set requirepass 123
    OK

配置完成后,我们再使用第一步的命令来查询,发现

127.0.0.1:6379> config get requirepass
(error) NOAUTH Authentication required.

没错,不给访问了,这是因为配置了密码后就即可生效了,此时要求我们进行密码验证,成功后才可以进行其余操作。

  1. 验证密码

    127.0.0.1:6379> auth 123
    OK

验证通过后,我们再来执行第一步的命令

127.0.0.1:6379> config get requirepass
1) "requirepass"
2) "123"

可以看到第二个字段已经不再是空字符串,而是我们刚刚配置的密码。

这样,我们就给 Redis 设置了密码,当有新的 Redis 客户端连接到服务器时,就会首先要求输入密码,验证通过后才能访问 Redis 中的数据。这样确实可以设置密码,但是缺点呢,也很明显,这样设置的密码在服务器重启后就失效了,只能重新设置。

配置文件配置

打开 Redis 目录下的 conf 配置文件,找到下面字眼,并添加密码

# requirepass foobared
requirepass 123

然后重启 Redis 服务器,但是要记得搭配这个配置文件使用命令行来启动 Redis :

redis-server.exe redis.windows.conf

如此,我们也成功为 Redis 配置了密码,而且只要 Redis 和这个配置文件一起启动,就可以免去每次重启都需要命令行配置密码了。如果想去掉或者修改密码,只需要修改配置文件后重启即可。

数据备份与恢复

创建当前数据库的数据备份可以使用 save 和 bgsave 命令,使用这两个命令会在 Redis 安装目录下生成一个 dump.rdb 文件,示例:

127.0.0.1:6379> bgsave
Background saving started
127.0.0.1:6379> save
OK

两者的区别在于 bgsave 是后台异步运行的,而 save 是同步操作,在这个过程中,服务器不能处理任何的命令请求。

如果我们需要恢复已备份的数据库文件,只需要将 dump.rdb 文件复制到 Redis 安装目录下即可。获取 Redis 安装目录可以使用命令:

127.0.0.1:6379> config get dir
1) "dir"
2) "D:\\LzpTools\\Redis"

数据持久化

Redis 的数据是保存在内存中,而内存中的内容在计算机重启后会丢失,这就意味着 Redis 中的数据会全部丢失,那怎么办呢?如果我们就将 Redis 保存在内存中的数据保存到硬盘,那不就 OK 了吗?我们都知道硬盘上的数据是持久化的,不会因为设备重启而造成内容丢失,那怎么将 Redis 中内容持久化到硬盘呢?

首先,我们当然可以自己来做这些事情,但是,这会造成巨大的任务量而且与业务逻辑无关,属于无意义工作。于是,我们期望 Redis 能够自己来实现数据持久化。

RDB 持久化

又称为快照持久化,是将在某一个时间点上所有的 Redis 数据保存到一个 dump.rdb 二进制文件中,它是 Redis 默认的持久化策略。我们可以显式进行数据持久化操作,也就是使用上面所提到的 save 和 bgsave 两个命令。当然 Redis 也支持自动进行持久化操作,在 redis 的配置文件中会有以下三行配置项:

save 900 1        # 900 秒内至少更新了 1 条数据
save 300 10       # 300 秒内至少更新了 10 条数据
save 60 10000     # 60 秒内至少更新了 10000 条数据

只要满足其中任意一个条件, Redis 就会自动触发 bgsave 命令进行持久化操作。注意,只要创建了 dump.rdb 文件后,所有的计数器都会清零,也就是说多个 save 策略不会叠加。

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

何为 COW 机制呢?简单来说就是父进程实例化一个子进程时,除了为子进程分配必要的堆栈等空间外,其余内存空间是与子进程共享的,且这些内存空间是处于只读状态的,当父进程或子进程中的某个进程出现了写操作时,就会陷入 kernel 的一个中断例程,为子进程分配内存空间,并将父进程中的触发中断的数据复制一份到给子进程分配的内存空间中,使得两者各自持有独立的一份,这里复制给子进程的数据理应为原来旧的数据。

那么在 Redis 中,使用 save 和 bgsave 命令就会以 COW 机制的方式创建出一个子进程来执行数据持久化操作,在此期间,子进程肯定是只读数据的,如果是 save 命令,那么 父进程,也就是 Redis 用以处理客户端网络 IO 请求的那个线程,也不会发生写事件,因为它会阻塞这个线程,知道数据持久化完成,因此 save 命令中并不会触发中断;但如果是 bgsave 命令,父进程仍然可以处理客户端请求,当请求皆是读命令时,也不会发生中断,但如果是写命令,就会触发中断,不得不耗费时间和性能在复制数据上,频繁的写命令对时间和性能的耗费更是尤为严重,好在 Redis 中,普通的写命令并不会触发大量异常中断,因为只是涉及一两个内存位置而且可能并不频繁。

AOF 持久化

与 RDB 一次性持久化整个 Redis 数据库的数据的方式不同,它采用的 “命令重演” 方式,也就是在每次有有效写命令被处理时,它并不是立刻执行,而是先将其以追加方式写入磁盘文件 aof 中,然后再执行这条命令,采用这种方式便可以记录自服务器启动以来对数据库的所有写操作记录,并在下次重启时,执行这些写操作命令,来达到数据的同步。

这样的方式,会带来两个瓶颈问题,第一个是写入磁盘,会占用许多不必要的 IO 时间,导致命令有效执行时间占比减少;第二个问题是如果在 Redis 重启时,记录在 aof 文件中的命令总数过大,导致 Redis 服务启动时间大幅延长。

针对第一个问题,IO 操作频繁,Redis 采用内存缓存区来缓存所有有效的写命令,等到缓存区满了或者有需要时才会将这些数据一并写入磁盘文件中的写入机制;对于第二个问题,Redis 提供了 AOF 重写机制,对 aop 文件进行瘦身,也就是合并同类型命令,去除多余操作,只要最终执行结果一致即可。

下面来介绍一下上面所提到的两种机制的简单使用:

一、写入机制

在 Redis 的配置文件中配置:

appendonly no                      # AOF 开关,默认是关闭状态,如要开启,修改为 yes 即可
appendfilename "appendonly.aof"    # 磁盘 AOF 文件名称,默认 appendonly.aof,可修改

# 将内存缓存区的数据写入磁盘的策略
# appendfsync always               # 每当有有效命令来时就马上写入磁盘(速度最慢)
appendfsync everysec               # 每隔一秒就就将缓存区的所有数据写入磁盘(推荐)
# appendfsync no                   # 由操作系统自行决定何时将缓存区的所有数据写入磁盘

二、AOF 重写机制

  • 手动执行 bgrewriteaof 命令:

      // 后台执行,不阻塞
      127.0.0.1:6379> BGREWRITEAOF
      Background append only file rewriting started
    
  • 自动触发 AOF 重写

    在 Redis 的配置文件中配置:

      auto-aof-rewrite-percentage 100   # 下一次重写时需要新增原来文件大小的百分之几大小,默认是增量为百分百才重写,若为 0 则关闭自动重写
      auto-aof-rewrite-min-size 64mb    # 最小重写大小,也就是第一次发生重写的触发条件
    

    也就是说,第一次触发 AOF 重写机制是 aof 文件达到 64 mb,第二次则要增量达到 64mb * 100% = 64mb ,也就是 aof 文件达到 128mb 时就重写 aof 文件,第三次增量要为 128mb * 100% = 128mb,也就是 aof 文件达到 256mb 时才重写,下次就在 256mb 的基础上重复同样的动作,以此类推。

AOF 重写命令并不受 AOF 是否处于开启状态影响,如果是手动执行,那么会直接生成一个 aof 文件,而我们可以看到自动 AOF 重写是处于开启状态的,但由于如果 AOF 未开启,则文件大小不会增长,自然就无法达到重写的条件。

RDB 与 AOF 持久化对比
RDB持久化 AOF持久化
全量备份,一次保存整个数据库。 增量备份,一次只保存一个修改数据库的命令。
每次执行持久化操作的间隔时间较长。 保存的间隔默认为一秒钟(Everysec)
数据保存为二进制格式,其还原速度快。 使用文本格式还原数据,所以数据还原速度一般。
执行 SAVE 命令时会阻塞服务器,但手动或者自动触发的 BGSAVE 不会阻塞服务器 AOF持久化无论何时都不会阻塞服务器。

指令安全

在 Redis 中,有些指令是被业界禁止随意使用的,比如 keys 、flushall 和 flushdb (两者都会清空数据) 等命令,那么,这时候,我们就需要对这些指令进行限制,比如修改指令名称或让其不可访问。

修改指令名称

在配置文件中添加:

rename-command keys dontTouchMe    # 后面的 dontTouchMe 是代替 keys 指令的最新名称

如此,搭配此配置文件重启后就会香(生效)。

禁止访问指令

如果,我们不想让某个指令被人访问到,那么我们可以在配置文件中这样写:

rename-command auth ""    // 新指令名称为空,就是永远也无法访问此指令

这样,当客户端尝试登录时就会

127.0.0.1:6379> auth 123
(error) ERR unknown command 'auth'

那要怎么登录呢?在配置文件中删除对应的配置项即可来解开禁制即可。

端口安全

Redis 默认的端口是 6379,如果 Redis 服务器具有外网 IP 地址且开放了 6379 端口,那么任何人都有可能可以连接到你的 Redis 服务器上,这就意味着你的数据完全暴露在了公共视野中,甚至会被清除。那么此时,我们有两种解决办法,第一、关闭 6379 端口;第二、限制客户端的连接。关闭 6379 端口并不属于 Redis 的功能范畴,所以我们能做的就是限制客户端的连接。

如何限制呢? Redis 为我们提供了 bind 配置项,译为绑定,意思是监听客户端的 IP 地址,当其在 bind 列表中时,那么就允许其连接到服务器。

bind 127.0.0.1    # 只允许本机客户端连接服务器

bind 后面可以跟随多个 IP 列表,以空格分隔。

bind 0.0.0.0      # 允许任意 IP 地址的客户端连接到服务器

Redis 简介

Redis 包括服务端和客户端两个架构。

Redis 服务器通过监听 TCP 端口的方式来接受客户端的连接,当一个连接建立后,Redis 服务端会自动执行以下过程:

  • 首先客户端 Socket 被设置为非阻塞模式,这是因为 Redis 在网络事件处理上采用了非阻塞式 IO(即 IO 多路复用模型);
  • 其次设置 socket 的 TCP_NODELAY 属性,从而禁用 Nagle 算法;
  • 最后创建一个可读的文件事件,用它来监听客户端 socket 的数据发送。

所谓 IO 多路复用模型,就是说多个客户端连接,复用一个服务端的处理线程,这个线程负责处理所有的客户端连接请求以及分发客户端的事件请求,来段专业一点的:

Redis 的底层是一个单线程模型,单线程指的是使用一个线程来处理所有的网络事件请求,这样就避免了多进程或者多线程切换导致的 CPU 消耗,而且也不用考虑各种锁的问题。

Redis 为了充分利用单线程,加快服务器的处理速度,它采用 IO 多路复用模型来处理客户端与服务端的连接,这种模型有三种实现方式,分别是 select、poll、epoll。Redis 正是采用 epoll 的方式来监控多个 IO 事件。当客户端空闲时,线程处于阻塞状态;当一个或多个 IO 事件触发时(客户端发起网路连接请求),线程就会从阻塞状态唤醒,并同时使用epoll来轮询触发事件,并依次提交给线程处理。

注意:“多路”指的是多个网络连接,“复用”指的是复用同一个线程。多路 IO 复用技术可以让单个线程高效的处理多个连接请求。

客户端命令

获取所有已连接的客户端

client list 命令:

127.0.0.1:6379> client list
addr=127.0.0.1:53086 fd=6 name= age=23 idle=10 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=keys
addr=127.0.0.1:53089 fd=7 name= age=5 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client

当前客户端的名称管理

client setname 命令:可以设置当前客户端的名称:

// client setname clientName :设置当前客户端的名称为 clientName
127.0.0.1:6379> client setname client_one
OK

client getname 命令:可以获取当前客户端的名称:

127.0.0.1:6379> client getname
"client_one"

注意,这个客户端的名称是可以被其他客户端看到的,即通过 client list 命令可以查看。

客户端连接管理

client pause 命令:挂起客户端连接,挂起时间以毫秒计算:

// client pause mills :暂停客户端连接 mills 毫秒,低版本的不存在这个指令
127.0.0.1:6379> client pause 10000
OK

client kill 命令:中断某个客户端的连接:

// client kill ip:port :中断地址为 ip:port 的客户端,示例:
127.0.0.1:6379> client list
addr=127.0.0.1:53086 fd=6 name=client_one age=923 idle=264 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=client
addr=127.0.0.1:53089 fd=7 name= age=905 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client
127.0.0.1:6379> client kill 127.0.0.1:53086
OK
127.0.0.1:6379> client list
addr=127.0.0.1:53089 fd=7 name= age=943 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client
127.0.0.1:6379>

服务器命令记录

命令 说明
BGREWRITEAOF 在后台以异步的方式执行一个 AOF 文件的重写操作,对源文件进行压缩,使其体积变小。AOF是实现数据持久化存储的方式之一。
BGSAVE 在后台执行初始化操作,并以异步的方式将当前数据库的数据保存到磁盘中。
CLIENT KILL [ip:port] [ID client-id] 关闭客户端连接。
CLIENT LIST 获取连接到服务器的客户端连接列表。
CLIENT GETNAME 获取当前连接客户端的名称。
CLIENT PAUSE timeout 使服务器在指定的时间停止执行来自客户端的命令。
CLIENT SETNAME connection-name 设置当前连接客户端的名称。
COMMAND 返回所有 Redis 命令的详细描述信息。
COMMAND COUNT 此命令用于获取 Redis 命令的总数。
COMMAND GETKEYS 获取指定命令的所有键。
INFO [section] 获取 Redis 服务器的各种信息和统计数值。
COMMAND INFO command-name [command-name ...] 用于获取指定 Redis 命令的描述信息。
CONFIG GET parameter 获取指定配置参数的值。
CONFIG REWRITE 修改启动 Redis 服务器时所指定的 redis.conf 配置文件。
CONFIG SET parameter value 修改 Redis 配置参数,无需重启。
CONFIG RESETSTAT 重置 INFO 命令中的某些统计数据。
DBSIZE 返回当前数据库中 key 的数量。
DEBUG OBJECT key 获取 key 的调试信息。当 key 存在时,返回有关信息;当 key 不存在时,返回一个错误。
DEBUG SEGFAULT 使用此命令可以让服务器崩溃。
FLUSHALL 清空数据库中的所有键。
FLUSHDB 清空当前数据库的所有 key。
LASTSAVE 返回最近一次 Redis 成功将数据保存到磁盘上的时间,以 UNIX 格式表示。
MONITOR 实时打印出 Redis 服务器接收到的命令。
ROLE 查看主从实例所属的角色,角色包括三种,分别是 master、slave、sentinel。
SAVE 执行数据同步操作,将 Redis 数据库中的所有数据以 RDB 文件的形式保存到磁盘中。RDB 是 Redis 中的一种数据持久化方式。
SHUTDOWN [NOSAVE] [SAVE] 将数据同步到磁盘后,然后关闭服务器。
SLAVEOF host port 此命令用于设置主从服务器,使当前服务器转变成为指定服务器的从属服务器,或者将其提升为主服务器(执行 SLAVEOF NO ONE 命令)。
SLOWLOG subcommand [argument] 用来记录查询执行时间的日志系统。
SYNC 用于同步主从服务器。
SWAPDB index index 用于交换同一 Redis 服务器上的两个数据库,可以实现访问其中一个数据库的客户端连接,也可以立即访问另外一个数据库的数据。
TIME 此命令用于返回当前服务器时间。

发布/订阅模式

Redis PubSub 模块又称发布订阅者模式,是一种消息传递系统,实现了消息多播功能。发布者(即发送方)发送消息,订阅者(即接收方)接收消息,而用来传递消息的链路则被称为 channel。在 Redis 中,一个客户端可以订阅任意数量的 channel(可译为频道)。

消息多播:生产者生产一次消息,中间件负责将消息复制到多个消息队列中,每个消息队列由相应的消费组进行消费,这是分布式系统常用的一种解耦方式。

发布/订阅

下面我们演示一下 Redis 中的发布/订阅功能:

客户端 A 订阅一个 channel:

// subscribe channel_name_1 [channel_name_2] [...] :订阅一个或多个频道,会进入等待接收消息的阻塞状态
// 下面订阅了一个 channel_one 的频道
127.0.0.1:6379> subscribe channel_one
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "channel_one"
3) (integer) 1

打开另一个客户端 B,进行消息发布:

// publish channel_name message :向频道 channel_name 发布 message 消息
127.0.0.1:6379> publish channel_one hello
(integer) 1

// 客户端 A 接收到消息
127.0.0.1:6379> subscribe channel_one
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "channel_one"
3) (integer) 1
1) "message"
2) "channel_one"
3) "hello"

如果客户端 A 想同时订阅所有以 channel 开头的频道的消息怎么办?用 psubscribe 命令:

// psubscribe pattern1 [pattern2] [...] :订阅多个模式匹配的频道,可用 * 表示任意个字符
// 下面就实现了订阅所有以 channel 开头的频道的消息
127.0.0.1:6379> psubscribe channel*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "channel*"
3) (integer) 1

此时在客户端 B 中:

127.0.0.1:6379> publish channel_one "Hello, Here is channel_one"
(integer) 1
127.0.0.1:6379> publish channel_two "Hello, Here is channel_two"
(integer) 1

再看客户端 A :

127.0.0.1:6379> psubscribe channel*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "channel*"
3) (integer) 1
1) "pmessage"
2) "channel*"
3) "channel_one"
4) "Hello, Here is channel_one"
1) "pmessage"
2) "channel*"
3) "channel_two"
4) "Hello, Here is channel_two"

取消订阅

使用 unsubscribe 、punsubscribe 命令:

// subscribe channel_name_1 [channel_name_2] [...] :取消订阅一个或多个频道
// psubscribe pattern1 [pattern2] [...] :取消订阅多个模式匹配的频道,可用 * 表示任意个字符

Redis 事务

不似数据库中事务,当事务中的一条命令执行失败的时候,没有回滚机制。但是它可以保证组成事务的所有语句会被连续执行,中间不会执行其余命令。

Redis 事务可以一次执行多个命令, 并且带有以下三个重要的保证:

  • 批量操作在发送 EXEC 命令前被放入队列缓存。
  • 收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。
  • 在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。

一个事务从开始到执行会经历以下三个阶段:

  1. 开始事务。
  2. 命令入队。
  3. 执行事务。

下表是有关事务操作的命令:

命令 说明 语法
multi 开始一个事务 multi
exec 提交一个事务 exec
discard 放弃一个事务 discard
watch 监视一个或多个 Redis Key,如果任意 Key 在事务执行前发生变化,事务将被打断 watch Key1 [Key2] [...]
unwatch 取消 watch 命令对所有 Key 的监视 unwatch

示例:

// 开启一个事务,后续输入的命令都暂时不会被执行,而是放入队列中,等待 exec 或 discard 命令
127.0.0.1:6379> multi          
OK
127.0.0.1:6379> set m1 m1
QUEUED
127.0.0.1:6379> get m1
QUEUED
127.0.0.1:6379> zadd zset 1 one 2 two
QUEUED
127.0.0.1:6379> sadd set 1 2 3
QUEUED
127.0.0.1:6379> hset hash 1 one
QUEUED
127.0.0.1:6379> exec        // 执行所有保存在队列中的命令并返回结果
1) OK
2) "m1"
3) (integer) 2
4) (integer) 3
5) (integer) 1

我们可以看到它是利用队列缓存指令来达到在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中的目的的。下面我们测试一下 watch 和 unwatch 命令,在客户端 A 中先输入以下命令:

127.0.0.1:6379> keys *
1) "test"
2) "zset"
3) "set"
4) "m1"
5) "hash"
127.0.0.1:6379> watch test one
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> get test
QUEUED
127.0.0.1:6379> set one hello
QUEUED

这里 watch 了 test 和 one 这两个键,其中 one 原是不存在的键,然后我们打开客户端 B ,修改 test 的值:

127.0.0.1:6379> set test changed
OK

回去客户端 A 执行事务:

127.0.0.1:6379> watch test one
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> get test
QUEUED
127.0.0.1:6379> set one hello
QUEUED
127.0.0.1:6379> exec
(nil)

事务确实没有被执行,而且经过我的多次测试,watch 只能在一次事务中生效,且即便客户端 A 是监视了还不存在的键 one,在客户端 B 处修改了 one,仍然会使事务失效。如果事务全部入队后,执行的不是 exec 命令,而是 discard 命令,则不会执行任何组成事务的所有命令,就是扔了的意思,适用于突然不想执行该事务的情况。watch 的位置只能在事务执行前进行监视,不能置于事务中,但是 unwatch 命令可以置于事务中,那这是否意味着可以在一个事务中取消所有监视?事实证明并非如此,而且 watch 命令明确说明了在事务执行之前如果有被监视的键发生变化就不会执行事务,那么放在事务里的 unwatch 也自然无法执行,因此,要想取消所有的监视,必须在事务开始前执行 unwatch 命令。那问题又来了,客户端 A 开启了有监视的事务,但还未执行,我能在客户端 B 给它取消所有监视吗?答案是不能,这样只能取消自己客户端的所有监视。

Redis Stream 消息队列

在 Redis 5.0 版本中,实现了 Stream 的数据结构,它的实现原理如下图所示:

看图说话,图中有一条核心链,名为 Stream direction ,又称为消息链,它保持着众多的消息 ID,并与消息内容 message content 相关联,在 Redis 中可以有许多条消息链,一条消息链就是 Redis Key-Value 键值对中的一个 Key。可以发现一条消息链上可以有多个消费组,每个消费组都持有一个消息链的消息 ID —— last_delivered_id ,这个 ID 表示这个消费组上次消费的消息,每消费一条消息,last_delivered_id 就会往前移动一次。每个消费组内又包含多个消费者,它们共同竞争一条消息,也就是说一条消息只能为一个消费者消费。pending_ids 数组表示消费者已接收消息,但还未被确认消费完的消息 ID。

消息链 Stream 的操作

添加消息

xadd Key Message_ID content_key1 content_value1 [content_key2 content_value2] [...]

  • Key 表示消息链 Stream 的名称;
  • Message_ID 是消息链中持有的消息 ID,注意这个 ID 必须是递增的,如果不想自己指定,那么可以用 * 表示由 Redis 自己生成
  • content_key1 content_value1 [content_key2 content_value2] [...] 这些 Hash 类型的数据集合是消息内容
  • 此命令会返回消息 ID

在这里必须要解释一下 Redis 中消息 ID 的默认生成规则,也就是使用 * 生成消息 ID 时,在 Redis 中,消息 ID 的结构是这样的:

<millisecondsTime>-<sequenceNumber>
<毫秒时间戳>-<顺序数字>

前者是 Redis 自己生成的当前时间戳,如果当前时间戳小于已存在的记录,就按最大的时间戳进行递增;如果当前时间戳一样,也就是说在一毫秒内有多条消息进来消息链,那么就需要用后者顺序表示,默认从 0 开始递增。如果我们不想要默认的生成规则,我们也要保证两点:

  • 消息 ID 结构不变
  • 消息 ID 符合递增规律

如果我们自定义时,并未显式指出顺序数字,就像下面一样,Redis 会自动给添加顺序数字 0,但不会帮我们自动递增,也就是说下面第一条指令再次执行将会失败。

// 假设有一条图书信息的消息链,第一批次进来的图书,有计算机类 (computer) 的 Java 和数学类 (math) 的 Advanced Math
127.0.0.1:6379> xadd book 1 computer Java math "Advanced Math"
"1-0"

// 第二批次进来的图书,有计算机类 (computer) 的 C 和数学类 (english) 的 Advanced English
127.0.0.1:6379> xadd book 2 computer C english "Advanced English"
"2-0"

那么此时在 book 这条消息链上就有两条消息,怎么查看消息链的信息呢?

查看消息链信息

xlen Key:查看消息链上的消息总数,一个消息 ID 对应一个消息
xrange Key start end [count num] :获取指定消息 ID 区间 [start, end] 的消息记录,[-, +] 表示全选,num 表示从中取几条消息返回
xrevrange Key end start [count num] :逆序获取指定消息 ID 区间 [end, start] 的消息记录,[+, -] 表示全选,num 表示从中取几条消息返回

127.0.0.1:6379> xlen book
(integer) 2

127.0.0.1:6379> xrange book - + count 2
1) 1) "1-0"
   2) 1) "computer"
      2) "Java"
      3) "math"
      4) "Advanced Math"
2) 1) "2-0"
   2) 1) "computer"
      2) "C"
      3) "english"
      4) "Advanced English"
127.0.0.1:6379> xrevrange book + - count 2
1) 1) "2-0"
   2) 1) "computer"
      2) "C"
      3) "english"
      4) "Advanced English"
2) 1) "1-0"
   2) 1) "computer"
      2) "Java"
      3) "math"
      4) "Advanced Math"

那如果已发布的消息出错了怎么办?

删除消息

xdel Key Message_ID1 [Message_ID2] [...] :从指定消息链中删除指定消息 ID 的一条或多条消息,返回删除的消息总数。
xtrim Key MAXLEN [~] length:将指定的消息链修剪为消息总数最多为 length(舍弃旧消息),~ 表示消息总数并非一定要精确到<= length,可以稍微大一点,加了之后效率会更高,返回删除的消息总数。

127.0.0.1:6379> xtrim book maxlen ~ 1
(integer) 0
127.0.0.1:6379> xtrim book maxlen 1
(integer) 1
127.0.0.1:6379> xrange book - +
1) 1) "2-0"
   2) 1) "computer"
      2) "C"
      3) "english"
      4) "Advanced English"

可以发现 xtrim 使用了 ~ 后,并没有实际删除任何消息,因为总共就两条消息,误差可以接受,而在未使用 ~ 的命令中,它删除了比较旧的消息记录 1-0 ,留下了最新的 2-0.

// 消息 ID 为 1-0 的消息记录已经被删除了,故再次删除会失败
127.0.0.1:6379> xdel book 1-0
(integer) 0

消息链已经准备好了,怎么用?

查看、发布/订阅

xread [COUNT count] [BLOCK mills] STREAMS Key1 [Key2] [...] Key1_Message_ID [Key2_Message_ID] [...] :从指定的一条或多条消息链中都读取 count 条消息

  • COUNT count :表示从读取出来的结果集中取 count 条数据
  • BLOCK mills :表示如果在 mills 毫秒内没有符合条件的消息可读取便阻塞,直到超时,默认 0 是永不超时,即一直阻塞直到有符合条件的消息出现
  • Key1 [Key2] [...] :表示 Stream Key
  • Key1_Message_ID [Key2_Message_ID] [...] : 表示每条对应 Stream 的指定消息 ID,Stream 中所有消息 ID 大于指定消息 ID 的消息都将作为结果集返回
查看

它与 xrange 命令的功能相似,但实现显然会更复杂些,因为它具有 xrange 所不具有的功能。

与 xrange Key - + 相同的命令为 :xread streams Key 0-0

127.0.0.1:6379> xread count 1 streams book 0-0
1) 1) "book"
   2) 1) 1) "2-0"
         2) 1) "computer"
            2) "C"
            3) "english"
            4) "Advanced English"

127.0.0.1:6379> xrange book - + count 1
1) 1) "2-0"
   2) 1) "computer"
      2) "C"
      3) "english"
      4) "Advanced English"

但 xread 只能读取 [start, +] 区间的数据,而无法精确地读取 [start, end] 区间地数据,也就是只能读取从某一个消息 ID 开始到结束的所有元素,因此它并不能完全代替 xrange。但是没关系,毕竟术业有专攻,xread 是用来实现更高级功能的。

发布/订阅

在发布/订阅模式中,订阅了某个 channel 的消费者总是会阻塞等待消息的到来,xread 要实现这个功能,意味着也必须实现阻塞等待的功能,所以它提供了 block 字段。但是 xread 的发布/订阅模式与 pub/sub 的发布/订阅模式有着许多的差别:

  • 首先,pub/sub 的消息一经消费就会马上从 channel 中移除,而 xread 的消息一经读取后只要没有 ACK (显式移除)就会一直存在;
  • 其次...来看看 Redis 官方翻译文档:

一个Stream可以拥有多个客户端(消费者)在等待数据。默认情况下,对于每一个新项目,都会被分发到等待给定Stream的数据的每一个消费者。这个行为与阻塞列表不同,每个消费者都会获取到不同的元素。但是,扇形分发到多个消费者的能力与Pub/Sub相似。

虽然在Pub/Sub中的消息是fire and forget并且从不存储,以及使用阻塞列表时,当一个客户端收到消息时,它会从列表中弹出(有效删除),Stream从跟本上以一种不同的方式工作。所有的消息都被无限期地附加到Stream中(除非用户明确地要求删除这些条目):不同的消费者通过记住收到的最后一条消息的ID,从其角度知道什么是新消息。

Streams 消费者组提供了一种Pub/Sub或者阻塞列表都不能实现的控制级别,同一个Stream不同的群组,显式地确认已经处理的项目,检查待处理的项目的能力,申明未处理的消息,以及每个消费者拥有连贯历史可见性,单个客户端只能查看自己过去的消息历史记录。

接下来我们演示一下如何利用 xread 命令实现发布/订阅模式:

客户端 A 使用 xread 命令阻塞读取 book 消息链中的最新消息,也就是比消息链中已有的最大消息 ID还要大的消息:

127.0.0.1:6379> xread block 0 streams book $

上面本该是 Key_Message_ID 的位置怎么变成一个 $ 字符了?它就是用来表示比消息链中已有的最大消息 ID还要大的消息的,也就是下一条新消息,如果我们不阻塞去读取它是读不出来的,譬如:

127.0.0.1:6379> xread streams book $
(nil)

客户端 A 启动后,我们如法炮制启动客户端 B:

从上面我们能可以看到,客户端 A 和 B 都在阻塞等待 book 消息链的新消息,然后我们再启动一个客户端 C ,让其作为消息链的消息生产者,执行以下命令:

127.0.0.1:6379>  xadd book 5-0 computer Python chinese "Advanced Chinese"
"5-0"

然后我们看一下客户端 A 和 B 作何反应:

可以看到客户端 A 和 B 都接收到了来自客户端 C 生产的消息,亦即 xread 命令实现了发布/订阅模式。

消费组与消费者

一个消息链可以有多个消费组,他们共享消息链的消息;一个消费组可以有多个消费者,他们互斥共享消息,即一条消息只能由消费组内的一个消费者消费。来看一段来自官方的翻译文档:

消费者组就像一个伪消费者,从流中获取数据,实际上为多个消费者提供服务,提供某些保证:

  • 每条消息都提供给不同的消费者,因此不可能将相同的消息传递给多个消费者。
  • 消费者在消费者组中通过名称来识别,该名称是实施消费者的客户必须选择的区分大小写的字符串。这意味着即便断开连接过后,消费者组仍然保留了所有的状态,因为客户端会 重新申请成为相同的消费者。 然而,这也意味着由客户端提供唯一的标识符。
  • 每一个消费者组都有一个第一个ID永远不会被消费的概念,这样一来,当消费者请求新消息时,它能提供以前从未传递过的消息。
  • 消费消息需要使用特定的命令进行显式确认,表示:这条消息已经被正确处理了,所以可以从消费者组中逐出。
  • 消费者组跟踪所有当前所有待处理的消息,也就是,消息被传递到消费者组的一些消费者,但是还没有被确认为已处理。由于这个特性,当访问一个Stream的历史消息的时候,每个消费者将只能看到传递给它的消息。

多说无益,实践出真知。先执行以下命令为 book 消息链添加消息(消息内容是可以重复的):

127.0.0.1:6379> xadd book 6-0 computer Java
"6-0"
127.0.0.1:6379> xadd book 8-0 tool IDEA
"8-0"
127.0.0.1:6379> xrange book - +
1) 1) "2-0"
   2) 1) "computer"
      2) "C"
      3) "english"
      4) "Advanced English"
2) 1) "5-0"
   2) 1) "computer"
      2) "Python"
      3) "chinese"
      4) "Advanced Chinese"
3) 1) "6-0"
   2) 1) "computer"
      2) "Java"
4) 1) "8-0"
   2) 1) "tool"
      2) "IDEA"

创建消费组

xgroup 命令由多个命令组成:

  • xgroup create stream_key group_name (id | $)

在消息链 stream_key 上创建一个名为 group_name 的消费组,并置此消费组的 last_delivered_id 为 id 或 $,即消费组从消息链何处开始消费消息,$ 表示从下一个新来的消息 ID 处开始消费,即只消费最新的未读消息。

  • xgroup setid stream_key group_name (id | $)

重新设置在 stream_key 上指定的消费组 group_name 的 last_delivered_id 为 id 或 $。

  • xgroup dedstory stream_key group_name

删除在 stream_key 上指定的消费组 group_name。

  • xgroup delconsumer stream_key group_name consumer_name

删除在 stream_key 上指定的消费组 group_name 里指定的消费者 consumer_name。

127.0.0.1:6379> xgroup create book book_group_1 0-0
OK
127.0.0.1:6379> xgroup create book book_group_2 6-0
OK
127.0.0.1:6379> xgroup create book book_group_3 $
OK

上面在 book 这条消息链上创建了三个消费组,其中,第一个消费组从头开始消费,第二个消费组从消息 ID 6-0 开始消费,第三个消费者从消息链尾部开始消费,它们的 last_delivered_id 分别为 0-0、6-0,8-0.

创建了消费组后,总要看看它有什么属性吧?

查看消费组信息

XINFO [CONSUMERS key groupname] [GROUPS key] [STREAM key]

xinfo 命令不仅可以查看消费组信息,还可以查看消费组信息和消息链信息:

// 查看消息链上的所有消费组信息
127.0.0.1:6379> xinfo groups book
1) 1) "name"
   2) "book_group_1"
   3) "consumers"              // 该消费组有多少个消费者
   4) (integer) 0
   5) "pending"
   6) (integer) 0
   7) "last-delivered-id"      // 该消费组在消息链上的游标,游标的下一个就是要被消费的消息
   8) "0-0"
2) 1) "name"
   2) "book_group_2"
   3) "consumers"
   4) (integer) 0
   5) "pending"
   6) (integer) 0
   7) "last-delivered-id"
   8) "6-0"
3) 1) "name"
   2) "book_group_3"
   3) "consumers"
   4) (integer) 0
   5) "pending"
   6) (integer) 0
   7) "last-delivered-id"
   8) "8-0"

// 查看消息链的信息
127.0.0.1:6379> xinfo stream book
 1) "length"
 2) (integer) 4
 3) "radix-tree-keys"
 4) (integer) 1
 5) "radix-tree-nodes"
 6) (integer) 2
 7) "groups"                 // 有多少个消费组正在共享此消息链
 8) (integer) 3
 9) "last-generated-id"      // 上一个最大消息 ID
10) "8-0"
11) "first-entry"            // 第一条消息
12) 1) "2-0"
    2) 1) "computer"
       2) "C"
       3) "english"
       4) "Advanced English"
13) "last-entry"            // 最后一条消息
14) 1) "8-0"
    2) 1) "tool"
       2) "IDEA"

有了消费组,下面就要给添加消费者来消费消息了。

消费者消费消息

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

除了添加了消费组和消费者的概念,其余概念与 xread 命令并无太大差异,只是代表最新消息的 $ 变换成了 >,下面看示例:

// 在消费组 book_group_1 中新建了一个消费者 consumer_1 
// 让这个消费者去获取 book 消息链上的第一条消息,也是 book_group_1 消费组创建时指定的 last-delivered-id 字段
// 却什么也没拿到
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 count 1 streams book 0-0
1) 1) "book"
   2) (empty list or set)

// 再让这个消费者去获取 book 消息链上最新的一条消息
// 拿到的是 book 消息链上的第一条消息
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 count 1 streams book >
1) 1) "book"
   2) 1) 1) "2-0"
         2) 1) "computer"
            2) "C"
            3) "english"
            4) "Advanced English"

// 再让这个消费者去获取 book 消息链上最新的一条消息
// 拿到的是 book 消息链上的第二条消息
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 count 1 streams book >
1) 1) "book"
   2) 1) 1) "5-0"
         2) 1) "computer"
            2) "Python"
            3) "chinese"
            4) "Advanced Chinese"

// 再在消费组 book_group_1 中新增一个消费者 consumer_2 ,
// 并让他连续要求获取消息链上指定消息 ID 的消息,却发现根本读不出来,
// 无论是已被 consumer_1 消费的 2-0 还是未被消费的 6-0,8-0
127.0.0.1:6379> xreadgroup group book_group_1 consumer_2 count 1 streams book 2-0
1) 1) "book"
   2) (empty list or set)
127.0.0.1:6379> xreadgroup group book_group_1 consumer_2 count 1 streams book 6-0
1) 1) "book"
   2) (empty list or set)
127.0.0.1:6379> xreadgroup group book_group_1 consumer_2 count 1 streams book 8-0
1) 1) "book"
   2) (empty list or set)

// 但是再让 consumer_2 去获取最新的一条消息,拿到的消息就是 6-0,消息链的第三条信息,
// 这意味消费者是所获取的消息 ID 是不能大于消费组的 last-delivered-id 字段值的,
// 而在此字段值之前的消息都是已被同组消费组消费了的,所以前面的获取都失败了,
// 那为什么下面的成功了呢?因为 > 会让 last-delivered-id 向后移动一位,
// 而这个新增的消息 ID 就被赋予了执行这条命令的消费组了。
127.0.0.1:6379> xreadgroup group book_group_1 consumer_2 count 1 streams book >
1) 1) "book"
   2) 1) 1) "6-0"
         2) 1) "computer"
            2) "Java"

// 再让 consumer_1 去获取最新的一条消息,拿到的消息就是 8-0,消息链的第四条信息,
// 还记得 consumer_1 上一次获取的是消息链的第二条信息,由于第三条已被 consumer_2 消费,一条消息无法被消费两次,
// 故而是第四条消息,同时 last-delivered-id 向后移动一位
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 count 1 streams book >
1) 1) "book"
   2) 1) 1) "8-0"
     2) 1) "tool"
        2) "IDEA"

// 但是这里消费者 consumer_1 这样执行,却返回了消息记录,为何?
// 它又不是获取最新消息,而是获取消息 ID 大于 0-0 的消息,按照上面的分析,
// 而且返回的 2-0 消息记录明显已被消费了,但是我们要看清楚 2-0 是被谁消费的,
// 没错,就是 consumer_1 他自己!因此,对于指定消息 ID 的 xreadgroup 命令,
// 它读取的是已被自己所消费而尚未确认的消息记录,这些记录被保存在消费者的 pending_ids 数组中
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 count 1 streams book 0-0
1) 1) "book"
   2) 1) 1) "2-0"
         2) 1) "computer"
            2) "C"
            3) "english"
            4) "Advanced English"

// 不限制个数后,可以发现全是它自己消费的消息记录
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 streams book 0-0
1) 1) "book"
   2) 1) 1) "2-0"
         2) 1) "computer"
            2) "C"
            3) "english"
            4) "Advanced English"
      2) 1) "5-0"
         2) 1) "computer"
            2) "Python"
            3) "chinese"
            4) "Advanced Chinese"
      3) 1) "8-0"
         2) 1) "tool"
            2) "IDEA"

再次查看消息链、消费组和消费者的信息:

127.0.0.1:6379> xinfo groups book
1) 1) "name"
   2) "book_group_1"
   3) "consumers"            // 消费者总共有 2 个
   4) (integer) 2
   5) "pending"              // 该消费组一共传递过多少次消息
   6) (integer) 4
   7) "last-delivered-id"    // 游标已经到了消息链的最后一条消息处了
   8) "8-0"
2) 1) "name"
   2) "book_group_2"
   3) "consumers"
   4) (integer) 0
   5) "pending"
   6) (integer) 0
   7) "last-delivered-id"
   8) "6-0"
3) 1) "name"
   2) "book_group_3"
   3) "consumers"
   4) (integer) 0
   5) "pending"
   6) (integer) 0
   7) "last-delivered-id"
   8) "8-0"

// 在消息链 book 上的消费组 book_group_1 中的所有消费者信息
127.0.0.1:6379> xinfo consumers book book_group_1
1) 1) "name"            
   2) "consumer_1"
   3) "pending"
   4) (integer) 3
   5) "idle"
   6) (integer) 473630
2) 1) "name"                // 消费者唯一名称
   2) "consumer_2"
   3) "pending"             // 消息被传递了多少次
   4) (integer) 1
   5) "idle"                // 空闲时间:自上次将消息传递给这个消费者以来经过了多少毫秒
   6) (integer) 1806314

消息确认

如果一条消息已经被消费者所消费了,那么它就可以从消费者的 pending_ids 数组中被移除了。

XACK key group ID [ID ...] :确认指定消息链 Key 中指定消息组 group 中的一个或多个消息(通过 ID 匹配)已经处理完毕

// 看看 consumer_1 中有哪些消息记录
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 streams book 0-0
1) 1) "book"
   2) 1) 1) "2-0"
         2) 1) "computer"
            2) "C"
            3) "english"
            4) "Advanced English"
      2) 1) "5-0"
         2) 1) "computer"
            2) "Python"
            3) "chinese"
            4) "Advanced Chinese"
      3) 1) "8-0"
         2) 1) "tool"
            2) "IDEA"

// 确认 consumer_1 中的 5-0, 2-0 消息记录
127.0.0.1:6379> xack book book_group_1 5-0 2-0
(integer) 2

// 再看看 consumer_1 有哪些消息记录,发现只有 8-0 了
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 streams book 0-0
1) 1) "book"
   2) 1) 1) "8-0"
         2) 1) "tool"
            2) "IDEA"

我们知道,在 Redis 中是不会主动删除消费组中消费者的信息,如果有些消费者拿到消息后,它不处理,也就是不发送消息确认,而后它永久下线了,这意味着堆积在这个消费者内的消息在这个消费组内将永远无法得到处理,因为在消费组内一个消息只能被一个消费者处理,那怎么办呢?

消息转移

我们可以使用 xpending 命令来找出消费组内所有已被消费却尚未发送消费确认的所有消息记录:

XPENDING key group [start end count] [consumer]: start ,end 指的是消息 ID,可用 - + 表示所有;count 表示选几条;consumer 表示指定消费者

// 新增一个消息用以测试,并让 consumer1 拿到这条消息
127.0.0.1:6379> xadd book 8-1 computer "design model"
"8-1"
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 count 1 streams book >
1) 1) "book"
   2) 1) 1) "8-1"
         2) 1) "computer"
            2) "design model"

// 查看消息链 book 上的消息组 book_group_1 内所有未经确认的消息记录
127.0.0.1:6379> xpending book book_group_1
1) (integer) 3      // 消费组内总共有多少条未经确认的消息记录
2) "6-0"            // 最小消息 ID
3) "8-1"            // 最大消息 ID
4) 1) 1) "consumer_1"      
      2) "2"              //  consumer_1 总共有多少条未经确认的消息记录
   2) 1) "consumer_2"
      2) "1"              //  consumer_2 总共有多少条未经确认的消息记录

// 具体查看每一条未经确认的消息记录
127.0.0.1:6379> xpending book book_group_1 - + 3
1) 1) "6-0"
   2) "consumer_2"
   3) (integer) 3287551      // 空闲时间: 自上次将消息传递给这个消费者以来经过了多少毫秒
   4) (integer) 1            // 这条消息被传递了多少次
2) 1) "8-0"
   2) "consumer_1"
   3) (integer) 889878
   4) (integer) 5
3) 1) "8-1"
   2) "consumer_1"
   3) (integer) 19705
   4) (integer) 1

这些未经确认的消息,如果它们的持有者,亦即消费者永久下线了怎么办?总不能一直不处理吧?于是我们可以使用 xclaim 命令来让其余消费者来认领处理:

XCLAIM key group consumer min-idle-time ID [ID ...]:在指定消息(消息 ID 匹配)的空闲时间大于 min-idle-time 毫秒时可被同组的另一个消费者 consumer 消费

注意,此消息会重置该消息的空闲时间为 0,并将其传递次数 + 1,触发传递次数增加的命令有 xclaim 和 xreadgroup ,它表示这条消息被读了几次,即便是使用 xreadgroup 处理消费者自己内部的消息记录,也会导致传递次数增加。下面我们来演示一下消息认领:

// consumer_2 认领 consumer_1 中的未确认消息
127.0.0.1:6379> xclaim book book_group_1 consumer_2 100000 8-0
1) 1) "8-0"
   2) 1) "tool"
      2) "IDEA"

// consumer_2 中的未确认消息
127.0.0.1:6379> xreadgroup group book_group_1 consumer_2 streams book 0-0
1) 1) "book"
   2) 1) 1) "6-0"
         2) 1) "computer"
            2) "Java"
      2) 1) "8-0"
         2) 1) "tool"
            2) "IDEA"

// consumer_1 中的未确认消息
127.0.0.1:6379> xreadgroup group book_group_1 consumer_1 streams book 0-0
1) 1) "book"
   2) 1) 1) "8-1"
         2) 1) "computer"
            2) "design model"

主从模式

一个主机,多个从机,从机从属于主机,即主机的数据从机都有备份,主机允许读写,从机只能读,当有写命令在主机中成功处理后,便会将数据复制到各个从机,以达到数据的同一性,接下来只简单地记录一下命令。

从属主机命令

redis-server.exe --port <slave-port> --slaveof <master-ip> <master-port>:启动 Redis 服务器并从属于指定的主机

# 启动 Redis 服务器并从属于主机 127.0.0.1:6379
D:\LzpTools\Redis\Redis-5> redis-server.exe --port 8888 --slaveof 127.0.0.1 6379

启动客户端:

D:\LzpTools\Redis\Redis-5>redis-cli.exe -p 8888 
127.0.0.1:8888> slaveof 127.0.0.1 6379
OK

然后执行以下命令也可以实现从属功能:

slaveof <master-ip> <master-port> :这个命令与上述启动命令作用一致,如果想取消从属,可以使用 slaveof no one

配置文件实现

添加以下配置信息:

slaveof 127.0.0.1 6379   #指定主机的ip与port
port 6302                #指定从机的端口

然后 Redis 服务器启动时搭配此配置文件启动即可。

记录之余,感谢阅读~

posted @ 2022-04-10 00:00  lizhpn  阅读(95)  评论(0)    收藏  举报