面試官:高并發場景下,如何處理消費過程中的重復消息?
大家好,我是秀才,接著聊消息隊列的最后一個高頻問題——消息重復消費。只要是在后端架構中用到了消息中間件,無一例外,都會涉及到消息重復消費問題。
假設有這樣一個場景:“在我們的電商系統中,訂單創建后會發送一條消息,下游的優惠券兌換系統會訂閱這個消息,然后發放優惠券。我們的系統需要確保每一張優惠券,無論網絡如何波動、系統如何異常,都只能被成功兌換一次。你會如何設計呢?”

這個問題看似簡單,但它背后考驗的是工程師對分布式系統復雜性的理解,尤其是對“冪等性”這一核心概念的掌握程度。很多同學的第一反應可能是“消息隊列不是有‘exactly-once’(精確一次)的保證嗎?”。但事實上,絕對的“精確一次”在分布式系統中是一個難以達到的理想狀態,都需要業務方配合于重試和冪等來達成。
下面,我們就從這個面試場景出發,層層遞進,探討如何構建一個從基礎到高并發都穩如磐石的消費冪等方案。
一、為什么消息會重復?
在分布式系統中,組件間的網絡通信本質上是不可靠的,生產端和消費端都有導致重復消費的場景。
- 生產者重復發送:生產者發送消息后,因為網絡超時等原因沒收到 Broker 的確認,它無法判斷消息是否發送成功,為了保證消息不丟失,通常會選擇重試。這就可能導致同一條消息被發送了多次。

- 消費者的重復消費:消費者拉取消息,業務邏輯處理完了,正準備提交消費位點(ACK)時,服務突然宕機或重啟。當服務恢復后,它會從未提交的位點重新拉取消息,導致同一條消息被再次消費。

二、面試實戰指南
OK,在上面分析了消息重復的原因之后,下面就正式進入到我們的重頭戲,面試的時候應對消息重復的場景問題,我們應該設計怎樣的方案呢?
依然是老規矩,場景問題的回答從最基礎的方案開始,層層遞進,分析其不足和適用場景,然后再逐步優化,最終給出我們的亮點方案
首先我們要搞清楚,我們在設計方案的時候不是去追求一個完美的、永不重復的環境,而是要讓我們的消費端服務具備冪等處理消息的能力。所謂冪等,就是無論一個請求被重復執行多少次,其對系統狀態產生的影響都和第一次執行時完全相同
那么如何實現冪等操作呢?最好的方式就是,從業務邏輯設計上入手,將消費的業務邏輯設計成具備冪等性的操作。但是,不是所有的業務都能設計成天然冪等的,這里就需要一些方法和技巧來實現冪等
1. 數據庫唯一約束
這是最簡單、最直接,也是最常用的一種方案。其核心思想是,利用數據庫中“唯一索引”或“主鍵”的特性,來阻擋重復數據的插入。
假設我們有一個電商系統,用戶下單后會發送一條消息,觸發給用戶增加積分的操作。消息內容可能包含{ "order_id": "202508310001", "user_id": 58, "points_to_add": 100 }。
這個“增加積分”的操作,天然是非冪等的。我們可以這樣改造:
- 建立一張積分流水表(points_log)。
- 表中包含字段:id (自增主鍵), order_id (訂單ID), user_id (用戶ID), points (變更積分), create_time。
- 關鍵一步: 對 order_id 這個字段建立一個唯一索引。
-- 嘗試插入積分流水記錄
-- 假設 order_id 字段上有唯一索引
INSERT INTO points_log (order_id, user_id, points) VALUES ('202508310001', 58, 100);- 第一次消費:該訂單ID首次出現,INSERT操作成功。然后我們可以安全地去更新用戶的總積分。
- 重復消費:MQ再次投遞相同的消息,消費者嘗試INSERT時,數據庫會因為order_id的唯一索引沖突而直接報錯。我們的代碼捕獲這個異常后,就可以知道這是重復操作,直接忽略并返回ACK即可。
這是一種最簡單的實現情況,面試的時候,為了展現你的思考能力,還可以做一個適當延伸,說明下這種方案的優缺點,以及擴展性
這種方案的優點是: 實現簡單,成本低,效果可靠。 缺點也很明顯: 強依賴數據庫特性,對于非數據庫操作的場景無能為力。
基于這個思路,如果不用關系型數據庫,Redis的SETNX命令(SET if Not eXists)也能達到異曲同工的效果,可以用order_id作為key,實現分布式鎖或狀態記錄。
2. 版本號機制
這個時候面試官可能會問:“上面的方案都是基于數據插入場景的,假設我們的業務操作不是數據插入,而是數據更新呢”
確實,如果我們的業務不是INSERT,而是UPDATE呢?比如,更新訂單狀態。這時,唯一約束就派不上用場了。我們可以引入“前置條件”或“版本號”機制,也就是常說的樂觀鎖。
假設有這樣一個場景,訂單支付成功后,需要將訂單狀態從“待支付”(status=1)更新為“待發貨”(status=2)。消息內容為{ "order_id": "202508310002", "target_status": 2 }。
直接執行UPDATE orders SET status = 2 WHERE order_id = '202508310002'是非冪等的。如果因為某種原因,后續還有一個“取消訂單”的操作把狀態改回了1,這條重復的消息可能會錯誤地再次把訂單改為“待發貨”。
我們可以這樣改造:
- 在orders表中增加一個version字段,默認為0或1。
- 消費消息時,我們從消息中(或者先查詢一次數據庫)拿到當前的版本號。
- 執行UPDATE時,帶上版本號作為條件。
-- 更新訂單狀態,同時檢查版本號
-- 假設當前數據庫中該訂單的 version 是 5
UPDATE orders
SET status = 2, version = version + 1
WHERE order_id = '202508310002' AND version = 5;- 第一次消費:version為5,條件滿足,UPDATE成功。數據庫中的version變為6。
- 重復消費:MQ再次投遞消息,消費者執行同樣的SQL,但此時數據庫中的version已經是6了,不滿足AND version = 5的條件。UPDATE語句會執行失敗,影響行數為0。我們就知道這是重復操作了。
同樣在分析完這個方案之后,你可以做一個方案優缺點的補充。優點: 適用范圍比唯一約束更廣,能處理大部分更新操作。 缺點: 需要在業務表中增加額外字段(如version),有一定侵入性。
3. 亮點方案
到這里,面試官還不滿意,接著追問,如果我們的業務邏輯非常復雜,可能涉及多個表的更新,甚至是一些外部RPC調用,這個時候版本號已經不起作用了,此時應該怎么辦呢?
這個時候就到了我們祭出我們第一個亮點方案的時候了:全局唯一ID + 單獨的防重表(或緩存)
(1) 防重表
防重表也叫冪等記錄表,這個方案的核心思想是,為每一次消息處理操作生成一個全局唯一的標識。在執行核心業務邏輯前,先將這個唯一標識插入一張“冪等記錄表”或直接利用業務表中的唯一約束字段。如果插入成功,說明是首次處理,繼續執行業務;如果插入失?。ㄒ驗槲ㄒ绘I沖突),則說明這條消息已經被處理過了,直接丟棄即可。
在面試的時候你可以先介紹下這個方案的基本流程
① 如果業務復雜,可以采用防重表的方案,將業務邏輯和冪等邏輯解耦。單獨建立一張防重表,具體的步驟如下:
② 為每條消息生成一個全局唯一ID(GUID)。這個ID可以在生產者發送時就放入消息體或Header中。
③ 建立一張“消費記錄表”(consumed_log),表結構很簡單,核心就是一個字段message_id,并將其設為主鍵或唯一索引。
消費者處理邏輯變為一個“三段式”:
- 開啟事務。
- INSERT消息的GUID到consumed_log表中。
- 執行真正的業務邏輯(更新數據庫、調用RPC等)。
- 提交事務。
這樣如果是重復消息的話,就會插入消費記錄表失敗,就不會執行后面的業務邏輯了

這里其實隱藏了一個問題,厲害的面試官可能會繼續深挖
“這里你的方案里提到了用事務,在一個數據庫里確實沒有問題,可以用本地事務來保證防重邏輯和業務邏輯的原子性,但是如果是分布式環境下,跨庫要怎么處理呢?”
(2) 異步校對
這里如果你能把分布式環境下的跨庫冪等性實現也講清楚的話,其實就已經可以跟一般候選人拉開差距了。確實,在微服務架構下,業務操作往往是跨服務的,比如“扣減庫存”和“創建物流單”可能分別由兩個不同的服務實現。這時,本地事務就失效了。
此時,我們需要引入最終一致性的設計思想,并輔以一個異步校對機制。整個流程會演變成三步:
- 預操作:收到消息后,第一步是在冪等記錄表中插入一條記錄,但狀態標記為“處理中(PROCESSING)”。例如,插入一條記錄 (order_id, 'PROCESSING')。這一步是冪等性的關鍵防線。
- 執行業務:調用庫存、物流等下游服務,執行核心業務邏輯。
- 確認操作:所有業務邏輯成功執行后,回來將冪等記錄表中的狀態更新為“已完成(COMPLETED)”。

這里由于沒有事務保證,所以很可能出現第二步執行業務成功了,但第三步更新冪等表對應數據為已完成的時候失敗了(比如網絡問題或服務宕機)。這時,冪等記錄表里會留下一條“處理中”的“記錄。
這個時候就是異步校對機制發揮作用的時候了。它會定期掃描冪等記錄表中那些長時間處于“處理中”狀態的記錄,然后反向查詢各個業務系統(比如查詢物流系統是否存在該訂單的物流單),來判斷業務是否真的執行成功。如果查詢下來業務確實已經成功,校對任務就負責將冪等記錄的狀態更新為“已完成”。
這里面試官可能問一個問題:“如果查詢下來業務也沒有成功,會怎么樣呢?”
這里其實就回到了我們的經典重試問題的處理方案了
你可以這樣回答:“其實這個時候還是一致性的狀態,就說明業務確實是沒有執行成功,所以不會修改狀態為已完成,這個時候可以直接重新再出發一次業務操作就可以了,可以設置一個重試次數,如果超過重試閾值,一致不成功,最后只能由人工介入”

到這里,你已經展示了在復雜分布式場景下的問題解決能力,但面試官可能還想繼續了解你的技術深度,會繼續施壓:
“這個方案很好,但所有請求,無論是首次還是重復的,最終都要訪問數據庫。在每秒幾萬甚至幾十萬請求的場景下,數據庫很快就會成為瓶頸。你有什么優化思路?”
這正是引出我們第二板斧的絕佳時機。
(3) 緩存判重
很容易想到,數據庫有讀瓶頸的話,最好的優化方式就是加緩存,同樣這里我們可以在防重表的上層加一個redis來緩存近期處理過的 key。你可以詳細解釋下這個方案
當一個新的消息進來的時候,我們先通過redis做一次判重校驗,如果這個key存在,那么我們就認為這是重復的key,如果redis不存在,再通過數據庫做一次兜底校驗,如果key存在就認為是重復的消息,如果key不存在,就認為不是重復消息,沒處理過
引入redis后的整個判重校驗邏輯如下圖:

一旦引入緩存,就涉及到緩存和數據庫的一致性問題了,這也是面試的時候,面試官最喜歡問的點,那這里我們每次處理一個新的消息之后,是先更新數據庫(防重表)呢,還是先更新緩存redis呢?
這里一定是先更新數據庫,因為它是最可靠的,也是我們的兜底方案,你可以這樣回答:
處理完業務邏輯之后,先更新數據庫,把這個新的消息寫入到防重表,在更新redis。這里即使redis更新失敗,也沒有關系,下一次這個重復的消息過來的時候,做重復性校驗的時候,無非就是redis這里的攔截不起作用了,但是還會透穿到數據庫層面去做校驗, 還是能把重復消息攔截掉。保證消息冪等消費。
“那這里如何確定redis里key的過期時間呢?”
但凡涉及到redis的緩存問題,過期時間的確定也是一個高頻考點。
關于redis的key過期時間其實沒有一個嚴格的標準,一般是根據業務場景來定的,可以先觀察具體消息隊列接入的業務key的出現頻率來設定,通過測試觀察比如key的過期時間大概是5分鐘出現一次的話,那我們比過期時間稍微設置長一點時間即可,比如六七分鐘都是可以的。但是這里的key過期時間不宜設置過長,比如半小時,一小時。如果設置的太長,如果業務量大,并發量高的話,會造成redis的存儲量暴漲,引起redis瓶頸
那假設這里重復的key出現間隔就是很久,比如一批一批的重復消息大量出現。redis用來做這一層隔離不合適,應該怎么辦呢?這里就要祭出我們的終極亮點方案了
(4) 布隆過濾器
既然數據庫是瓶頸,那么優化的核心思路就是:盡可能地減少對數據庫的直接訪問。redis緩存已訪問的消息,又達不到性能要求,那我們可以就在數據庫前構建一個或多個高性能的過濾層,讓絕大多數的重復請求在到達數據庫之前就被攔截掉。
思考一下,這里我們要的是什么,沒有出現過的直接放行,出現過的攔截掉。這不正是布隆過濾器的用武之地嗎。所以我們可以在數據庫前面加一層布隆過濾器作為攔截層。
這里我們首先來回顧下什么是布隆過濾器。
布隆過濾器是一種高效的概率性數據結構,用于判斷一個元素是否可能在一個集合中。它由一個很長的二進制位數組(位數組)和一組哈希函數組成。工作原理如下:
① 初始化時,布隆過濾器是一個長度為 m 的 位數組(bit array),所有位都置為 0。
② 插入元素時,會用 k 個哈希函數分別計算元素的哈希值,每個哈希函數對應一個數組下標,把對應的 bit 位置為 1。
③ 查詢元素時,再用相同的 k 個哈希函數計算位置:
- 如果這些位置有任意一個是 0 → 元素一定不存在。
- 如果這些位置全部是 1 → 元素可能存在。

這里需要注意一點,布隆過濾器不會出現漏判(False Negative),即如果它說不存在,那一定不存在。但是會出現誤判(False Positive),即可能說一個元素存在,但實際上卻不存在。

比如上圖,這里假設進來一個新的元素z,要判定z元素是否存在,對Z做hash映射,這里發現映射到的3個bit位都是1,會認為Z已經存在。但其實這里的bit位為1是前面的X和Y元素存在導致的
解釋完布隆過濾器的原理之后,接下來就可以跟面試官說明下我們的設計方案了。
“為了增加性能,不讓每一次判重邏輯都走數據庫,我們可以在數據庫前面加個布隆過濾器,每一個新的消息過來,先用布隆過濾器判重,如果不存在,我們就正常處理業務邏輯,然后再更新布隆過濾器和防重表。如果布隆過濾器存在這個key的話,由于布隆過濾器存在假陽性的問題,所以這里就可以透穿到數據庫再做一次校驗。就如果是重復消息,直接拒絕就可以了,如果不是重復消息,就正常處理消息”

到這一步其實防重方案已經很完善了。從上面的方案可以知道存在一個數據的一致性問題,面試官可能在這里深挖,如果防重表或者是布隆過濾器失敗該怎么處理,這里其實很好解決了,復用前面版本號機制的思路,雖然用分布式事務可以來保障但是,會給系統帶來很大的復雜性提升。這里我們同樣可以用重試配合人工兜底來處理
(5) 擴展方案
有沒有比布隆過濾器更好的選擇?
在某些特定場景下,確實有。如果你的業務唯一標識本身就是連續或接近連續的整數(例如,訂單系統分庫分表后的數據庫自增ID),那么使用位圖(Bit Array / Bitmap)會是比布隆過濾器更優的選擇。

位圖用每一個比特位來標記一個ID是否存在,1代表存在,0代表不存在。它不僅完全沒有假陽性的問題,判斷絕對精確,而且在數據密集的情況下,內存效率極高。我們甚至可以用一個很小的偏移量來映射業務ID,比如,業務ID從8,800,000開始,那么我們可以讓位圖的第0位對應ID 8,800,000,第1位對應8,800,001,以此類推,極大地節省了存儲空間。
但是這種方案有一個局限性,那就是我們的業務唯一ID必須是數值型的,且最好是自增的,這樣用起來才比較方便。如果滿足這種條件的話,用位圖來替換布隆過濾器是個絕佳選擇
在介紹完上面的布隆過濾器方案的時候,你可以主動引出這個位圖的方案,分析下它的優劣勢,說明其局限性和使用場景。到這里,你基本上已經可以打敗90%的面試候選人了。
三、小結
到這里,我們的消息冪等方案就介紹的差不多了。我們從一個簡單的消息重復消費問題出發,從最基礎的數據庫唯一鍵約束,一路升級打怪,共同構建了一套由 Redis、布隆過濾器乃至位圖組成的完整技術方案。
這些方案的設計思路其實都遵循著同一個核心原則:讓系統具備冪等處理能力。無論是通過數據庫約束阻擋重復數據,還是通過緩存和過濾器提升性能,本質上都是在解決"如何讓重復的操作產生相同的結果"這個根本問題。
可以看到,在應對面試官關于消息重復消費的層層追問時,關鍵并不僅僅在于給出一個單一的“最優解”,而在于展現我們作為工程師解決問題的完整思考框架:從識別問題、到設計基礎方案、再到分析瓶頸并層層優化。
最后,技術方案永遠沒有標準答案,關鍵是要能夠根據具體的業務場景、并發量級、團隊技術棧等因素,選擇最合適的解決方案。這才是一個成熟工程師應該具備的核心素養。
































