Redis 竟然能用 List 實現訊息佇列

碼哥位元組發表於2022-02-17

分散式系統中必備的一箇中介軟體就是訊息佇列,通過訊息佇列我們能對服務間進行非同步解耦、流量消峰、實現最終一致性。

目前市面上已經有 RabbitMQ、RochetMQ、ActiveMQ、Kafka等,有人會問:“Redis 適合做訊息佇列麼?”

在回答這個問題之前,我們先從本質思考:

  • 訊息佇列提供了什麼特性?
  • Redis 如何實現訊息佇列?是否滿足存取需求?

今天,碼哥結合訊息佇列的特點一步步帶大家分析使用 Redis 的 List 作為訊息佇列的實現原理,並分享如何把 SpringBoot 與 Redission 整合運用到專案中。

什麼是訊息佇列

訊息佇列是一種非同步的服務間通訊方式,適用於分散式和微服務架構。訊息在被處理和刪除之前一直儲存在佇列上。

每條訊息僅可被一位使用者處理一次。訊息佇列可被用於分離重量級處理、緩衝或批處理工作以及緩解高峰期工作負載。

訊息佇列

  • Producer:訊息生產者,負責產生和傳送訊息到 Broker;
  • Broker:訊息處理中心。負責訊息儲存、確認、重試等,一般其中會包含多個 queue;
  • Consumer:訊息消費者,負責從 Broker 中獲取訊息,並進行相應處理;

訊息佇列的使用場景有哪些呢?

訊息佇列在實際應用中包括如下四個場景:

  • 應用耦合:傳送方、接收方系統之間不需要了解雙方,只需要認識訊息。多應用間通過訊息佇列對同一訊息進行處理,避免呼叫介面失敗導致整個過程失敗;
  • 非同步處理:多應用對訊息佇列中同一訊息進行處理,應用間併發處理訊息,相比序列處理,減少處理時間;
  • 限流削峰:廣泛應用於秒殺或搶購活動中,避免流量過大導致應用系統掛掉的情況;
  • 訊息驅動的系統:系統分為訊息佇列、訊息生產者、訊息消費者,生產者負責產生訊息,消費者(可能有多個)負責對訊息進行處理;

訊息佇列滿足哪些特性

訊息有序性

訊息是非同步處理的,但是消費者需要按照生產者傳送訊息的順序來消費,避免出現後傳送的訊息被先處理的情況。

重複訊息處理

生產者可能因為網路問題出現訊息重傳導致消費者可能會收到多條重複訊息。

同樣的訊息重複多次的話可能會造成一業務邏輯多次執行,需要確保如何避免重複消費問題。

可靠性

一次保證訊息的傳遞。如果傳送訊息時接收者不可用,訊息佇列會保留訊息,直到成功地傳遞它。

當消費者重啟後,可以繼續讀取訊息進行處理,防止訊息遺漏。

List 實現訊息佇列

Redis 的列表(List)是一種線性的有序結構,可以按照元素被推入列表中的順序來儲存元素,能滿足「先進先出」的需求,這些元素既可以是文字資料,又可以是二進位制資料。

LPUSH

生產者使用 LPUSH key element[element...] 將訊息插入到佇列的頭部,如果 key 不存在則會建立一個空的佇列再插入訊息。

如下,生產者向佇列 queue 先後插入了 「Java」「碼哥位元組」「Go」,返回值表示訊息插入佇列後的個數。

> LPUSH queue Java 碼哥位元組 Go
(integer) 3

RPOP

消費者使用 RPOP key 依次讀取佇列的訊息,先進先出,所以 「Java」會先讀取消費:

> RPOP queue
"Java"
> RPOP queue
"碼哥位元組"
> RPOP queue
"Go"

List佇列

實時消費問題

65 哥:這麼簡單就實現了麼?

別高興的太早,LPUSH、RPOP 存在一個效能風險,生產者向佇列插入資料的時候,List 並不會主動通知消費者及時消費。

我們需要寫一個 while(true) 不停地呼叫 RPOP 指令,當有新訊息就會返回訊息,否則返回空。

程式需要不斷輪詢並判斷是否為空再執行消費邏輯,這就會導致即使沒有新訊息寫入到佇列,消費者也要不停地呼叫 RPOP 命令佔用 CPU 資源。

65 哥:要如何避免迴圈呼叫導致的 CPU 效能損耗呢?

Redis 提供了 BLPOP、BRPOP 阻塞讀取的命令,消費者在在讀取佇列沒有資料的時候自動阻塞,直到有新的訊息寫入佇列,才會繼續讀取新訊息執行業務邏輯。

BRPOP queue 0

引數 0 表示阻塞等待時間無無限制

重複消費

  • 訊息佇列為每一條訊息生成一個「全域性 ID」;
  • 生產者為每一條訊息建立一條「全域性 ID」,消費者把一件處理過的訊息 ID 記錄下來判斷是否重複。

其實這就是冪等,對於同一條訊息,消費者收到後處理一次的結果和多次的結果是一致的。

訊息可靠性

65 哥:消費者從 List 中讀取一條在訊息處理過程中當機了就會導致訊息沒有處理完成,可是資料已經沒有儲存在 List 中了咋辦?

本質就是消費者在處理訊息的時候崩潰了,就無法再還原訊息,缺乏一個訊息確認機制。

Redis 提供了 RPOPLPUSH、BRPOPLPUSH(阻塞)兩個指令,含義是從 List 從讀取訊息的同時把這條訊息複製到另一個 List 中(備份),並且是原子操作。

我們就可以在業務流程正確處理完成後再刪除佇列訊息實現訊息確認機制。如果在處理訊息的時候當機了,重啟後再從備份 List 中讀取訊息處理。

LPUSH redisMQ 公眾號 碼哥位元組
BRPOPLPUSH redisMQ redisMQBack

生產者用 LPUSH 把訊息插入到 redisMQ 佇列中,消費者使用 BRPOPLPUSH 讀取訊息「公眾號」,同時該訊息會被插入到 「redisMQBack」佇列中。

如果消費成功則把「redisMQBack」的訊息刪除即可,異常的話可以繼續從 「redisMQBack」再次讀取訊息處理。

redis訊息確認機制

需要注意的是,如果生產者訊息傳送的很快,而消費者處理速度慢就會導致訊息堆積,給 Redis 的記憶體帶來過大壓力。

Redission 實戰

在 Java 中,我們可以利用 Redission 封裝的 API 來快速實現佇列,接下來碼哥基於 SpringBoot 2.1.4 版本來交大家如何整合並實戰。

詳細 API 文件大家可查閱:https://github.com/redisson/redisson/wiki/7.-Distributed-collections

新增依賴

<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.16.7</version>
</dependency>

新增 Redis 配置,碼哥的 Redis 沒有配置密碼,大家根據實際情況配置即可。

spring:
  application:
    name: redission
  redis:
    host: 127.0.0.1
    port: 6379
    ssl: false

Java 程式碼實戰

RBlockingDeque 繼承 java.util.concurrent.BlockingDeque ,在使用過程中我們完全可以根據介面文件來選擇合適的 API 去實現業務邏輯。

主要方法如下

碼哥採用了雙端佇列來舉例

@Slf4j
@Service
public class QueueService {

    @Autowired
    private RedissonClient redissonClient;

    private static final String REDIS_MQ = "redisMQ";

    /**
     * 傳送訊息到佇列頭部
     *
     * @param message
     */
    public void sendMessage(String message) {
        RBlockingDeque<String> blockingDeque = redissonClient.getBlockingDeque(REDIS_MQ);

        try {
            blockingDeque.putFirst(message);
            log.info("將訊息: {} 插入到佇列。", message);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * 從佇列尾部阻塞讀取訊息,若沒有訊息,執行緒就會阻塞等待新訊息插入,防止 CPU 空轉
     */
    public void onMessage() {
        RBlockingDeque<String> blockingDeque = redissonClient.getBlockingDeque(REDIS_MQ);
        while (true) {
            try {
                String message = blockingDeque.takeLast();
                log.info("從佇列 {} 中讀取到訊息:{}.", REDIS_MQ, message);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

單元測試

@RunWith(SpringRunner.class)
@SpringBootTest(classes = RedissionApplication.class)
public class RedissionApplicationTests {

    @Autowired
    private QueueService queueService;

    @Test
    public void testQueue() throws InterruptedException {
        new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                queueService.sendMessage("訊息" + i);
            }
        }).start();

        new Thread(() -> queueService.onMessage()).start();

        Thread.currentThread().join();
    }


}

總結

可以使用 List 資料結構來實現訊息佇列,滿足先進先出。為了實現訊息可靠性,Redis 提供了 BRPOPLPUSH 命令是解決。

Redis 是一個非常輕量級的鍵值資料庫,部署一個 Redis 例項就是啟動一個程式,部署 Redis 叢集,也就是部署多個 Redis 例項。

而 Kafka、RabbitMQ 部署時,涉及額外的元件,例如 Kafka 的執行就需要再部署 ZooKeeper。相比 Redis 來說,Kafka 和 RabbitMQ 一般被認為是重量級的訊息佇列。

需要注意的是,我們要避免生產者過快,消費者過慢導致的訊息堆積佔用 Redis 的記憶體。

在訊息量不大的情況下使用 Redis 作為訊息佇列,他能給我們帶來高效能的訊息讀寫,這似乎也是一個很好訊息佇列解決方案。

相關文章