細心的你可能發現了,本系列課程中竟然出現了三個課時都是在說消息隊列,第 10 課時講了程序級別的消息隊列以及延遲消息隊列的實現,而第 15 課時講了常見的消息隊列中間件 RabbitMQ、Kafka 等,由此可見消息隊列在整個 Java 技術體系中的重要程度。本課時我們將重點來看一下 Redis 是如何實現消息隊列的。
我們本課時的面試題是,在 Redis 中實現消息隊列的方式有幾種?
典型回答
早在 Redis 2.0 版本之前使用 Redis 實現消息隊列的方式有兩種:
- 使用 List 類型實現
- 使用 ZSet 類型實現
其中使用List 類型實現的方式最爲簡單和直接,它主要是通過 lpush、rpop 存入和讀取實現消息隊列的,如下圖所示:
lpush 可以把最新的消息存儲到消息隊列(List 集合)的首部,而 rpop 可以讀取消息隊列的尾部,這樣就實現了先進先出,如下圖所示:
命令行的實現命令如下:
127.0.0.1:6379> lpush mq "java" #推送消息 java
(integer) 1
127.0.0.1:6379> lpush mq "msg" #推送消息 msg
(integer) 2
127.0.0.1:6379> rpop mq #接收到消息 java
"java"
127.0.0.1:6379> rpop mq #接收到消息 msg
"mq"
其中,mq 相當於消息隊列的名稱,而 lpush 用於生產並添加消息,而 rpop 用於拉取並消費消息。
使用 List 實現消息隊列的優點是消息可以被持久化,List 可以藉助 Redis 本身的持久化功能,AOF 或者是 RDB 或混合持久化的方式,用於把數據保存至磁盤,這樣當 Redis 重啓之後,消息不會丟失。
但使用 List 同樣存在一定的問題,比如消息不支持重複消費、沒有按照主題訂閱的功能、不支持消費消息確認等。
ZSet 實現消息隊列的方式和 List 類似,它是利用 zadd 和 zrangebyscore 來實現存入和讀取消息的,這裏就不重複敘述了。但 ZSet 的實現方式更爲複雜一些,因爲 ZSet 多了一個分值(score)屬性,我們可以使用它來實現更多的功能,比如用它來存儲時間戳,以此來實現延遲消息隊列等。
ZSet 同樣具備持久化的功能,List 存在的問題它也同樣存在,不但如此,使用 ZSet 還不能存儲相同元素的值。因爲它是有序集合,有序集合的存儲元素值是不能重複的,但分值可以重複,也就是說當消息值重複時,只能存儲一條信息在 ZSet 中。
在 Redis 2.0 之後 Redis 就新增了專門的發佈和訂閱的類型,Publisher(發佈者)和 Subscriber(訂閱者)來實現消息隊列了,它們對應的執行命令如下:
-
發佈消息,publish channel “message”
-
訂閱消息,subscribe channel
使用發佈和訂閱的類型,我們可以實現主題訂閱的功能,也就是 Pattern Subscribe 的功能。因此我們可以使用一個消費者“queue_*”來訂閱所有以“queue_”開頭的消息隊列,如下圖所示:
發佈訂閱模式的優點很明顯,但同樣存在以下 3 個問題: -
無法持久化保存消息,如果 Redis 服務器宕機或重啓,那麼所有的消息將會丟失;
-
發佈訂閱模式是“發後既忘”的工作模式,如果有訂閱者離線重連之後就不能消費之前的歷史消息;
-
不支持消費者確認機制,穩定性不能得到保證,例如當消費者獲取到消息之後,還沒來得及執行就宕機了。因爲沒有消費者確認機制,Redis 就會誤以爲消費者已經執行了,因此就不會重複發送未被正常消費的消息了,這樣整體的 Redis 穩定性就被沒有辦法得到保障了。
然而在 Redis 5.0 之後新增了 Stream 類型,我們就可以使用 Stream 的 xadd 和 xrange 來實現消息的存入和讀取了,並且 Stream 提供了 xack 手動確認消息消費的命令,用它我們就可以實現消費者確認的功能了,使用命令如下:
127.0.0.1:6379> xack mq group1 1580959593553-0
(integer) 1
相關語法如下:
xack key group-key ID [ID ...]
消費確認增加了消息的可靠性,一般在業務處理完成之後,需要執行 ack 確認消息已經被消費完成,整個流程的執行如下圖所示:
其中“Group”爲羣組,消費者也就是接收者需要訂閱到羣組才能正常獲取到消息。
以上就 Redis 實現消息隊列的四種方式,他們分別是:
- 使用 List 實現消息隊列;
- 使用 ZSet 實現消息隊列;
- 使用發佈訂閱者模式實現消息隊列;
- 使用 Stream 實現消息隊列。
考點分析
本課時的題目比較全面的考察了面試者對於 Redis 整體知識框架和新版本特性的理解和領悟。早期版本中比較常用的實現消息隊列的方式是 List、ZSet 和發佈訂閱者模式,使用 Stream 來實現消息隊列是近兩年才流行起來的方案,並且很多企業也沒有使用到 Redis 5.0 這麼新的版本。因此只需回答出前三種就算及格了,而 Stream 方式實現消息隊列屬於附加題,如果面試中能回答上來的話就更好了,它體現了你對新技術的敏感度與對技術的熱愛程度,屬於面試中的加分項。
和此知識點相關的面試題還有以下幾個:
- 在 Java 代碼中使用 List 實現消息隊列會有什麼問題?應該如何解決?
- 在程序中如何使用 Stream 來實現消息隊列?
知識擴展
使用 List 實現消息隊列
在 Java 程序中我們需要使用 Redis 客戶端框架來輔助程序操作 Redis,比如 Jedis 框架。
使用 Jedis 框架首先需要在 pom.xml 文件中添加 Jedis 依賴,配置如下:
<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${version}</version>
</dependency>
List 實現消息隊列的完整代碼如下:
import redis.clients.jedis.Jedis;
publicclass ListMQTest {
public static void main(String[] args){
// 啓動一個線程作爲消費者
new Thread(() -> consumer()).start();
// 生產者
producer();
}
/**
* 生產者
*/
public static void producer() {
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 推送消息
jedis.lpush("mq", "Hello, List.");
}
/**
* 消費者
*/
public static void consumer() {
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 消費消息
while (true) {
// 獲取消息
String msg = jedis.rpop("mq");
if (msg != null) {
// 接收到了消息
System.out.println("接收到消息:" + msg);
}
}
}
}
以上程序的運行結果是:
接收到消息:Hello, Java.
但是以上的代碼存在一個問題,可以看出以上消費者的實現是通過 while 無限循環來獲取消息,但如果消息的空閒時間比較長,一直沒有新任務,而 while 循環不會因此停止,它會一直執行循環的動作,這樣就會白白浪費了系統的資源。
此時我們可以藉助 Redis 中的阻塞讀來替代 rpop 的方法就可以解決此問題,具體實現代碼如下:
import redis.clients.jedis.Jedis;
public class ListMQExample {
public static void main(String[] args) throws InterruptedException {
// 消費者
new Thread(() -> bConsumer()).start();
// 生產者
producer();
}
/**
* 生產者
*/
public static void producer() throws InterruptedException {
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 推送消息
jedis.lpush("mq", "Hello, Java.");
Thread.sleep(1000);
jedis.lpush("mq", "message 2.");
Thread.sleep(2000);
jedis.lpush("mq", "message 3.");
}
/**
* 消費者(阻塞版)
*/
public static void bConsumer() {
Jedis jedis = new Jedis("127.0.0.1", 6379);
while (true) {
// 阻塞讀
for (String item : jedis.brpop(0,"mq")) {
// 讀取到相關數據,進行業務處理
System.out.println(item);
}
}
}
}
以上程序的運行結果是:
接收到消息:Hello, Java.
以上代碼是經過改良的,我們使用 brpop 替代 rpop 來讀取最後一條消息,就可以解決 while 循環在沒有數據的情況下,一直循環消耗系統資源的情況了。brpop 中的 b 是 blocking 的意思,表示阻塞讀,也就是當隊列沒有數據時,它會進入休眠狀態,當有數據進入隊列之後,它纔會“甦醒”過來執行讀取任務,這樣就可以解決 while 循環一直執行消耗系統資源的問題了。
使用 Stream 實現消息隊列
在開始實現消息隊列之前,我們必須先創建分組纔行,因爲消費者需要關聯分組信息才能正常運行,具體實現代碼如下:
import com.google.gson.Gson;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.StreamEntry;
import redis.clients.jedis.StreamEntryID;
import utils.JedisUtils;
import java.util.AbstractMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class StreamGroupExample {
private static final String _STREAM_KEY = "mq"; // 流 key
private static final String _GROUP_NAME = "g1"; // 分組名稱
private static final String _CONSUMER_NAME = "c1"; // 消費者 1 的名稱
private static final String _CONSUMER2_NAME = "c2"; // 消費者 2 的名稱
public static void main(String[] args) {
// 生產者
producer();
// 創建消費組
createGroup(_STREAM_KEY, _GROUP_NAME);
// 消費者 1
new Thread(() -> consumer()).start();
// 消費者 2
new Thread(() -> consumer2()).start();
}
/**
* 創建消費分組
* @param stream 流 key
* @param groupName 分組名稱
*/
public static void createGroup(String stream, String groupName) {
Jedis jedis = JedisUtils.getJedis();
jedis.xgroupCreate(stream, groupName, new StreamEntryID(), true);
}
/**
* 生產者
*/
public static void producer() {
Jedis jedis = JedisUtils.getJedis();
// 添加消息 1
Map<String, String> map = new HashMap<>();
map.put("data", "redis");
StreamEntryID id = jedis.xadd(_STREAM_KEY, null, map);
System.out.println("消息添加成功 ID:" + id);
// 添加消息 2
Map<String, String> map2 = new HashMap<>();
map2.put("data", "java");
StreamEntryID id2 = jedis.xadd(_STREAM_KEY, null, map2);
System.out.println("消息添加成功 ID:" + id2);
}
/**
* 消費者 1
*/
public static void consumer() {
Jedis jedis = JedisUtils.getJedis();
// 消費消息
while (true) {
// 讀取消息
Map.Entry<String, StreamEntryID> entry = new AbstractMap.SimpleImmutableEntry<>(_STREAM_KEY,
new StreamEntryID().UNRECEIVED_ENTRY);
// 阻塞讀取一條消息(最大阻塞時間120s)
List<Map.Entry<String, List<StreamEntry>>> list = jedis.xreadGroup(_GROUP_NAME, _CONSUMER_NAME, 1,
120 * 1000, true, entry);
if (list != null && list.size() == 1) {
// 讀取到消息
Map<String, String> content = list.get(0).getValue().get(0).getFields(); // 消息內容
System.out.println("Consumer 1 讀取到消息 ID:" + list.get(0).getValue().get(0).getID() +
" 內容:" + new Gson().toJson(content));
}
}
}
/**
* 消費者 2
*/
public static void consumer2() {
Jedis jedis = JedisUtils.getJedis();
// 消費消息
while (true) {
// 讀取消息
Map.Entry<String, StreamEntryID> entry = new AbstractMap.SimpleImmutableEntry<>(_STREAM_KEY,
new StreamEntryID().UNRECEIVED_ENTRY);
// 阻塞讀取一條消息(最大阻塞時間120s)
List<Map.Entry<String, List<StreamEntry>>> list = jedis.xreadGroup(_GROUP_NAME, _CONSUMER2_NAME, 1,
120 * 1000, true, entry);
if (list != null && list.size() == 1) {
// 讀取到消息
Map<String, String> content = list.get(0).getValue().get(0).getFields(); // 消息內容
System.out.println("Consumer 2 讀取到消息 ID:" + list.get(0).getValue().get(0).getID() +
" 內容:" + new Gson().toJson(content));
}
}
}
}
以上代碼運行結果如下:
消息添加成功 ID:1580971482344-0
消息添加成功 ID:1580971482415-0 Consumer 1 讀取到消息
ID:1580971482344-0 內容:{“data”:“redis”} Consumer 2 讀取到消息
ID:1580971482415-0 內容:{“data”:“java”}
其中,jedis.xreadGroup() 方法的第五個參數 noAck 表示是否自動確認消息,如果設置 true 收到消息會自動確認 (ack) 消息,否則需要手動確認。
可以看出,同一個分組內的多個 consumer 會讀取到不同消息,不同的 consumer 不會讀取到分組內的同一條消息。
小貼士:Jedis 框架要使用最新版,低版本 block 設置大於 0 時,會出現 bug,拋連接超時異常。
小結
本課時我們講了 Redis 中消息隊列的四種實現方式:List 方式、ZSet 方式、發佈訂閱者模式、Stream 方式,其中發佈訂閱者模式不支持消息持久化、而其他三種方式支持持久化,並且 Stream 方式支持消費者確認。我們還使用 Jedis 框架完成了 List 和 Stream 的消息隊列功能,需要注意的是在 List 中需要使用 brpop 來讀取消息,而不是 rpop,這樣可以解決沒有任務時 ,while 一直循環浪費系統資源的問題。