一、redis可以用来做消息队列么

redis可以做消息队列,可以利用list 和 streams 两个方案比较如下图所示

java redis pop并发 redis spop 并发问题_客户端

BRPOP:堵塞读取,不需要一直轮询获取数据

BRPOPLPUSH:是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。(我理解如果消费成功还需要手动维护删除消费成功的消息,要不消息堆积太多,也难也分辨出哪些是消费成功的 哪些是消费失败的)而streams则是通过XACK确认消息成功消费后,自动删除,比较优雅

基于List,如果生产者消息发送很快,但是消费者处理消息的速度比较慢,这就导致 List 中的消息越积越多,给 Redis 的内存带来很大压力。所以不太建议用list

基于Stream,可以利用消费组下多个消费者来一起消费消息

二、为什么不建议用redis做消息队列

一、无法保证数据的完整性(容易丢失消息)

1、生产者在发布消息时异常:
a) 网络故障或其他问题导致发布失败(直接返回错误,消息根本没发出去)
b) 网络抖动导致发布超时(可能发送数据包成功,但读取响应结果超时了,不知道结果如何)
情况a还好,消息根本没发出去,那么重新发一次就好了。但是情况b没办法知道到底有没有发布成功,所以也只能再发一次。所以这两种情况,生产者都需要重新发布消息,直到成功为止(一般设定一个最大重试次数,超过最大次数依旧失败的需要报警处理)。这就会导致消费者可能会收到重复消息的问题,所以消费者需要保证在收到重复消息时,依旧能保证业务的正确性(设计幂等逻辑),一般需要根据具体业务来做,例如使用消息的唯一ID,或者版本号配合业务逻辑来处理。
2、消费者在处理消息时异常:
也就是消费者把消息拿出来了,但是还没处理完,消费者就挂了。这种情况,需要消费者恢复时,依旧能处理之前没有消费成功的消息。使用List当作队列时,也就是利用备份队列来保证,代价是增加了维护这个备份队列的成本。而Streams则是采用ack的方式,消费成功后告知中间件,这种方式处理起来更优雅,成熟的队列中间件例如RabbitMQ、Kafka都是采用这种方式来保证消费者不丢消息的。
3、消息队列中间件丢失消息
上面2个层面都比较好处理,只要客户端和服务端配合好,就能保证生产者和消费者都不丢消息。但是,如果消息队列中间件本身就不可靠,也有可能会丢失消息,毕竟生产者和消费这都依赖它,如果它不可靠,那么生产者和消费者无论怎么做,都无法保证数据不丢失。
a) 在用Redis当作队列或存储数据时,是有可能丢失数据的:一个场景是,如果打开AOF并且是每秒写盘,因为这个写盘过程是异步的,Redis宕机时会丢失1秒的数据。而如果AOF改为同步写盘,那么写入性能会下降。另一个场景是,如果采用主从集群,如果写入量比较大,从库同步存在延迟,此时进行主从切换,也存在丢失数据的可能(从库还未同步完成主库发来的数据就被提成主库)。总的来说,Redis不保证严格的数据完整性和主从切换时的一致性。我们在使用Redis时需要注意。

b) 而采用RabbitMQ和Kafka这些专业的队列中间件时,就没有这个问题了。这些组件一般是部署一个集群,生产者在发布消息时,队列中间件一般会采用写多个节点+预写磁盘的方式保证消息的完整性,即便其中一个节点挂了,也能保证集群的数据不丢失。当然,为了做到这些,方案肯定比Redis设计的要复杂(毕竟是专们针对队列场景设计的)。

三、redis堵塞点 

1. 和客户端交互时的阻塞点

第一个阻塞点:集合全量查询和聚合操作(集合的聚合统计操作,例如求交、并和差集,CPU密集型计算,可以使用 SCAN 命令,分批读取数据,再在客户端进行聚合计算)

第二个阻塞点:bigkey 删除操作(删除操作的本质是要释放键值对占用的内存空间。释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序,所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞。)

第三个阻塞点:清空数据库。(涉及到删除和释放所有的键值对)

删除操作并不需要给客户端返回具体的数据结果,所以可以使用后台子线程来异步执行删除操作。

2. 和磁盘交互时的阻塞点

第四个阻塞点:AOF 日志同步写(一个同步写磁盘的操作的耗时大约是 1~2ms,如果有大量的写操作需要记录在 AOF 日志中,并同步写回的话,就会阻塞主线程了)

AOF 日志同步写”,为了保证数据可靠性,Redis 实例需要保证 AOF 日志中的操作记录已经落盘,这个操作虽然需要实例等待,但它并不会返回具体的数据结果给实例。所以,也可以启动一个子线程来执行 AOF 日志的同步写,而不用让主线程等待 AOF 日志的写完成。

3. 主从节点交互时的阻塞点

第五个阻塞点:加载 RDB 文件(从库在清空当前数据库后,还需要把 RDB 文件加载到内存,这个过程的快慢和 RDB 文件的大小密切相关,RDB 文件越大,加载过程越慢,可以把主库的数据量大小控制在 2~4GB 左右,以保证 RDB 文件能以较快的速度加载。)

4. 切片集群实例交互时的阻塞点

异步的子线程机制

Redis 主线程启动后,会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别由它们负责 AOF 日志写操作、键值对删除以及文件关闭的异步执行。

主线程通过一个链表形式的任务队列和子线程进行交互。当收到键值对删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间(Lazy-free,只有达到一定条件了,在删除key释放内存时,才会真正放到异步线程中执行,其他情况一律还是在主线程操作)。当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到任务队列中。后台子线程读取任务后,开始自行写入 AOF 日志,这样主线程就不用一直等待 AOF 日志写完了。下面这张图展示了 Redis 中的异步子线程执行机制

java redis pop并发 redis spop 并发问题_客户端_02

 四、如何应对redis变慢

三个因素,自身特性,操作系统,文件系统

自身特性:

1、慢查询命令(1.用其他高效命令代替。比如说,如果需要返回一个 SET 中的所有成员时,不要使用 SMEMBERS 命令,而是要使用 SCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。在使用SCAN命令时,不会漏key,但可能会得到重复的key。2.当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。3.keys 会查询所有键值对)

2、过期key操作(以前是同步删除会造成堵塞,4.0以后是异步删除)

Redis 键值对的 key 可以设置过期时间。默认情况下,Redis 每 100 毫秒会删除一些过期 key

具体的算法如下:

1、采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的 key 全部删除;

2、如果超过 25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下。

文件系统

AOF 日志提供了三种日志写回策略:no、everysec、always。这三种写回策略依赖文件系统的两个系统调用完成,也就是 write 和 fsync。

当AOF写回策略配置为 everysec 时,Redis 会使用后台的子线程异步完成 fsync 的操作(刷回磁盘)。

在使用 AOF 日志时,为了避免日志文件不断增大,Redis 会执行 AOF 重写,Redis 使用子进程来进行 AOF 重写。但是有一个潜在的风险点:由于 fsync 后台子线程和 AOF 重写子进程的存在,主 IO 线程一般不会被阻塞。但是,如果在重写日志时,AOF 重写子进程的写入量比较大,fsync 线程也会被阻塞,进而阻塞主线程(主线程会监控子线程是否处理完成),导致延迟增加。如下图所示

java redis pop并发 redis spop 并发问题_数据_03

 

解决方案:可以把配置项 no-appendfsync-on-rewrite 设置为 yes 这样AOF 重写日志就不会调用fsync,直接返回即可,也就不会影响到主线程(风险点:宕机会丢失数据)

操作系统:

1、swap

触发 swap 的原因主要是物理机器内存不足

解决方案:增加机器的内存或者使用 Redis 集群。

2、内存大页

redis采用写时复制机制,一旦有数据要被修改,Redis 并不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。

如果采用了内存大页,那么,即使客户端请求只修改 100B 的数据,Redis 也需要拷贝 2MB 的大页。

解决方案:关闭内存大页

9 个检查点的 Checklist,

1、获取 Redis 实例在当前环境下的基线性能。

2、是否用了慢查询命令?如果是的话,就使用其他命令替代慢查询命令,或者把聚合计算命令放在客户端做。

3、是否对过期 key 设置了相同的过期时间?对于批量删除的 key,可以在每个 key 的过期时间上加一个随机数,避免同时删除。

4、是否存在 bigkey? 对于 bigkey 的删除操作,如果你的 Redis 是 4.0 及以上的版本,可以直接利用异步线程机制减少主线程阻塞;如果是 Redis 4.0 以前的版本,可以使用 SCAN 命令迭代删除;对于 bigkey 的集合查询和聚合操作,可以使用 SCAN 命令在客户端完成。

5、Redis AOF 配置级别是什么?业务层面是否的确需要这一可靠性级别?如果我们需要高性能,同时也允许数据丢失,可以将配置项 no-appendfsync-on-rewrite 设置为 yes,避免 AOF 重写和 fsync 竞争磁盘 IO 资源,导致 Redis 延迟增加。当然, 如果既需要高性能又需要高可靠性,最好使用高速固态盘作为 AOF 日志的写入盘。

6、Redis 实例的内存使用是否过大?发生 swap 了吗?如果是的话,就增加机器内存,或者是使用 Redis 集群,分摊单机 Redis 的键值对数量和内存压力。同时,要避免出现 Redis 和其他内存需求大的应用共享机器的情况。

7、在 Redis 实例的运行环境中,是否启用了透明大页机制?如果是的话,直接关闭内存大页机制就行了。

8、是否运行了 Redis 主从集群?如果是的话,把主库实例的数据量大小控制在 2~4GB,以免主从复制时,从库因加载大的 RDB 文件而阻塞。

9、是否使用了多核 CPU 或 NUMA 架构的机器运行 Redis 实例?使用多核 CPU 时,可以给 Redis 实例绑定物理核;使用 NUMA 架构时,注意把 Redis 实例和网络中断处理程序运行在同一个 CPU Socket 上。

五、内存碎片

删除数据后,为什么内存占用率还是很高?

这是因为,当数据删除后,Redis 释放的内存空间会由内存分配器管理,并不会立即返回给操作系统。所以,操作系统仍然会记录着给 Redis 分配了大量内存。

而Redis 释放的内存空间可能会有好多不连续的内存碎片,所以导致有很多空闲的内存,但是存不进去数据

内存碎片的形成有内因和外因两个层面的原因。简单来说,内因是操作系统的内存分配机制,外因是 Redis 的负载特征

内因:内存分配器的分配策略

redis用的jemalloc,jemalloc 的分配策略之一,是按照一系列固定的大小划分内存空间,例如 8 字节、16 字节、32 字节、48 字节,…, 2KB、4KB、8KB 等。当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间。所以就导致有的元素申请的是是20字节,但是分配器给分配的是32字节,就会有12字节的碎片

外因:键值对大小不一样和删改操作(会导致空间的扩容和释放)

如何清理内存碎片

当有数据把一块连续的内存空间分割成好几块不连续的空间时,操作系统就会把数据拷贝到别处。此时,数据拷贝需要能把这些数据原来占用的空间都空出来,把原本不连续的内存空间变成连续的空间,如下图所示

java redis pop并发 redis spop 并发问题_数据_04

 

但是碎片清理是有代价的,操作系统需要把多份数据拷贝到新位置,把原有空间释放出来,这会带来时间开销。因为 Redis 是单线程,在数据拷贝时,Redis 只能等着,这就导致 Redis 无法及时处理请求,性能就会降低

可以通过设置参数,来控制碎片清理的开始和结束时机,以及占用的 CPU 比例,从而减少碎片清理对 Redis 本身请求处理的性能影响。

这两个参数分别设置了触发内存清理的一个条件,如果同时满足这两个条件,就开始清理。在清理的过程中,只要有一个条件不满足了,就停止自动清理。

active-defrag-ignore-bytes 100mb:表示内存碎片的字节数达到 100MB 时,开始清理;

active-defrag-threshold-lower 10:表示内存碎片空间占操作系统分配给 Redis 的总空间比例达到 10% 时,开始清理。

自动内存碎片清理功能在执行时,还会监控清理操作占用的 CPU 时间,而且还设置了两个参数,分别用于控制清理操作占用的 CPU 时间比例的上、下限,既保证清理工作能正常进行,又避免了降低 Redis 性能。这两个参数具体如下:

active-defrag-cycle-min 25: 表示自动清理过程所用 CPU 时间的比例不低于 25%,保证清理能正常开展;

active-defrag-cycle-max 75:表示自动清理过程所用 CPU 时间的比例不高于 75%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞 Redis,导致响应延迟升高。

六、缓冲区

缓冲区分成了客户端的输入和输出缓冲区,以及主从集群中主节点上的复制缓冲区和复制积压缓冲区

客户端输入和输出缓冲区(主要使用两类客户端和 Redis 服务器端交互,分别是普通客户端,以及订阅了 Redis 频道的订阅客户端也就是pub/sub,普通客户端是堵塞式发送,订阅不是堵塞式)溢出会导致网络连接关闭

为了避免客户端和服务器端的请求发送和处理速度不匹配,服务器端给每个连接的客户端都设置了一个输入缓冲区和输出缓冲区,输入缓冲区会先把客户端发送过来的命令暂存起来,Redis 主线程再从输入缓冲区中读取命令,进行处理。当 Redis 主线程处理完数据后,会把结果写入到输出缓冲区,再通过输出缓冲区返回给客户端,如下图

java redis pop并发 redis spop 并发问题_Redis_05

 

主从集群中的缓冲区分为复制缓冲区和复制积压缓冲区:复制缓冲区是增量复制的时候用到了,如果溢出了,会导致主从复制失败,主节点在把接收到的写命令同步给从节点时,同时会把这些写命令写入复制积压缓冲区。一旦从节点发生网络闪断,再次和主节点恢复连接后,从节点就会从复制积压缓冲区中,读取断连期间主节点接收到的写命令,进而进行增量同步(主从会各自记住自己的偏移量),复制积压缓冲区是个环形,不会溢出,会覆盖之前的数据,如果之前的数据从库还没来得及同步,就会造成主从节点间重新开始执行全量复制。