Redis高级特性和应用

慢查询

许多存储系统(例如 MySQL)提供慢查询日志帮助开发和运维人员定位系统存在的慢操作。所谓慢查询日志就是系统在命令执行前后计算每条命令的执行时间,当超过预设阈值,就将这条命令的相关信息(例如:发生时间,耗时,命令的详细信息)记录下来,Redis也提供了类似的功能。

Redis客户端执行一条命令分为如下4个部分: 1、发送命令

2、命令排队

3、命令执行

4、返回结果

需要注意,慢查询只统计步骤3的时间,所以没有慢查询并不代表客户端没有超时问题。因为有可能是命令的网络问题或者是命令在Redis在排队,所以不是说命令执行很慢就说是慢查询,而有可能是网络的问题或者是Redis服务非常繁忙(队列等待长)。

慢查询配置

动态设置

慢查询的阈值默认值是10毫秒

参数:slowlog-log-slower-than就是时间预设阀值,它的单位是微秒(1秒=1000毫秒=1 000 000微秒),默认值是10 000,假如执行了一条"很慢"的命令(例如keys *),如果它的执行时间超过了10 000微秒,也就是10毫秒,那么它将被记录在慢查询日志中。 我们通过动态命令修改

js 复制代码
127.0.0.1:6379> config set slowlog-log-slower-than 20000 
OK

//使用config set完后,若想将配置持久化保存到Redis.conf,要执行config rewrite
127.0.0.1:6379> config rewrite
(error) ERR Rewriting config file: Device or resource busy //不过我一直不行,之前报没有权限,现在又报这个,麻了

配置文件设置

打开Redis的配置文件redis.conf,就可以看到以下配置: slowlog-max-len用来设置慢查询日志最多存储多少条 实际上Redis服务器将所有的慢查询日志保存在服务器状态的slowlog链表中(内存列表),slowlog-max-len就是列表的最大长度(默认128条)。当慢查询日志列表被填满后,新的慢查询命令则会继续入队,队列中的第一条数据就会出列。虽然慢查询日志是存放在Redis内存列表中的,但是Redis并没有告诉我们这里列表是什么,而是通过一组命令来实现对慢查询日志的访问和管理。并没有说明存放在哪。这个怎么办呢?Redis提供了一系列的慢查询操作命令让我们可以方便的操作。

慢查询操作命令

获取慢查询日志

js 复制代码
//获取慢查询日志列表当前的长度
slowlog len
//慢查询日志重置
slowlog reset

慢查询建议

慢查询功能可以有效地帮助我们找到Redis可能存在的瓶颈,但在实际使用过程中要注意以下几点:

slowlog-max-len配置建议:

建议调大慢查询列表,记录慢查询时Redis会对长命令做截断操作,并不会占用大量内存。增大慢查询列表可以减缓慢查询被剔除的可能,线上生产建议设置为1000以上。

slowlog-log-slower-than配置建议: 配置建议:默认值超过10毫秒判定为慢查询,需要根据Redis并发量调整该值。

由于Redis采用单线程响应命令,对于高流量的场景,如果命令执行时间在1毫秒以上,那么Redis最多可支撑OPS不到1000。因此对于高OPS场景的Redis建议设置为1毫秒或者更低比如100微秒。慢查询只记录命令执行时间,并不包括命令排队和网络传输时间。因此客户端执行命令的时间会大于命令实际执行时间。因为命令执行排队机制,慢查询会导致其他命令级联阻塞,因此当客户端出现请求超时,需要检查该时间点是否有对应的慢查询,从而分析出是否为慢查询导致的命令级联阻塞。 由于慢查询日志是一个先进先出的队列,也就是说如果慢查询比较多的情况下,可能会丢失部分慢查询命令,为了防止这种情况发生,可以定期执行slow get命令将慢查询日志持久化到其他存储中。

Pipeline

其中1和4花费的时间称为Round Trip Time (RTT,往返时间),也就是数据在网络上传输的时间。

Redis提供了批量操作命令(例如mget、mset等),有效地节约RTT。

但大部分命令是不支持批量操作的,例如要执行n次 hgetall命令,并没有mhgetall命令存在,需要消耗n次RTT。

举例:Redis的客户端和服务端可能部署在不同的机器上。例如客户端在本地,Redis服务器在阿里云的广州,两地直线距离约为800公里,那么1次RTT时间=800 x2/ ( 300000×2/3 ) =8毫秒,(光在真空中传输速度为每秒30万公里,这里假设光纤为光速的2/3 )。而Redis命令真正执行的时间通常在微秒(1000微妙=1毫秒)级别,所以才会有Redis 性能瓶颈是网络这样的说法。

Pipeline(流水线)机制能改善上面这类问题,它能将一组 Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端,没有使用Pipeline执行了n条命令,整个过程需要n次RTT。
使用Pipeline 执行了n次命令,整个过程需要1次RTT。 Pipeline并不是什么新的技术或机制,很多技术上都使用过。而且RTT在不同网络环境下会有不同,例如同机房和同机器会比较快,跨机房跨地区会比较慢。 redis-cli的--pipe选项实际上就是使用Pipeline机制,但绝大部分情况下,我们使用Java语言的Redis客户端中的Pipeline会更多一点。

结论:

1、Pipeline执行速度一般比逐条执行要快。

2、客户端和服务端的网络延时越大,Pipeline的效果越明显。

Pipeline虽然好用,但是每次Pipeline组装的命令个数不能没有节制,否则一次组装Pipeline数据量过大,一方面会增加客户端的等待时间,另一方面会造成一定的网络阻塞,可以将一次包含大量命令的Pipeline拆分成多次较小的Pipeline来完成,比如可以将Pipeline的总发送大小控制在内核输入输出缓冲区大小之内或者控制在单个TCP 报文最大值1460字节之内。

内核的输入输出缓冲区大小一般是4K-8K,不同操作系统会不同(当然也可以配置修改)

最大传输单元(Maximum Transmission Unit,MTU),这个在以太网中最大值是1500字节。那为什么单个TCP 报文最大值是1460,因为因为还要扣减20个字节的IP头和20个字节的TCP头,所以是1460。

同时Pipeline只能操作一个Redis实例,但是即使在分布式Redis场景中,也可以作为批量操作的重要优化手段。

事务

Redis提供了简单的事务功能,将一组需要一起执行的命令放到multi和exec两个命令之间。multi 命令代表事务开始,exec命令代表事务结束。另外discard命令是回滚。Redis提供了简单的事务功能,将一组需要一起执行的命令放到multi和exec两个命令之间。multi 命令代表事务开始,exec命令代表事务结束。另外discard命令是回滚。

一个客户端:

另一个客户端:在事务没提交的时候查询,查不到数据

提交后可以查到:

可以看到sadd命令此时的返回结果是QUEUED,代表命令并没有真正执行,而是暂时保存在Redis中的一个缓存队列(所以discard也只是丢弃这个缓存队列中的未执行命令,并不会回滚已经操作过的数据,这一点要和关系型数据库的Rollback操作区分开)。只有当exec执行后,用户A关注用户B的行为才算完成,如下所示exec返回的两个结果对应sadd命令。但是要注意Redis的事务功能很弱。在事务回滚机制上,Redis只能对基本的语法错误进行判断。

如果事务中的命令出现错误,Redis 的处理机制也不尽相同。

1、语法命令错误 例如下面操作错将set写成了sett,属于语法错误,会造成整个事务无法执行,事务内的操作都没有执行.

2.运行时错误 例如:事务内第一个命令简单的设置一个string类型,第二个对这个key进行sadd命令,这种就是运行时命令错误,因为语法是正确的

可以看到Redis并不支持回滚功能,第一个set命令已经执行成功,开发人员需要自己修复这类问题。

redis事务原理

事务是Redis实现在服务器端的行为,用户执行MULTI命令时,服务器会将对应这个用户的客户端对象设置为一个特殊的状态,在这个状态下后续用户执行的查询命令不会被真的执行,而是被服务器缓存起来,直到用户执行EXEC命令为止,服务器会将这个用户对应的客户端对象中缓存的命令按照提交的顺序依次执行。

redis的watch命令

有些应用场景需要在事务之前,确保事务中的key没有被其他客户端修改过,才执行事务,否则不执行(类似乐观锁)。Redis 提供了watch命令来解决这类问题。

客户端1:

客户端2:

客户端1继续:

可以看到"客户端-1"在执行multi之前执行了watch命令,"客户端-2"在"客户端-1"执行exec之前修改了key值,造成客户端-1事务没有执行(exec结果为nil)。

Pipeline和事务的区别

PipeLine看起来和事务很类似,感觉都是一批批处理,但两者还是有很大的区别。简单来说。

1、pipeline是客户端的行为,对于服务器来说是透明的,可以认为服务器无法区分客户端发送来的查询命令是以普通命令的形式还是以pipeline的形式发送到服务器的;

2、而事务则是实现在服务器端的行为,用户执行MULTI命令时,服务器会将对应这个用户的客户端对象设置为一个特殊的状态,在这个状态下后续用户执行的查询命令不会被真的执行,而是被服务器缓存起来,直到用户执行EXEC命令为止,服务器会将这个用户对应的客户端对象中缓存的命令按照提交的顺序依次执行。

3、应用pipeline可以提服务器的吞吐能力,并提高Redis处理查询请求的能力。

但是这里存在一个问题,当通过pipeline提交的查询命令数据较少,可以被内核缓冲区所容纳时,Redis可以保证这些命令执行的原子性。然而一旦数据量过大,超过了内核缓冲区的接收大小,那么命令的执行将会被打断,原子性也就无法得到保证。因此pipeline只是一种提升服务器吞吐能力的机制,如果想要命令以事务的方式原子性的被执行,还是需要事务机制,或者使用更高级的脚本功能以及模块功能。

4、可以将事务和pipeline结合起来使用,减少事务的命令在网络上的传输时间,将多次网络IO缩减为一次网络IO。

Redis提供了简单的事务,之所以说它简单,主要是因为它不支持事务中的回滚特性,同时无法实现命令之间的逻辑关系计算,当然也体现了Redis 的"keep it simple"的特性.

lua

lua入门

Redis中的lus

Redis与限流

使用Redis+Lua语言实现限流

方案好处: 支持分布式

使用lua脚本的好处: 减少网络开销,原子操作,复用

限流算法

固定窗口算法

滑动窗口算法

在线演示滑动窗口网站:media.pearsoncmg.com/aw/ecs_kuro... 滑动窗口通俗来讲就是一种流量控制技术。

它本质上是描述接收方的TCP数据报缓冲区大小的数据,发送方根据这个数据来计算自己最多能发送多长的数据,如果发送方收到接收方的窗口大小为0的TCP数据报,那么发送方将停止发送数据,等到接收方发送窗口大小不为0的数据报的到来。

首先是第一次发送数据这个时候的窗口大小是根据链路带宽的大小来决定的。我们假设这个时候窗口的大小是3。这个时候接受方收到数据以后会对数据进行确认告诉发送方我下次希望手到的是数据是多少。这里我们看到接收方发送的ACK=3(这是发送方发送序列2的回答确认,下一次接收方期望接收到的是3序列信号)。这个时候发送方收到这个数据以后就知道我第一次发送的3个数据对方只收到了2个。就知道第3个数据对方没有收到。下次在发送的时候就从第3个数据开始发。

此时窗口大小变成了2 。

于是发送方发送2个数据。看到接收方发送的ACK是5就表示他下一次希望收到的数据是5,发送方就知道我刚才发送的2个数据对方收了这个时候开始发送第5个数据。

这就是滑动窗口的工作机制,当链路变好了或者变差了这个窗口还会发生变话,并不是第一次协商好了以后就永远不变了。

所以滑动窗口协议,是TCP使用的一种流量控制方法。该协议允许发送方在停止并等待确认前可以连续发送多个分组。由于发送方不必每发一个分组就停下来等待确认,因此该协议可以加速数据的传输。

只有在接收窗口向前滑动时(与此同时也发送了确认),发送窗口才有可能向前滑动。

收发两端的窗口按照以上规律不断地向前滑动,因此这种协议又称为滑动窗口协议。

TCP中的滑动窗口

发送方和接收方都会维护一个数据帧的序列,这个序列被称作窗口。发送方的窗口大小由接收方确认,目的是控制发送速度,以免接收方的缓存不够大导致溢出,同时控制流量也可以避免网络拥塞。 在TCP 的可靠性的图中,我们可以看到,发送方每发送一个数据接收方就要给发送方一个ACK对这个数据进行确认。只有接收了这个确认数据以后发送方才能传输下个数据。

存在的问题:如果窗口过小,当传输比较大的数据的时候需要不停的对数据进行确认,这个时候就会造成很大的延迟。

如果窗口过大,我们假设发送方一次发送100个数据,但接收方只能处理50个数据,这样每次都只对这50个数据进行确认。发送方下一次还是发送100个数据,但接受方还是只能处理50个数据。这样就避免了不必要的数据来拥塞我们的链路。

漏桶算法

定义

先有一个桶,桶的容量是固定的。

以任意速率向桶流入水滴,如果桶满了则溢出(被丢弃)。

桶底下有个洞,按照固定的速率从桶中流出水滴。

特点

漏桶核心是:请求来了以后,直接进桶,然后桶根据自己的漏洞大小慢慢往外面漏。

具体实现的时候要考虑性能(比如Redis实现的时候数据结构的操作是不是会导致性能问题)

令牌算法

定义

先有一个桶,容量是固定的,是用来放令牌的。

以固定速率向桶放令牌,如果桶满了就不放令牌了。

Ø处理请求是先从桶拿令牌,先拿到令牌再处理请求,拿不到令牌同样也被限流了。

特点

突发情况下可以一次拿多个令牌进行处理。

发布和订阅

Redis提供了基于"发布/订阅"模式的消息机制,此种模式下,消息发布者和订阅者不进行直接通信,发布者客户端向指定的频道( channel)发布消息,订阅该频道的每个客户端都可以收到该消息。

发布消息

js 复制代码
//publish channel message
127.0.0.1:6379> publish topic niuye
(integer) 0
//返回值是接收到信息的订阅者数量,如果是0说明没有订阅者,这条消息就丢了(再启动订阅者也不会收到)

订阅消息

订阅者可以订阅一个或多个频道,如果此时另一个客户端发布一条消息,当前订阅者客户端会收到消息;如果有多个客户端同时订阅了同一个频道,都会收到消息。客户端在执行订阅命令之后进入了订阅状态(类似于监听),只能接收subscribe、psubscribe,unsubscribe、 punsubscribe的四个命令。

js 复制代码
//先订阅topic1
127.0.0.1:6379> SUBSCRIBE topic1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "topic1"
3) (integer) 1

//然后publish
127.0.0.1:6379> publish topic1 niuye
(integer) 1

//订阅者这边就会收到消息
127.0.0.1:6379> SUBSCRIBE topic1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "topic1"
3) (integer) 1
1) "message"
2) "topic1"
3) "niuye"

查看订阅情况

  1. 查看活跃的频道

    js 复制代码
    //Pubsub 命令用于查看订阅与发布系统状态,包括活跃的频道(是指当前频道至少有一个订阅者),其中[pattern]是可以指定具体的模式,类似于通配符
    127.0.0.1:6379> PUBSUB channels
    1) "topic1"
    
    127.0.0.1:6379> PUBSUB channels t*
    1) "topic1"
    127.0.0.1:6379> PUBSUB channels l*
    (empty array)
    127.0.0.1:6379> 
  2. 查看频道订阅数

    js 复制代码
    127.0.0.1:6379> PUBSUB numsub topic1
    1) "topic1"
    2) (integer) 1
    127.0.0.1:6379> 

使用场景和缺点

需要消息解耦又并不关注消息可靠性的地方都可以使用发布订阅模式。

PubSub 的生产者传递过来一个消息,Redis会直接找到相应的消费者传递过去。如果一个消费者都没有,那么消息直接丢弃。如果开始有三个消费者,一个消费者突然挂掉了,生产者会继续发送消息,另外两个消费者可以持续收到消息。但是挂掉的消费者重新连上的时候,这断连期间生产者发送的消息,对于这个消费者来说就是彻底丢失了。 所以和很多专业的消息队列系统(例如Kafka、RocketMQ)相比,Redis 的发布订阅很粗糙,例如无法实现消息堆积和回溯。但胜在足够简单,如果当前场景可以容忍的这些缺点,也不失为一个不错的选择。正是因为 PubSub 有这些缺点,它的应用场景其实是非常狭窄的

Stream

Redis5.0 最大的新特性就是多出了一个数据结构 Stream,它是一个新的强大的支持多播的可持久化的消息队列,Redis的作者声明Redis Stream地借鉴了 Kafka 的设计。

Sream总述

Redis Stream 的结构如上图所示,每一个Stream都有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。消息是持久化的,Redis 重启后,内容还在.

具体流程如下:

  1. 每个 Stream 都有唯一的名称,它就是 Redis 的 key,在我们首次使用xadd指令追加消息时自动创建。

消息 ID 的形式是timestampInMillis-sequence,例如1527846880572-5,它表示当前的消息在毫米时间戳1527846880572时产生,并且是该毫秒内产生的第 5 条消息。消息 ID 可以由服务器自动生成(*代表默认自动),也可以由客户端自己指定,但是形式必须是整数-整数,而且必须是后面加入的消息的 ID 要大于前面的消息 ID。

  1. 每个 Stream 都可以挂多个消费组,每个消费组会有个游标last_delivered_id在 Stream 数组之上往前移动,表示当前消费组已经消费到哪条消息了。

每个消费组都有一个Stream 内唯一的名称,消费组不会自动创建,它需要单独的指令xgroup create进行创建,需要指定从 Stream 的某个消息 ID 开始消费,这个 ID 用来初始化last_delivered_id变量。

  1. 每个消费组 (Consumer Group) 的状态都是独立的,相互不受影响。也就是说同一份 Stream 内部的消息会被每个消费组都消费到。

  2. 同一个消费组 (Consumer Group) 可以挂载多个消费者 (Consumer),这些消费者之间是竞争关系,任意一个消费者读取了消息都会使游标last_delivered_id往前移动。每个消费者有一个组内唯一名称。

  3. 消费者 (Consumer) 内部会有个状态变量pending_ids,它记录了当前已经被客户端读取,但是还没有 ack的消息。如果客户端没有 ack,这个变量里面的消息 ID 会越来越多,一旦某个消息被 ack,它就开始减少。这个 pending_ids 变量在 Redis 官方被称之为PEL,也就是Pending Entries List,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。

常用操作命令

  1. 生产端

    xadd 追加消息

    js 复制代码
    127.0.0.1:6379> XADD 2023-12-08 * name niuye age 18
    "1702007455525-0"
    127.0.0.1:6379> 
    * 号表示服务器自动生成 ID,后面顺序跟着一堆 key/value
    1626705954593-0 则是生成的消息 ID,由两部分组成:时间戳-序号。时间戳时毫秒级单位,是生成消息的Redis服务器时间,它是个64位整型。序号是在这个毫秒时间点内的消息序号。它也是个64位整型。
    为了保证消息是有序的,因此Redis生成的ID是单调递增有序的。由于ID中包含时间戳部分,为了避免服务器时间错误而带来的问题(例如服务器时间延后了),Redis的每个Stream类型数据都维护一个latest_generated_id属性,用于记录最后一个消息的ID。若发现当前时间戳退后(小于latest_generated_id所记录的),则采用时间戳不变而序号递增的方案来作为新消息ID(这也是序号为什么使用int64的原因,保证有足够多的的序号),从而保证ID的单调递增性质。强烈建议使用Redis的方案生成消息ID,因为这种时间戳+序号的单调递增的ID方案,几乎可以满足你全部的需求。但ID是支持自定义的。

    xrange 获取消息列表,会自动过滤已经删除的消息

    js 复制代码
    127.0.0.1:6379> XRANGE 2023-12-08 - +
    1) 1) "1702007455525-0"
       2) 1) "name"
          2) "niuye"
          3) "age"
          4) "18"
    //其中-表示最小值 , + 表示最大值,或者我们可以指定消息 ID 的列表:
    127.0.0.1:6379> XRANGE stream1 - 1702004743623-0
    1) 1) "1702003736260-0"
       2) 1) "name"
          2) "niuye"
    2) 1) "1702004743623-0"
       2) 1) "name"
          2) "niuye1"
    127.0.0.1:6379> 

    xlen 消息长度

    js 复制代码
    127.0.0.1:6379> xlen stream1
    (integer) 22
    127.0.0.1:6379> 

    del 删除 Stream

    js 复制代码
    127.0.0.1:6379> del 2023-12-08
    (integer) 1
    //xdel可以删除指定的消息(指定ID)
  2. 消费端

    单消费者

    虽然Stream中有消费者组的概念,但是可以在不定义消费组的情况下进行 Stream 消息的独立消费,当 Stream 没有新消息时,甚至可以阻塞等待。Redis 设计了一个单独的消费指令xread,可以将 Stream 当成普通的消息队列 (list) 来使用。使用 xread 时,我们可以完全忽略消费组 (Consumer Group) 的存在,就好比 Stream 就是一个普通的列表 (list)。

    js 复制代码
    127.0.0.1:6379> XRANGE stream1 - +
    1) 1) "1702013972718-0"
       2) 1) "aaa"
          2) "bbbc"
    2) 1) "1702014303621-0"
       2) 1) "a"
          2) "a"
    3) 1) "1702014305801-0"
       2) 1) "a"
          2) "b"
    4) 1) "1702014307800-0"
       2) 1) "a"
          2) "c"
    5) 1) "1702014309568-0"
       2) 1) "a"
          2) "d"
          3) 
    //表示从 Stream 头部读取1条消息,0-0指从头开始
    127.0.0.1:6379> XREAD count 1 streams stream1 0-0
    1) 1) "stream1"
       2) 1) 1) "1702013972718-0"
             2) 1) "aaa"
                2) "bbbc"
    127.0.0.1:6379> 
    
    //也可以指定从streams的消息Id开始(不包括命令中的消息id)
    127.0.0.1:6379> XREAD count 2 streams stream1 1702013972718-0
    1) 1) "stream1"
       2) 1) 1) "1702014303621-0"
             2) 1) "a"
                2) "a"
          2) 1) "1702014305801-0"
             2) 1) "a"
                2) "b"
    127.0.0.1:6379> 
    //xread count 1 streams stream1 $
    //block 0 block后面的数字代表阻塞时间,单位毫秒,0代表一直阻塞
    127.0.0.1:6379> XREAD count 1 block 0 streams stream1 $
    1) 1) "stream1"
       2) 1) 1) "1702015553719-0"
             2) 1) "111"
    (812.66s)

    可以看到看到阻塞解除了,返回了新的消息内容,而且还显示了一个等待时间,这里我们等待了10.82s

    一般来说客户端如果想要使用 xread 进行顺序消费,一定要记住当前消费到哪里了,也就是返回的消息 ID。下次继续调用 xread 时,将上次返回的最后一个消息 ID 作为参数传递进去,就可以继续消费后续的消息。不然很容易重复消息,基于这点单消费者基本上没啥运用场景。

消费组

  1. 创建消费组

    Stream 通过xgroup create指令创建消费组 (Consumer Group),需要传递起始消息 ID 参数用来初始化last_delivered_id变量。0-表示从头开始消费

js 复制代码
127.0.0.1:6379> XRANGE stream1 - +
1) 1) "1702013972718-0"
   2) 1) "aaa"
      2) "bbbc"
2) 1) "1702014303621-0"
   2) 1) "a"
      2) "a"
3) 1) "1702014305801-0"
   2) 1) "a"
      2) "b"
4) 1) "1702014307800-0"
   2) 1) "a"
      2) "c"
5) 1) "1702014309568-0"
   2) 1) "a"
      2) "d"
6) 1) "1702015553719-0"
   2) 1) "111"
      2) "222"

//0-表示从头开始消费
127.0.0.1:6379> XGROUP create stream1 c1 0-0
OK

//$ 表示从尾部开始消费,只接受新消息,当前 Stream 消息会全部忽略
127.0.0.1:6379> XGROUP create stream1 c2 $
OK
 
//现在我们可以用xinfo命令来看看stream1的情况:
127.0.0.1:6379> XINFO stream stream1
 1) "length"  //消息长度为6
 2) (integer) 6
 3) "radix-tree-keys"
 4) (integer) 1
 5) "radix-tree-nodes"
 6) (integer) 2
 7) "last-generated-id" //最后生效的消息ID
 8) "1702015553719-0"
 9) "groups"  //2个消费群组
10) (integer) 2
11) "first-entry"
12) 1) "1702013972718-0"
    2) 1) "aaa"
       2) "bbbc"
13) "last-entry"
14) 1) "1702015553719-0"
    2) 1) "111"
       2) "222"
//查看stream1的消费组的信息
127.0.0.1:6379> xinfo groups stream1
1) 1) "name"
   2) "c1"
   3) "consumers"
   4) (integer) 0
   5) "pending"
   6) (integer) 0
   7) "last-delivered-id"
   8) "0-0"
2) 1) "name"
   2) "c2"
   3) "consumers"
   4) (integer) 0
   5) "pending"
   6) (integer) 0
   7) "last-delivered-id"
   8) "1702015553719-0"
  1. 消息消费

    有了消费组,自然还需要消费者,Stream提供了 xreadgroup 指令可以进行消费组的组内消费,需要提供消费组名称、消费者名称和起始消息 ID。它同 xread 一样,也可以阻塞等待新消息。读到新消息后,对应的消息 ID 就会进入消费者的PEL(正在处理的消息) 结构里,客户端处理完毕后使用 xack 指令通知服务器,本条消息已经处理完毕,该消息 ID 就会从 PEL 中移除。

    js 复制代码
    //consumer1代表消费者的名字,">"表示从当前消费组的 last_delivered_id 后面开始读,每当消费者读取一条消息,last_delivered_id 变量就会前进。前面我们定义c1的时候是从头开始消费的,自然就获得stream1中第一条消息,将Stream1中的消息读取完,很自然就没有消息可读了。
    127.0.0.1:6379> XREADGROUP group c1 consumer1 count 1 streams stream1 >
    1) 1) "stream1"
       2) 1) 1) "1702013972718-0"
             2) 1) "aaa"
                2) "bbbc"
    
    //此时可以看下stream1的groups信息
    127.0.0.1:6379> XINFO groups stream1
    1) 1) "name"
       2) "c1"
       3) "consumers"
       4) (integer) 1
       5) "pending" //表示有1条消息未ack
       6) (integer) 1
       7) "last-delivered-id"
       8) "1702013972718-0" // 这里可以看到last-delivered-id已经从之前的0-0变成现在这个id了
    2) 1) "name"
       2) "c2"
       3) "consumers"
       4) (integer) 0
       5) "pending"
       6) (integer) 0
       7) "last-delivered-id"
       8) "1702015553719-0"
    
    //如果同一个消费组有多个消费者,我们还可以通过 xinfo consumers 指令观察每个消费者的状态
    127.0.0.1:6379> XINFO consumers stream1 c1
    1) 1) "name"
       2) "consumer1"
       3) "pending"
       4) (integer) 1
       5) "idle"
       6) (integer) 854147
    2) 1) "name"
       2) "consumer2"
       3) "pending"
       4) (integer) 1
       5) "idle"
       6) (integer) 99480
    //可以看到目前c1这个消费者有1条待ACK的消息,空闲了854147ms 没有读取消息。
    
    //消息确认
    127.0.0.1:6379> XACK stream1 c1 1702014303621-0 //该id的消息被consumer2消费了,所以consumer2的pending从1变成了0
    (integer) 1
    127.0.0.1:6379> XINFO consumers stream1 c1
    1) 1) "name"
       2) "consumer1"
       3) "pending"
       4) (integer) 1
       5) "idle"
       6) (integer) 944407
    2) 1) "name"
       2) "consumer2"
       3) "pending"
       4) (integer) 0
       5) "idle"
       6) (integer) 189740
    
    //xack允许带多个消息id,比如 同时Stream还提供了命令XPENDING 用来获消费组或消费内消费者的未处理完毕的消息。
    127.0.0.1:6379> XPENDING stream1 c1
    1) (integer) 1
    2) "1702013972718-0"
    3) "1702013972718-0"
    4) 1) 1) "consumer1" //限制c1组中的consumer1还有1条待ack
          2) "1"
    
    //命令XCLAIM[kleɪm]用以进行消息转移的操作,将某个消息转移到自己的Pending[ˈpendɪŋ]列表中。需要设置组、转移的目标消费者和消息ID,同时需要提供IDLE(已被读取时长),只有超过这个时长,才能被转移。具体见http://www.redis.cn/commands/xpending.html

在Redis中实现消息队列

  1. 基于pub/sub

  2. 基于Stream

  3. Redis中几种消息队列实现的总结

    基于List的 LPUSH+BRPOP 的实现

    bash 复制代码
    足够简单,消费消息延迟几乎为零,但是需要处理空闲连接的问题。如果线程一直阻塞在那里,Redis客户端的连接就成了闲置连接,闲置过久,服务器一般会主动断开连接,减少闲置资源占用,这个时候blpop和brpop或抛出异常,所以在编写客户端消费者的时候要小心,如果捕获到异常,还有重试。
    其他缺点包括:做消费者确认ACK麻烦,不能保证消费者消费消息后是否成功处理的问题(宕机或处理异常等),通常需要维护一个Pending列表,保证消息处理确认;不能做广播模式,如pub/sub,消息发布/订阅模型;不能重复消费,一旦消费就会被删除;不支持分组消费。

    基于Sorted-Set的实现

    多用来实现延迟队列,当然也可以实现有序的普通的消息队列,但是消费者无法阻塞的获取消息,只能轮询,不允许重复消息。
    

    PUB/SUB,订阅/发布模式

    vbnet 复制代码
    优点:
    典型的广播模式,一个消息可以发布到多个消费者;多信道订阅,消费者可以同时订阅多个信道,从而接收多类消息;消息即时发送,消息不用等待消费者读取,消费者会自动接收到信道发布的消息。
    
    缺点:
    消息一旦发布,不能接收。换句话就是发布时若客户端不在线,则消息丢失,不能寻回;不能保证每个消费者接收的时间是一致的;若消费者客户端出现消息积压,到一定程度,会被强制断开,导致消息意外丢失。通常发生在消息的生产远大于消费速度时;可见,Pub/Sub 模式不适合做消息存储,消息积压类的业务,而是擅长处理广播,即时通讯,即时反馈的业务。

    基于Stream类型的实现

    基本上已经有了一个消息中间件的雏形,可以考虑在生产过程中使用。
    

消息队列问题

从我们上面对Stream的使用表明,Stream已经具备了一个消息队列的基本要素,生产者API、消费者API,消息Broker,消息的确认机制等等,所以在使用消息中间件中产生的问题,这里一样也会遇到。

Stream 消息太多怎么办?

要是消息积累太多,Stream 的链表岂不是很长,内容会不会爆掉?xdel 指令又不会删除消息,它只是给消息做了个标志位。Redis 自然考虑到了这一点,所以它提供了一个定长 Stream 功能。在 xadd 的指令提供一个定长长度 maxlen,就可以将老的消息干掉,确保最多不超过指定长度。

消息如果忘记 ACK 会怎样?

Stream 在每个消费者结构中保存了正在处理中的消息 ID 列表 PEL,如果消费者收到了消息处理完了但是没有回复 ack,就会导致 PEL 列表不断增长,如果有很多消费组的话,那么这个 PEL 占用的内存就会放大。所以消息要尽可能的快速消费并确认。

PEL 如何避免消息丢失?

在客户端消费者读取 Stream 消息时,Redis 服务器将消息回复给客户端的过程中,客户端突然断开了连接,消息就丢失了。但是 PEL 里已经保存了发出去的消息 ID。待客户端重新连上之后,可以再次收到 PEL 中的消息 ID 列表。不过此时 xreadgroup 的起始消息 ID 不能为参数>,而必须是任意有效的消息 ID,一般将参数设为 0-0,表示读取所有的 PEL 消息以及自last_delivered_id之后的新消息。

死信问题

如果某个消息,不能被消费者处理,也就是不能被XACK,这是要长时间处于Pending列表中,即使被反复的转移给各个消费者也是如此。此时该消息的delivery counter(通过XPENDING可以查询到)就会累加,当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用XDEL语法,注意,这个命令并没有删除Pending中的消息,因此查看Pending,消息还会在,可以在执行执行XDEL之后,XACK这个消息标识其处理完毕。

Stream 的高可用

Stream 的高可用是建立主从复制基础上的,它和其它数据结构的复制机制没有区别,也就是说在 Sentinel 和 Cluster 集群环境下 Stream 是可以支持高可用的。不过鉴于 Redis 的指令复制是异步的,在 failover 发生时,Redis 可能会丢失极小部分数据,这点 Redis 的其它数据结构也是一样的。

分区 Partition

Redis 的服务器没有原生支持分区能力,如果想要使用分区,那就需要分配多个 Stream,然后在客户端使用一定的策略来生产消息到不同的 Stream。

Stream小结

Stream 的消费模型借鉴了Kafka 的消费分组的概念,它弥补了 Redis Pub/Sub 不能持久化消息的缺陷。但是它又不同于 kafka,Kafka 的消息可以分 partition,而 Stream 不行。如果非要分 parition 的话,得在客户端做,提供不同的 Stream 名称,对消息进行 hash 取模来选择往哪个 Stream 里塞。

关于 Redis 是否适合做消息队列,业界一直是有争论的。很多人认为,要使用消息队列,就应该采用 Kafka、RabbitMQ 这些专门面向消息队列场景的软件,而 Redis 更加适合做缓存。

Redis 是一个非常轻量级的键值数据库,部署一个 Redis 实例就是启动一个进程,部署 Redis 集群,也就是部署多个 Redis 实例。而 Kafka、RabbitMQ 部署时,涉及额外的组件,例如 Kafka 的运行就需要再部署ZooKeeper。相比 Redis 来说,Kafka 和 RabbitMQ 一般被认为是重量级的消息队列。 所以,关于是否用 Redis 做消息队列的问题,不能一概而论,我们需要考虑业务层面的数据体量,以及对性能、可靠性、可扩展性的需求。如果分布式系统中的组件消息通信量不大,那么,Redis 只需要使用有限的内存空间就能满足消息存储的需求,而且,Redis 的高性能特性能支持快速的消息读写,不失为消息队列的一个好的解决方案。

Redis的Key和Value的数据结构组织

全局哈希表

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。 哈希桶中的 entry 元素中保存了key和value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。

哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对:我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。

但当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在 的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。

当你往哈希表中写入更多数据时,哈希冲突是不可避免的问题。这里的哈希冲突,两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。 Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

当然如果这个数组一直不变,那么hash冲突会变很多,这个时候检索效率会大打折扣,所以Redis就需要把数组进行扩容(一般是扩大到原来的两倍),但是问题来了,扩容后每个hash桶的数据会分散到不同的位置,这里设计到元素的移动,必定会阻塞IO,所以这个ReHash过程会导致很多请求阻塞。

渐进式rehash

为了避免这个问题,Redis 采用了渐进式 rehash。

首先、Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash。

1、给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍

2、把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中

3、释放哈希表 1 的空间

在上面的第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。 在Redis 开始执行 rehash,Redis仍然正常处理客户端请求,但是要加入一个额外的处理:

处理第1个请求时,把哈希表 1中的第1个索引位置上的所有 entries 拷贝到哈希表 2 中

处理第2个请求时,把哈希表 1中的第2个索引位置上的所有 entries 拷贝到哈希表 2 中

如此循环,直到把所有的索引位置的数据都拷贝到哈希表 2 中。

这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

所以这里基本上也可以确保根据key找value的操作在O(1)左右。

不过这里要注意,如果Redis中有海量的key值的话,这个Rehash过程会很长很长,虽然采用渐进式Rehash,但在Rehash的过程中还是会导致请求有不小的卡顿。并且像一些统计命令也会非常卡顿:比如keys

按照Redis的配置每个实例能存储的最大的key的数量为2的32次方,即2.5亿,但是尽量把key的数量控制在千万以下,这样就可以避免Rehash导致的卡顿问题,如果数量确实比较多,建议采用分区hash存储。

相关推荐
九圣残炎30 分钟前
【springboot】简易模块化开发项目整合Redis
spring boot·redis·后端
小登ai学习2 小时前
简单认识 redis -3 -其他命令
数据库·redis·缓存
BergerLee14 小时前
对不经常变动的数据集合添加Redis缓存
数据库·redis·缓存
huapiaoy15 小时前
Redis中数据类型的使用(hash和list)
redis·算法·哈希算法
【D'accumulation】16 小时前
令牌主动失效机制范例(利用redis)注释分析
java·spring boot·redis·后端
Cikiss16 小时前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
一休哥助手17 小时前
Redis 五种数据类型及底层数据结构详解
数据结构·数据库·redis
盒马盒马18 小时前
Redis:zset类型
数据库·redis
Jay_fearless20 小时前
Redis SpringBoot项目学习
spring boot·redis
Wang's Blog20 小时前
Redis: 集群环境搭建,集群状态检查,分析主从日志,查看集群信息
数据库·redis