1. 程式人生 > >網際網路面試必殺:如何保證訊息中介軟體全鏈路資料100%不丟失

網際網路面試必殺:如何保證訊息中介軟體全鏈路資料100%不丟失

背景引入

這篇文章,我們來聊聊在線上生產環境使用訊息中介軟體技術的時候,從前到後的全鏈路到底如何保證資料不能丟失。

這個問題,在網際網路公司面試的時候高頻出現,而且也是非常現實的生產環境問題。

如果你的簡歷中寫了自己熟悉MQ技術(RabbitMQ、RocketMQ、Kafka),而且在專案裡有使用的經驗,那麼非常實際的一個生產環境問題就是:投遞訊息到MQ,然後從MQ消費訊息來處理的這個過程,資料到底會不會丟失。

面試官此時會問:如果資料會丟失的話,你們專案生產部署的時候,是通過什麼手段保證基於MQ傳輸的資料100%不會丟失的?麻煩結合你們線上使用的訊息中介軟體來具體說說你們的技術方案。

這個其實就是非常區分面試候選人技術水平的一個問題。

實際上相當大比例的普通工程師,哪怕是在一些中小型網際網路公司裡工作過的,也就是基於公司部署的MQ叢集簡單的使用一下罷了,可能程式碼層面就是基本的傳送訊息和消費訊息,基本沒考慮太多的技術方案。

但是實際上,對於MQ、快取、分庫分表、NoSQL等各式各類的技術以及中介軟體在使用的時候,都會有對應技術相關的一堆生產環境問題。

那麼針對這些問題,就必須要有相對應的一整套技術方案來保證系統的健壯性、穩定性以及高可用性。

所以其實中大型網際網路公司的面試官在面試候選人的時候,如果考察對MQ相關技術的經驗和掌握程度,十有八九都會丟擲這個使用MQ時一定會涉及的資料丟失問題。因為這個問題,能夠非常好的區分候選人的技術水平。

所以這篇文章,我們就來具體聊聊基於RabbitMQ這種訊息中介軟體的背景下,從投遞訊息到MQ,到從MQ消費訊息出來,這個過程中有哪些資料丟失的風險和可能。

然後我們再一起來看看,應該如何結合MQ自身提供的一些技術特性來保證資料不丟失?

目前已有的技術方案

經過之前幾篇文章的討論,目前我們已經初步知道,第一個會導致資料丟失的地方,就是消費者獲取到訊息之後,沒有來得及處理完畢,自己直接宕機了。

此時RabbitMQ的自動ack機制會通知MQ叢集這條訊息已經處理好了,MQ叢集就會刪除這條訊息。

那麼這條訊息不就丟失了麼?不會有任何一個消費者處理到這條訊息了。

所以之前我們詳細討論過,通過在消費者服務中調整為手動ack機制,來確保訊息一定是已經成功處理完了,才會傳送ack通知給MQ叢集。

否則沒傳送ack之前消費者服務宕機,此時MQ叢集會自動感知到,然後重發訊息給其他的消費者服務例項。

《扎心!線上服務宕機時,如何保證資料100%不丟失?》這篇文章,詳細討論了這個問題,手動ack機制之下的架構圖如下所示:

當時除了這個資料丟失問題之外,還有另外一個問題,就是MQ叢集自身如果突然宕機,是不是會導致資料丟失?

預設情況下是肯定會的,因為queue和message都沒采用持久化的方式來投遞,所以MQ叢集重啟會導致部分資料丟失。

所以《訊息中介軟體叢集崩潰,如何保證百萬生產資料不丟失?》這篇文章,我們分析瞭如何採用持久化的方式來建立queue,同時採用持久化的方式來投遞訊息到MQ叢集,這樣MQ叢集會將訊息持久化到磁碟上去。

此時如果訊息還沒來得及投遞給消費者服務,然後MQ叢集突然宕機了,資料是不會丟失的,因為MQ叢集重啟之後會自動從磁碟檔案里加載出來沒投遞出去的訊息,然後繼續投遞給消費者服務。

同樣,該方案沉澱下來的系統架構圖,如下所示:

資料100%不丟失了嗎?

大家想一想,到目前為止,咱們的架構一定可以保證資料不丟失了嗎?

其實,現在的架構,還是有一個數據可能會丟失的問題。

那就是上面作為生產者的訂單服務把訊息投遞到MQ叢集之後,暫時還駐留在MQ的記憶體裡,還沒來得及持久化到磁碟上,同時也還沒來得及投遞到作為消費者的倉儲服務。

此時要是MQ叢集自身突然宕機,咋辦呢?

尷尬了吧,駐留在記憶體裡的資料是一定會丟失的,我們來看看下面的圖示。

按需制定技術方案

現在,我們需要考慮的技術方案是:訂單服務如何保證訊息一定已經持久化到磁碟?

實際上,作為生產者的訂單服務把訊息投遞到MQ叢集的過程是很容易丟資料的。

比如說網路出了點什麼故障,資料壓根兒沒傳輸過去,或者就是上面說的訊息剛剛被MQ接收但是還駐留在記憶體裡,沒落地到磁碟上,此時MQ叢集宕機就會丟資料。

所以首先,我們得考慮一下作為生產者的訂單服務要如何利用RabbitMQ提供的相關功能來實現一個技術方案。

這個技術方案需要保證:只要訂單服務傳送出去的訊息確認成功了,此時MQ叢集就一定已經將訊息持久化到磁碟了。

我們必須實現這樣的一個效果,才能保證投遞到MQ叢集的資料是不會丟失的。

需要研究的技術細節

這裡我們需要研究的技術細節是:倉儲服務手動ack保證資料不丟失的實現原理。

之前,筆者就收到很多同學提問:

倉儲服務那塊到底是如何基於手動ack就可以實現資料不丟失的?

RabbitMQ底層實現的細節和原理到底是什麼?

為什麼倉儲服務沒傳送ack就宕機了,RabbitMQ可以自動感知到他宕機了,然後自動重發訊息給其他的倉儲服務例項呢?

這些東西背後的實現原理和底層細節,到底是什麼?

還有一個最大的問題,就是生產者投遞出去的訊息,可能會丟失。

丟失的原因有很多,比如訊息在網路傳輸到一半的時候因為網路故障就丟了,或者是訊息投遞到MQ的記憶體時,MQ突發故障宕機導致訊息就丟失了。

針對這種生產者投遞資料丟失的問題,RabbitMQ實際上是提供了一些機制的。

比如,有一種重量級的機制,就是事務訊息機制。採用類事務的機制把訊息投遞到MQ,可以保證訊息不丟失,但是效能極差,經過測試效能會呈現幾百倍的下降。

所以說現在一般是不會用這種過於重量級的機制,而是會用輕量級的confirm機制。

但是我們這篇文章還不能直接講解生產者保證訊息不丟失的confirm機制,因為這種confirm機制實際上是採用了類似消費者的ack機制來實現的。

所以,要深入理解confirm機制,我們得先從這篇文章開始,深入的分析一下消費者手動ack機制保證訊息不丟失的底層原理。

ack機制回顧

其實手動ack機制非常的簡單,必須要消費者確保自己處理完畢了一個訊息,才能手動傳送ack給MQ,MQ收到ack之後才會刪除這個訊息。

如果消費者還沒傳送ack,自己就宕機了,此時MQ感知到他的宕機,就會重新投遞這條訊息給其他的消費者例項。

通過這種機制保證消費者例項宕機的時候,資料是不會丟失的。

再次提醒一下大家,如果還對手動ack機制不太熟悉的同學,可以回頭看一下之前的一篇文章:《扎心!線上服務宕機時,如何保證資料100%不丟失?》。然後這篇文章,我們將繼續深入探討一下ack機制的實現原理。

ack機制實現原理:delivery tag

如果你寫好了一個消費者服務的程式碼,讓他開始從RabbitMQ消費資料,這時這個消費者服務例項就會自己註冊到RabbitMQ。

所以,RabbitMQ其實是知道有哪些消費者服務例項存在的。

大家看看下面的圖,直觀的感受一下:

接著,RabbitMQ就會通過自己內部的一個“basic.delivery”方法來投遞訊息到倉儲服務裡去,讓他消費訊息。

投遞的時候,會給這次訊息的投遞帶上一個重要的東西,就是“delivery tag”,你可以認為是本次訊息投遞的一個唯一標識。

這個所謂的唯一標識,有點類似於一個ID,比如說訊息本次投遞到一個倉儲服務例項的唯一ID。通過這個唯一ID,我們就可以定位一次訊息投遞。

所以這個delivery tag機制不要看很簡單,實際上他是後面要說的很多機制的核心基礎。

而且這裡要給大家強調另外一個概念,就是每個消費者從RabbitMQ獲取訊息的時候,都是通過一個channel的概念來進行的。

大家回看一下下面的消費者程式碼片段,我們必須是先對指定機器上部署的RabbitMQ建立連線,然後通過這個連接獲取一個channel。

ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();

而且如果大家還有點印象的話,我們在倉儲服務裡對訊息的消費、ack等操作,全部都是基於這個channel來進行的,channel又有點類似於是我們跟RabbitMQ進行通訊的這麼一個控制代碼,比如看看下面的程式碼:

另外這裡提一句:之前寫那篇文章講解手動ack保證資料不丟失的時候,有很多人提出疑問:為什麼上面程式碼裡直接是try finally,如果程式碼有異常,那還是會直接執行finally裡的手動ack?其實很簡單,自己加上catch就可以了。

好的,咱們繼續。你大概可以認為這個channel就是進行資料傳輸的一個管道吧。對於每個channel而言,一個“delivery tag”就可以唯一的標識一次訊息投遞,這個delivery tag大致而言就是一個不斷增長的數字。

大家來看看下面的圖,相信會很好理解的:

如果採用手動ack機制,實際上倉儲服務每次消費了一條訊息,處理完畢完成排程發貨之後,就會發送一個ack訊息給RabbitMQ伺服器,這個ack訊息是會帶上自己本次訊息的delivery tag的。

咱們看看下面的ack程式碼,是不是帶上了一個delivery tag?

channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);

然後,RabbitMQ根據哪個channel的哪個delivery tag,不就可以唯一定位一次訊息投遞了?

接下來就可以對那條訊息刪除,標識為已經處理完畢。

這裡大家必須注意的一點,就是delivery tag僅僅在一個channel內部是唯一標識訊息投遞的。

所以說,你ack一條訊息的時候,必須是通過接受這條訊息的同一個channel來進行。

大家看看下面的圖,直觀的感受一下。

其實這裡還有一個很重要的點,就是我們可以設定一個引數,然後就批量的傳送ack訊息給RabbitMQ,這樣可以提升整體的效能和吞吐量。

比如下面那行程式碼,把第二個引數設定為true就可以了。

channel.basicAck(delivery.getEnvelope().getDeliveryTag(), true);

看到這裡,大家應該對這個ack機制的底層原理有了稍微進一步的認識了。起碼是知道delivery tag是啥東西了,他是實現ack的一個底層機制。

然後,我們再來簡單回顧一下自動ack、手動ack的區別。

實際上預設用自動ack,是非常簡單的。RabbitMQ只要投遞一個訊息出去給倉儲服務,那麼他立馬就把這個訊息給標記為刪除,因為他是不管倉儲服務到底接收到沒有,處理完沒有的。

所以這種情況下,效能很好,但是資料容易丟失。

如果手動ack,那麼就是必須等倉儲服務完成商品排程發貨以後,才會手動傳送ack給RabbitMQ,此時RabbitMQ才會認為訊息處理完畢,然後才會標記訊息為刪除。

這樣在傳送ack之前,倉儲服務宕機,RabbitMQ會重發訊息給另外一個倉儲服務例項,保證資料不丟。

RabbitMQ如何感知到倉儲服務例項宕機

之前就有同學提出過這個問題,但是其實要搞清楚這個問題,其實不需要深入的探索底層,只要自己大致的思考和推測一下就可以了。

如果你的倉儲服務例項接收到了訊息,但是沒有來得及排程發貨,沒有傳送ack,此時他宕機了。

我們想一想就知道,RabbitMQ之前既然收到了倉儲服務例項的註冊,因此他們之間必然是建立有某種聯絡的。

一旦某個倉儲服務例項宕機,那麼RabbitMQ就必然會感知到他的宕機,而且對傳送給他的還沒ack的訊息,都發送給其他倉儲服務例項。

所以這個問題以後有機會我們可以深入聊一聊,在這裡,大家其實先建立起來這種認識即可。

我們再回頭看看下面的架構圖:

倉儲服務處理失敗時的訊息重發

首先,我們來看看下面一段程式碼:

假如說某個倉儲服務例項處理某個訊息失敗了,此時會進入catch程式碼塊,那麼此時我們怎麼辦呢?難道還是直接ack訊息嗎?

當然不是了,你要是還是ack,那會導致訊息被刪除,但是實際沒有完成排程發貨。

這樣的話,資料不是還是丟失了嗎?因此,合理的方式是使用nack操作。

就是通知RabbitMQ自己沒處理成功訊息,然後讓RabbitMQ將這個訊息再次投遞給其他的倉儲服務例項嘗試去完成排程發貨的任務。

我們只要在catch程式碼塊里加入下面的程式碼即可:

channel.basicNack(delivery.getEnvelope().getDeliveryTag(),  true);

注意上面第二個引數是true,意思就是讓RabbitMQ把這條訊息重新投遞給其他的倉儲服務例項,因為自己沒處理成功。

你要是設定為false的話,就會導致RabbitMQ知道你處理失敗,但是還是刪除這條訊息,這是不對的。

同樣,我們還是來一張圖,大家一起來感受一下:

階段總結

這篇文章對之前的ack機制做了進一步的分析,包括底層的delivery tag機制,以及訊息處理失敗時的訊息重發。

通過ack機制、訊息重發等這套機制的落地實現,就可以保證一個消費者服務自身突然宕機、訊息處理失敗等場景下,都不會丟失資料。

來源:【微信公眾號 - 石杉的架構筆記】