在消息傳遞過程當中,若是出現傳遞失敗的狀況,發送方會執行重試,重試的過程當中就有可能會產生重複的消息。對使用消息隊列的業務系統來講,若是沒有對重複消息進行處理,就有可能會致使系統的數據出現錯誤。前端
好比說,一個消費訂單消息,統計下單金額的微服務,若是沒有正確處理重複消息,那就會出現重複統計,致使統計結果錯誤。程序員
你可能會問,若是消息隊列自己能保證消息不重複,那應用程序的實現不就簡單了?那有沒有消息隊列能保證消息不重複呢?數據庫
在 MQTT 協議中,給出了三種傳遞消息時可以提供的服務質量標準,這三種服務質量從低到高依次是:併發
At most once: 至多一次。消息在傳遞時,最多會被送達一次。換一個說法就是,沒什麼消息可靠性保證,容許丟消息。通常都是一些對消息可靠性要求不過高的監控場景使用,好比每分鐘上報一次機房溫度數據,能夠接受數據少許丟失。框架
At least once: 至少一次。消息在傳遞時,至少會被送達一次。也就是說,不容許丟消息,可是容許有少許重複消息出現。異步
Exactly once:剛好一次。消息在傳遞時,只會被送達一次,不容許丟失也不容許重複,這個是最高的等級。分佈式
這個服務質量標準不只適用於 MQTT,對全部的消息隊列都是適用的。咱們如今經常使用的絕大部分消息隊列提供的服務質量都是 At least once,包括 RocketMQ、RabbitMQ 和 Kafka 都是這樣。也就是說,消息隊列很難保證消息不重複。ide
說到這兒我知道確定有的同窗會反駁我:「你說的不對,我看過 Kafka 的文檔,Kafka 是支持 Exactly once 的。」我在這裏跟這些同窗解釋一下,你說的沒錯,Kafka 的確是支持 Exactly once,可是我講的也沒有問題,爲何呢?函數
Kafka 支持的「Exactly once」和咱們剛剛提到的消息傳遞的服務質量標準「Exactly once」是不同的,它是 Kafka 提供的另一個特性,Kafka 中支持的事務也和咱們一般意義理解的事務有必定的差別。在 Kafka 中,事務和 Excactly once 主要是爲了配合流計算使用的特性,咱們在專欄「進階篇」這個模塊中,會有專門的一節課來說 Kafka 的事務和它支持的 Exactly once 特性。微服務
稍微說一些題外話,Kafka 的團隊是一個很是善於包裝和營銷的團隊,你看他們很巧妙地用了兩個全部人都很是熟悉的概念「事務」和「Exactly once」來包裝它的新的特性,實際上它實現的這個事務和 Exactly once 並非咱們一般理解的那兩個特性,可是你深刻了解 Kafka 的事務和 Exactly once 後,會發現其實它這個特性雖然和咱們一般的理解不同,但確實和事務、Exactly once 有必定關係。
這一點上,咱們都要學習 Kafka 團隊。一個優秀的開發團隊,不只要能寫代碼,更要能寫文檔,能寫 Slide(PPT),還要能講,會分享。對於每一個程序員來講,也是同樣的。
咱們把話題收回來,繼續來講重複消息的問題。既然消息隊列沒法保證消息不重複,就須要咱們的消費代碼可以接受「消息是可能會重複的」這一現狀,而後,經過一些方法來消除重複消息對業務的影響。
通常解決重複消息的辦法是,在消費端,讓咱們消費消息的操做具有冪等性。
冪等(Idempotence) 原本是一個數學上的概念,它是這樣定義的:
若是一個函數 f(x) 知足:f(f(x)) = f(x),則函數 f(x) 知足冪等性。
這個概念被拓展到計算機領域,被用來描述一個操做、方法或者服務。一個冪等操做的特色是,其任意屢次執行所產生的影響均與一次執行的影響相同。
一個冪等的方法,使用一樣的參數,對它進行屢次調用和一次調用,對系統產生的影響是同樣的。因此,對於冪等的方法,不用擔憂重複執行會對系統形成任何改變。
咱們舉個例子來講明一下。在不考慮併發的狀況下,「將帳戶 X 的餘額設置爲 100 元」,執行一次後對系統的影響是,帳戶 X 的餘額變成了 100 元。只要提供的參數 100 元不變,那即便再執行多少次,帳戶 X 的餘額始終都是 100 元,不會變化,這個操做就是一個冪等的操做。
再舉一個例子,「將帳戶 X 的餘額加 100 元」,這個操做它就不是冪等的,每執行一次,帳戶餘額就會增長 100 元,執行屢次和執行一次對系統的影響(也就是帳戶的餘額)是不同的。
若是咱們系統消費消息的業務邏輯具有冪等性,那就不用擔憂消息重複的問題了,由於同一條消息,消費一次和消費屢次對系統的影響是徹底同樣的。也就能夠認爲,消費屢次等於消費一次。
從對系統的影響結果來講:At least once + 冪等消費 = Exactly once。
那麼如何實現冪等操做呢?最好的方式就是,從業務邏輯設計上入手,將消費的業務邏輯設計成具有冪等性的操做。可是,不是全部的業務都能設計整天然冪等的,這裏就須要一些方法和技巧來實現冪等。
下面我給你介紹幾種經常使用的設計冪等操做的方法:
1. 利用數據庫的惟一約束實現冪等
例如咱們剛剛提到的那個不具有冪等特性的轉帳的例子:將帳戶 X 的餘額加 100 元。在這個例子中,咱們能夠經過改造業務邏輯,讓它具有冪等性。
首先,咱們能夠限定,對於每一個轉帳單每一個帳戶只能夠執行一次變動操做,在分佈式系統中,這個限制實現的方法很是多,最簡單的是咱們在數據庫中建一張轉帳流水錶,這個表有三個字段:轉帳單 ID、帳戶 ID 和變動金額,而後給轉帳單 ID 和帳戶 ID 這兩個字段聯合起來建立一個惟一約束,這樣對於相同的轉帳單 ID 和帳戶 ID,表裏至多隻能存在一條記錄。
這樣,咱們消費消息的邏輯能夠變爲:「在轉帳流水錶中增長一條轉帳記錄,而後再根據轉帳記錄,異步操做更新用戶餘額便可。」在轉帳流水錶增長一條轉帳記錄這個操做中,因爲咱們在這個表中預先定義了「帳戶 ID 轉帳單 ID」的惟一約束,對於同一個轉帳單同一個帳戶只能插入一條記錄,後續重複的插入操做都會失敗,這樣就實現了一個冪等的操做。咱們只要寫一個 SQL,正確地實現它就能夠了。
基於這個思路,不光是可使用關係型數據庫,只要是支持相似「INSERT IF NOT EXIST」語義的存儲類系統均可以用於實現冪等,好比,你能夠用 Redis 的 SETNX 命令來替代數據庫中的惟一約束,來實現冪等消費。
2. 爲更新的數據設置前置條件
另一種實現冪等的思路是,給數據變動設置一個前置條件,若是知足條件就更新數據,不然拒絕更新數據,在更新數據的時候,同時變動前置條件中須要判斷的數據。這樣,重複執行這個操做時,因爲第一次更新數據的時候已經變動了前置條件中須要判斷的數據,不知足前置條件,則不會重複執行更新數據操做。
好比,剛剛咱們說過,「將帳戶 X 的餘額增長 100 元」這個操做並不知足冪等性,咱們能夠把這個操做加上一個前置條件,變爲:「若是帳戶 X 當前的餘額爲 500 元,將餘額加 100 元」,這個操做就具有了冪等性。對應到消息隊列中的使用時,能夠在發消息時在消息體中帶上當前的餘額,在消費的時候進行判斷數據庫中,當前餘額是否與消息中的餘額相等,只有相等才執行變動操做。
可是,若是咱們要更新的數據不是數值,或者咱們要作一個比較複雜的更新操做怎麼辦?用什麼做爲前置判斷條件呢?更加通用的方法是,給你的數據增長一個版本號屬性,每次更數據前,比較當前數據的版本號是否和消息中的版本號一致,若是不一致就拒絕更新數據,更新數據的同時將版本號 +1,同樣能夠實現冪等更新。
3. 記錄並檢查操做
若是上面提到的兩種實現冪等方法都不能適用於你的場景,咱們還有一種通用性最強,適用範圍最廣的實現冪等性方法:記錄並檢查操做,也稱爲「Token 機制或者 GUID(全局惟一 ID)機制」,實現的思路特別簡單:在執行數據更新操做以前,先檢查一下是否執行過這個更新操做。
具體的實現方法是,在發送消息時,給每條消息指定一個全局惟一的 ID,消費時,先根據這個 ID 檢查這條消息是否有被消費過,若是沒有消費過,才更新數據,而後將消費狀態置爲已消費。
原理和實現是否是很簡單?其實一點兒都不簡單,在分佈式系統中,這個方法實際上是很是難實現的。首先,給每一個消息指定一個全局惟一的 ID 就是一件不那麼簡單的事兒,方法有不少,但都不太好同時知足簡單、高可用和高性能,或多或少都要有些犧牲。更加麻煩的是,在「檢查消費狀態,而後更新數據而且設置消費狀態」中,三個操做必須做爲一組操做保證原子性,才能真正實現冪等,不然就會出現 Bug。
好比說,對於同一條消息:「全局 ID 爲 8,操做爲:給 ID 爲 666 帳戶增長 100 元」,有可能出現這樣的狀況:
t0 時刻:Consumer A 收到條消息,檢查消息執行狀態,發現消息未處理過,開始執行「帳戶增長 100 元」;
t1 時刻:Consumer B 收到條消息,檢查消息執行狀態,發現消息未處理過,由於這個時刻,Consumer A 還將來得及更新消息執行狀態。
這樣就會致使帳戶被錯誤地增長了兩次 100 元,這是一個在分佈式系統中很是容易犯的錯誤,必定要引覺得戒。
對於這個問題,固然咱們能夠用事務來實現,也能夠用鎖來實現,可是在分佈式系統中,不管是分佈式事務仍是分佈式鎖都是比較難解決問題。
這節課咱們主要介紹了經過冪等消費來解決消息重複的問題,而後我重點講了幾種實現冪等操做的方法,你能夠利用數據庫的約束來防止重複更新數據,也能夠爲數據更新設置一次性的前置條件,來防止重複消息,若是這兩種方法都不適用於你的場景,還能夠用「記錄並檢查操做」的方式來保證冪等,這種方法適用範圍最廣,可是實現難度和複雜度也比較高,通常不推薦使用。
這些實現冪等的方法,不只能夠用於解決重複消息的問題,也一樣適用於,在其餘場景中來解決重複請求或者重複調用的問題。好比,咱們能夠將 HTTP 服務設計成冪等的,解決前端或者 APP 重複提交表單數據的問題;也能夠將一個微服務設計成冪等的,解決 RPC 框架自動重試致使的重複調用問題。這些方法都是通用的,但願你能作到舉一反三,觸類旁通。