面試官:在使用 MQ 的時候,怎么確保消息 100% 不丟失?
大家好,我是秀才,在上一篇文章我們聊了消息隊列的消息積壓問題,這篇文章我們接著剖析消息隊列,來跟大家一起過一下另一個高頻問題:在使用 MQ 的時候,怎么確保消息 100% 不丟失?
這個問題在實際工作中非常常見,既能考察候選者對于 MQ 中間件技術的掌握程度,又能很好地區分候選人的架構設計水平。接下來,我們以Kafka作為消息的隊列選型,還是從面試視角出發,層層剖析這個問題。探討你應該掌握的基礎知識和答題思路,以及延伸的面試考點。
還是老規矩,應對場景題,一個優秀的工程師,在面試的時候不應該直接拋出解決方案,而應該先展現自己的分析思路層層遞進。拿到這個問題我們腦海中首先應該想到的就是以下三個問題:
- 哪些地方可能導致消息丟失?
- 怎樣檢測有沒有消息丟失?
- 怎樣確保消息不丟失?
一、Kafka的消息存儲機制
在深入分析解決方案之前,我們必須對kafka的消息存儲機制有所了解,這樣才能解決我們上面分析的第一個問題,哪些地方可能丟失消息。
Kafka 是一個分布式的消息系統,它的數據都存儲在 Topic(主題) 中。為了實現高吞吐和水平擴展,一個 Topic 又被劃分為一個或多個 Partition(分區)。你可以把 Topic 理解為一個邏輯概念,而分區才是物理存儲的單元。
為了保證高可用,防止單點故障,每個分區又可以配置多個 Replica(副本)。這些副本中,只有一個是 Leader(主分區),負責處理所有的讀寫請求;其余的都是 Follower(從分區),它們唯一的任務就是從 Leader 那里同步數據,保持和 Leader 的數據一致。
這些 Leader 和 Follower 副本會被分散地部署在不同的 Broker(服務器節點) 上,以此來規避單臺服務器宕機帶來的風險。

二、哪些地方可能導致消息丟失?
我們首先來看第一個問題,消息可能在哪里丟失?一條消息從產生到結束,會經歷三個關鍵階段:
- 生產階段:從業務代碼中被創建,然后通過網絡發送給MQ Broker。
- 存儲階段:MQ Broker接收到消息,并將其持久化存儲。
- 消費階段:消費者從Broker拉取消息,并在本地完成業務邏輯處理。

在這三個階段中,任何一個環節出現網絡抖動、服務宕機或者代碼Bug,都可能導致消息丟失。我們的任務,就是為這三個階段分別設計出保險方案。
1. 生產階段消息丟失
從上圖可知,生產階段包括生產者發送消息給Broker,然后Broker回復確認消息。由于kafka的消息發送機制有不同的模式,所以在這個發送和確認的過程中存在著多種情況都可能導致消息丟失
(1) Kafka的消息寫入機制
kafka的消息寫入機制是由acks參數控制的,這個參數有三種不同的級別,對應了三種不同的可靠性承諾。
① acks = 0:“發送不管模式” 這種模式下,生產者把消息發出去就不管了,就可以接著發送下一條消息。這種配置性能最高,但可靠性最差。

在這種模式下是最容易發生消息丟失的,因為沒有確認響應嘛,只管發送,收沒收到都不知道。所以一旦出現網絡都懂,或者是Broker宕機,或者重啟都會直接丟失消息
② acks = 1(默認值):“寫入 Leader 即成功模式” ,這是 Kafka 的默認配置。只要消息成功寫入 Leader 分區,生產者就會收到成功的響應。這種模式在性能和可靠性之間取得了平衡。

這種模式犧牲了一定的性能,但是在性能和可靠性之間取得了一定的平衡,增加了消息可靠性,這也是kafka的默認消息發送機制。但是還是會出現消息丟失,比如 Leader 剛寫完消息,還沒來得及同步給任何一個 Follower 就宕機了,那么這條消息就會永久丟失。
③ acks = all (或 -1):“寫入所有 partion 副本才成功模式”, 確保消息寫入到leader分區、還確保消息寫入到對應副本都成功后,接著發送下一條,性能是最差的,但最安全

這種模式就很安全了,它要等 partition集合中所有的 Follower 都同步完成,才會發送下一條數據。所以在消息生產階段一般不會丟失消息。問題肯定沒有這么簡單,到這里面試官可能就會追問了。
這樣的話,那我們把消息發送模式的acks設置為-1,是否就能保證消息不再丟失了呢。
2. 存儲階段消息丟失
這其實就引出了另一個階段的消息丟失情況,存儲階段消息丟失。
和數據庫一樣,Kafka 在寫入數據時,為了性能考慮,也是先寫入操作系統的 Page Cache(頁緩存),然后由操作系統在合適的時機異步地刷寫到磁盤。
這意味著,即使 acks = all,所有 partition 副本都確認收到了消息,但這些消息可能都還靜靜地躺在各個 Broker 的內存里。如果此時整個機房突然斷電,所有 Broker 同時宕機且無法恢復,那么這部分在內存中的數據就會全部丟失。

當然,這種情況極其罕見,但理論上確實存在。Kafka 提供了幾個參數來控制刷盤策略:
log.flush.interval.messages 消息達到多少條時刷盤
log.flush.interval.ms 距離上次刷盤超過多少毫秒就強制刷盤。
log.flush.scheduler.interval.ms 周期性檢查,是否需要將信息刷盤Broker要通過調用fsync函數完成刷盤動作,理論上,要完全讓kafka保證單個broker不丟失消息是做不到的,只能通過調整刷盤機制的參數緩解該情況。比如,減少刷盤間隔,減少刷盤數據量大小。時間越短,性能越差,可靠性越好
不過,在實踐中,我們很少會去主動調整這些參數。因為強制同步刷盤會極大地犧牲性能,我們更愿意依賴 Kafka 自身強大的副本機制來保證可靠性。
3. 消費階段消息丟失
消費階段的消息丟失往往就涉及到消息的異步消費了。有些業務會在并發量很大,消息量很大的情況下選擇異步消費來提升消費能力。
- 一個專門的消費者線程:它的唯一職責就是高效地從消息隊列中拉取消息,然后迅速將消息放入一個內存隊列(如Java中的ArrayBlockingQueue)中,它完成這個動作之后就提交了,其實消息并沒有被真正消費。
- 一個獨立的線程池:這個線程池中的工作線程,從內存隊列中獲取消息,并執行真正的業務邏輯。

這就可能出現消費者線程將消息放入任務隊列后,worker線程還未處理完消息,應用就宕機了,worker重啟之后會接著消費后續消息,剛才這條消息就永久丟失了。
4. 消息丟失監測機制
在明確了消息丟失場景之后,我們下面就需要思考,在業務中如何能檢測到消息丟失呢?
如果公司有成熟的分布式鏈路追蹤系統(比如SkyWalking、Jager),那自然是首選,每一條消息的生命周期都能被完整追蹤。但如果沒有,我們也可以自己動手,實現一個輕量級的檢測方案。
核心思路是利用消息隊列在單個分區內的有序性。我們可以在生產者(Producer)發送消息時,為每一條消息注入一個唯一且連續遞增的序列號。消費者(Consumer)在接收到消息后,只需檢查這個序列號是否連續,就能判斷出是否有消息丟失。
舉個例子,假設我們正在處理一個電商訂單系統,生產者A負責發送訂單創建消息到分區0。
- 第一條消息,我們給它一個ID:ProducerA-Partition0-1
- 第二條消息,ID就是:ProducerA-Partition0-2
- 以此類推...
消費者在處理時,只需要維護一個對ProducerA-Partition0的期望序列號。比如當前收到的是...-2,那么下一條期望的就是...-3。如果下一條收到的是...-4,那么我們就知道,第3條消息“失蹤”了,需要立即告警,并根據ID進行追查。
在分布式環境下,這個方案需要注意幾個細節:
- 分區維度的有序性:像Kafka、RocketMQ這類MQ,全局有序很難保證,但分區內是有序的。因此,序列號的生成和檢測都必須在分區這個維度上進行。
- 多生產者問題:如果多個生產者實例同時向一個分區發送消息,協調全局序列號會非常復雜。更實際的做法是,每個生產者維護自己的序列號,并在消息中附帶上自己的唯一標識(如IP地址或實例ID),消費者則需要為每個生產者分別維護一套序列號檢測邏輯。
當你把這套監控方案清晰地闡述給面試官后,你已經成功了一半。這表明你不僅懂技術,更有系統化、產品化的設計思維。
三、怎樣確保消息不丟失?
終于進入到最核心的地方,也是在面試的時候最能展現我們亮點的地方了,這里我們模擬一個面試場景來展開
面試官:“OK。上面消息丟失的一些場景分析的不錯,那么在你的項目中,是如何設計一套方案來確保消息從生產到消費的整個鏈路都絕對可靠呢?”
基于前面的分析,我們知道了消息的丟失可能出現在消息生產,消息存儲,和消息消費三個階段,那么在設計保障方案的時候,我們也要構建一套從生產端到消費端全鏈路的消息保障體系。
1. 參數配置
其實在前面分析消息丟失場景的時候,我們已經知道了大部分的消息生產端的消息丟失都是與acks參數設置相關。那么這里如果要保證消息100%不丟,這里我們自然要設置acks參數為all/-1。不過這也要依據業務場景來。一般只有在特別關鍵,并且性能要求不高的業務上才會這樣去設置,而對于性能要求較高的業務是不合適的。具體可以做如下“極限”的配置:
- acks = all:確保消息寫入所有 partition 副本。
- min.insync.replicas = 2(或更高):這個參數設定了 ISR 中最少需要有幾個副本。比如,如果 Topic 的副本因子是 3,這里設置為 2,就意味著至少要有一個 Leader 和一個 Follower 存活,acks=all 的寫入請求才能成功。這可以防止在 ISR 副本數不足時,數據寫入的可靠性降級。
- unclean.leader.election.enable = false:堅決杜絕“不干凈”的 Leader 選舉,防止數據丟失。設置為false之后,Kafka 不會從非ISR副本中選舉新的Leader。由于非ISR副本可能含有不完整或滯后的數據,從它們中選擇Leader會帶來數據丟失或不一致的風險。
2. 代碼健壯性保證
假設你的業務代碼調用send()方法時,來發送消息。在編碼時,我們必須對發送操作的結果進行處理:
① 同步發送:這種方式下,send()方法會阻塞,直到收到Broker的響應或者超時。java代碼的話我們可以用try-catch塊來捕獲可能出現的異常(如網絡抖動、Broker無響應等)。一旦捕獲到異常,就必須進行重試,或者將失敗的消息記錄下來,后續進行補償。
// 以Kafka同步發送為例
try {
// send方法返回一個Future,調用get()會阻塞等待結果
RecordMetadata metadata = producer.send(record).get();
// 收到metadata,說明發送成功,可以記錄日志或繼續業務
System.out.println("消息發送成功,分區:" + metadata.partition() + ", 偏移量:" + metadata.offset());
} catch (Throwable e) {
// 捕獲到異常,說明發送失敗
System.out.println("消息發送失敗,準備重試或記錄日志!");
// 在這里實現重試邏輯或將消息持久化到本地磁盤/數據庫
System.out.println(e);
}② 異步發送:為了追求更高的吞吐量,如果是用異步發送。這時,send()方法會立即返回,不會等待Broker的響應。因此,我們必須在提供的回調函數(Callback)中檢查發送結果。很多新手在這里“踩坑”,只管發不管結果,這是導致消息丟失的常見原因之一。
// 以Kafka異步發送為例
producer.send(record, (metadata, exception) -> {
// exception不為null,說明發送過程中出現了錯誤
if (exception != null) {
System.out.println("消息發送失敗,進行處理!");
// 打印異常信息,用于排查問題
System.out.println(exception);
// 在這里同樣需要實現重試或補償邏輯
} else {
// 發送成功,可以打印日志,方便追蹤
System.out.println("消息異步發送成功,分區:" + metadata.partition() + ", 偏移量:" + metadata.offset());
}
});3. 消息消費確認
消息在消費端的丟失,基本上都是消息消費后沒來得及發送確認給生產者導致的,而導致這種情況一般都是異步消費引起的,所以這里可以參考消息積壓這篇文章的異步消費優化情況來進行處理。
核心思想就是采用批量提交。異步消費的時候消費者線程一次性拉取一批消息(比如100條),分發給工作線程池。然后,它會等待這100條消息全部被工作線程處理完畢后,才一次性向MQ提交這批消息。提交完成之后才會發送下一批消息,這樣來保證每一條消息都被消費了。

4. 亮點方案展示
到這里消息隊列層面的消息保障基本上就做到位了。但是厲害的面試官可能還會問一個消息發送的可靠性問題
面試官:消息隊列層面分析的挺全面的,這里有個問題,我們在業務側發送消息的時候一般是有業務場景需要,比如注冊完用戶之后要給用戶加積分,那這里這個加積分的操作一般都是通過消息隊列異步化來實現的,這里注冊和發消息到消息隊列加積分可以看作是兩個操作,怎么保證這個注冊完成之后,消息一定會發送成功呢
(1) 消息事務
這其實可以看作是個分布式的事務問題,業務操作和發送消息是兩個獨立的步驟,這里就是要保證這兩個操作要么都成功,要么都失敗,而如果這里業務操作成功,發消息失敗,即發送消息的時候出現了消息丟失。這就會造成一致性問題了。所以這里就可以用消息事務來解決了
你可以這樣回復,這里其實是個分布式的事務問題,我們要保證注冊和發消息要么都成功,要么都失敗,可以用消息事務來實現,具體方案我們可以選擇業界用的比較多的本地消息表來實現
這個方案的核心思想是,將消息的發送封裝進本地數據庫事務中。具體流程如下:
① 開啟本地事務:啟動一個數據庫事務。
② 執行業務操作:比如,在用戶庫里創建一條用戶記錄。
③ 記錄消息:在同一個事務中,向一張“本地消息表”里插入一條記錄,狀態為“待發送”。這條記錄包含了完整的消息體、目標 Topic 等信息。
④ 提交本地事務:提交數據庫事務。
到這一步,即使應用立刻宕機,由于用戶操作和“待發送”的消息記錄在同一個事務里,數據的一致性得到了保證。用戶創建成功了,那么要發送的消息也一定被記錄下來了。
接下來,我們再處理真正的消息發送:
⑤ 嘗試發送消息:事務提交后,立即嘗試將消息發送到 Kafka。
⑥更新狀態:如果發送成功,就更新本地消息表里對應記錄的狀態為“已發送”,或者直接刪除。
⑦ 失敗與補償:如果發送失敗,也不用擔心。我設計了一個異步補償任務(比如一個定時任務),它會定期掃描本地消息表中那些“待發送”且超過一定時間(比如 5 分鐘)的記錄,然后進行重試發送。為了避免無限重試,表中還會記錄重試次數,達到閾值后就告警,轉由人工處理。

介紹完具體方案之后,面試官還可能接著追問:如果數據庫事務提交了,但是服務器宕機了,消息還沒發送出去怎么辦?
這里其實不用擔心,我們有補充機制,異步補償任務會輪訓掃描消息表中待發送的消息,找出這條消息進行補發
在講完本地消息表方案之后,還可以適當引申一下你對這種方案的優缺點分析,突出你考察問題的全面性和一個架構師方案選型方面的能力
當然這種方案也有它的優缺。優點是實現邏輯簡單,開發成本比較低。
缺點也比較突出:
- 與業務場景綁定,高耦合,不通用。
- 本地消息表與業務數據表在同一個庫,占用業務系統資源,量大可能會影響數據庫性能。
本地消息表的方案也并非最優選擇,現在有很多的消息隊列也支持事務了,比如RocketMQ這類消息中間件,本身就支持事務消息,在選用上就更方便。如果在業務中已經選用了本身就不支持事務的消息隊列,并且業務量也不是太大的話,可以考慮本地消息表方案。
通過“生產者保障 + 消費者保障 + 事務消息”的這一套組合拳,我們就可以構建出一套從生產端到消費端全鏈路的消息保障體系,這套方案整個思考過程就是你在面試官前的一個亮點呈現
四、小結
回到我們最初的面試題:在使用 MQ 的時候,怎么確保消息 100% 不丟失?
要構建一個真正“不丟消息”的可靠性系統,關鍵在于建立一個從生產者到消費者的端到端保障閉環,而不是孤立地調整某個環節的參數。在生產者端,可以采用本地消息表實現消息事務,確保將業務操作和消息發送進行綁定;在 Broker 端,則可以通過 acks=all、min.insync.replicas > 1 以及禁用 unclean 選舉來定義一個嚴格的數據一致性模型;最后在消費端,務必堅持“先處理業務,后提交位移”的原則,在異步消費提升性能的前提下,采用批量提交策略,確保消息等到正確消費,在進行提交。
將這三者融為一體,而不是零散的技術點堆砌,你的方案才顯得完整可用,才能升級為一個復雜分布式環境中的消息可靠架構,這正是衡量一位架構師系統設計能力的試金石。
































