1、如何利用數據庫實現並發扣減?
1.1、扣減類業務的技術關注點
發生扣減必然就會存在歸還,比如用戶購買了商品之后因為一些原因想要退貨,這個時候就需要將商品的庫存、商品設置的購買次數以及訂單金額等進行歸還。
基於扣減類業務的定義,關於扣減的實現,需要關注的技術點總結如下:
- 當前剩余的數量需要大於等於當次扣減的數量,即不允許超賣;
- 對同一個數據的數量存在用戶並發扣減,需要保證並發一致性;
- 需要保證可用性和性能,性能至少是秒級;
- 一次的扣減會包含多個目標數量;
- 當次扣減有多個數量時,其中一個扣減不成功即不成功,需要回滾。
對於返還的實現需要關注的技術點:
- 必須有扣減才能返還;
- 返還的數量必須要加回,不能丟失;
- 返還的數據總量不能大於扣減的總量;
- 一次扣減可以有多次返還;
- 返還需要保證冪等。
1.2、純數據庫式扣減實現
純數據庫實現之所以能夠滿足扣減業務的各項功能要求,主要依賴各類主流數據庫提供的兩個特性:
- 第一是基於數據庫樂觀鎖的方式保證數據並發扣減的強一致性;
- 第二是基於數據庫的事務實現批量扣減部分失敗時的數據回滾。
數據庫存儲扣減中所有數據,主要包含兩張表:扣減剩余數量表和流水表,扣減剩余數量表是最主要的表,包含實時的剩余數量。
如上表所示,對於當前剩余可購買的數量,當用戶進行取消訂單、售后等場景時,都需要把數量加回到此字段。同時,當商家補齊庫存時,也需要把數量加回。
從完成業務功能的角度來看,只要扣減剩余數量表即可,但是在實際場景中,會需要查看明細進行對賬、盤貨、排查問題等需求。其次,在扣減后需要進行返還時是非常依賴流水的。因為只能返還有扣減記錄的庫存數量。最后,在技術上的冪等性,也非常依賴流水表。
1.2.1、扣減接口實現
扣減接口接受用戶提交的扣減請求,包含用戶賬號、一批商品及對應的購買數量,大致實現邏輯如下圖所示:
流程開始時,首先進行的是數據校驗,在其中可以做一些常規的參數格式校驗,其次它還可以進行庫存扣減的前置校驗,比如當前數據庫存庫只有8個時,而用戶需要10個,此時在數據校驗中即可前置攔截,減少對於數據庫的寫操作。
當用戶只購買某商品2個時,如果在校驗時剩余庫存有8個,此時校驗會通過,但在后續的實際扣減時,因為其他用戶也在並發的扣減,可能會出現幻讀,即此用戶實際去扣減時不足2個,導致失敗。這種場景就會導致多一次數據庫查詢,降低了整體的扣減性能。其次,即使將校驗放置在事務內,先查詢數據庫數量校驗通過后再扣減,也會增加性能。
扣減完成之后,需要記錄流水數據,每一次扣減時,都需要外部用戶傳入一個uuid作為流水編號,此編號是全局唯一的,用戶在扣減時傳入唯一的編號有兩個作用。
1、當用戶歸還數量時,需要帶回此編號,用來表示此次返還屬於歷史上的具體那次扣減。
2、進行冪等控制,當用戶調用扣減接口出現超時時,因為不知道是否成功,用戶可以采用此編號進行重試或反查,在重試時,使用此編號進行標示防重。
存在的問題:多一次查詢,就會增加數據庫的壓力,同時對整體服務性能也有一定影響,對外提供的查詢庫存數量的接口也會對數據庫產生壓力,同時讀的請求量遠大於寫,由此帶來的壓力會更大。
1.2.2、扣減接口實現升級
根據業務場景分析,讀庫存的請求一般是顧客瀏覽商品時產生的,而調用扣減庫存基本是用戶購買時才會觸發,用戶購買請求的業務價值相比讀請求會更大,因此對於寫需要重點保障,轉換到技術上,價值相對低的讀來說是可以降級的,有損的。對於寫要盡可能性能好、盡量減少不必要的讀與寫請求等。
針對上述問題,可以對整體架構進行升級。
整體的升級策略采用了讀寫分離的方式,另外主從復制直接使用了MySQL等數據庫已有的功能,改動上非常小,只要在扣減服務里配置兩個數據源,當客戶查詢剩余庫存數量、扣減服務中的前置校驗時,讀取從數據庫即可。而真正數據扣減使用主庫。
1.2.3、扣減接口實現再升級
在基於數據庫的主從復制降低了主庫流量壓力之后,還需要升級的就是讀取的性能了,這里我們使用Binlog實現簡單,可靠的異構數據同步的技能。
1.3、純數據庫扣減方案適用性
純數據庫方案有以下幾個優點:
- 實現簡單,即使讀使用了前置緩存,整體代碼工程就兩個,即扣減服務與數據映射服務,在需求交付周期非常短、人力緊張的場景是非常適用的;
- 使用了數據庫的ACID特性進行扣減,在業務上,庫存數據庫不會出現超賣和少賣的問題。
存在的不足:
- 當扣減SKU數據增多時,性能非常差,因為對每一個SKU都要單獨扣減,導致事務非常大,極端情況下,可能出現幾十秒的情況。
2、如何利用緩存實現萬級並發扣減?
2.1、純緩存方案淺析
純數據庫的方案雖然避免了超賣與少賣的問題,但因采用了事務的方式保證一致性和原子性,所以在SKU數量較多時性能下降較明顯。
因為扣減有一個要求即當一個SKU購買的數量不夠時,整個批量扣減就要回滾,因此我們需要使用類似for循環的方式對每一個扣減的SQL的返回值進行檢查,另一個原因是,當多個用戶買一個SKU時,它的性能也不客觀。因為當出現高並發扣減或並發扣減同一個SKU時,事務的隔離性會導致加鎖等待以及死鎖的情況發生。
下面我們對問題再次梳理一遍,進而尋找可升級演化方案。
首先,扣減只需要保證原子性即可,並不需要數據庫提供的ACID。在扣減庫存時,重點保證商品不超賣不少賣。而持久化這個功能,只有在數據庫故障切換及恢復時才有需要,因為被中斷的事務需要持久化的日志進行重演,也就是說持久化是主功能之外的后置功能,附加功能。
那么,在不改變機器配置情況下,把傳統的SQL類數據庫替換為性能更好的NoSQL類數據存儲試試?是不是有一個性能又好同時又能滿足扣減多個SKU具有原子性的NoSQL數據庫呢?
Redis 采用了單線程的事件模型,保障了我們對於原子性的要求。對於單線程的事件模型,簡單的比喻就是說當我們多個客戶端給 Redis 同時發送命令后,Redis 會按接收到的順序進行串行的執行,對於已經接收而未能執行的命令,只能排隊等待。基於此特性,當我們的扣減請求在 Redis 執行時,也即是原子性的。此特性剛好符合我們對於扣減原子性的要求。
2.2、方案實現剖析
緩存中存儲的信息和數據庫表結構基本類似,包含當前商品和剩余的庫存數量和當次的扣減流水,這里需要注意兩點。
-
首先,因為扣減全部依賴於緩存,不依賴數據庫,所有存儲於Redis的數據均不設置過期並全量存儲。
-
其次,Redis是以k-v結構為主,伴隨hash、set等結構,與MySQL以表+行為主的結構有一定差異。Redis中庫存數量結構大致如下:
key為:sku_stock_{sku}。前綴sku_stock是固定不變,所有以此為前綴的均表示是庫存。{sku}是占位符,在實際存儲時被具體的skuid替代。 value:庫存數量。當前此key表示的sku剩余可購買的數量。
對於Redis中存儲的流水表采用hash結構,即key+hashField+hashValue的形式,結構大致如下:
key:sx_{sku}。前綴sx_是按上述縮短的形式設計的,只起到了區分的作用。{sku}為占位符 hashField:此次扣減流水編號。 hashValue: 此次扣減的數量
在一次扣減時,會按SKU在Redis中先扣減完庫存數量在記錄流水信息。
扣減接口支持一次扣減多個SKU+數量,查詢Redis的命令文檔會發現:
首先,Redis對於hash結構不支持多個key的批量操作;
其次,Redis對於不同數據結構鍵不支持批量操作,如KV與Hash間。
如果對於多個SKU不支持批量操作,我們就需要按單個SKU發起Redis調用,在上文中提到過,Redis不對命令間保證單線程執行。如果采用上述Redis的數據結構,一次扣減必須要發起多次對Redis的命令才可完成。這樣上文提到的利用Redis單線程來保證扣減的原子性此時則滿足不了了。
針對上述問題,我們可以采用Redis的lua腳本來實現批量扣減的單線程訴求。
Redis中的lua腳本執行時,首先會使用get命令查閱uuid是否存在,如已存在則直接返回,並提示用戶請求重復,當防重通過后,會按SKU批量獲取對應的剩余庫存狀態並進行判斷,如果其中一個SKU此次扣減的數量大於剩余數量,則直接給扣減服務返回錯誤並提示數量不足。通過Redis的單線程模型,確保當所有SKU的扣減數量在判斷均滿足后,在實際扣減時,數量不夠的情況是不會出現的,同時單線程保證判斷數量的步驟和后續扣減步驟之間,沒有其他任何線程出現並發的執行。
判斷數量滿足之后,lua腳本后續就可以按SKU進行循環的扣減數量並記錄流水。
當Redis扣減成功后,扣減接口會異步的將此次扣減內容保存至數據庫,異步保存數據庫的目的是防止出現極端情況,Redis當即后數據未持久化到磁盤,此時我們可以使用數據庫恢復或校准數據。
最后,在純緩存架構圖中還有一個運營后台,它直接連接了數據庫,是運營和商家修改庫存的入口。當商品補齊了新的貨物時,商家在運營后台將此SKU庫存數量加回。同時運營后台的實現需要將此數量同步的增加至Redis。因為當前方案的所有實際扣減都在Redis中。
2.3、異常情況分析
因為Redis不支持ACID特性,導致在使用Redis進行可偶見時相比純數據庫方案有較多異常場景需要處理。
-
Redis突然宕機
如果Redis宕機,請求在Redis中只進行了前置的防重和數量驗證,此時則沒有任何影響,直接返回給客戶扣減失敗即可。
但如果此時Redis中的lua腳本執行到了扣減邏輯並做了實際的扣減,則會出現數據丟失的情況,因為Redis沒有事務的保證,宕機時已經扣減的數量不會回滾,宕機導致扣減服務給客戶返回扣減失敗,但實際上Redis已經扣減了部分數據並刷新了磁盤,當此Redis故障處理完成再次啟動后或者failover之后,部分庫存數量已經丟失。
為了解決這種問題,可以使用數據庫中的數據進行校准。常見的方式是開發對賬程序,通過對比Redis與數據庫中的數據是否一致,並結合扣減服務的日志。當發現數據不一致同時日志記錄扣減失敗時,可以將數據庫比Redis多的庫存數據在Redis中進行加回。
-
扣減Redis完成並成功返回客戶后,異步刷新數據庫失敗
此時,Redis中的數據庫是准的,但數據庫中的庫存數量時多的,在結合扣減服務的日志確定是Redis扣減成功但異步記錄數據失敗后,可以將數據庫比Redis多的庫存數據在數據庫中進行扣減。
2.4、升級純緩存實現方案
扣減服務不僅包含扣減接口還包含數量查詢接口,查詢接口的量級相比寫接口至少是十倍以上,即使是使用了緩存進行抗量,但讀寫都請求了同一個Redis,就會導致扣減請求被讀影響。
其次,運營在后台進行操作增加或者修改庫存時,是在修改完數據庫之后在代碼中異步修改刷新Redis,因為數據庫和Redis不支持分布式事務,為了保證在修改時它們的數據一致性,在實際開發中,需要增加很多手段來保證一致性,成本較高。
我們可以增加一個Redis節點,在扣減服務里根據請求類型路由到不同的Redis節點。使用主從分離的好處是,不用太多的數據庫同步開發,直接使用Redis主從同步方案,成本低開發量小。
運營后台修改數據庫數量后同步至Redis的邏輯使用binlog進行處理,當商家修改了數據庫中的數量之后,MySQL的binlog會自動發出,在數據轉換模塊接受binlog並轉換格式插入Redis即可。因為binlog消費是采用ack機制,如果在轉換和插入Redis時出錯,ack不確定即可。下一次數據轉換代碼運行時,會繼續上一次未消費的binlog繼續執行。最終binlog機制不需要太多邏輯處理即可達到最終一致性。
2.5、純緩存方案適用性分析
純緩存方案的主要優點是性能提升明顯。使用緩存的扣減方案在保證了扣減的原子性和一致性等功能性要求之外,相比純數據庫的扣減方案至少提升十倍以上。
除了優點之外,純緩存的方案同樣存在一些缺點。Redis 及其他一些緩存實現,為了高性能,並沒有實現數據庫的 ACID 特性。導致在極端情況下可能會出現丟數據,進而產生少賣。另外,為了保證不出現少賣,純緩存的方案需要做很多的對賬、異常處理等的設計,系統復雜度會大幅增加。
3、如何利用緩存+數據庫構建高可靠的扣減方案?
3.1、順序寫的性能更好
在向磁盤進行數據操作時,向文件末尾不斷追加寫入的性能要遠大於隨機修改的性能,數據庫同樣是插入要比更新的性能好,對於數據庫的更新,為了保證對同一條數據並發更新的一致性,會在更新時增加鎖,但加鎖是十分消耗性能的,此外對於沒有索引的更新條件,要想找到需要更新的那條數據,需要遍歷整張表,時間復雜度為O(N),而插入只在末尾進行追加,性能非常好。
上述的架構和純緩存的架構區別在於,寫數據庫不是異步寫入,而是在扣減的時候同步寫入。
同步寫入數據庫使用的是insert操作,也就是順序寫,而不是update做數據庫數據量的扣減。
insert的數據庫稱為任務庫,它只存儲每次扣減的原始數據,而不做真實的扣減(即不進行update),它的表結構大致如下:
create table task{
id bigint not null comment "任務順序編號",
task_id bigint not null
}
任務表里存儲內容格式可以是json、xml等結構化的數據,以json為例,數據內容大致可以如下:
{
"扣減號":uuid,
"skuid1":"數量",
"skuid2":"數量",
"xxxx":"xxxx"
}
在上述的架構中,還有一個正式業務庫,這里存儲的存儲的才是真正的扣減明細和SKU的匯總數據,對於正式庫的數據,通過任務表的任務進行同步即可,此種方式保證了數據的最終一致性。
3.2、扣減流程
在引入了任務表之后,整體的扣減流程如下圖所示:
1、首先是前置業務參數校驗(包括基礎參數,數量檢驗等)
2、開啟事務
3、當開啟事務后,首先將此次序列化后的扣減明細寫入到扣減數據庫的任務表里面。
4、假設數據庫插入扣減明細失敗,則事務回滾,任務表中無新增數據,數據一致,無任何影響。
5、當數據庫插入扣減明細成功后,便針對緩存進行扣減,使用lua等功能扣減就行。
6、如果緩存扣減成功,也就是流程正常結束,提交數據庫事務,給客戶返回扣減成功。
7、如果緩存扣減失敗,可能有兩大類原因。
1、此次扣減數量不夠
2、緩存出現故障,導致扣減失敗,緩存失敗的可能性有很多,比如網絡不通、調用緩存扣減超時,在扣減到一半時緩存宕機。
完成上面步驟,便可以進行任務庫里的數據處理了,任務庫里存儲的是純文本的JSON數據,無法被直接使用,需要將其中的數據轉儲至實際的業務庫里,業務庫里會存儲兩類數據,一類每次扣減的流水數據,他與任務表里的數據區別在於它是結構化的,而不是json文本大字段內容,另一類是匯總數據,即每一個sku當前總共有多少量,當前還剩多少量(即從任務庫同步時需要進行扣減的),表結構大致如下:
create table 流水表{
id bigint not null,
uuid bigint not null comment '扣減編號',
sku_id bigint not null comment '商品編號',
num int not null comment '當次扣減的數量'
}comment '扣減流水表'
商品的實時數據匯總表,結構如下:
create table 匯總表{
id bitint not null,
sku_id unsigned bigint not null comment '商品編號',
total_num unsigned int not null comment '總數量',
leaved_num unsigned int not null comment '當前剩余的商品數量'
}comment '記錄表'
3.3、原理分析
數據庫+緩存的架構主要利用了數據庫順序寫入要比更新性能快的這一特性,在寫入的基礎上,又利用了數據庫事務特性來保證數據的最終一致性,當異常出現后,通過事務進行回滾,來保證數據庫的數據不會丟失。
在整體流程上,還是復用了純緩存的架構流程,當新加入一個商品,或者對已有商品進行補貨時,對應的新增商品數量都會通過Binlog同步至緩存里,在扣減時,依然以緩存中的數量為准。
3.4、性能提升
進行方案升級后,我們便完成了一個更加可靠的扣減架構,且使用任務數據庫的順序插入也保證了一定的性能,但是即使是基於數據庫的順序插入,緩存操作的性能和數據庫的順序插入也不是一個量級。
任務庫主要提供兩個作用,一個是事務支持 ,其次是隨機的扣減流水任務的存取,這兩個功能均不依賴具體的路由規則,也是隨機的,無狀態的。
4、如何設計和實現扣減中的返還
4.1、什么是扣減的返還
扣減的返還指的是在扣減完成之后,業務上發生了一些逆向行為,導致原先已扣減的數據需要恢復以便供后續的扣減請求使用的場景。以在購買商品時的扣減庫存舉例,其中常見的逆向行為有:
1、當客戶下單之后,發現某個商品買錯了(商品品類買錯或數量填錯),客戶便會取消訂單,此時該訂單對應的所有商品的庫存數量需要返還;
2、其次,假設客戶在收到訂單后,發現其中某一個商品質量有問題或者商品的功能和預期有差異,便會發起訂單售后流程,比如退、換貨。此時該訂單下被退貨的商品,也需要單獨進行庫存返還。
4.2、返還實現原則
相比扣減,返還的並發量比較低,因為下單完成后繁盛整單取消或者個別商品售后概率較低。因此,返還實現上,可以參考商家對已有商品補貨的實現,直接基於數據庫進行落地,但返還自身也具備以下實現原則:
-
原則一:扣減完成才能返還
返還接口設計時,必須要有扣減號這個字段。因為所有的返還都是依賴於扣減的,如果某一個商品的返還沒有帶上當時的扣減號,后續很難對當時的情況作出准確判斷。
- 當前商品是否能夠返還。 因為沒有扣減號,無法找到當時的扣減明細,無法判斷此商品當時是否做了扣減,沒有做扣減的商品是無法進行返還的。
- 當前返還的商品數量是否超過扣減值。假設外部系統因為異常,傳入了一個超過當時扣減值的數量,如果不通過扣減號獲取當時的扣減明細,你無法判斷此類異常。
-
原則二:一次扣減可以多次返還
-
原則三:返還的總數量要小於等於原始扣減的數量
-
原則四:返還要保證冪等
5、秒殺場景:熱點扣減如何保證命中的存儲分片不掛?
5.1、如何應對秒殺流量?
從秒殺的業務上進行分析,雖然秒殺帶來的熱點扣減請求非常大,但每次參與秒殺的商品數量有限,可能就幾百個或者上千個,而熱點扣減的流量可能達到上百萬,通過簡單的計算可以得出,秒殺到商品的概率只有0.1%,其中99%的扣減請求都是"陪跑"。
這些“陪跑”的請求對於使用這來說可能只是一次簡單的點擊,但很可能會把正在運行的扣減服務打掛,此時我們可以對這些瞬間量非常大的“陪跑”請求進行一些前置處理,降低“陪跑”請求的瞬間請求量,或者降低他們對於系統的沖擊,此方式叫作流量消峰。
如何實現流量消峰
-
基於用戶維護設置限制。
比如同一個賬號在5秒內最多可以請求扣減多少次,超過多少次便進行攔截,直接返回失敗信息給商品頁面。
-
基於來源IP設置限制。
有些黃牛會提前預申請很多賬號,因此使用上述賬戶限制方式並不能完全攔截住,在賬戶基礎上,可以對用戶來源IP設置限制。
-
通過設備的唯一編碼等設置限制。
上述提到的攔截在實現上,可以采用比較成熟的漏桶算法,令牌桶算法。
限流在實現上有兩種方式,一種是集中式,一種是單機式。集中式是指設置一個總的限流閾值,並將此值存儲在一個單獨的限流應用中,所有的扣減應用在接收到請求后,均采用遠程請求此限流應用的方式,來判斷當前是否達到限流值。單機式限流是指將限流閾值在管理端配置后,主動下發到每一台扣減應用中去。
第二步進行消峰的是,業務層面需要設置權重登記。秒殺是一種營銷活動,營銷是有目的的,比如激活許久未下單用戶,或者優先讓會員搶到商品,增加會員的續費意願等。
第三步進行的削峰是,增加一定的過濾比例。 如果上述兩個方式過濾后,熱點扣減的並發量仍然較大。可以設置一個固定比例,如 10% 的請求前置過濾並直接返回失敗消息,告知用戶“搶購火爆,請稍后再試”,也可以降低一部分無效請求。
第四步進行的削峰是,兜底降級不可少。
最后進行的削峰是,售完的商品需前置攔截。 秒殺商品會在瞬間售完,后續所有的請求都會返回無貨。對於已經無貨的商品,將商品已經無貨的標記記錄在本地緩存里。在秒殺扣減前,先在本地緩存進行判斷,如果無貨直接返回即可。
5.2、水平擴展架構升級
通過上述幾種限流的組合,便可以應對秒殺的熱點流量了。但上述的方式會犧牲一定的用戶體驗,比如按一定比例過濾用戶請求、按緩存分片維度過濾用戶請求等。
我們可以在上述方案的基礎上,做一定的升級來減少有損體驗。在設置秒殺庫存時,將秒殺庫存按緩存分片的數量進行平均等分,每一個緩存里均存儲一等份即可。比如某一個商品(記為 SKU1)的秒殺庫存為 10,當前部署的緩存分片共計 10 個,那么每一個分片里存儲該 SKU 的庫存數可以為 1,存儲在各個緩存里的 key 可以為:SKU1_1、SKU1_2、...、SKU1_10。在處理秒殺請求時,不只是固定地命中某一個緩存分片,而是在每次請求時輪詢命中緩存集群中的每一個緩存分片。將秒殺商品的庫存前置散列到各個緩存分片,可以將原先熱點扣減只能使用一個緩存分片升級至多個,提升吞吐量。但此方式有一個弊端,就是更加的定制化。