我負責的系統(tǒng)老是數(shù)據(jù)出錯,Leader催我優(yōu)化系統(tǒng)架構(gòu),難
業(yè)務(wù)背景
今天給大家聊聊線上系統(tǒng)的接口冪等問題,以及如何通過分布式鎖來保障接口的冪等性,同時會給大家分享一下我們在基于分布式鎖實現(xiàn)接口冪等性的時候,一些生產(chǎn)實踐經(jīng)驗的積累。
首先給大家說說,假如說要是我們線上系統(tǒng)的核心接口要是沒有冪等性保障機制的話,可能會出現(xiàn)什么情況?
其實非常簡單,假設(shè)你有一個系統(tǒng),他有一個接口,這個接口接受請求的時候假設(shè)會在數(shù)據(jù)庫里插入一條數(shù)據(jù),正常情況下一個用戶對這個接口發(fā)起一次請求應(yīng)該就只有一條數(shù)據(jù),結(jié)果可能某一天你會發(fā)現(xiàn)這個用戶通過這個接口插入了多條數(shù)據(jù)。
如下圖 1 所示:
初版防重代碼
那么為什么會這樣呢?其實我們一般這類系統(tǒng)接口,但凡是寫的稍微好一點的,都會在接口里加入防重代碼。
就是會有代碼判斷一下,當前你要寫入的這條數(shù)據(jù)是否存在,如果他要是不存在的話,就會進行插入,但是如果他存在的話就不會允許你重復插入的。
這種防重代碼如下所示:
結(jié)合上面這段代碼的防重邏輯,我們可以看下圖 2 的運行邏輯展示:
在插入數(shù)據(jù)之前一定會先根據(jù)請求參數(shù)查詢這條數(shù)據(jù),如果查詢到了,則此時直接返回不會重復插入,但是如果沒有查詢到這條數(shù)據(jù),則此時會插入這條數(shù)據(jù)。
那么大家可能問題來了,那既然都已經(jīng)有這個防重邏輯了,即使你用相同的請求參數(shù)重復多次調(diào)用這個接口插入數(shù)據(jù),也不應(yīng)該重復插入數(shù)據(jù)啊!
按說確實是這樣子的,但是凡事總有例外,那就是大名鼎鼎的瞬時重試+多線程并發(fā)問題。
瞬時重試+多線程并發(fā)問題分析
下面我們給大家解釋一下,在上述的代碼防重邏輯下,如果要是短時間內(nèi)用戶用相同的請求參數(shù)重復的發(fā)起了兩次請求,為什么會穿透防重邏輯,在數(shù)據(jù)庫里插入兩條一樣的數(shù)據(jù)。
大家要打起且精神來,仔細來看這個過程了, 首先用戶可能會因為過于激動、手抖或者是網(wǎng)絡(luò)抽風等各種原因,在一瞬間發(fā)起兩次請求參數(shù)完全相同的請求。
如下圖 3 所示:
接著呢,這兩個請求到了我們的系統(tǒng)后,其實是分別由一個線程來處理的,不管你是用 tomcat 部署提供的 controller 接口,還是基于 dubbo 提供的 rpc 接口,其實每個請求過來都是由一個獨立的線程來處理的。
如下圖 4 所示:
接著呢,這兩個線程會并發(fā)的運行相同的一段代碼邏輯,就是先根據(jù)請求參數(shù)查詢這條數(shù)據(jù)是否存在,存在就返回,不存在就進行插入。
這個時候可能會出現(xiàn)一個問題,因為是多線程并發(fā),所以很可能這兩個線程會同時執(zhí)行數(shù)據(jù)查詢邏輯,但是他們倆同時執(zhí)行數(shù)據(jù)查詢邏輯的時候,有一個問題,那就是此時數(shù)據(jù)庫里沒數(shù)據(jù)?。?/p>
所以說,這兩個線程并發(fā)運行,完全可能會同時發(fā)現(xiàn)從數(shù)據(jù)庫里查詢出來的數(shù)據(jù)是空的。
如下圖 5 所示:
然后這個時候,兩個線程既然發(fā)現(xiàn)自己查詢到的數(shù)據(jù)都是空的,那當然都可以去插入數(shù)據(jù)了。
所以此時這兩個線程會基于這個請求參數(shù)分別插入一條數(shù)據(jù),而這條數(shù)據(jù)其實對于業(yè)務(wù)來說是完全重復的,因為請求參數(shù)是完全相同的。
如下圖 6 所示:
這個時候就會導致本次數(shù)據(jù)重復問題了,針對這種情況,我們一般把這種接口稱之為沒有冪等性。
因為如果一個接口是有冪等性的,其實對于這個接口如果說用相同的參數(shù)發(fā)起請求,那肯定是只會有一條數(shù)據(jù),不可能會有重復數(shù)據(jù)的,這才叫做冪等性。
而現(xiàn)在的問題是,這個接口用相同的請求參數(shù)發(fā)起多次,結(jié)果數(shù)據(jù)有重復了,此時接口就沒有冪等性。
數(shù)據(jù)庫唯一索引實現(xiàn)冪等
針對上述這種接口冪等問題,其實比較簡單的一種解決方案,就是基于我們依賴的數(shù)據(jù)庫去實現(xiàn)冪等。
也就是說,用數(shù)據(jù)庫的唯一索引來實現(xiàn)即可,如果我們要是基于請求中的某一個或者多個業(yè)務(wù)字段組成一個唯一索引,那么其實你要往數(shù)據(jù)庫中用相同參數(shù)插入重復數(shù)據(jù),那就是不可能的。
因為數(shù)據(jù)庫層面就會阻止你插入的,唯一索引會確保這一點,你要重復插入,他就會拋異常。
如下 7 所示:
分布式鎖實現(xiàn)冪等
但是很多時候我們會發(fā)現(xiàn)一個問題,那就是我們可能不一定說每次都可以依賴數(shù)據(jù)庫的 唯一索引實現(xiàn)這種冪等性。
因為有可能你在業(yè)務(wù)邏輯里,除了依賴數(shù)據(jù)庫以外,還依賴了別的服務(wù)接口,或者是 elasticsearch、redis 等多種數(shù)據(jù)存儲,也可能是依賴了數(shù)據(jù)庫中的多張表里的數(shù)據(jù),你不可能每張表都做一個唯一索引來確保冪等性。
所以對于有復雜業(yè)務(wù)邏輯的接口來說,要確保冪等性,往往需要引入一個關(guān)鍵組件,那就是分布式鎖。
所謂的分布式鎖,意思就是依賴外部的某個系統(tǒng)來加一把鎖,鎖加了以后后續(xù)還可以釋放這把鎖,現(xiàn)在比較常見的分布式鎖實現(xiàn)主要是依賴 redis 和 zookeeper 這兩個來實現(xiàn)的,我們這里就以 redis 分布式鎖來舉例說明。
先往簡單了說,我們可以在接口的入口代碼處,基于 redis 加一把分布式的鎖,這個時候只有一個線程可以成功加鎖。
加鎖之后,就這一個線程就可以去查詢這條數(shù)據(jù)是否存在,如果不存在,就可以插入一條數(shù)據(jù)進去,然后再釋放鎖,在這個過程中,另外一個線程因為獲取不到 redis 分布式鎖,所以只能干等著。
如下圖 8 所示:
等第一個線程加鎖,然后查詢數(shù)據(jù),發(fā)現(xiàn)數(shù)據(jù)不存在,接著插入一條數(shù)據(jù),最后釋放鎖之后,接著第二個線程就才能得到機會再次加鎖,接著第二個線程加鎖后查詢數(shù)據(jù),發(fā)現(xiàn)數(shù)據(jù)已經(jīng)存在了,此時他就會直接返回,不會重復插入數(shù)據(jù)了。
如下圖 9 所示:
如上圖,大家可以發(fā)現(xiàn),只要在核心接口的入口處加一把分布式鎖,就可以實現(xiàn)多線程并發(fā)下,復雜業(yè)務(wù)邏輯不會被重復執(zhí)行了,而且不依賴數(shù)據(jù)庫某個表的唯一索引,只要基于 redis 實現(xiàn)加鎖和釋放鎖就可以了。
而至于 redis 分布式鎖是如何實現(xiàn)的,就不在本文的討論中了,我們這次主要是給大家先分析一下線上系統(tǒng)接口的冪等問題,當沒有冪等性的時候,接口是如何在多線程并發(fā)場景下出現(xiàn)數(shù)據(jù)重復問題的。
總結(jié)
然后我們分析了,如果要是基于數(shù)據(jù)庫表加一個唯一索引,就可以實現(xiàn)接口冪等了 ,可是如果業(yè)務(wù)邏輯過于復雜,有很多數(shù)據(jù)存儲,或者涉及很多表,此時就不能單單依賴一個唯一索引了,需要依靠在接口入口處加分布式鎖,然后才可以解決復雜接口的冪等性。