深入理解 MQ 的設計思想

消息隊列是分佈式系統中重要的組件,在很多生產環境如商品搶購等需要控制併發量的場景下都需要用到,消息隊列主要解決了應用耦合、異步處理、流量削鋒等問題,當前使用較多的消息隊列有 RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、MetaMq 等,而部分數據庫如 Redis,Mysql 以及 phxsql 也可實現消息隊列的功能。

消息隊列在實際應用場景:

應用解耦:多應用間通過消息隊列對同一消息進行處理,避免調用接口失敗導致整個過程失敗;

異步處理:多應用對消息隊列中同一消息進行處理,應用間併發處理消息,相比串行處理,減少處理時間;

限流削峯:廣泛應用於秒殺或搶購活動中,避免流量過大導致應用系統掛掉的情況;

今天分享一篇經典的 Kafka 設計剖析文章給大家,Kafka 作爲頂級消息中間件,據 Confluent 稱, 超過三分之一的財富 500 強公司使用 Apache Kafka,kafka 的性能快,吞吐量大,並且高於其他消息隊列一個水平, 即使在消息量巨大的情況下還能保持高性能, 在互聯網公司中非常流行, 希望大家領悟到 kafka 設計的核心原理。

Kafka 架構

Kafka 是一個被精心設計的東西,我只能這樣說。我這裏所謂的精心不是說它很完備的實現了某種規範,像個學生那般完成了某個作業,比如 JMS,恰恰相反,Kafka 突破了類似 JMS 這種規範性的束縛,它是卓越的,乃 yet another JMS。當我用 yet… 如此稱呼一個技術的時候,意味着這玩意兒已經進入了我的視野。好了,現在是 Kafka 和 Storm 時間,本文先談 Kafka。

Kafka 是什麼?

參見官方文檔,它是 Apache 的一個項目。它是一個消息隊列。

消息隊列若何

消息隊列是生產者和消費者之間的信使,避免了二者之間直接的接觸。在效果上,它可能和緩存所起的作用一樣,平滑了生產者和消費者之間的代謝速率差,但是在其根本目的上,它是爲了解除生產者和消費者之間的耦合。如果你覺得有點費解,那麼簡單點說。

fire and forget,這句話的意思再簡單點說,就是真男人從不看爆炸,菸頭往油箱裏一丟,把風衣的領子一豎,手插褲兜裏,徑直走開,決不不回頭。

消息隊列,以下簡稱 MQ,就是造就這種真男人的。它能讓生產者把消息扔進 MQ 就不管了,然後消費者從 MQ 裏取消息即可,不用和生產者交互。下面的篇幅,我將逐步用我的方式演化出 Kafka 的原型,爲了掌握整體脈絡,難免會隱掉很多細節,當然這些細節可以隨便在其官方文檔以及別人的博客裏搜到,我的目的只是希望能整理出一個脈絡,在設計類似的系統的時候,見招拆招以備參考

MQ 朝着” 正確” 方向的演化

Kafka 就一定正確嗎?客觀講,肯定不,但是它是本文的主角,所以它就一定正確。

我們先來看看作爲通用的 MQ,其最簡單的形式,一般而言,這是大家在首次接觸到 MQ 後的一個課後作業。

現在有個問題,如果有兩個或者多個消費者需要消費消息,怎麼辦?很簡單,廣播唄:

消費者是上帝,很難搞的,你推給它們的東西,並不是它們全部都想要的,只要一部分怎麼辦?

好吧,消費者一定在怪 MQ 服務不周,然而 MQ 有什麼錯,它又不理解消息的語義,面對百般刁難的消費者,它最多隻能要求生產者把消息細分一下,因此就出現了多個 Topic

這是很顯然的想法,就是是在消息入隊處區分消息的 Topic,然消費者從取自己感興趣的消息隊列取消息即可。

但還是會潛在的多個消費對同一 Topic 消息感興趣的情況:

如果採用廣播,那麼就仍然會出現冗餘傳播問題,如果單播,那麼一個消費者取出消息後,這條消息該不該刪除呢?如果刪除了,另一個消費者怎麼辦?廣播會浪費帶寬,不廣播也不行… 這貌似進入了一個死循環,必須一勞永逸地從根源解決問題纔行。顯然的想法是下面的方案 (至少我自己設計的話就會這麼做):

問題是解決了,然而我的天啊,仔細想一下先前的架構,把簡圖畫出來後,會發現事情會一發而不可收拾,MQ 本身的邏輯太複雜了:

回到 UNIX 哲學,遇到新問題的時候,要新編一個程序,而不是爲已有的程序添加一個功能。本着這個思路,爲什麼不把這件因爲消費者而導致複雜化的事情完全交給消費者呢?

有點往 Kafka 上靠了啊。

如果把 MQ 裏面的數據全部持久化存儲,消費者不就可以各取所需了嗎?這是一個根本的轉變,如果以前的方式是限量商務套餐 - 套餐強行推給你,不想要的自己扔掉,那麼現在的方式就是無限量自助餐 - 想要什麼自己去拿即可。消息自取,消息永遠都在 MQ,消費者隨便取,取哪個消息都行,什麼時候取都行。消費者只需要告訴 MQ 它想要哪個消息就好,因此需要傳遞一個消息的 offset 參數:

(然而自助餐也有打烊的時候,部分也會限制就餐時長,這是 Kafka 策略化存儲的問題,詳見文檔。)

簡化一下,現在看下圖:

一切 OK 了。嗯,是的,這就是 Kafka 的原始模型。然而 Kafka 遠不僅此而已。且看下文繼續演化。

集羣化,容錯

先看一下現在的情況:

這是在**_邏輯上_**一個 Kafka 類似的 MQ 應有的結構。但是在物理實現上,它又如何呢?

常聽人說,Kafka 一開始就是爲分佈式而生的,這話怎麼理解呢?我們只需要先理解它如何擴容,然後再理解它如何將擴容作用於不同的機器即可。先看擴容。

類似高速公路,一般當你聽到廣深高速的時候,我們知道這是從廣州到深圳的一條高速公路,這是邏輯上的說法,類似到目前爲止我們討論的 MQ 的 Topic。然而這條高速公路到底長什麼樣子,沿途怎麼路由,這就是物理實現了。此外,所有的道路都會分多個車道用於並行。嚴格來講,每一個車道都會被細分,比如小型車道,客車道,大貨車道,超車道等等,所有這些車道上的車都是到達同一個目的地 (屬於同一個 Topic),然而它們確實是細分的不同種類。把一個叫做 partition 的概念類比爲車道,如下圖:

注意這個 key hash 模塊,這裏就是區分車子要進入哪個車道的邏輯。在 Kafka 的術語中,車道就是 partition,即分區。在同一個 Topic 中分發消息的時候,你要自己設計 hash 函數,該 hash 函數就是一個分發策略,決定把消息按序放到哪一個分區中去。

溫州皮鞋廠老闆說類比和舉例不好,但這是技術散文,不是技術文檔,多半是給自己看,所以還要類比。Topic Routing 做的事是決定從哪條高速公路到哪裏,而 key hash 則是決定你是坐轎車,客車還是卡車過去。

值得注意的是,Kafka 只保證同一 Topic 內同一 partition 內消息的有序性,無法做到全局有序性。這並不是一個缺陷,這是兩全不能齊美的。完全的順序就需要串行化,然而串行化就無法並行,這簡直就是廢話!

現在,在 Topic 之下,我們又有了一個新的單位,叫做 partition,這個叫做 partition 的就是 Kafka 中最基本的部署單位,這一點務必要記住,它關乎到如何組織你的集羣。

好了,看一下這些 Topic 以及其旗下的 partition 是如何部署在 M1 和 M2 兩臺機器上的吧:

以上是花開兩朵,各表一枝,現在該說說消費者了。

消費者面對 MQ 本身進化到如此細粒度,該如何應對呢?其實消費者也有橫向擴展的需求,如果說消費者對應 partition,那麼對應 Topic 的就是消費者的上級了。因此多加了一個層次,引出消費組的概念,解決問題:

從 CPU cache 到 Kafka,設計思路殊途同歸,這就是一個典型的全方位組相聯結構:

到此爲止,全部圖景已經完全繪製完畢,是時候展示集羣的部署了。我們知道所謂的 Kafka 集羣,就是將各個 Topic 的 partition 部署在不同的機器上,達到兩個目的,一個是負載均衡,即提供訪問的並行性,另一個就是提供高可用性,即做熱備份,這兩個功能我希望能用一個圖展示:

總體的一個結構如下:

持久化存儲 / 查詢機制

上面的兩個小節,我已經展示了 Kafka 是如何一步一步地肚子裏面的勾當內外有別的,雖然我不知道作者怎麼去設計,但如果是我自己,我肯定就是上面這個思路了。

前面的敘述終究是概覽,不甚過癮。本節將給出半點細節,瑾闡釋一下 Kafka 存儲的半景。

我們知道,Kafka 爲了卸載 MQ 本身的複雜性,爲了其真正無狀態的設計,它將狀態維護機制這口鍋完全甩給了消費者,因此取消息的問題就轉化成了消費者拿着一個 offset 索引來 Kafka 存儲器裏取消息的問題,這就涉及到了性能。But 如何能查的更快?How?

還是先給出一個最簡單的場景。假設 Kafka 的每一個 partition 都一個完整獨立的文件,那麼如果這個文件非常大,事實上也確實非常大 (有可能到達 T 級別甚至 P 級別…),那麼在大文件中檢索一個特定的消息本身就是一個頭疼的問題,並且該文件還在磁盤中,這更是雪上加霜,我們都知道磁盤的隨機讀寫是硬傷,順序讀寫也好不到哪去,這怎麼辦?

遍歷?如果每一個 partition 只是一個獨立文件,那麼只能遍歷:

面對這個遍歷問題,一般的解決方案就是建立索引,並且把索引數據常駐內存,很多數據庫就是這麼幹的,Kafka 當然也可以這麼幹。

Kafka 比較帥的一點就是它並不藉助任何特殊的文件系統,它的數據就存在一般的文件中,然而它把一個 partition 分成了等大小的一系列小文件,因此在物理上,並不存在一個完整的 partition 文件,partiotion 只是表現爲一個目錄。我們知道,文件系統管理幾個等大的文件是非常方便的:

以上的例子中,一個 partiton 被分成了 100M 大小的文件,這種小文件叫做分段,在 Kafka 存儲的時候,每一個段文件存滿爲止再開闢下一個,由於消息的長度並不一定統一,因此每一個小段文件裏面包含的消息數量並不一定一樣多。

但是不管怎樣,抽取每一個段文件的首尾消息偏移作爲元數據保存起來是一件一勞永逸的事情,這便於建立一個常駐內存的索引:

通過這個區間查找樹,很快就能定位到特定的段文件,但是事情並沒有結束。

在 Kafka 中,每一個 partition 的段文件,均配帶一個 index 索引文件,這個文件是做什麼的呢?它是段文件內部消息的稀疏索引,見下圖:

最終,經過兩次區間樹查找之後,最多再經歷一次簡單的遍歷即可完成 offset 定位工作。誠然,最終的遍歷可能是少不了的,但是 Kafka 儘可能地避免了大長段耗時的遍歷計算,而是將遍歷壓縮到一個很小的量級,這是一個權衡!跟誰權衡呢?爲什麼不把段文件所有消息的索引均建立起來呢?

很簡單,建立全部的索引會造成索引非常大,這樣如果你還想其常駐內存的話,內存佔用會很大,這確實又是一個時間和空間之間的權衡了。

稀疏索引閒談

稀疏索引很有用,除了本文列舉的 Kafka 的 segment index 稀疏索引之外,還有兩個更爲常見的例子 (我不是應用編程的,我是搞內核網絡協議棧的,所以在我看來 Kafka 更不常見!):

索引整個內存地址空間,稀疏化的做法就是分頁,即採用規則的方式將內存劃分爲等大小的塊,叫做內存頁,然後索引這些內存頁即可,頁表而不是地址表稀疏化索引,減小索引的大小。

另外,IP 地址具有地域聚集性,因此對於路由器物理設備而言,對於每一個接口引出的方向,其 IP 地址集在很大程度上是可以聚集的,路由表一開始採用地址分類的方法,後來採用了前綴匹配的方法稀疏化索引,地址分類有點像內存地址分頁,只是頁面有多種大小而不僅僅是一種,而這裏的地址前綴則比較像 Kafka 使用的兩種索引,第一種是段索引,這是規則的,第二種是消息索引,這是不規則的。因爲消息並不定長。

兩種說法總結如下。

在從虛擬地址定位物理地址的時候,需要一一對應定位到每一個地址嗎?假如真是這樣子,那麼光頁表項這種管理內存就要耗多少你算過嗎?虛擬地址和物理地址將會是全相聯結構。

採用稀疏索引後,只需要定位一個 4K 大小的頁面即可,這將大大減小內存頁表的內存佔用。從而更加高效。

將每一個 IP 地址均對應到路由器設備的接口嗎?這不現實。解決方案一開始是基於分配機構的分類地址稀疏索引,後來採用了基於使用結構的無類子網的前綴係數索引,無論哪種情況,均大大減少了路由表項的數量。

UNIX 哲學的出路

沒出路了!Why?因爲只有複雜才能體現自己的工作量。

人們都希望製造門檻,把程序做的非常複雜,方纔體現自己的能力,畢竟簡單的東西大家都會,想體現區別,只能讓自己的東西更復雜。如果你用幾行 Bash 腳本完成了一項艱鉅的工作,經理大概率會覺得你這是奇技淫巧,完全無法和 C++ 的方案相比。Python 好一點,Java 則更好。

4,5 年以前的曾經,我們有個編程道場的活動,有一次的一個題目是拼接字符串,即 join 操作,當時的經理兼主持者強調儘量用現成的接口,然而…

多少個優秀的極簡方案沒有被表揚,最後被表揚的方案你們知道其特徵是什麼嗎?其特徵就是複雜。我記得當時這個方案的作者上臺介紹他的方案,上來就說” 我這個設計非常簡單…” 結果呢,唉,用技術術語講,過度設計了,用白話講,裝逼了。主持者顯然也是完全半瓶子晃盪的吧,哈哈。

事情必須做的儘量複雜,這樣纔是能力的體現,2 行能搞定的東西,必須湊夠 30 行纔算牛逼。UNIX 哲學,在我們這,顯然不合適吧。

擴展閱讀:

https://blog.csdn.net/dog250/article/details/79588437

https://www.cloudamqp.com/blog/when-to-use-rabbitmq-or-apache-kafka.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8H7YpeEVwslZluE86KIcmQ