出庫重構

賈智文發表於2019-01-20

1.業務介紹 業務介紹 1.圖書業務中有兩類場景,1購買圖書,2回收的圖書有次品需要寄回給使用者,都需要進行出庫。圖書的isbn根據新舊區分之後,就是圖書商品的最小庫存單位。出於在一些場景下整理庫存以及有些業務方需要分清楚具體是sku下的哪一本書,所以每一本書還有一個sn碼進行標示,sn也是圖書庫存的實際核算依據。所以圖書的出庫就是在接收到出庫請求後,進行sn匹配,匹配到即為有貨,然後根據配送地址和業務型別進行打包,呼叫中臺傳送出庫單。

業務難點 1.每一本書出庫時都需要匹配sn,現階段的方式是採取資料庫鎖的方式,在某個請求出庫時,查詢即對相應的isbn進行加鎖,然後獲得sn,更新資料後才釋放鎖,如果對相應isbn請求較多,可能導致其它請求阻塞等待時間較長,引發業務方呼叫超時。

2.業務對一致性的要求很高,購買記錄中的出庫狀態,sn的佔用狀態,出庫記錄的狀態必須一致,購買記錄佔用的sn,和sn表中被使用的sn需要一致。任何的不一致情況,都需要大量的相關同學大量的時間和精力去做排查和資料修復。

2.重構方向 1.業務建模方向 1.區分出庫業務和購買業務,將業務和出庫相關的程式碼分離,出庫只做出庫的事情,業務相關的東西丟回到業務方處理。

2.規範出庫api層,出庫相關的呼叫只能使用api中的方法,避免出庫的程式碼遭到入侵。

3.修改同步為非同步,將之前同步的很多方法拆成最小單元的一步,每一步從指定狀態到對應狀態,每一步保證自身業務資料的一致性。

2.sn分配方向 sn分配的重構踩坑許多,將在後文詳述。

3.一致性方向 在每一步已經被拆到很小的基礎上,每一步所做的都是查詢出資料,然後進行邏輯計算,然後呼叫原子層,原子層通過事務包著,保證每一步的資料在可預料的前提下運轉。比如sn匹配job,查詢出所有待匹配的出庫記錄詳情,每一條資料單獨去匹配sn,匹配到的進行更新,修改詳情記錄和修改sn記錄在一個事務內,成功準備由下一個job進行下一步操作,不成功該job會繼續進行重試,所有的資料會在我們允許的一致狀態中運轉。

3.業務架構

出庫重構

4.狀態流轉

出庫重構

補充:1.sn預訂後,可以呼叫提供的確認介面,狀態將會變為待合併。

2.出庫回撥和取消業務相對複雜,涉及的狀態修改分支較多,在圖中顯示很難清晰表述,以後有機會補充詳述。

3.各個狀態流轉之前的邏輯處理,如合併job的演算法等,為了避免文章過於冗長暫且省去。

5.sn分配迭代心路 1.最初的設計版本中,只需要根據狀態篩選出需要進行匹配的資料,然後根據isbn取餘,然後保證相同isbn的詳情只會在唯一的執行緒中進行匹配。比如isbn為1結尾的詳情,會被髮到唯一的機器上進行匹配,對應的sn也只會在唯一的一臺機器上被查詢出來,這樣避免了併發的問題,實現了無鎖匹配。

2.第一版的修改方案,通過非同步,資料分片等方式提高了效能保證了一致性,但是在後續的一次討論中,我們發現一個較為嚴重的問題,根據業務背景,如果是第三方接入出庫那麼會在匹配到sn後才減庫存,從查詢庫存到插入記錄再到非同步執行sn匹配之間的時間差會很容易導致超賣。比如第三方搞一個圖書拼團,很容易出現使用者拼好團,下單後才發現沒有庫存(沒有sn可以分配)可以出庫。從這個問題還引發一個問題,出庫系統只能做到,業務方插入出庫請求,有則匹配,無則失敗。沒有辦法在呼叫出庫時同步的告訴呼叫方,是否可以出庫給定數量的書籍。

3.雖然重構的出庫系統足夠支撐目前的業務場景(圖書購買業務的設計本身就是容忍超賣的),但是如果出庫系統不能夠支援未來第三方需要同步得到結果的場景,那麼本次出庫重構的意義也將會大打折扣。接下來就是徹夜常思和週末的連續加班。

4.首先想到的方案是,匹配sn的關鍵是同一個sn只能夠被用來和一個出庫記錄詳情進行匹配。那麼如果所有的sn都從同一個地方取的話,既可以避免匹配sn的競爭,也無需資料庫加鎖。首先想到的方案是使用redis的list結構來做,但是這個方案有很大的弊端,1.查詢出sn,壓入list的過程需要加鎖,那麼各個執行緒在獲得sn的時候,會在初始化list的地方阻塞,如果選擇等待會有最早版本介面超時的風險,難道直接返回失敗?2.sn記錄也是在不斷變化的,需要考慮在sn新增之後,這些sn可以被壓入list中,且list中不能有重複的sn。(只保留了主要的思考過程)

5.使用list會極大的增加系統的複雜性,思考後發現可以利用redis的set結構來處理,將每個isbn對應的sn壓入每一個set中,set結構不允許重複資料,加上pop的原子性操作,會很好的滿足業務場景。使用set後不需要進行加鎖,各個業務也不需要阻塞在初始化方法外,但是如果在併發操作下,兩個執行緒對set進行scrad操作都為零,都進入初始化,第一個執行緒查詢出的sn集合第一個sn被pop,第二個執行緒又查詢出來壓入set,就會出現不一致。

出庫重構

6.解決的方案是採取了兩個set的方式。初始化查出來的資料會壓入兩個set中,第一個用來做防重複的,第二個是實際消耗的。想要壓入第二個set,必須sn不存在於第一個set中。

7.競爭條件解決了,但是放重複的set資料不能一直留著佔用記憶體。所以一開始採取的方案是如果從第二個set中pop出來的資料為空,則刪除掉防重複的set。然後引發了新的併發問題,我們假設有三個執行緒,第一個執行緒從set中pop出了最後一個sn,呼叫原子層處理中,第二個執行緒pop發現sn為null,刪除了放重複的set,第三個執行緒正在查詢可用sn,查詢到第一個執行緒用的sn(第一個執行緒的sn還沒有更新完成)壓入了set之中。

8.直接說解決方案,防重複set採取zset的方式,當pop出的sn為null時,通過redis延時佇列的方式刪除六分鐘之前的該放重複set的資料,且當呼叫原子層介面成功時,單獨從防重複set中刪除該資料,此步驟不保證成功。

(這個過程中還考慮了,通過redis鎖避免在資料為空的時候,多次查庫的問題,為避免冗長省去)

9.經過此次重構,線上匹配耗時已經穩定在幾毫秒內,配合forkjoin框架,每次請求即使為幾十個isbn(介面要求每次的idbn一致),耗時也不會有顯著變化。每個子訂單為一個isbn,粗略估算單機每秒可承受上萬筆訂單,並且因為架構的調整,可以支援伸縮擴充套件,希望圖書早日tps超過一萬。

6.併發場景

出庫重構

為了更加清晰的明確系統內可能存在的併發問題,通過行為狀態圖的方式,在同一狀態下,如果有定時任務和api需要同時進行,就需要考慮併發問題。

7.其它問題 因為圖書業務本身是支援使用者退貨(呼叫取消出庫)和取消退貨(再出庫)。所以所有的出庫記錄都要允許正向逆向反覆操作,如果使用者呼叫取消介面失敗,取消介面呼叫失敗,取消job會稍後重試,過程中如果使用者取消退貨,這次操作有可能會被取消job的重試覆蓋。

如果使用者呼叫取消介面超時,稍後mq重試,再重試之前使用者呼叫了取消退貨(再出庫),那麼最後的業務結果可能與使用者的實際行為不同。

類似上面說的問題還有很多很多(在大神上有備註),都是一步步趟過來的。本文主要講述業務的整體設計思考和sn匹配的具體實現,其它問題以後有機會再對實現過程進行詳述。

相關文章