1. 程式人生 > >什麼鬼,面試官竟然讓我用Redis實現一個訊息佇列!!?

什麼鬼,面試官竟然讓我用Redis實現一個訊息佇列!!?

GitHub 9.4k Star 的Java工程師成神之路 ,不來了解一下嗎?

GitHub 9.4k Star 的Java工程師成神之路 ,真的不來了解一下嗎?

GitHub 9.4k Star 的Java工程師成神之路 ,真的確定不來了解一下嗎?

眾所周知,redis是一個高效能的key-value資料庫,在NoSQL資料庫市場上,redis自己就佔據了將近半壁江山,足以見到其強大之處。同時,由於redis的單執行緒特性,我們可以將其用作為一個訊息佇列。本篇文章就來講講如何將redis整合到spring boot中,並用作訊息佇列的……

一、什麼是訊息佇列

“訊息佇列”是在訊息的傳輸過程中儲存訊息的容器。——《百度百科》

訊息我們可以理解為在計算機中或在整個計算機網路中傳遞的資料。

佇列是我們在學習資料結構的時候學習的基本資料結構之一,它具有先進先出的特性。

所以,訊息佇列就是一個儲存訊息的容器,它具有先進先出的特性。

為什麼會出現訊息佇列?

  1. 非同步:常見的B/S架構下,客戶端向伺服器傳送請求,但是伺服器處理這個訊息需要花費的時間很長的時間,如果客戶端一直等待伺服器處理完訊息,會造成客戶端的系統資源浪費;而使用訊息佇列後,伺服器直接將訊息推送到訊息佇列中,由專門的處理訊息程式處理訊息,這樣客戶端就不必花費大量時間等待伺服器的響應了;
  2. 解耦:傳統的軟體開發模式,模組之間的呼叫是直接呼叫,這樣的系統很不利於系統的擴充套件,同時,模組之間的相互呼叫,資料之間的共享問題也很大,每個模組都要時時刻刻考慮其他模組會不會掛了;使用訊息佇列以後,模組之間不直接呼叫,而是通過資料,且當某個模組掛了以後,資料仍舊會儲存在訊息佇列中。最典型的就是生產者-消費者模式,本案例使用的就是該模式;
  3. 削峰填谷:某一時刻,系統的併發請求暴增,遠遠超過了系統的最大處理能力後,如果不做任何處理,系統會崩潰;使用訊息佇列以後,伺服器把請求推送到訊息佇列中,由專門的處理訊息程式以合理的速度消費訊息,降低伺服器的壓力。

下面一張圖我們來簡單瞭解一下訊息佇列

由上圖可以看到,訊息佇列充當了一箇中間人的角色,我們可以通過操作這個訊息佇列來保證我們的系統穩定。

二、環境準備

Java環境:jdk1.8

spring boot版本:2.2.1.RELEASE

redis-server版本:3.2.100

三、相關依賴

這裡只展示與redis相關的依賴,

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-redis</artifactId>
</dependency>

這裡解釋一下這兩個依賴:

  • 第一個依賴是對redis NoSQL的支援
  • 第二個依賴是spring integration與redis的結合,這裡新增這個程式碼主要是為了實現分散式鎖

四、配置檔案

這裡只展示與redis相關的配置

# redis所在的的地址
spring.redis.host=localhost
# redis資料庫索引,從0開始,可以從redis的視覺化客戶端檢視
spring.redis.database=1
# redis的埠,預設為6379
spring.redis.port=6379
# redis的密碼
spring.redis.password=
# 連線redis的超時時間(ms),預設是2000
spring.redis.timeout=5000
# 連線池最大連線數
spring.redis.jedis.pool.max-active=16
# 連線池最小空閒連線
spring.redis.jedis.pool.min-idle=0
# 連線池最大空閒連線
spring.redis.jedis.pool.max-idle=16
# 連線池最大阻塞等待時間(負數表示沒有限制)
spring.redis.jedis.pool.max-wait=-1
# 連線redis的客戶端名
spring.redis.client-name=mall

五、程式碼配置

redis用作訊息佇列,其在spring boot中的主要表現為一個RedisTemplate.convertAndSend()方法和一個MessageListener介面。所以我們要在IOC容器中注入一個RedisTemplate和一個實現了MessageListener介面的類。話不多說,先看程式碼

配置RedisTemplate

配置RedisTemplate的主要目的是配置序列化方式以解決亂碼問題,同時合理配置序列化方式還能降低一點效能開銷。

/**
 * 配置RedisTemplate,解決亂碼問題
 */
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    LOGGER.debug("redis序列化配置開始");
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    // string序列化方式
    RedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
    // 設定預設序列化方式
    template.setDefaultSerializer(serializer);
    template.setKeySerializer(new StringRedisSerializer());
    template.setHashValueSerializer(serializer);
    LOGGER.debug("redis序列化配置結束");
    return template;
}

程式碼第12行,我們配置預設的序列化方式為GenericJackson2JsonRedisSerializer

程式碼第13行,我們配置鍵的序列化方式為StringRedisSerializer

程式碼第14行,我們配置雜湊表的值的序列化方式為GenericJackson2JsonRedisSerializer

RedisTemplate幾種序列化方式的簡要介紹

序列化方式 介紹
StringRedisSerializer 將物件序列化為字串,但是經測試,無法序列化物件,一般用在key上
OxmSerializer 將物件序列化為xml性質,本質上是字串
ByteArrayRedisSerializer 預設序列化方式,將物件序列化為二進位制位元組,但是需要物件實現Serializable介面
GenericFastJsonRedisSerializer json序列化,使用fastjson序列化方式序列化物件
GenericJackson2JsonRedisSerializer json序列化,使用jackson序列化方式序列化物件

六、redis佇列監聽器(消費者)

上面說了,與redis佇列監聽器相關的類為一個名為MessageListener的介面,下面是該介面的原始碼

public interface MessageListener {
    void onMessage(Message message, @Nullable byte[] pattern);
}

可以看到,該介面僅有一個onMessage(Message message, @Nullable byte[] pattern)方法,該方法便是監聽到佇列中訊息後的回撥方法。下面解釋一下這兩個引數:

  • message:redis訊息類,該類中僅有兩個方法
    • byte[] getBody()以二進位制形式獲取訊息體
    • byte[] getChannel()以二進位制形式獲取訊息通道
  • pattern:二進位制形式的訊息通道,和message.getChannel()返回值相同

介紹完介面,我們來實現一個簡單的redis佇列監聽器

@Component
public class RedisListener implement MessageListener{
    private static final Logger LOGGER = LoggerFactory.getLogger(RedisListener.class);

    @Override
    public void onMessage(Message message,byte[] pattern){
        LOGGER.debug("從訊息通道={}監聽到訊息",new String(pattern));
        LOGGER.debug("從訊息通道={}監聽到訊息",new String(message.getChannel()));
        LOGGER.debug("元訊息={}",new String(message.getBody()));
        // 新建一個用於反序列化的物件,注意這裡的物件要和前面配置的一樣
        // 因為我前面設定的預設序列化方式為GenericJackson2JsonRedisSerializer
        // 所以這裡的實現方式為GenericJackson2JsonRedisSerializer
        RedisSerializer serializer=new GenericJackson2JsonRedisSerializer();
        LOGGER.debug("反序列化後的訊息={}",serializer.deserialize(message.getBody()));
    }
}

程式碼很簡單,就是輸出引數中包含的關鍵資訊。需要注意的是,RedisSerializer的實現要與上面配置的序列化方式一致。

佇列監聽器實現完以後,我們還需要將這個監聽器新增到redis佇列監聽器容器中,程式碼如下:

@Bean
public public RedisMessageListenerContainer container(RedisConnectionFactory factory) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(redisListener, new PatternTopic("demo-channel"));
    return container;
}

這幾行程式碼大概意思就是新建一個Redis訊息監聽器容器,然後將監聽器和管道名想繫結,最後返回這個容器。

這裡要注意的是,這個管道名和下面將要說的推送訊息時的管道名要一致,不然監聽器監聽不到訊息。

七、redis佇列推送服務(生產者)

上面我們配置了RedisTemplate將要在這裡使用到。

程式碼如下:

@Service
public class Publisher{
    @Autowrite
    private RedisTemplate redis;

    public void publish(Object msg){
        redis.convertAndSend("demo-channel",msg);
    }
}

關鍵程式碼為第7行,redis.convertAndSend()這個方法的作用為,向某個通道(引數1)推送一條訊息(第二個引數)。

這裡還是要注意上面所說的,生產者和消費者的通道名要相同。

至此,訊息佇列的生產者和消費者已經全部編寫完成。

八、遇到的問題及解決辦法

1、spring boot使用log4j2日誌框架問題

在我添加了spring-boot-starter-log4j2依賴並在spring-boot-starter-web中排除了spring-boot-starter-logging後,執行專案,還是會提示下面的錯誤:

SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:.....m2/repository/ch/qos/logback/logback-classic/1.2.3/logback-classic-1.2.3.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:.....m2/repository/org/apache/logging/log4j/log4j-slf4j-impl/2.12.1/log4j-slf4j-impl-2.12.1.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual binding is of type [ch.qos.logback.classic.util.ContextSelectorStaticBinder]

這個錯誤就是maven中有多個日誌框架導致的。後來通過依賴分析,發現在spring-boot-starter-data-redis中,也依賴了spring-boot-starter-logging,解決辦法也很簡單,下面貼出詳細程式碼

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-redis</artifactId>
</dependency>

2、redis佇列監聽器執行緒安全問題

redis佇列監聽器的監聽機制是:使用一個執行緒監聽佇列,佇列有未消費的訊息則取出訊息並生成一個新的執行緒來消費訊息。如果你還記得,我開頭說的是由於redis單執行緒特性,因此我們用它來做訊息佇列,但是如果監聽器每次接受一個訊息就生成新的執行緒來消費資訊的話,這樣就完全沒有使用到redis的單執行緒特性,同時還會產生執行緒安全問題。

單一消費者(一個通道只有一個消費者)的解決辦法

最簡單的辦法莫過於為onMessage()方法加鎖,這樣簡單粗暴卻很有用,不過這種方式無法控制佇列監聽的速率,且無限制的創造執行緒最終會導致系統資源被佔光。

那如何解決這種情況呢?執行緒池。

在將監聽器新增到容器的配置的時候,RedisMessageListenerContainer類中有一個方法setTaskExecutor(Executor taskExecutor)可以為監聽容器配置執行緒池。配置執行緒池以後,所有的執行緒都會由該執行緒池產生,由此,我們可以通過調節執行緒池來控制佇列監聽的速率。

多個消費者(一個通道有多個消費者)的解決辦法

單一消費者的問題相比於多個消費者來說還是較為簡單,因為Java內建的鎖都是隻能控制自己程式的執行,不能干擾其他的程式的執行;然而現在很多時候我們都是在分散式環境下進行開發,這時處理多個消費者的情況就很有意義了。

那麼這種問題如何解決呢?分散式鎖。

下面來簡要科普一下什麼是分散式鎖:

分散式鎖是指在分散式環境下,同一時間只有一個客戶端能夠從某個共享環境中(例如redis)獲取到鎖,只有獲取到鎖的客戶端才能執行程式。

然後分散式鎖一般要滿足:排他性(即同一時間只有一個客戶端能夠獲取到鎖)、避免死鎖(即超時後自動釋放)、高可用(即獲取或釋放鎖的機制必須高可用且效能佳)

上面講依賴的時候,我們匯入了一個spring-integration-redis依賴,這個依賴裡面包含了很多實用的工具類,而我們接下來要講的分散式鎖就是這個依賴下面的一個工具包RedisLockRegistry

首先講一下如何使用,匯入了依賴以後,首先配置一個Bean

@Bean
public RedisLockRegistry redisLockRegistry(RedisConnectionFactory factory) {
    return new RedisLockRegistry(factory, "demo-lock",60);
}

RedisLockRegistry的建構函式,第一個引數是redis連線池,第二個引數是鎖的字首,即取出的鎖,鍵名為“demo-lock:KEY_NAME”,第三個引數為鎖的過期時間(秒),預設為60秒,當持有鎖超過該時間後自動過期。

使用鎖的方法,下面是對監聽器的修改

@Component
public class RedisListener implement MessageListener{
    @Autowrite
    private RedisLockRegistry redisLockRegistry;

    private static final Logger LOGGER = LoggerFactory.getLogger(RedisListener.class);

    @Override
    public void onMessage(Message message,byte[] pattern){
        Lock lock=redisLockRegistry.obtain("lock");
        try{
            lock.lock(); //上鎖
            LOGGER.debug("從訊息通道={}監聽到訊息",new String(pattern));
            LOGGER.debug("從訊息通道={}監聽到訊息",new String(message.getChannel()));
            LOGGER.debug("元訊息={}",new String(message.getBody()));
            // 新建一個用於反序列化的物件,注意這裡的物件要和前面配置的一樣
            // 因為我前面設定的預設序列化方式為GenericJackson2JsonRedisSerializer
            // 所以這裡的實現方式為GenericJackson2JsonRedisSerializer
            RedisSerializer serializer=new GenericJackson2JsonRedisSerializer();
            LOGGER.debug("反序列化後的訊息={}",serializer.deserialize(message.getBody()));
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); //解鎖
        }
    }
}

上面程式碼的程式碼比起前面的監聽器程式碼,只是多了一個注入的RedisLockRegistry,一個通過redisLockRegistry.obtain()方法獲取鎖,一個加鎖一個解鎖,然後這就完成了分散式鎖的使用。

注意這個獲取鎖的方法redisLockRegistry.obtain(),其返回的是一個名為RedisLock的鎖,這是一個私有內部類,它實現了Lock介面,因此我們不能從程式碼外部建立一個他的例項,只能通過obtian()方法來獲取這個鎖。