這就叫“面試造火箭,工作擰螺絲!”

why技术發表於2024-06-24

你好呀,我是歪歪。

我想再討論一下上次的這篇文章《哎,被這個叫做at least once的玩意坑麻了》

因為有些朋友看完之後再評論區給出了自己的思考,也有朋友和我私聊,分享了自己的看法,我覺得有些想法很好,所以我決定一魚兩吃,再聊聊這個問題。

假設,我們是一場面試,面試官給你丟擲了這樣一個問題:

如果一個消費佇列由於某些原因,對於某個訊息發起了兩次。導致一樣的資料落庫兩條,請問你會怎麼處理這個問題?

這題你一拿到手上,應該就立馬能分析出是在問如何實現一個冪等機制。

想著這玩意我熟啊,張口就能給出方案:

業務訊息 = select(業務唯一流水號);
if(業務訊息 == null){
save(業務訊息);
}

面試官一聽,提示道:你這個方案在多執行緒的情況下會不會有什麼問題呢?

於是你的小腦瓜子立刻開始轉了起來:先查詢,再判斷,最後儲存。

如果兩個執行緒同時過來,都查不到資料,那麼就能都走到儲存的邏輯裡面去,確實攔不住。

於是你扣了一下腦殼,想起了你上家公司針對這個問題,就是在資料庫的表結構裡面,對業務唯一流水號做了唯一索引,所以不會出現重複插入的情況。

然後你給出了“加唯一索引”的方案,準備絕殺這個問題。

沒想到面試官非常不懂事,還在繼續追問:我想盡量不要讓程式丟擲異常,還有沒有其他的方案呢?

Redis

你抱著自己的左手,邊啃指甲邊思考:唯一索引是資料庫幫我們保證的邏輯,現在面試官這個老登不想讓我用資料庫來做這件事情。那就必須要控制在併發的場景下,只有一個請求能抵達資料庫。

鎖!這不就是鎖乾的事兒嗎?

於是你飛快的又想到了一個方法:

flag = redis(業務唯一流水號,過期時間);
if(flag){
save(業務訊息);
}

可以利用業務唯一流水號結合 Redis 來做一個鎖,加鎖成功的請求才能走到 save 邏輯中。

這樣就能解決併發場景下,多個請求穿透到 save 邏輯這一步的問題。

面試官聽到你這個方案之後,立馬就啟動了追問技能:如果放 Redis 成功了,但是還沒來得及 save,服務重啟了。

這個請求理論上是應該能再次發起的,但是由於 Redis 鎖的存在,導致不會走到 save 的邏輯去,怎麼辦呢?

於是你又扣了一下腦殼,想起你在上家公司的時候,好像也遇到過這個情況。

當時的解決方案就是人工介入,分析了一波資料,確認了這個訊息確實應該被繼續處理,於是你找 DBA 幫忙刪除了 Redis 對應的 key,流程就通了。

然而這個回答面試官並不滿意:人工就顯得不優雅了,要不再想想?

你又抱著自己的右手,邊啃指甲邊思考:這個老登考慮的確實挺多的,感覺應該在一個很厲害的團隊,我得加把勁兒,再想想。

現在要人工介入的原因,是因為我們把第二次的請求攔截住並丟棄了。

如果不丟棄,那麼理論上在“過期時間”到了,鎖被釋放後,第二次的請求拿到鎖,就能接著往下走。

所以,這裡需要在 Redis 這裡加一個加鎖失敗則等待的邏輯:

flag = redis(業務唯一流水號,過期時間,獲取不到則等待);
if(flag){
save(業務訊息);
}

但是你一看這個邏輯又不對了:由於有鎖等待的邏輯,那麼如果兩個請求過來,還是有可能會都放入到 Redis 裡面,flag 都會為 true,那麼 save 方法還是會走兩遍。

所以,還得在獲取鎖成功之後加上一個查詢資料庫的邏輯:

flag = redis(業務唯一流水號,過期時間,獲取不到則等待);
if(flag){
業務訊息 = select(業務唯一流水號);
if(業務訊息 == null){
save(業務訊息);
}
}else{
//等待結束後還是未獲取到鎖,傳送預警
monitor(預警資訊);
}

第一層的 Redis 相當於讓請求排隊,確保只有一個請求進來。

第二層的 select 才是真正的防止重複的業務邏輯。

同時,如果等待結束後還是未獲取到鎖,出現這種低機率情況,就預警出來,人工兜底嘛,一旦人工介入,那就是能解決任何問題。

你心想這波應該是穩了,應該是可以換題了。

然而面試官並不打算在這個回合上輕易放過你:這個方案確實是可以解決這個問題,但是在技術實現上引入了 Redis 框架,如果我不使用 Redis,單純的靠 MySQL 呢?

回到 MySQL

聽到這個問題的時候你覺得不對啊,最開始的時候不就是說了“加唯一索引”就可以解決這個問題嗎?

於是面試官補充了一下描述:

最開始的加唯一索引是基於業務表來做的,如果出現問題就讓其丟擲主鍵衝突異常,這個方案確實是可以實現需求。但是我現在想讓你給我設計一個通用的技術元件,不需要基於某個具體的業務場景去設計。我想聽聽你的思路。

拿到新的題目,你開始覺得這是***難,看著面試官求知的眼神,你又開始懷疑:這個老登不會是來套方案的吧?

看著自己已經被咬禿了的左右大拇指指甲,感覺自己的靈感和指甲一樣都光禿禿的。

開始後悔前面幾個回合咬得太快了,原以為可以秒殺這個面試,沒想到面試官還在纏鬥。你動了使用必殺技來結束戰鬥的念想。

於是從帽子的縫隙中插進入一根指甲已經禿了的手指,在差不多禿了的頭頂,用指腹畫圈,給自己頭皮按摩,醫生說這樣的有助於毛囊發育,你想著頭髮還會長出來,就思如泉湧,這就是必殺技。

你陷入了思考,Redis 在前面的方案中是為了防止有多條資料穿透到 save 方法中去,如果不讓用 Redis。MySQL 怎麼實現類似的效果呢?

也加鎖嗎?for update?

業務訊息 = select(業務唯一流水號);//select *** for update
if(業務訊息 == null){
save(業務訊息);
}

這玩意一看上去就是效能就拉胯了,為了解決這個偶發的問題,犧牲了介面的效能,這個路線就走的有點遠了。

而且這個上鎖的邏輯隱藏的有點深,容易留下後患,面試官肯定不會滿意的。

那還有什麼辦法,能把 MySQL 當作鎖來用,確保併發情況下只有一個請求能穿過這個鎖呢?

那還是得靠唯一索引的約束才行。

但是這個唯一索引面試官不讓用業務表的,那就只能直接搞個“訊息消費記錄表”,裡面有個“訊息唯一標識”的欄位,這個欄位是唯一索引。

這張表面試官問起來,我就說這張表是完全獨立於業務的存在,只是為了解決訊息冪等這個存粹的技術問題而出現的,基於它,我們就可以設計出一個通用的技術元件,這樣應該說的過去。

表有了,技術方法大概的雛形就有了。

然而你還不能開始答題,現在思路還不是特別清晰,你要把方案捋清楚了再張口。

在不知不覺間,你的指腹已經摩擦的有點麻木了,於是你換了一個手,穿過帽子,接著按摩著自己的頭頂。

這個表我怎麼用呢?

if(儲存資料到訊息消費記錄表){//出現主鍵衝突就返回false
save(業務訊息);
}

先校驗,再儲存,非原子性,這樣肯定不行啊,

我們想想一個場景,如果儲存資料到訊息消費記錄表成功,還沒來得及 save(業務訊息) ,服務重啟了,怎麼辦?

所以為了保證原子性,我們可以加入事務,把這兩步繫結到一起:

開啟事務;
if(儲存資料到訊息消費記錄表){//出現主鍵衝突就返回false
save(業務訊息);
}
提交事務;

這樣,如果儲存資料到訊息消費記錄表成功,還沒來得及 save(扣款資訊) ,服務重啟,事務回滾,訊息消費記錄表就不會真的插入成功。

而 MQ 沒有收到這個訊息的回執,也會再次進行投遞。

由於訊息消費記錄表裡沒有這個資料,所以會再次進行消費。

現在你覺得似乎沒啥問題了,剛想給面試官說你這個思路,但是立馬又想到了另外一個問題:透過引入事務來解決了“非原子性”的問題,但是事務這玩意,一般來說,大家都是能不使用事務的地方就儘量不使用事務,透過最終一致性來保證資料的完整性。

這個老登肯定會在這個地方繼續窮追猛打的,我先預判了他,想想這個問題怎麼解決。

我們可以在訊息消費記錄表裡面再引入一個“狀態”欄位,這個欄位有兩個取值:消費中、消費完成。

同時把唯一索引改成“訊息唯一標識+狀態”。

首先,MQ 發起請求,資料往訊息消費記錄表插的時候,狀態直接就是“消費中”。

如果插入成功,則說明是第一次消費,進入到業務邏輯中去。

  • 如果業務邏輯執行成功,則更新訊息消費記錄表對應資料為“消費完成”。
  • 如果業務邏輯執行失敗,則刪除訊息消費記錄表對應資料,把訊息仍回 MQ,等待下次重試。

如果插入失敗,則說明是重複消費,直接扔掉。

畫成流程圖上大概是這樣的:

順便提一嘴,上面這個流程圖我是用這個網站直接生成的,我覺得這個網站畫圖還挺舒服的:

https://excalidraw.com/

你感覺這波應該穩了,於是給面試官說出了自己的方案,並在白字上畫了流程圖。

面試官拿著你的流程圖,看了一眼,立馬就看出了一個問題:如果一個訊息插入失敗,你的邏輯是扔掉。那假設這條訊息的狀態是消費中,業務邏輯執行失敗,是不是應該重新消費才對呢?

於是你立馬反映過來,如果插入失敗,則說明是重複消費,還需要判斷資料的狀態。

  • 如果狀態是“消費成功”,則說明重複請求,直接返回成功
  • 如果狀態是“消費中”,則說明還未處理完成,為了確保成功,需要把請求再次仍回到 MQ。

修改了流程圖:

面試官拿著這個流程圖,微微一笑:

倘若我業務執行完之後,狀態更新之前,服務掛了,閣下又該如何應對?

巧了,這個問題上一篇文章的評論區也提到了:

所以,還需要針對長時間在“消費中”的資料進行一個監控,人工兜底一下。

此外,為了防止“消費完成”的資料量過多,還應該對於這個狀態的資料做一個定時清理的任務。

終於,你看到了面試官臉上那一閃而過的滿意表情,在你覺得面試官應該會放過你了的時候,他又提出了另外的問題:

你這個通用元件理論上確實是可行的。

但是,這張表放在哪個庫的哪個表裡呢?

是統一放在一個庫裡呢還是就放在業務服務的庫裡呢?

統一放一個庫的話太大了怎麼辦呢是不是要按日期分表?

萬一跟業務庫用的資料庫不是一個資料庫產品那事務不生效咋辦呢?

放在業務庫裡的話萬一業務服務連好幾個庫那我具體放哪一個呢?

是不是所有業務庫我都得加這麼一張表強制綁架他們的資料庫?

...

這一部分問題,也來自上一篇文章評論區。

聽到這些問題,你開始覺得這個面試官是在胡攪蠻纏,一氣之下,準備拿回簡歷,結束面試。

但是手上動作稍微大了一點,一不小心掀起了自己的帽子,漏出了“資深的髮型”。

面試官也愣住了,看著你“資深的髮型”,當即就握住了你的手:你就是我要找的人才。不面了,就你了,明天來報導!

入職

入職之後你第一件事情就是看看這個公司的程式碼。

當你看第一個介面的時候,發現根本沒有做冪等。

當你看第二個介面的時候,發現就是靠業務表的唯一索引做的冪等。

當你看第三個介面的時候,Redis 的方案躍然紙上。

突然一個哥們氣喘吁吁的跑過來找昨天面試你的老登,說:快,又出問題了,幫忙刪除一個 Redis key。

於是,你抽過去準備看一下怎麼操作。

不經意間看到了老登正在寫一個文件,題目叫做《一種分散式系統中資料唯一性的訊息冪等保障策略》。

老登看到你過來了,說:正好,你來寫這個文件,我已經把名字給你想好了,你就按照這個寫,把你昨天的思路寫清楚,到時候我去彙報。

你興奮的問:彙報過了之後我們要按照這個方案落地嗎?

老登說:不不不,落地幹啥啊,多麻煩啊,方案彙報嘛,體現一下我們在技術方面的時刻,在領導面前去刷個臉,所以你要多用一些高大上的詞,越晦澀難懂越好。哦,對了,我順便教教你怎麼“刪除 Redis key”,以後就讓他們找你了。這幫老登,大半夜的,老是給我打電話。

相關文章