搞技術的對“高內聚,低耦合”這幾個字應該很熟悉,這是程序設計的一個基本原則,無論對於分布式系統,有幾個模塊的單體程序,以及程序中具體的類、類中的方法,都可以拿來講。這個原則本質上是“分治法”,將一個大問題分解為一個個的小問題,然后各個擊破,整個問題就解決了。相信大家都很明白了,這里對這個原則就不過多解釋了。
為什么要使用隊列解耦?
讓我們來看看不使用隊列的情況下如何解耦的:
原始需求
假設有一個商城系統,業務上划分為用戶、訂單、財務、消息、倉儲幾個模塊(模塊的划分實際上也是解耦設計的重要部分,但非這篇文章的關注點),這幾個模塊是分布式部署的,用戶在下單成功以后要做這么幾件事:通知用戶下單成功、通知倉庫發貨、給財務生成銷售憑證,那么就要在下單成功的程序邏輯中去調用消息、倉儲、財務模塊的接口。

對於一個不經常變動、吞吐量也不是很大的系統,做到這一步也就可以了。
新增需求
假設商城最近又上線了一個優惠券的功能,需要在下單成功后給用戶發優惠券,這時候怎么去做呢?一個很直接的想法就是修改下單成功的程序邏輯,增加一個調用發優惠券接口的處理。

也能解決問題,但是這時候就要考慮下了,以后還會不會有別的需求?比如下單成功后給用戶增加積分,給推薦的用戶做返利等等。每次都修改下單的程序邏輯其實還是有一定技術風險的,能不能以后不改下單的代碼也能擴展呢?
更好的擴展性
聰明的你一定想到了辦法,用配置。

在訂單模板中定義一個配置文件,所有需要下單成功后調用的接口地址都寫到這里,下單的程序讀取這個配置,一個個去調用。如果以后還有新增的下單后處理,在這里增加一行配置就行了,不用改下單的代碼。
不過需要注意,每個接口接收的參數應該都是一樣的,或者支持通過參數模板賦值(比如url:http://blog.bossma.cn/notice?orderId={OrderId}&Status={Status},其中{XXX}的內容會被實際值替換,不同的業務可以定義不同的url參數),否則還是要改代碼。
還有沒有問題
有一天你可能發現下單成功后,也通知用戶了, 也發貨了,但是沒有生成財務憑證,然后到服務器上翻日志發現下單處理超時了,調用生成憑證接口沒有成功,至於原因可能是網絡抖動了,也可能是開發人員在升級程序…你想到了分布式事務,不過這個似乎不太好搞。你可能覺得也就是偶爾出現一次,手工處理下就好了。
然后雙十一到了,超過平常10倍的用戶來下單,用戶可能發現提交訂單一直在等待,等待,等…。至於原因也許就是上次發現的超時問題更嚴重了,本來處理很快的接口調用突然都慢了下來。
這時候你可能需要一個隊列了。
使用隊列
先來看看使用隊列后是什么樣的?
很明顯只是在下單操作和下單后的操作中間增加了一個隊列,下單成功后訂單數據發送到隊列,通知、發貨、憑證等等操作從隊列接收訂單數據,然后按照自身的業務需求進行處理。
仔細想一下,其實是把上邊提到的配置方式換成了隊列方式,而且它們做的事以及做事方法本質是差不多的,接收數據,然后把數據分發給預先配置好的程序。形式上最大不同是隊列從進程內獨立了出來。
那么使用隊列帶來了什么好處呢?
1、更低的耦合,下單操作和后續的通知、發貨、憑證操作完全分開了,下單完畢后發送訂單數據到隊列就像發送一個事件,需要的地方訂閱這個事件就可以了。
2、更好的性能,沒有使用隊列時,下單操作要一次執行下單、通知、發貨、憑證等多個處理,耗時較長;同時可能因為某個調用服務不穩定,導致整個下單操作不穩定,甚至完全不可用;要對下單操作進行性能優化時,需要考慮的方面過多,不容易達成。
3、容錯,對於瞬時的異常,比如網絡抖動、磁盤IO打滿,導致后續操作無法執行時,隊列可以緩存這部分數據,直到程序恢復處理能力后繼續處理。沒有使用隊列的時候,只能記個日志,人工處理。
可能的坑
說幾句廢話,有些坑是使用隊列新引入的,有些坑本來一直就存在,有的坑可以解決,有的坑只能把危害盡量降低。
最終一致性
如果沒有使用隊列,可以通過本地事務,甚至分布式事務來保證數據的嚴格一致性。
這不能算一個坑,但是需要理解使用了隊列后就是選擇了最終一致性,盡管有些隊列支持RPC調用,但本質上仍是最終一致性。
通知可能延遲了2秒,發貨可能推遲了1分鍾,憑證可能晚生成了10秒,這些應該都是可以接受的,因為對於用戶最重要的下單成功了,至於后邊相對不那么緊急的事慢慢搞就好了,當然也不能慢的超出人的正常認知,響應速度取決於這些操作的處理能力。
消息仍可能會丟
為了防止數據在發送隊列時丟失而生產者卻不知情的情況,很多的隊列都提供了發送確認,只有發送者收到了發送確認,消息才算投遞成功。
但丟失消息的情況不止這一種,假設隊列服務正常,在下單完成,發送訂單數據到隊列之前,服務器斷電了,消息就永遠不可能發到隊列了。

為了處理此類極端情況,可以采用的方案也有幾個,比如:
- 將消息和下單放到一個數據庫事務中,即使當時沒能發送到隊列,也能在檢查未發送消息的時候補上這一條。
- 在所有事務執行前記錄日志,在每個事務完成后記錄日志,從故障恢復后檢查未完成的事務,執行這些事務。
不過除非逼不得已,波斯碼仍然不建議在系統開發之初就搞這個方案,復雜了。
重復消息
由於網絡問題或者因程序內部異常中斷,發送者不能確定消息是否發送成功時,可能就會再次發送。
如果業務嚴格限制數據只能處理一次,消費者應該有能力來處理這種重復,可能的解決方案:在數據表中增加一個已處理消息的標識,或者緩存最近處理過的消息進行判重。
不當消息
在不使用隊列,多個操作在同一個進程內執行的情況下,不同的接口可能設計了不同的參數,程序編寫者需要在調用接口時傳遞不同的數據,以滿足接口的業務需求。這種慣性思維可能被帶入使用隊列的情況下,為不同的業務發送不同的數據到隊列,消費者消費各自定制的數據。這種做法完全忽視了使用隊列進行解耦的好處。
應該把發送到隊列的數據看作一個消息、或者一個事件,而不是某個具體業務方需要的某幾個數據,這個消息可能是和業務方需求的數據完全吻合,也可能少或者多,對於業務方需要的缺少的數據應該可以根據消息中某個標識去查詢,這樣才算比較合適的解耦。
比如例子中發送下單成功的通知,需要訂單金額和用戶手機號,從隊列接收到的是訂單數據,其中有訂單金額,沒有手機號,但是有用戶Id,程序需要根據用戶Id去查詢用戶的手機號。
不當分發
在這篇文章舉的例子中可以使用廣播或者主題的分發方式,一條消息分發到多個消費者隊列,每個消費者消費的消息互相之間沒有影響。
在使用隊列時需要特別關注分發方式,避免消息發送到了不需要的消費者隊列,導致消費者因無法處理而崩潰;或者不同的業務消費同一個消費者隊列,導致消息丟失業務處理。
不當高可用
每種隊列產品都提供了高可用的解決方案,我們一般都會在生產環境采用高可用部署。
在實施高可用方案時應該清醒的認識到,可用性越高,就要在性能或一致性上有些損失,需要按照業務需求平衡這些指標。
選擇隊列產品
市面上常見的隊列也不少,RabbitMQ、RocketMQ、Kafka、ActiveMQ、MetaMQ,甚至Redis也可以干這件事。網上有大量的文章介紹他們的原理和使用,這里也不過多的進行說明了。
說一下波斯碼認為的主要三個:RabittMQ、RocketMQ、Kafka。
RabittMQ 社區活躍、管理界面易用、各種開發語言支持的比較好,單機萬級別並發,適合中小型公司。
Kafka 為處理日志而生,吞吐量單機十萬級,社區也很活躍。
RocketMQ 基於Kafka衍生而來,既保持了原有的高並發支持,又在可靠性、穩定性上得到了加持。阿里開源,社區活躍度一般,適合大公司。