Redis

65 阅读1小时+

1.概念

Redis是一个由ANSI C语言编写,性能优秀、支持网络、可持久化的Key-Value内存的NoSQL数据库,并提供多种语言的API。

2.为什么使用redis?

(一)性能 我们在碰到需要执行耗时特别久,且结果不频繁变动的SQL,就特别适合将运行结果放入缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应

(二)并发 在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问数据库。

3.单线程的redis为什么这么快?

  1. 纯内存操作
  2. 单线程操作,避免了频繁的上下文切换
  3. 采用了非阻塞I/O多路复用机制
  4. 数据结构简单,对数据操作也简单,Redis 不使用表,不会强制用户对各个关系进行关联,不会有复杂的关系限制,其存储结构就是键值对,类似于 HashMap,HashMap 最大的优点就是存取的时间复杂度为 O(1)。

打一个比方:小曲在S城开了一家快递店,负责同城快送服务。小曲因为资金限制,雇佣了一批快递员,然后小曲发现资金不够了,只够买一辆车送快递。

经营方式一 客户每送来一份快递,小曲就让一个快递员盯着,然后快递员开车去送快递。慢慢的小曲就发现了这种经营方式存在下述问题 - 几十个快递员基本上时间都花在了抢车上了,大部分快递员都处在闲置状态,谁抢到了车,谁就能去送快递 - 随着快递的增多,快递员也越来越多,小曲发现快递店里越来越挤,没办法雇佣新的快递员了 - 快递员之间的协调很花时间

综合上述缺点,小曲痛定思痛,提出了下面的经营方式

经营方式二 小曲只雇佣一个快递员。然后呢,客户送来的快递,小曲按送达地点标注好,然后依次放在一个地方。最后,那个快递员依次的去取快递,一次拿一个,然后开着车去送快递,送好了就回来拿下一个快递。

对比 上述两种经营方式对比,是不是明显觉得第二种,效率更高,更好呢。

在上述比喻中:

  • 每个快递员------------------>每个线程

  • 每个快递-------------------->每个socket(I/O流)

  • 快递的送达地点-------------->socket的不同状态

  • 客户送快递请求-------------->来自客户端的请求

  • 小曲的经营方式-------------->服务端运行的代码

  • 一辆车---------------------->CPU的核数

于是我们有如下结论

  1. 经营方式一就是传统的并发模型,每个I/O流(快递)都有一个新的线程(快递员)管理。

  2. 经营方式二就是I/O多路复用。只有单个线程(一个快递员),通过跟踪每个I/O流的状态(每个快递的送达地点),来管理多个I/O流。

下面类比到真实的redis线程模型,如图所示

我们的redis-client在操作的时候,会产生具有不同事件类型的socket。在服务端,有一段I/0多路复用程序,将其置入队列之中。然后,文件事件分派器,依次去队列中取,转发到不同的事件处理器中。 这个I/O多路复用机制,redis还提供了select、epoll、evport、kqueue等多路复用函数库。

4.与传统RDBMS的不同

RedisRDBMS
非关系型数据库关系型数据库
数据存储在内存 ,受内存限制大多小文件数据保存在磁盘,可保存大文件
k-v类型存储表格存储
支持高并发高并发容易连接异常
String、List、Hash、Set、ZSet数值数据类型、日期/时间类型、字符串类型

5.事务

相关命令

1. MULTI

用于标记事务块的开始

这个命令的返回值是一个简单的字符串,总是OK。

multi 命令不能嵌套使用,如果已经开启了事务的情况下,再执行 multi 命令,会提示如下错误,但不会终止客户端为事务的状态:

(error) ERR MULTI calls can not be nested

客户端进入事务状态之后,执行的常规 Redis 操作命令会依次入列,命令入列成功后会返回 QUEUED

2. EXEC

在一个事务中执行所有先前放入队列的命令,然后恢复正常的连接状态。

当使用WATCH命令时,只有当受监控的键没有被修改时,EXEC命令才会执行事务中的命令,这种方式利用了检查再设置(CAS)的机制。

这个命令的返回值是一个数组,其中的每个元素分别是原子化事务中的每个命令的返回值。 当使用WATCH命令时,如果事务执行中止,那么EXEC命令就会返回一个Null值。

3. DISCARD

清除所有先前在一个事务中放入队列的命令,然后恢复正常的连接状态。

如果使用了WATCH命令,那么DISCARD命令就会将当前连接监控的所有键取消监控。

这个命令的返回值是一个简单的字符串,总是OK。

4. WATCH

当某个事务需要按条件执行时,就要使用这个命令将给定的键设置为受监控的。

这个命令的运行格式如下所示:

WATCH key [key ...]

这个命令的返回值是一个简单的字符串,总是OK。

对于每个键来说,时间复杂度总是O(1)。

5. UNWATCH

清除所有先前为一个事务监控的键。

如果你调用了EXEC或DISCARD命令,那么就不需要手动调用UNWATCH命令。

为什么事务不支持回滚?

  • 认为 Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为 Redis 开发事务回滚功能;
  • 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。

事务的错误处理

1.语法错误

语法错误指命令不存在或者命令参数的个数不对。比如:

redis>MULTI

OK

redis>SET key value

QUEUED

redis>SET key

(error)ERR wrong number of arguments for 'set' command

redis> errorCOMMAND key

(error) ERR unknown command 'errorCOMMAND'

redis> EXEC

(error) EXECABORT Transaction discarded because of previous errors.

跟在MULTI命令后执行了3个命令:一个是正确的命令,成功地加入事务队列;其余两个命令都有语法错误。而只要有一个命令有语法错误,执行EXEC命令后Redis就会直接返回错误,连语法正确的命令也不会执行

这里需要注意一点:

Redis 2.6.5之前的版本会忽略有语法错误的命令,然后执行事务中其他语法正确的命令。就此例而言,SET key value会被执行,EXEC命令会返回一个结果:1) OK。

2.运行错误

运行错误指在命令执行时出现的错误,比如使用散列类型的命令操作集合类型的键,这种错误在实际执行之前Redis是无法发现的,所以在事务里这样的命令是会被Redis接受并执行的。如果事务里的一条命令出现了运行错误,事务里其他的命令依然会继续执行(包括出错命令之后的命令) ,示例如下:

redis>MULTI

OK

redis>SET key 1

QUEUED

redis>SADD key 2

QUEUED

redis>SET key 3

QUEUED

redis>EXEC

1) OK

2) (error) ERR Operation against a key holding the wrong kind of value

3) OK

redis>GET key

"3"

可见虽然SADD key 2出现了错误,但是SET key 3依然执行了。

3.入列时执行multi,watch命令导致错误,会正常执行

监控

watch 命令用于客户端并发情况下,为事务提供一个乐观锁(CAS,Check And Set),也就是可以用 watch 命令来监控一个或多个变量,如果在事务的过程中,某个监控项被修改了,那么整个事务就会终止执行watch 基本语法如下:

www.jianshu.com/p/88000d19d…

watch key [key ...]

watch 示例代码如下:

 watch k

OK

> multi

OK

> set k v2

QUEUED

> exec

(nil)

> get k

"v"

从以上命令可以看出,如果 exec 返回的结果是 nil 时,表示 watch 监控的对象在事务执行的过程中被修改了。从 getk 的结果也可以看出,在事务中设置的值 setk v2 并未正常执行。执行流程如下图所示:

watch 命令只能在客户端开启事务之前执行,在事务中执行 watch 命令会引发错误,但不会造成整个事务失败,如下代码所示:

unwatch 命令用于清除所有之前监控的所有对象(键值对)。unwatch 示例如下所示:

> set k v

OK

> watch k

OK

> multi

OK

> unwatch

QUEUED

> set k v2

QUEUED

> exec

1) OK

2) OK

> get k

"v2"

可以看出,即使在事务的执行过程中,k 值被修改了,因为调用了 unwatch 命令,整个事务依然会顺利执行。执行EXEC命令后会取消对所有键的监控

6.消息队列

List 队列

想把 Redis 当作队列来使用,肯定最先想到的就是使用 List 这个数据类型。因为 List 底层的实现就是一个「链表」,在头部和尾部操作元素,时间复杂度都是 O(1),这意味着它非常符合消息队列的模型。

如果把 List 当作队列。

生产者使用 LPUSH 发布消息:

127.0.0.1:6379> LPUSH queue msg1

(integer) 1

127.0.0.1:6379> LPUSH queue msg2

(integer) 2

消费者这一侧,使用 RPOP 拉取消息:

127.0.0.1:6379> RPOP queue

"msg1"

127.0.0.1:6379> RPOP queue

"msg2"

但这里有个小问题,当队列中已经没有消息了,消费者在执行 RPOP 时,会返回 NULL。

127.0.0.1:6379> RPOP queue

(nil)   // 没消息了

而我们在编写消费者逻辑时,一般是一个「死循环」,这个逻辑需要不断地从队列中拉取消息进行处理,伪代码一般会这么写:

while true:

    msg = redis.rpop("queue")

    // 没有消息,继续循环

    if msg == null:

        continue

    // 处理消息

    handle(msg)

如果此时队列为空,那消费者依旧会频繁拉取消息,这会造成「CPU 空转」,不仅浪费 CPU 资源,还会对 Redis 造成压力。

当队列为空时,我们可以「休眠」一会,再去尝试拉取消息。代码可以修改成这样:

while true:

    msg = redis.rpop("queue")

    // 没有消息,休眠2s

    if msg == null:

        sleep(2)

        continue

    // 处理消息        

    handle(msg)

这就解决了 CPU 空转问题。

这个问题虽然解决了,但又带来另外一个问题:当消费者在休眠等待时,有新消息来了,那消费者处理新消息就会存在「延迟」。

假设设置的休眠时间是 2s,那新消息最多存在 2s 的延迟。

要想缩短这个延迟,只能减小休眠的时间。但休眠时间越小,又有可能引发 CPU 空转问题。

那如何做,既能及时处理新消息,还能避免 CPU 空转呢?

Redis 确实提供了「阻塞式」拉取消息的命令:BRPOP / BLPOP,这里的 B 指的是阻塞(Block)。

现在,你可以这样来拉取消息了:

while true:

    // 没消息阻塞等待,0表示不设置超时时间

    msg = redis.brpop("queue", 0)

    if msg == null:

        continue

    // 处理消息

    handle(msg)

使用 BRPOP 这种阻塞式方式拉取消息时,还支持传入一个「超时时间」,如果设置为 0,则表示不设置超时,直到有新消息才返回,否则会在指定的超时时间后返回 NULL。

这个方案不错,既兼顾了效率,还避免了 CPU 空转问题,一举两得。

注意:如果设置的超时时间太长,这个连接太久没有活跃过,可能会被 Redis Server 判定为无效连接,之后 Redis Server 会强制把这个客户端踢下线。所以,采用这种方案,客户端要有重连机制。

解决了消息处理不及时的问题,有什么缺点?

  1. 不支持重复消费:消费者拉取消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费,即不支持多个消费者消费同一批数据
  2. 消息丢失:消费者拉取到消息后,如果发生异常宕机,那这条消息就丢失了

第一个问题是功能上的,使用 List 做消息队列,它仅仅支持最简单的,一组生产者对应一组消费者,不能满足多组生产者和消费者的业务场景。

第二个问题就比较棘手了,因为从 List 中 POP 一条消息出来后,这条消息就会立即从链表中删除了。也就是说,无论消费者是否处理成功,这条消息都没办法再次消费了。

这也意味着,如果消费者在处理消息时异常宕机,那这条消息就相当于丢失了。

发布/订阅模型:Pub/Sub

这个模块是 Redis 专门是针对「发布/订阅」这种队列模型设计的。

它正好可以解决前面提到的第一个问题:重复消费。

即多组生产者、消费者的场景

Redis 提供了 PUBLISH / SUBSCRIBE 命令,来完成发布、订阅的操作。

假设你想开启 2 个消费者,同时消费同一批数据,就可以按照以下方式来实现。

首先,使用 SUBSCRIBE 命令,启动 2 个消费者,并「订阅」同一个队列。

// 2个消费者 都订阅一个队列

127.0.0.1:6379> SUBSCRIBE queue

Reading messages... (press Ctrl-C to quit)

1) "subscribe"

2) "queue"

3) (integer) 1

此时,2 个消费者都会被阻塞住,等待新消息的到来。

之后,再启动一个生产者,发布一条消息。

127.0.0.1:6379> PUBLISH queue msg1

(integer) 1

这时,2 个消费者就会解除阻塞,收到生产者发来的新消息。

127.0.0.1:6379> SUBSCRIBE queue

// 收到新消息

1) "message"

2) "queue"

3) "msg1"

使用 Pub/Sub 这种方案,既支持阻塞式拉取消息,还很好地满足了多组消费者,消费同一批数据的业务需求。

除此之外,Pub/Sub 还提供了「匹配订阅」模式,允许消费者根据一定规则,订阅「多个」自己感兴趣的队列。

// 订阅符合规则的队列

127.0.0.1:6379> PSUBSCRIBE queue.*

Reading messages... (press Ctrl-C to quit)

1) "psubscribe"

2) "queue.*"

3) (integer) 1

这里的消费者,订阅了 queue.* 相关的队列消息。

之后,生产者分别向 queue.p1 和 queue.p2 发布消息。

127.0.0.1:6379> PUBLISH queue.p1 msg1

(integer) 1

127.0.0.1:6379> PUBLISH queue.p2 msg2

(integer) 1

这时再看消费者,它就可以接收到这 2 个生产者的消息了。

127.0.0.1:6379> PSUBSCRIBE queue.*

Reading messages... (press Ctrl-C to quit)

...

// 来自queue.p1的消息

1) "pmessage"

2) "queue.*"

3) "queue.p1"

4) "msg1"



// 来自queue.p2的消息

1) "pmessage"

2) "queue.*"

3) "queue.p2"

4) "msg2"

我们可以看到,Pub/Sub 最大的优势就是,支持多组生产者、消费者处理消息。

Pub/Sub 最大问题是:丢数据

如果发生以下场景,就有可能导致数据丢失:

  1. 消费者下线
  2. Redis 宕机
  3. 消息堆积

这其实与 Pub/Sub 的实现方式有很大关系。

Pub/Sub 在实现时非常简单,它没有基于任何数据类型,也没有做任何的数据存储,它只是单纯地为生产者、消费者建立「数据转发通道」,把符合规则的数据,从一端转发到另一端。

一个完整的发布、订阅消息处理流程是这样的:

  1. 消费者订阅指定队列,Redis 就会记录一个映射关系:队列->消费者
  2. 生产者向这个队列发布消息,那 Redis 就从映射关系中找出对应的消费者,把消息转发给它

整个过程中,没有任何的数据存储,一切都是实时转发的。

这种设计方案,就导致了上面提到的那些问题。

例如,如果一个消费者异常挂掉了,它再重新上线后,只能接收新的消息,在下线期间生产者发布的消息,因为找不到消费者,都会被丢弃掉。

如果所有消费者都下线了,那生产者发布的消息,因为找不到任何一个消费者,也会全部「丢弃」。

所以,当你在使用 Pub/Sub 时,一定要注意:消费者必须先订阅队列,生产者才能发布消息,否则消息会丢失。

这也是前面讲例子时,我们让消费者先订阅队列,之后才让生产者发布消息的原因。

另外,因为 Pub/Sub 没有基于任何数据类型实现,所以它也不具备**「数据持久化」**的能力。

也就是说,Pub/Sub 的相关操作,不会写入到 RDB 和 AOF 中,当 Redis 宕机重启,Pub/Sub 的数据也会全部丢失。

最后,我们来看 Pub/Sub 在处理「消息积压」时,为什么也会丢数据?

当消费者的速度,跟不上生产者时,就会导致数据积压的情况发生。

如果采用 List 当作队列,消息积压时,会导致这个链表很长,最直接的影响就是,Redis 内存会持续增长,直到消费者把所有数据都从链表中取出。

但 Pub/Sub 的处理方式却不一样,当消息积压时,有可能会导致消费失败和消息丢失

这是怎么回事?

还是回到 Pub/Sub 的实现细节上来说。

每个消费者订阅一个队列时,Redis 都会在 Server 上给这个消费者再分配一个「缓冲区」,这个缓冲区其实就是一块内存。

当生产者发布消息时,Redis 先把消息写到对应消费者的缓冲区中。

之后,消费者不断地从缓冲区读取消息,处理消息。

但是,问题就出在这个缓冲区上。

因为这个缓冲区其实是有「上限」的(可配置),如果消费者拉取消息很慢,就会造成生产者发布到缓冲区的消息开始积压,缓冲区内存持续增长。

如果超过了缓冲区配置的上限,此时,Redis 就会「强制」把这个消费者踢下线。

这时消费者就会消费失败,也会丢失数据。

如果你有看过 Redis 的配置文件,可以看到这个缓冲区的默认配置:client-output-buffer-limit pubsub 32mb 8mb 60。

它的参数含义如下:

  • 32mb:缓冲区一旦超过 32MB,Redis 直接强制把消费者踢下线
  • 8mb + 60:缓冲区超过 8MB,并且持续 60 秒,Redis 也会把消费者踢下线

Pub/Sub 的这一点特点,是与 List 作队列差异比较大的。

从这里你应该可以看出,List 其实是属于「拉」模型,而 Pub/Sub 其实属于「推」模型

List 中的数据可以一直积压在内存中,消费者什么时候来「拉」都可以。

但 Pub/Sub 是把消息先「推」到消费者在 Redis Server 上的缓冲区中,然后等消费者再来取。

当生产、消费速度不匹配时,就会导致缓冲区的内存开始膨胀,Redis 为了控制缓冲区的上限,所以就有了上面讲到的,强制把消费者踢下线的机制。

好了,现在我们总结一下 Pub/Sub 的优缺点:

  1. 支持发布 / 订阅,支持多组生产者、消费者处理消息
  2. 消费者下线,数据会丢失
  3. 不支持数据持久化,Redis 宕机,数据也会丢失
  4. 消息堆积,缓冲区溢出,消费者会被强制踢下线,数据也会丢失

所以,很多人看到 Pub/Sub 的特点后,觉得这个功能很「鸡肋」。

也正是以上原因,Pub/Sub 在实际的应用场景中用得并不多。

目前只有哨兵集群和 Redis 实例通信时,采用了 Pub/Sub 的方案,因为哨兵正好符合即时通讯的业务场景。

我们再来看一下,Pub/Sub 有没有解决,消息处理时异常宕机,无法再次消费的问题呢?

其实也不行,Pub/Sub 从缓冲区取走数据之后,数据就从 Redis 缓冲区删除了,消费者发生异常,自然也无法再次重新消费。。

当我们在使用一个消息队列时,希望它的功能如下:

  • 支持阻塞等待拉取消息
  • 支持发布 / 订阅模式
  • 消费失败,可重新消费,消息不丢失
  • 实例宕机,消息不丢失,数据可持久化
  • 消息可堆积

趋于成熟的队列:Stream

其他数据类型,例如Lists,Sets等,如果所有元素都被删除,那么key也不存在。而streams允许所有entry都被删除。

首先,Stream 通过 XADD 和 XREAD 完成最简单的生产、消费模型:

  • XADD:发布消息
  • XREAD:读取消息

生产者发布 2 条消息:

// *表示让Redis自动生成消息ID

127.0.0.1:6379> XADD queue * name zhangsan

"1618469123380-0"

127.0.0.1:6379> XADD queue * name lisi

"1618469127777-0"

使用 XADD 命令发布消息,其中的「*」表示让 Redis 自动生成唯一的消息 ID。

这个消息 ID 的格式是「时间戳-自增序号」。

消费者拉取消息:

// 从开头读取5条消息,0-0表示从开头读取

127.0.0.1:6379> XREAD COUNT 5 STREAMS queue 0-0

1) 1) "queue"

   2) 1) 1) "1618469123380-0"

         2) 1) "name"

            2) "zhangsan"

      2) 1) "1618469127777-0"

         2) 1) "name"

            2) "lisi"

如果想继续拉取消息,需要传入上一条消息的 ID:

127.0.0.1:6379> XREAD COUNT 5 STREAMS queue 1618469127777-0

(nil)

没有消息,Redis 会返回 NULL。

以上就是 Stream 最简单的生产、消费。

这里不再重点介绍 Stream 命令的各种参数,在例子中演示时,凡是大写的单词都是「固定」参数,凡是小写的单词,都是可以自己定义的,例如队列名、消息长度等等,下面的例子规则也是一样,为了方便你理解,这里有必要提醒一下。

下面我们来看,针对前面提到的消息队列要求,Stream 都是如何解决的?

1) Stream 是否支持「阻塞式」拉取消息?

可以的,在读取消息时,只需要增加 BLOCK 参数即可。

// BLOCK 0 表示阻塞等待,不设置超时时间

127.0.0.1:6379> XREAD COUNT 5 BLOCK 0 STREAMS queue 1618469127777-0

这时,消费者就会阻塞等待,直到生产者发布新的消息才会返回。

2) Stream 是否支持发布 / 订阅模式?

也没问题,Stream 通过以下命令完成发布订阅:

  • XGROUP:创建消费者组
  • XREADGROUP:在指定消费组下,开启消费者拉取消息

下面我们来看具体如何做?

首先,生产者依旧发布 2 条消息:

127.0.0.1:6379> XADD queue * name zhangsan

"1618470740565-0"

127.0.0.1:6379> XADD queue * name lisi

"1618470743793-0"

之后,我们想要开启 2 组消费者处理同一批数据,就需要创建 2 个消费者组:

// 创建消费者组1,0-0表示从头拉取消息

127.0.0.1:6379> XGROUP CREATE queue group1 0-0

OK

// 创建消费者组2,0-0表示从头拉取消息

127.0.0.1:6379> XGROUP CREATE queue group2 0-0

OK

消费者组创建好之后,我们可以给每个「消费者组」下面挂一个「消费者」,让它们分别处理同一批数据。

第一个消费组开始消费:

// group1的consumer开始消费,>表示拉取最新数据

127.0.0.1:6379> XREADGROUP GROUP group1 consumer COUNT 5 STREAMS queue >

1) 1) "queue"

   2) 1) 1) "1618470740565-0"

         2) 1) "name"

            2) "zhangsan"

      2) 1) "1618470743793-0"

         2) 1) "name"

            2) "lisi"

同样地,第二个消费组开始消费:

// group2的consumer开始消费,>表示拉取最新数据

127.0.0.1:6379> XREADGROUP GROUP group2 consumer COUNT 5 STREAMS queue >

1) 1) "queue"

   2) 1) 1) "1618470740565-0"

         2) 1) "name"

            2) "zhangsan"

      2) 1) "1618470743793-0"

         2) 1) "name"

            2) "lisi"

我们可以看到,这 2 组消费者,都可以获取同一批数据进行处理了。

这样一来,就达到了多组消费者「订阅」消费的目的。

3) 消息处理时异常,Stream 能否保证消息不丢失,重新消费?

除了上面拉取消息时用到了消息 ID,这里为了保证重新消费,也要用到这个消息 ID。

当一组消费者处理完消息后,需要执行 XACK 命令告知 Redis,这时 Redis 就会把这条消息标记为「处理完成」。

// group1下的 1618472043089-0 消息已处理完成

127.0.0.1:6379> XACK queue group1 1618472043089-0

如果消费者异常宕机,肯定不会发送 XACK,那么 Redis 就会依旧保留这条消息。

待这组消费者重新上线后,Redis 就会把之前没有处理成功的数据,重新发给这个消费者。这样一来,即使消费者异常,也不会丢失数据了。

// 消费者重新上线,0-0表示重新拉取未ACK的消息

127.0.0.1:6379> XREADGROUP GROUP group1 consumer1 COUNT 5 STREAMS queue 0-0

// 之前没消费成功的数据,依旧可以重新消费

1) 1) "queue"

   2) 1) 1) "1618472043089-0"

         2) 1) "name"

            2) "zhangsan"

      2) 1) "1618472045158-0"

         2) 1) "name"

            2) "lisi"

4) Stream 数据会写入到 RDB 和 AOF 做持久化吗?

Stream 是新增加的数据类型,它与其它数据类型一样,每个写操作,也都会写入到 RDB 和 AOF 中。

我们只需要配置好持久化策略,这样的话,就算 Redis 宕机重启,Stream 中的数据也可以从 RDB 或 AOF 中恢复回来。

5) 消息堆积时,Stream 是怎么处理的?

其实,当消息队列发生消息堆积时,一般只有 2 个解决方案:

  1. 生产者限流:避免消费者处理不及时,导致持续积压
  2. 丢弃消息:中间件丢弃旧消息,只保留固定长度的新消息

而 Redis 在实现 Stream 时,采用了第 2 个方案。

在发布消息时,你可以指定队列的最大长度,防止队列积压导致内存爆炸。

// 队列长度最大10000

127.0.0.1:6379> XADD queue MAXLEN 10000 * name zhangsan

"1618473015018-0"

当队列长度超过上限后,旧消息会被删除,只保留固定长度的新消息。

这么来看,Stream 在消息积压时,如果指定了最大长度,还是有可能丢失消息的。

除了以上介绍到的命令,Stream 还支持查看消息长度(XLEN)、查看消费者状态(XINFO)等命令,使用也比较简单,你可以查询官方文档了解一下,这里就不过多介绍了。

好了,通过以上介绍,我们可以看到,Redis 的 Stream 几乎覆盖到了消息队列的各种场景,是不是觉得很完美?

既然它的功能这么强大,这是不是意味着,Redis 真的可以作为专业的消息队列中间件来使用呢?

与专业的消息队列对比

其实,一个专业的消息队列,必须要做到两大块:

  1. 消息不丢
  2. 消息可堆积

前面我们讨论的重点,很大篇幅围绕的是第一点展开的。

这里我们换个角度,从一个消息队列的「使用模型」来分析一下,怎么做,才能保证数据不丢?

使用一个消息队列,其实就分为三大块:生产者、队列中间件、消费者

消息是否会发生丢失,其重点也就在于以下 3 个环节:

  1. 生产者会不会丢消息?
  2. 消费者会不会丢消息?
  3. 队列中间件会不会丢消息?

1) 生产者会不会丢消息?

当生产者在发布消息时,可能发生以下异常情况:

  1. 消息没发出去:网络故障或其它问题导致发布失败,中间件直接返回失败
  2. 不确定是否发布成功:网络问题导致发布超时,可能数据已发送成功,但读取响应结果超时了

如果是情况 1,消息根本没发出去,那么重新发一次就好了。

如果是情况 2,生产者没办法知道消息到底有没有发成功?所以,为了避免消息丢失,它也只能继续重试,直到发布成功为止。

生产者一般会设定一个最大重试次数,超过上限依旧失败,需要记录日志报警处理。

也就是说,生产者为了避免消息丢失,只能采用失败重试的方式来处理。

在使用消息队列时,要保证消息不丢,宁可重发,也不能丢弃。

那消费者这边,就需要多做一些逻辑了。

对于敏感业务,当消费者收到重复数据数据时,要设计幂等逻辑,保证业务的正确性。

从这个角度来看,生产者会不会丢消息,取决于生产者对于异常情况的处理是否合理。

所以,无论是 Redis 还是专业的队列中间件,生产者在这一点上都是可以保证消息不丢的。

2) 消费者会不会丢消息?

这种情况就是我们前面提到的,消费者拿到消息后,还没处理完成,就异常宕机了,那消费者还能否重新消费失败的消息?

要解决这个问题,消费者在处理完消息后,必须「告知」队列中间件,队列中间件才会把标记已处理,否则仍旧把这些数据发给消费者。

这种方案需要消费者和中间件互相配合,才能保证消费者这一侧的消息不丢。

无论是 Redis 的 Stream,还是专业的队列中间件,例如 RabbitMQ、Kafka,其实都是这么做的。

所以,从这个角度来看,Redis 也是合格的。

3) 队列中间件会不会丢消息?

前面 2 个问题都比较好处理,只要客户端和服务端配合好,就能保证生产端、消费端都不丢消息。

但是,如果队列中间件本身就不可靠呢?

毕竟生产者和消费这都依赖它,如果它不可靠,那么生产者和消费者无论怎么做,都无法保证数据不丢。

在这个方面,Redis 其实没有达到要求。

Redis 在以下 2 个场景下,都会导致数据丢失。

  1. AOF 持久化配置为每秒写盘,但这个写盘过程是异步的,Redis 宕机时会存在数据丢失的可能
  2. 主从复制也是异步的,主从切换时,也存在丢失数据的可能(从库还未同步完成主库发来的数据,就被提成主库)

基于以上原因我们可以看到,Redis 本身的无法保证严格的数据完整性

所以,如果把 Redis 当做消息队列,在这方面是有可能导致数据丢失的。

再来看那些专业的消息队列中间件是如何解决这个问题的?

像 RabbitMQ 或 Kafka 这类专业的队列中间件,在使用时,一般是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,以此保证消息的完整性。这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。

也正因为如此,RabbitMQ、Kafka在设计时也更复杂。毕竟,它们是专门针对队列场景设计的。

但 Redis 的定位则不同,它的定位更多是当作缓存来用,它们两者在这个方面肯定是存在差异的。

最后,我们来看消息积压怎么办?

4) 消息积压怎么办?

因为 Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。

所以,Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。

但 Kafka、RabbitMQ 这类消息队列就不一样了,它们的数据都会存储在磁盘上,磁盘的成本要比内存小得多,当消息积压时,无非就是多占用一些磁盘空间,相比于内存,在面对积压时也会更加「坦然」。

综上,我们可以看到,把 Redis 当作队列来使用时,始终面临的 2 个问题:

  1. Redis 本身可能会丢数据
  2. 面对消息积压,Redis 内存资源紧张

到这里,Redis 是否可以用作队列

如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。

而且,Redis 相比于 Kafka、RabbitMQ,部署和运维也更加轻量。

如果你的业务场景对于数据丢失非常敏感,而且写入量非常大,消息积压时会占用很多的机器资源,那么我建议你使用专业的消息队列中间件。

总结

7.过期策略以及内存淘汰机制

设置过期时间的四种方式

# 将 key 的过期时间设置为 ttl 秒

expire <key> <ttl> 

# 将 key 的过期时间设置为 ttl 毫秒

pexpire <key> <ttl>

# 将 key 的过期时间设置为 timestamp 指定的秒数时间戳

expire <key> <timestamp>

# 将 key 的过期时间设置为 timestamp 指定的毫秒数时间戳

pexpire <key> <timestamp>

其中前三种方式都会转化为最后一种方式来实现过期时间

保存过期时间

看下 redisDb 的结构

typedef struct redisDb {

    dict *dict;                 /* The keyspace for this DB */

    dict *expires;              /* Timeout of keys with a timeout set */

    ...

}

可见在 redisDb 结构的 expire 字典(过期字典)保存了所有键的过期时间

过期字典的键是一个指向键空间中的某个键对象的指针

过期字典的值保存了键所指向的数据库键的过期时间

图中过期字段和键空间中键对象有重复,实际中不会出现重复对象,键空间的键和过期字典的键都指向同一个键对象

过期键的判断

通过查询过期字典,检查下面的条件判断是否过期

  1. 检查给定的键是否在过期字典中,如果存在就获取键的过期时间
  2. 检查当前 UNIX 时间戳是否大于键的过期时间,是就过期,否则未过期

redis采用的是定期删除+惰性删除策略

为什么不用定时删除策略?

定时删除,用一个定时器来负责监视key,过期则自动删除。虽然内存及时释放,但是十分消耗CPU资源。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略.

定期删除+惰性删除是如何工作的呢?

定期删除,redis默认每个100ms检查(redis.conf 中通过 hz 配置),是否有过期的key,有过期key则删除。需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis岂不是卡死)。如果过期键的比例超过 25% ,重复步骤。为了保证扫描不会出现循环过度,导致线程卡死现象,还增加了扫描时间的上限,默认是 25 毫秒(即默认在慢模式下,如果是快模式,扫描上限是 1 毫秒)

因此,如果只采用定期删除策略,会导致很多key到时间没有删除。 于是,惰性删除派上用场。也就是说在你获取某个key的时候,redis会expireIfNeeded 方法对键做过期检查,这个key如果设置了过期时间那么是否过期了?如果过期了此时就会删除。

因为 Redis 在扫描过期键时,一般会循环扫描多次,如果请求进来,且正好服务器正在进行过期键扫描,那么需要等待 25 毫秒,如果客户端设置的超时时间小于 25 毫秒,那就会导致链接因为超时而关闭,就会造成异常,这些现象还不能从慢查询日志( Redis慢查询日志)中查询到,因为慢查询只记录逻辑处理过程,不包括等待时间。

所以我们在设置过期时间时,一定要避免同时大批量键过期的现象,所以如果有这种情况,最好给过期时间加个随机范围,缓解大量键同时过期,造成客户端等待超时的现象

生成 RDB 文件

在执行 save 命令或 bgsave 命令创建一个新的 RDB文件时,程序会对数据库中的键进行检查,已过期的键就不会被保存到新创建的 RDB文件中

载入 RDB 文件

主服务器:载入 RDB 文件时,会对键进行检查,过期的键会被忽略

从服务器:载入 RDB文件时,所有键都会载入。但是会在主从同步的时候,清空从服务器的数据库,所以过期的键载入也不会造成啥影响

AOF 文件写入

当过期键被惰性删除或定期删除后,程序会向 AOF 文件追加一条 del 命令,来显示的记录该键已经被删除

AOF 重写

重启过程会对键进行检查,如果过期就不会被保存到重写后的 AOF 文件中

从服务器的过期键删除动作由主服务器控制

主服务器在删除一个过期键后,会显示地向所有从服务器发送一个 del 命令,告知从服务器删除这个过期键

从服务器收到在执行客户端发送的读命令时,即使碰到过期键也不会将其删除,只有在收到主服务器的 del 命令后,才会删除,这样就能保证主从服务器的数据一致性

采用定期删除+惰性删除就没其他问题了么?

不是的,如果定期删除没删除key。然后你也没即时去请求key,也就是说惰性删除也没生效。这样,redis的内存会越来越高。那么就应该采用内存淘汰机制。 在redis.conf中有一行配置

# maxmemory-policy volatile-lru

该配置就是配内存淘汰策略的

1)noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。

2)allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。

3)allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。

4)volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。

5)volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。

6)volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。ps:如果没有设置 expire 的key, 不满足先决条件(prerequisites); 那么 volatile-lru, volatile-random 和 volatile-ttl 策略的行为, 和 noeviction(不删除) 基本上一致。

8. 持久化

Redis 提供了 RDB 和 AOF 两种持久化机制,前者将当前的数据保存到磁盘,后者则是将每次执行的写命令保存到磁盘(类似于 MySQL 的 Binlog)。

RDB 持久化

RDB 持久化(也称作快照持久化)是指将内存中的数据生成快照保存到磁盘里面,保存的文件后缀是 .rdb。rdb 文件是一个经过压缩的二进制文件,当 Redis 重新启动时,可以读取 rdb 快照文件恢复数据。RDB 功能最核心的是 rdbSave 和 rdbLoad 两个函数, 前者用于生成 RDB 文件并保存到磁盘,而后者则用于将 RDB 文件中的数据重新载入到内存中:

RDB 文件是一个单文件的全量数据,很适合数据的容灾备份与恢复,通过 RDB 文件恢复数据库耗时较短,通常 1G 的快照文件载入内存只需 20s 左右。Redis 提供了手动触发保存、自动保存间隔两种 RDB 文件的生成方式

RDB 的创建和载入

Redis 服务器默认是通过 RDB 方式完成持久化的,对应 redis.conf 文件的配置项如下:

# RDB文件的名称

dbfilename dump.rdb

# 备份RDB和AOF文件存放路径

dir /usr/local/var/db/redis/

手动触发保存

  1. SAVE 命令

SAVE 是一个同步式的命令,它会阻塞 Redis 服务器进程,直到 RDB 文件创建完成为止。在服务器进程阻塞期间,服务器不能处理任何其他命令请求。

  • 客户端命令
127.0.0.1:6379> SAVE

OK
  1. BGSAVE 命令

BGSAVE 是一个异步式的命令,和 SAVE 命令直接阻塞服务器进程的做法不同,BGSAVE 命令会派生出一个子进程,由子进程负责创建 RDB 文件,服务器进程(父进程)继续处理客户的命令。

  • 客户端命令
127.0.0.1:6379> BGSAVE

Background saving started

  1. 客户端发起 BGSAVE 命令,Redis 主进程判断当前是否存在正在执行备份的子进程,如果存在则直接返回
  2. 父进程 fork 一个子进程 (fork 的过程中会造成阻塞的情况),这个过程可以使用 info stats 命令查看 latest_fork_usec 选项,查看最近一次 fork 操作消耗的时间,单位是微秒
  3. 父进程 fork 完成之后,则会返回 Background saving started 的信息提示,此时 fork 阻塞解除
  4. fork 创建的子进程开始根据父进程的内存数据生成临时的快照文件,然后替换原文件
  5. 子进程备份完毕后向父进程发送完成信息,父进程更新统计信息

自动触发保存

因为 BGSAVE 命令可以在不阻塞服务器进程的情况下执行,所以 Redis 的配置文件 redis.conf 提供了一个 save 选项,让服务器每隔一段时间自动执行一次 BGSAVE 命令。用户可以通过 save 选项设置多个保存条件,只要其中任意一个条件被满足,服务器就会执行 BGSAVE 命令。 Redis 配置文件 redis.conf 默认配置了以下 3 个保存条件:

save 900 1

save 300 10

save 60 10000

那么只要满足以下 3 个条件中的任意一个,BGSAVE 命令就会被自动执行:

  • 服务器在 900 秒之内,对数据库进行了至少 1 次修改。
  • 服务器在 300 秒之内,对数据库进行了至少 10 次修改。
  • 服务器在 60 秒之内,对数据库进行了至少 10000 次修改。

启动自动载入

和使用 SAVE 和 BGSAVE 命令创建 RDB 文件不同,Redis 没有专门提供用于载入 RDB 文件的命令,RDB 文件的载入过程是在 Redis 服务器启动时自动完成的。启动时只要在指定目录检测到 RDB 文件的存在,Redis 就会通过 rdbLoad 函数自动载入 RDB 文件。

由于 AOF 文件属于增量的写入命令备份,RDB 文件属于全量的数据备份,所以更新频率比 RDB 文件的更新频率高。所以如果 Redis 服务器开启了 AOF 持久化功能,那么服务器会优先使用 AOF 文件来还原数据库状态;只有在 AOF 的持久化功能处于关闭状态时,服务器才会使用使用 RDB 文件还原数据库状态。

文件格式

RDB 文件有固定的格式要求,它保存的是二进制数据,大体可以分为以下 5 部分:

  • REDIS:文件头保存的是长为 5 个字节的 REDIS 字符,用于标识当前文件为 RDB 类型
  • db_version:一个 4 个字节长的整数字符串,用于记录 RDB 文件的版本号
  • aux:记录着 RDB 文件中元数据信息,包含 8 个附加

    • redis-ver:Redis 实例的版本号
  • redis-bits:运行 Redis 实例的主机架构,64 位或 32 位
  • ctime:RDB 创建时的 Unix 时间戳
  • used_mem:存储快照时使用的内存大小
  • repl-stream-db:Redis 服务器的 db 的索引
  • repl-id:Redis 主实例的 ID(replication id)
  • repl-offset:Redis 主实例的偏称量(replication offset)
  • aof-preamble:是否在 AOF 文件头部放置 RDB 快照(即开启混合持久化)
  • databases:部分包含着零个或者任意多个数据库,以及各个数据库的键值对数据
  • EOF:是 1 个字节的常量,用于标志 RDB 文件的正文内容结束
  • check_sum:一个 8 字节长的整数,保存着由前面四个部分计算得到的校验和,用于检测 RDB 文件的完整性

1. database

一个 RDB 文件的 databases 部分包含着零个或者任意多个数据库(database),而每个非空的 database 都包含 SELECTDB、db_number 以及 key_value_pairs 三个部分:

  • SELECTDB:长度为一个字节的常量,告诉用户程序接下来要读取的是一个 db_number
  • db_number:保存着一个数据库编号。当程序读到 db_number 时,服务器会立即调用 SELECT 命令切换到对应编号的数据库
  • key_value_pairs:保存了数据库中的所有键值对数据,包括带过期时间和不带过期时间两种类型的键值对

2. key_value_pairs

RDB 的 key_value_pairs 部分保存了一个或者多个键值对,如果键值对有过期时间,过期时间会被保存在键值对的前面。下面是这两种键值对的内部结构:

  • EXPIREMENT_MS:长度为一个字节的常量,告诉用户程序接下来要读取的是一个以毫秒为单位的过期时间
  • ms:一个长度为 8 个字节的整数,记录着键值对的过期时间,是一个以毫秒为单位的时间戳
  • TYPE:记录了 value 的类型,长度为 1 个字节。每个 TYPE 常量都代表了一种对象类型或者底层编码, 当服务器读入 RDB 文件中的键值对数据时, 程序会根据 TYPE 的值来决定如何读入和解释 value 的数据。它的值定义通常为以下常量之一:

    • REDIS_RDB_TYPE_STRING:字符串
  • REDIS_RDB_TYPE_LIST:列表类型
  • REDIS_RDB_TYPE_SET:集合类型
  • REDIS_RDB_TYPE_ZSET:有序集合
  • REDIS_RDB_TYPE_HASH:哈希类型
  • REDIS_RDB_TYPE_LIST_ZIPLIST:列表类型
  • REDIS_RDB_TYPE_SET_INT_SET:集合类型
  • REDIS_RDB_TYPE_ZSET_ZIPLIST:有序集合
  • REDIS_RDB_TYPE_HASH_ZIPLIST:哈希类型
  • key:一个字符串对象,编码格式和 REDIS_RDB_TYPE_STRING 类型的 value 一样
  • value:取决于 TYPE 的类型,对象类型可以是 string、list、set、zset 和 hash

AOF 持久化

AOF(Append Only File)会把 Redis 服务器每次执行的写命令记录到一个日志文件中,当服务器重启时再次执行 AOF 文件中的命令来恢复数据。解决了数据持久化的实时性

AOF 的创建和载入

默认情况下 AOF 功能是关闭的,Redis 只会通过 RDB 完成数据持久化的。开启 AOF 功能需要 redis.conf 文件中将 appendonly 配置项修改为 yes,这样在开启 AOF 持久化功能的同时,将基于 RDB 的快照持久化置于低优先级。修改 redis.conf 如下:

# 此选项为AOF功能的开关,默认为no,通过yes来开启aof功能

appendonly yes

# 指定AOF文件名称

appendfilename appendonly.aof

# 备份RDB和AOF文件存放路径

dir /usr/local/var/db/redis/

AOF 的创建

重启 Redis 服务器进程以后,dir 目录下会生成一个 appendonly.aof 文件,由于此时服务器未执行任何写指令,因此 AOF 文件是空的。执行以下命令写入几条测试数据:

127.0.0.1:6379> SADD fruits "apple" "banana" "orange"

(integer) 3

127.0.0.1:6379> LPUSH numbers 128 256 512

(integer) 3

127.0.0.1:6379> SET msg "hello"

OK

AOF 文件是纯文本格式的,上述写命令按顺序被写入了 appendonly.aof 文件(省掉换行符 '\r\n'):

/usr/local/var/db/redis$ cat appendonly.aof

*2 $6 SELECT $1 0//2为词数,6为字符长度

*5 $4 SADD $6 fruits $5 apple $6 banana $6 orange

*5 $5 LPUSH $7 numbers $3 128 $3 256 $3 512

*3 $3 SET $3 msg $5 hello

RDB 持久化的方式是将 apple、banana、orange 的键值对数据保存为 RDB 的二进制文件,而 AOF 是通过把 Redis 服务器执行的 SADD、LPUSH、SET 等命令保存到 AOF 的文本文件中。

AOF 的载入

再次重启 Redis 服务器进程,观察启动日志会发现 Redis 会通过 AOF 文件加载数据:

52580:M 15 Sep 2019 16:09:47.015 # Server initialized

52580:M 15 Sep 2019 16:09:47.015 * DB loaded from append only file: 0.001 seconds

52580:M 15 Sep 2019 16:09:47.015 * Ready to accept connections

通过命令读取 AOF 文件还原的键值对数据:

127.0.0.1:6379> SMEMBERS fruits

1) "apple"

2) "orange"

3) "banana"

127.0.0.1:6379> LRANGE numbers 0 -1

1) "512"

2) "256"

3) "128"

127.0.0.1:6379> GET msg

"hello"

AOF 的执行流程

AOF 不需要设置任何触发条件,对 Redis 服务器的所有写命令都会自动记录到 AOF 文件中

AOF 文件的写入流程可以分为以下 3 个步骤:

  1. 命令追加(append):将 Redis 执行的写命令追加到 AOF 的缓冲区 aof_buf
  2. 文件写入(write)和文件同步(fsync):AOF 根据对应的策略将 aof_buf 的数据同步到硬盘
  3. 文件重写(rewrite):定期对 AOF 进行重写,从而实现对写命令的压缩。

命令追加

Redis 使用单线程处理客户端命令,为避免每次有写命令就直接写入磁盘,导致磁盘 IO 成为 Redis 的性能瓶颈,Redis 先把执行的写命令追加到一个 aof_buf 缓冲区,而不是直接写入文件。

在 AOF 文件中,除了用于指定数据库的 select 命令(比如:select 0 为选中 0 号数据库)是由 Redis 添加的,其他都是客户端发送来的写命令。

文件写入和文件同步

Redis 提供了多种 AOF 缓存区的文件同步策略,相关策略涉及到操作系统的 write() 函数和 fsync() 函数,说明如下:

  1. write()

为了提高文件的写入效率,当用户调用 write 函数将数据写入文件时,操作系统会先把数据写入到一个内存缓冲区里,当缓冲区被填满或超过了指定时限后,才真正将缓冲区的数据写入到磁盘里。

  1. fsync()

虽然操作系统底层对 write() 函数进行了优化 ,但也带来了安全问题。如果宕机内存缓冲区中的数据会丢失,因此系统同时提供了同步函数 fsync() ,强制操作系统立刻将缓冲区中的数据写入到磁盘中,从而保证了数据持久化。

Redis 提供了 appendfsync 配置项来控制 AOF 缓存区的文件同步策略,appendfsync 可配置以下三种策略:

appendfsync always:每执行一次命令保存一次

命令写入 aof_buf 缓冲区后立即调用系统 fsync 函数同步到 AOF 文件,fsync 操作完成后线程返回,整个过程是阻塞的。这种情况下,每次有写命令都要同步到 AOF 文件,硬盘 IO 成为性能瓶颈,Redis 只能支持大约几百 TPS 写入,严重降低了 Redis 的性能。

appendfsync no:不保存

命令写入 aof_buf 缓冲区后调用系统 write 操作,不对 AOF 文件做 fsync 同步;同步由操作系统负责,通常同步周期为 30 秒。这种情况下,文件同步的时间不可控,且缓冲区中堆积的数据会很多,数据安全性无法保证。

appendfsync everysec:每秒钟保存一次

命令写入 aof_buf 缓冲区后调用系统 write 操作,write 完成后线程立刻返回,fsync 同步文件操作由单独的进程每秒调用一次。everysec 是前述两种策略的折中,是性能和数据安全性的平衡,因此也是 Redis 的默认配置,也是比较推崇的配置选项。

文件重写

随着命令不断写入 AOF,文件会越来越大,导致文件占用空间变大,数据恢复时间变长。为了解决这个问题,Redis 引入了重写机制来对 AOF 文件中的写命令进行合并,进一步压缩文件体积。

AOF 文件重写指的是把 Redis 进程内的数据转化为写命令,同步到新的 AOF 文件中,然后使用新的 AOF 文件覆盖旧的 AOF 文件,这个过程不对旧的 AOF 文件的进行任何读写操作。

AOF 重写过程提供了手动触发和自动触发两种机制:

  • 手动触发:直接调用 bgrewriteaof 命令,该命令的执行与 bgsave 有些类似,都是 fork 子进程进行具体的工作,且都只有在 fork 时会阻塞
  • 自动触发:根据 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 配置项,以及 aof_current_size 和 aof_base_size 的状态确定触发时机
  • auto-aof-rewrite-min-size:执行 AOF 重写时,文件的最小体积,默认值为 64MB
  • auto-aof-rewrite-percentage:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值

重写流程

下面以手动触发 AOF 重写为例,当 bgrewriteaof 命令被执行时,AOF 文件重写的流程如下:

  1. 客户端通过 bgrewriteaof 命令对 Redis 主进程发起 AOF 重写请求
  2. 当前不存在正在执行 bgsave/bgrewriteaof 的子进程时,Redis 主进程通过 fork 操作创建子进程,这个过程主进程是阻塞的。如果发现 bgrewriteaof 子进程直接返回;如果发现 bgsave 子进程则等 bgsave 执行完成后再执行 fork 操作
  3. 主进程的 fork 操作完成后,继续处理其他命令,把新的写命令同时追加到 aof_buf 和 aof_rewrite_buf 缓冲区中
  4. 在文件重写完成之前,主进程会继续把写命令追加到 aof_buf 缓冲区,根据 appendfsync 策略同步到旧的 AOF 文件,这样可以避免 AOF 重写失败造成数据丢失,保证原有的 AOF 文件的正确性
  5. 由于 fork 操作运用写时复制技术,子进程只能共享 fork 操作时的内存数据,主进程会把新命令追加到一个 aof_rewrite_buf 缓冲区中,避免 AOF 重写时丢失这部分数据
  6. 子进程读取 Redis 进程中的数据快照,生成写入命令并按照命令合并规则批量写入到新的 AOF 文件
  7. 子进程写完新的 AOF 文件后,向主进程发信号,主进程更新统计信息,具体可以通过 info persistence 查看
  8. 主进程接收到子进程的信号以后,将 aof_rewrite_buf 缓冲区中的写命令追加到新的 AOF 文件
  9. 主进程使用新的 AOF 文件替换旧的 AOF 文件,AOF 重写过程完成

压缩机制

文件重写之所以能够压缩 AOF 文件的大小,原因在于以下几方面:

  • 过期的数据不再写入 AOF 文件
  • 无效的命令不再写入 AOF 文件。比如:重复为数据设值(set mykey v1, set mykey v2)、删除键值对数据(sadd myset v1, del myset)等等
  • 多条命令可以合并为单个。比如:sadd myset v1, sadd myset v2, sadd myset v3 可以合并为 sadd myset v1 v2 v3。不过为了防止单条命令过大造成客户端缓冲区溢出,对于 list、set、hash、zset 类型的 key,并不一定只使用单条命令,而是以某个 Redis 定义的一个常量为界,将命令拆分为多条

数据恢复机制

前面提到当 AOF 持久化功能开启时,Redis 服务器启动时优先执行 AOF 文件的命令恢复数据,只有当 AOF 功能关闭时,才会优先载入 RDB 快照的文件数据。当 AOF 功能开启,且 AOF 文件不存在时,即使 RDB 文件存在也不会加载

RDB 的优缺点

优点

  • RDB 是一个压缩过的非常紧凑的文件,保存着某个时间点的数据集,适合做数据的备份、灾难恢复
  • 可以最大化 Redis 的性能,在保存 RDB 文件,服务器进程只需 fork 一个子进程来完成 RDB 文件的创建,父进程不需要做 IO 操作
  • 与 AOF 持久化方式相比,恢复大数据集的时候会更快

缺点

  • RDB 的数据安全性是不如 AOF 的,保存整个数据集是个重量级的过程,根据配置可能要几分钟才进行一次持久化,如果服务器宕机,那么就可能丢失几分钟的数据
  • Redis 数据集较大时,fork 的子进程要完成快照会比较耗费 CPU 和时间

AOF 的优缺点

优点

  • 数据更完整,安全性更高,秒级数据丢失(取决于 fsync 策略,如果是 everysec,最多丢失 1 秒的数据)
  • AOF 文件是一个只进行追加的命令文件,且写入操作是以 Redis 协议的格式保存的,内容是可读的,适合误删紧急恢复

缺点

  • 对于相同的数据集,AOF 文件的体积要远远大于 RDB 文件,数据恢复也会比较慢
  • 根据所使用的 fsync 策略,AOF 的速度可能会慢于 RDB。不过在一般情况下, 每秒 fsync 的性能依然非常高

RDB-AOF 混合持久化

在重启 Redis 服务器时,一般很少使用 RDB 快照文件来恢复内存状态,因为会丢失大量数据。更多的是使用 AOF 文件进行命令重放,但是执行 AOF 命令性能相对 RDB 来说要慢很多。这样在 Redis 数据很大的情况下,启动需要消耗大量的时间。

鉴于 RDB 快照可能会造成数据丢失,AOF 指令恢复数据慢,Redis 4.0 版本提供了一套基于 AOF-RDB 的混合持久化机制,保留了两种持久化机制的优点。这样重写的 AOF 文件由两部分组成,一部分是 RDB 格式的头部数据,另一部分是 AOF 格式的尾部指令。

如图所示,将 RDB 数据文件的内容和增量的 AOF 命令文件存在一起。这里的 AOF 命令不再是全量的命令,而是自持久化开始到持久化结束的这段时间服务器进程执行的增量 AOF 命令,通常这部分 AOF 命令很小。

9.数据结构

Redis核心对象

在Redis中有一个**「核心的对象」**叫做redisObject ,是用来表示所有的key和value的,用redisObject结构体来表示String、Hash、List、Set、ZSet五种数据类型。

在redisObject中**「type表示属于哪种数据类型,encoding表示该数据的存储方式」**,也就是底层的实现的该数据类型的数据结构。

在Redis中设置一个字符串key 234,然后查看这个字符串的存储类型就会看到为int类型,非整数型的使用的是embstr储存类型,具体操作如下图所示:

String类型

String类型的数据结构存储方式有三种int、raw、embstr。

int

Redis中规定假如存储的是**「整数型值」,比如set num 123这样的类型,就会使用 int的存储方式进行存储,在redisObject的「ptr属性」**中就会保存该值。把这个value当作字符串来看,它的长度不能超过20

SDS

假如存储的**「字符串是一个字符串值并且长度大于44个字节」就会使用SDS(simple dynamic string)方式进行存储,并且encoding设置为raw;若是「字符串长度小于等于44个字节」**就会将encoding改为embstr来保存字符串,只读,修改后转变为raw。

SDS称为**「简单动态字符串」**,对于SDS中的定义在Redis的源码中有的三个属性int len、int free、char buf[]

len保存了字符串的长度,free表示buf数组中未使用的字节数量,buf数组则是保存字符串的每一个字符元素。

因此当你在Redsi中存储一个字符串Hello时,根据Redis的源代码的描述可以画出SDS的形式的redisObject结构图如下图所示:

SDS与c语言字符串对比

Redis使用SDS作为存储字符串的类型肯定是有自己的优势,SDS与c语言的字符串相比,SDS对c语言的字符串做了自己的设计和优化,具体优势有以下几点:

(1)c语言中的字符串并不会记录自己的长度,因此**「每次获取字符串的长度都会遍历得到,时间的复杂度是O(n)」**,而Redis中获取字符串只要读取len的值就可,时间复杂度变为O(1)。

(2)「c语言」中两个字符串拼接,若是没有分配足够长度的内存空间就「会出现缓冲区溢出的情况」;而**「SDS」会先根据len属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以「不会出现缓冲区溢出的情况」**。

(3)SDS还提供**「空间预分配」「惰性空间释放」两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能「减少连续的执行字符串增长带来内存重新分配的次数」**。

当字符串被缩短的时候,SDS也不会立即回收不适用的空间,而是通过free属性将不使用的空间记录下来,等后面使用的时候再释放。

具体的空间预分配原则是:「当修改字符串后的长度len小于1MB,就会预分配和len一样长度的空间,即len=free;若是len大于1MB,free分配的空间大小就为1MB」

(4)SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据);而c语言中的字符串是以空字符串作为结束符,一些图片中含有结束符,因此不是二进制安全的。

Hash类型

Hash对象的实现方式有两种分别是ziplist、hashtable,其中hashtable的存储方式key是String类型的,value也是以key value的形式进行存储。

hashtable

通过key计算出数组下标,不同的是计算法方式不同,HashMap中是以hash函数的方式,而hashtable中计算出hash值后,还要通过sizemask 属性和哈希值再次得到数组下标。

hash表最大的问题就是hash冲突,为了解决hash冲突,假如hashtable中不同的key通过计算得到同一个index,就会形成单向链表(「链地址法」

rehash

value对象以每一个dictEntry的对象进行存储,当hash表中的存放的键值对不断的增加或者减少时,需要对hash表进行一个扩展或者收缩。

这里就会和HashMap一样也会就进行rehash操作,进行重新散列排布。从上图中可以看到有ht[0]ht[1]两个对象

在hash表结构定义中有四个属性分别是dictEntry **table、unsigned long size、unsigned long sizemask、unsigned long used,分别表示的含义就是**「哈希表数组、hash表大小、用于计算索引值,总是等于size-1、hash表中已有的节点数」**。

ht[0]是用来最开始存储数据的,当要进行扩展或者收缩时,ht[0]的大小就决定了ht[1]的大小,ht[0]中的所有的键值对就会重新散列到ht[1]中。

扩展操作:ht[1]扩展的大小是比当前 ht[0].used 值的二倍大的第一个 2 的整数幂;收缩操作:ht[0].used 的第一个大于等于的 2 的整数幂。

当ht[0]上的所有的键值对都rehash到ht[1]中,会重新计算所有的数组下标值,当数据迁移完后ht[0]就会被释放,然后将ht[1]改为ht[0],并新创建ht[1],为下一次的扩展和收缩做准备。

渐进式rehash

假如在rehash的过程中数据量非常大,Redis不是一次性把全部数据rehash成功,这样会导致Redis对外服务停止,Redis内部为了处理这种情况采用**「渐进式的rehash」**。

Redis将所有的rehash的操作分成多步进行,直到都rehash完成,具体的实现与对象中的rehashindex属性相关,「若是rehashindex 表示为-1表示没有rehash操作」

当rehash操作开始时会将该值改成0,在渐进式rehash的过程**「更新、删除、查询会在ht[0]和ht[1]中都进行」**,比如更新一个值先更新ht[0],然后再更新ht[1]。

而新增操作直接就新增到ht[1]表中,ht[0]不会新增任何的数据,这样保证**「ht[0]只减不增,直到最后的某一个时刻变成空表」**,这样rehash操作完成。

ziplist(较少,较小时选择)

压缩列表(ziplist)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。

压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间,压缩列表的内存结构图如下:

压缩列表中每一个节点表示的含义如下所示:

  1. zlbytes:4个字节的大小,记录压缩列表占用内存的字节数。
  2. zltail:4个字节大小,记录表尾节点距离起始地址的偏移量,用于快速定位到尾节点的地址。
  3. zllen:2个字节的大小,记录压缩列表中的节点数。
  4. entry:表示列表中的每一个节点。
  5. zlend:表示压缩列表的特殊结束符号'0xFF'

再压缩列表中每一个entry节点又有三部分组成,包括previous_entry_ength、encoding、content

  1. previous_entry_ength表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。
  2. encoding:这里保存的是content的内容类型和长度。
  3. content:content保存的是每一个节点的内容。

存储用户数据

普通

SDS

List类型

Redis中的列表在3.2之前的版本是使用ziplistlinkedlist进行实现的。在3.2之后的版本就是引入了quicklist

linkedlist是一个双向链表,他和普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度为O(1),但是查询的时间复杂度确实O(n)。

linkedlist和quicklist的底层实现是采用链表进行实现,在c语言中并没有内置的链表这种数据结构,Redis实现了自己的链表结构。

Redis中链表的特性:

  1. 每一个节点都有指向前一个节点和后一个节点的指针。
  2. 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
  3. 链表有自己长度的信息,获取长度的时间复杂度为O(1)。

Set集合

Redis中列表和集合都可以用来存储字符串,但是**「Set是不可重复的集合,而List列表可以存储相同的字符串」**,Set集合是无序的这个和后面讲的ZSet有序集合相对。

Set的底层实现是**「ht和intset」,Set是一个特殊的value为空的Hash。**

inset也叫做整数集合,底层为数组。查找为二等查找,用于保存整数值的数据结构类型,它可以保存int16_tint32_t 或者int64_t 的整数值。

  1. Set集合中必须是64位有符号的十进制整型;
  2. 元素个数不能超过set-max-intset-entries配置,默认512;

在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。

在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:

  1. 首先扩展底层数组的大小,并且数组的类型为新元素的类型。
  2. 然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
  3. 整数集合升级后就不会再降级,编码会一直保持升级后的状态。

ZSet集合

ZSet是有序集合,ZSet的底层实现是ziplistskiplist实现的,ziplist上面已经详细讲过,这里来讲解skiplist的结构实现。

skiplist也叫做**「跳跃表」**,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。

skiplist由如下几个特点:

  1. 有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
  2. 每一层都是一个有序链表,只扫包含两个节点,头节点和尾节点。
  3. 每一层的每一个每一个节点都含有指向同一层下一个节点下一层同一个位置节点的指针。
  4. 如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。

具体实现的结构图如下所示:

在跳跃表的结构中有head和tail表示指向头节点尾节点的指针,能后快速的实现定位。level表示层数,len表示跳跃表的长度,BW表示后退指针,在从尾向前遍历的时候使用。

BW下面还有两个值分别表示分值(score)和成员对象(各个节点保存的成员对象)。

跳跃表的实现中,除了最底层的一层保存的是原始链表的完整数据,上层的节点数会越来越少,并且跨度会越来越大。

跳跃表的上面层就相当于索引层,都是为了找到最后的数据而服务的,数据量越大,跳表所体现的查询的效率就越高,和平衡树的查询效率相差无几。

String:缓存、限流、计数器、分布式锁、分布式Session

Hash:存储用户信息、用户主页访问量、组合查询

List:微博关注人时间轴列表、简单队列

Set:赞、踩、标签、好友关系

Zset:排行榜

10.Pipelining管道

Redis是一种基于客户端-服务端模型以及请求/响应协议的TCP服务器。

  • client发送请求到server端,然后需要阻塞等待server端的响应
  • server端处理请求然后返回响应给client

在这种模式中,数据包必须从client发送到server端,然后再从server端返回到client端,这个时间叫做RTT(Round Trip Time)。假设Redis Server每秒能处理100k请求,但是RTT是250ms,这样Redis Server实际每秒只能处理4个请求,而且这种影响会随着网络延迟越大而逐渐加剧。所带来的直接影响:

  • 阻塞客户端线程或进程,消耗资源,大量请求时无疑降低client性能
  • 降低server端的吞吐量

为了解决这一问题,需要一种client无等待响应的方式发送请求至server的模式,即Redis pipelining。它使得client能够无等待响应的方式连续发送多条命令请求至Redis Server端,然后Server端按照请求顺序返回响应结果。

  • Client: INCR X
  • Server: 1
  • Client: INCR X
  • Server: 2
  • Client: INCR X
  • Server: 3
  • Client: INCR X
  • Server: 4
  • Client: INCR X
  • Client: INCR X
  • Client: INCR X
  • Client: INCR X
  • Server: 1
  • Server: 2
  • Server: 3
  • Server: 4

Redis Pipelining降低了高延迟网络中request/response方式带来的请求应答的环回时间消耗:

  • 无序等待响应的方式发送请求,减少等待时间(特别在高延迟网络中)
  • 一次性返回响应,减少多次响应的带来的时间消耗

更重要的一点是,在request/response的方式中,逐次发送请求至server端,server端每次都需要read/write,这里的read/write是systcall,涉及到内核态和用户态的切换,非常消耗系统资源。Redis pipelining的方式尽量减少了这种系统状态的切换开销。因此它不仅减少了RTT,同时也减少了IO调用次数。

所以Redis Pipelining的特点:

  • 批量流水线发送
  • 一次返回相应的响应

这两个特点就决定了应用场景必须有以下特点:

  • 由于是批量流水线发送,所以每个请求之间需要无状态,即后请求不依赖前请求的响应结果
  • 由于是一次返回响应,所以响应中可能会存在有些请求命令执行成功,有些失败。所以应用场景必须能够容忍数据处理错误或者数据丢失的风险,或者能够接受通过其他机制弥补丢失或者错误的数据方式

因此它一般适用于批量缓存数据或者预加载缓存数据。因为不需要逐条发送缓存的数据,可以以pipelining方式发送。因为是缓存数据,缓存的场景能够容忍某一些数据缓存失败,无非是没有命中,再次加载单条加载至缓存。

需要注意:

Redis的Pipeline和Transaction不同,Transaction会存储客户端的命令,在最后一次性执行,而Pipeline则是处理一条,响应一条。但是这里却有一点,就是客户端并不会调用read去读取socket里面的缓冲数据,这也就造就了,如果Redis应答的数据填满了该接收缓冲(SO_RECVBUF),那么客户端就会通过ACK,WIN=0(接收窗口)来控制服务端不能再发送数据,那样子数据就会缓冲在Redis的客户端应答列表里面。所以需要注意控制Pipeline的大小,否则会消耗Redis的内存。

11.存在问题

缓存穿透

指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。

解决办法:

(1)布隆过滤:对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃。

(2)布隆过滤器: 将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

(3)缓存空对象: 将 null 变成一个值,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

缓存空对象会有两个问题:

①空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

②缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

缓存雪崩

缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,这就是缓存雪崩。

解决方法:

(1)要保证redis的高可用,可以使用主从+哨兵或redis cluster,避免服务器不可用;

(2)二级缓存:当redis查询不到结果,可以从本地缓存中查询,如果还是查不到,就要从数据库中查询

(3)设置缓存时间:需要给redis缓存中的key值设置过期时间时,尽量不要设置同一时间,如果业务场景允许可以将缓存时间加个随机数。

缓存击穿

缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

2.解决方法:

(1)维护一个定时任务,将快要过期的key重新设置;

(2)可以使用分布式锁,当在缓存中拿不到数据时,使用分布式锁去数据库中拿到数据后,重新设置到缓存

12.bigkey

在Redis中,一个字符串最大512MB,一个二级数据结构(例如hash、list、set、zset)可以存储大约40亿个(2^32-1)个元素,但实际上中如果下面两种情况,我就会认为它是bigkey。

  • 字符串类型:它的big体现在单个value值很大,一般认为超过10KB就是bigkey。
  • 非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多。

实际中根据需求而定

危害

1.内存空间不均匀

这样会不利于集群对内存的统一管理,存在丢失数据的隐患。

2.超时阻塞

由于Redis单线程的特性,操作bigkey的通常比较耗时,也就意味着阻塞Redis可能性越大,这样会造成客户端阻塞或者引起故障切换,它们通常出现在慢查询中。

3.网络拥塞

bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例造成影响。

4.过期删除

有个bigkey,它安分守己(只执行简单的命令,例如hget、lpop、zscore等),但它设置了过期时间,当它过期后,会被删除,如果没有使用Redis 4.0的过期异步删除(lazyfree-lazy-expire yes),就会存在阻塞Redis的可能性,而且这个过期删除不会从主节点的慢查询发现(因为这个删除不是客户端产生的,是内部循环事件,可以从latency命令中获取或者从slave节点慢查询发现)。

目前对于集合键的删除,redis提供了异步删除方式,主线程中只是断开了数据库与该键的引用关系,真正的删除动作通过队列异步交由另外的子线程处理。对应的,异步删除需要使用新的删除命令unlink。另外,时间事件循环中也会周期性删除过期键,这里的删除也可以采用异步删除方式,不过需要配置lazyfree-lazy-expire=yes。

5.迁移困难

当需要对bigkey进行迁移(例如Redis cluster的迁移slot),实际上是通过migrate命令来完成的,migrate实际上是通过dump + restore + del三个命令组合成原子命令完成,如果是bigkey,可能会使迁移失败可能提升,而且较慢的migrate会阻塞Redis。

怎么产生的

一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的,来看几个:

(1) 社交类:粉丝列表,如果某些明星或者大v不精心设计下,必是bigkey。

(2) 统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey。

(3) 缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用,但有两个地方需要注意:

  • 第一,是不是有必要把所有字段都缓存
  • 第二,有没有相关关联的数据

如何发现

redis-cli提供了--bigkeys来查找bigkey,例如下面就是一次执行结果:

-------- summary -------

Biggest string found 'user:1' has 5 bytes

Biggest list found 'taskflow:175448' has 97478 items

Biggest set found 'redisServerSelect:set:11597' has 49 members

Biggest hash found 'loginUser:t:20180905' has 863 fields

Biggest zset found 'hotkey:scan:instance:zset' has 3431 members

40 strings with 200 bytes (00.00% of keys, avg size 5.00)

2747619 lists with 14680289 items (99.86% of keys, avg size 5.34)

2855 sets with 10305 members (00.10% of keys, avg size 3.61)

13 hashs with 2433 fields (00.00% of keys, avg size 187.15)

830 zsets with 14098 members (00.03% of keys, avg size 16.99)

可以看到--bigkeys给出了每种数据结构的top 1 bigkey,同时给出了每种数据类型的键值个数以及平均大小。

bigkeys对问题的排查非常方便,但是在使用它时候也有几点需要注意:

  • 建议在从节点执行,因为--bigkeys也是通过scan完成的。
  • 建议在节点本机执行,这样可以减少网络开销。
  • 如果没有从节点,可以使用--i参数,例如(--i 0.1 代表100毫秒执行一次)
  • --bigkeys只能计算每种数据结构的top1

debug object ${key}命令获取键值的相关信息:

127.0.0.1:6379> hlen big:hash

(integer) 5000000

127.0.0.1:6379> debug object big:hash

Value at:0x7fda95b0cb20 refcount:1 encoding:hashtable serializedlength:87777785 lru:9625559 lru_seconds_idle:2

(1.08s)

其中serializedlength表示key对应的value序列化之后的字节数,当然如果是字符串类型,完全看可以执行strlen,例如:

127.0.0.1:6379> strlen key

(integer) 947394

可以用scan + debug object的方式遍历Redis所有的键值,找到你需要阈值的数据了。

但是在使用debug object时候一定要注意以下几点:

  • debug object bigkey本身可能就会比较慢,它本身就会存在阻塞Redis的可能
  • 建议在从节点执行
  • 建议在节点本地执行
  • 如果不关系具体字节数,完全可以使用scan + strlen|hlen|llen|scard|zcard替代,他们都是o(1)

memory usage命令

可以计算每个键值的字节数(自身、以及相关指针开销),例如下面是一次执行结果:

127.0.0.1:6379> memory usage big:hash

(integer) 318663444

如何删除

1.字符串一般来说,对于string类型使用del命令不会产生阻塞。

2. hash

使用hscan命令,每次获取部分(例如100个)field-value,再利用hdel删除每个field(为了快速可以使用pipeline)。

3. list

Redis并没有提供lscan这样的API来遍历列表类型,但是提供了ltrim这样的命令可以渐进式的删除列表元素,直到把列表删除。

4.set

使用sscan命令,每次获取部分(例如100个)元素,再利用srem删除每个元素。

5. sorted set

使用zscan命令,每次获取部分(例如100个)元素,再利用zremrangebyrank删除元素。

13.缓存一致性

mp.weixin.qq.com/s/dYvM8_6SQ…