概述
常用的消息队列有,rabbitMq、kafka、RocketMq、ActiveMq等。这些消息队列需要独立安装部署,作为一个中间件来提供服务,虽然有着高性能、高可靠的优点,但是额外部署这些中间件也会增加运维成本,和服务器成本。
本篇文章探讨了一下如何使用redis实现消息队列。使用redis无需额外的部署,如果原先就有使用redis的话。此外redis更为轻量也更容易维护。但是redis实现消息队列有多种方案,这些方案有其优点也有其缺点,适用于不同的应用场景。以下从“实时性”、“可靠性”、“功能性”这几个维度做一些对比分析探讨。
一、理论部分
“消息队列”是在消息的传输过程中保存消息的容器。
消息队列常被使用在“流量削峰”、“系统解耦”、“异步调用”这几个方面。
消息队列主要面对的几个问题是,
1、并发性能
2、实时性
3、如何防止消息丢失,保证可靠性
从简单的讲,消息队列就是一个“队列”queue,生产者负责发送消息,消息队列存储消息,消费者则负责接收消息。
在面对一些亿级流量场景,消息队列届的大哥kafka是如何保证高性能的呢?
- Kafka Reactor模型架构
- 页缓存技术+磁盘顺序写
- ZeroCopy:零拷贝技术
- 使用批量消息提升服务端处理能力
https://www.hengyumo.cn/momoblog/detail/202204162051750
那使用redis能否获得和kafka一样的高性能呢?答案是一定的。
redis是如何实现高性能的呢?
- IO多路复用
- 单线程
- 基于内存存储
- 高效数据结构
- 写时拷贝(CopyOnWrite)
- 客户端管道批量命令
- 零拷贝技术
https://www.hengyumo.cn/momoblog/detail/202204162116630
二、实现消息队列
2.1 基于list实现
消息队列的基础结构是队列,而redis正好有相对应的数据结构:list。
实现方式
-
生产者写消息
lpush mq hello1
lpush mq hello2
lpush mq hello3
lpush mq hello4
lpush mq hello5
lpush 命令向指定列表的左边推入元素,以上命令模拟了向mq这个消息队列列表中写入五条消息,分别是hello1 ~ hello5。
同时写入多条也可以跟着多个,如
lpush mq hello6 hello7
- 消费者读取消息
首先,基于list实现的消息队列是可以有效保证实时性的。消费者要如何检测到有新消息推送过来呢?
- 要么是不停自旋调用
llen mq
获取队列的长度,如果不为0则读取。或者自旋调用rpop
不停读取数据。虽然能保证高实时性,但是这会造成redis的性能浪费和消费者本身的性能浪费,严重时会导致系统崩溃。 - 定时调用
llen mq
获取队列的长度。实时性取决于定时任务的频率,如果每100ms一次,则就有100ms的延迟。 - brpop,brpop可以理解为rpop命令的阻塞升级版,
brpop mq 1
,会尝试阻塞读取mq 1秒时间,如果1秒内没有消息则会返回nil,如果有消息,会立即返回。
生产者
127.0.0.1:6379>
127.0.0.1:6379> rpush mqb hello
(integer) 1
消费者
127.0.0.1:6379> brpop mqb 1
1) "mqb"
2) "hello"
127.0.0.1:6379> brpop mqb 1
(nil)
(1.08s)
基于list实现,读取消息可以通过两种方式,一种是rpop
,从列表的右边读取并弹出元素。该操作是原子性的,并发下安全。
rpop mq
rpop mq
依次弹出的是hello1,hello2,按照先进先出的顺序弹出。
第二种方式是lrange
,使用lrange可以实现消息的批量消费,lrange list start stop
读取list的从start - stop之间的元素。读取之后为了防止重复消费,需要使用ltrim start stop
进行清除。因为期间需要进行两个操作,因此不是并发安全的,需要通过分布式锁来保证安全性。此外还存在着事务的问题,如果读取完消息之后进程挂掉,会导致之前已经读取的消息在下次运行时被重复消费。这种方式适用于对消息可靠性要求不高,但是要求处理性能高的情况,如处理大量的日志数据进行分析操作。除了使用ltrim之外也可以使用LREM key count value
来删除已经消费的数据。
如图所示,假设mq中有7条消息,每次消费3条消息。那么第一条命令lrange mq -3 -1
,读取倒数第3到倒数第1之间的所有元素。第二条命令ltrim mq 0 -4
,保留未读取的零到倒数第4条消息,把已经读取的消息删除。
以下演示了在redis-cli中的模拟:
127.0.0.1:6379> lrange mq -3 -1
1) "hello3"
2) "hello2"
3) "hello1"
127.0.0.1:6379> ltrim mq 0 -4
OK
127.0.0.1:6379> lrange mq 0 -1
1) "hello7"
2) "hello6"
3) "hello5"
4) "hello4"
- 多生产者多消费者
-
多生产者
基于list的多生产者是没有问题的,多个生产者同时向mq中推送消息,仍然能保证消息有序。 -
多消费者
- rpop方式 多消费者下并发同样安全,不会出现消息被重复消费的情况
- lrange + ltrim 方式 多消费者下并发不安全,需要使用分布式锁保证有序,否则会出现消息被重复消费的问题。同时不保证事务安全性。需要通过额外手段记录读取mq的位置,以保证宕机复位时不会出现消息重复读取的问题。
- 发布订阅方式
发布订阅简单的理解是将一个消息广播给多个消费者,每个消费者针对该消息只消费一次。
针对发布订阅有两种思路,一种是较为简单的,既然一对一消费可以通过一个list实现,那么一对多消费就使用多个list来一一对应各个消费者:
这里只需要维护一个消费者和消息队列名称映射的列表,生产者发送消息时发送给所有的消费者对应的队列。
消费者读取自己对应的消息队列。
实现起来简单,但是存在两个问题:
- 资源浪费,原本只需要一个列表存储,变成了几个消费者就需要几个列表,而且列表的数据都是相同的,这无疑造成了浪费。当数据量不大,消费者不多时可以不顾及这点。
- 无法保证消息可靠的同步发送到各个队列上。如果生产者写入完mq1之后就宕机了,就好导致只有消费者1接收到了消息,而其它的消费者无法接收到消息,