“秒殺”問題的資料庫和SQL設計

語言: CN / TW / HK

本文作者: 璀璨小二

原文: https://www.cnblogs.com/clphp/

1. 問題的來源

最近發現很多人被類似秒殺這樣的設計困擾,其實這類問題可以很方便地解決,先來說說這類問題的關鍵點是什麼:

  1. 一定要高效能,不然還能叫秒殺嗎?

  2. 要強一致性,庫存只有100個,不能賣出去101個吧?但是庫存10000實際只賣了9999是否允許呢?

  3. 既然這裡說了是秒殺,那往往還會針對每個使用者有購買數量的限制。

總結一下,還是那幾個詞:高效能強一致性!

下文的所有解決方案是在 Mysql InnoDB 下做的。因為用到了很多資料庫特性。其他的資料庫或其他的資料庫引擎會有不同的表現,請注意。

2.完全不考慮一致性的方案

2.1 表結構

2.2 方案

表結構很簡單,其實就是一個 user deal 的關聯表。誰買了多少就插入資料唄。

首先,還要檢查一下傳過來的 buy_count 是否超過單人購買限制。

接下來,每次插入前執行以下以下操作檢查一下是否超賣即可:

select sum(buy_count) from UserDeal where deal_id = ?

最後還要檢查一下這個使用者是否購買過:

select count(*) from UserDeal where user_id = ? and deal_id = ?

全都沒問題了就插入資料:

insert into UserDeal (user_id, deal_id, buy_count) values (?, ?, ?)

2.3存在的問題

大家別笑,這樣的設計你一定做過,剛畢業的時候誰沒設計過這樣的系統啊?而且大部分系統對效能和一致性的要求並沒有那麼高,所以以上的設計方案還真是普遍存在的。

那就說說在什麼情況下會出問題吧:

  1. 如果庫存只剩一個,兩個使用者同時點購買,兩個人檢查全部成功,最後,就超賣了。

  2. 如果一個使用者同時發起兩次請求,檢測部分同樣可能會同時通過,最後,資料就異常了。

那就讓我們一步步來解決裡面存在的問題吧。

3.保證單使用者不會重複購買

先來解決最簡單的問題,保證單使用者不會重複購買。

其實只要利用資料庫特性即可,讓我們來加一個索引:

alter table UserDeal add unique user_id_deal_id(user_id, deal_id)

加上唯一索引後,不僅查詢效能提高了,插入的時候如果重複還會自動報錯。

當然別忘了在業務程式碼中 catch 一下這個異常,並在頁面上給使用者友好的提醒。

4. 解決超賣問題

4.1 方案

為了解決這個問題,第一個想到的就是把這幾次操作在事務中操作。否則無論怎麼改,也都不是原子性的了。

但是加完事務後就完了?

上面的 select 語句沒有使用 for update 關鍵字,所以就算加入了事務也不會影響其他人讀寫。

所以我們只要改一下 select 語句即可:

select sum(buy_count) from UserDeal where deal_id = ? for update

4.2 優化

剛改完後發現,問題解決了!so easy!步步高點讀機,哪裡不會點哪裡,so easy!

但是不對啊!為什麼兩個使用者操作不同的 deal 也會相互影響呢?

原來我們的 select 語句中的查詢條件是 where deal_id = ? ,你以為只會鎖所有滿足條件的資料對吧?

但實際上,如果你查詢的條件不在索引中,那麼 InnoDB 會啟用表鎖!

那就加一個索引唄:

alter table UserDeal add index ix_deal_id(deal_id)

05. 提高效能了

好了,到目前為止,無論使用者怎沒點,無論多少個人買同一單,都不會出現一致性的問題的。

而且事務都是行鎖,如果你的業務場景不是秒殺,操作是分散在各個單子上的。而且你的壓力不大,那麼優化到這就夠了。

但是,如果你真的會有幾萬人、幾十萬人同時秒殺一個單子怎麼辦?

很多交易類網站都會有這樣的活動。

我們現在思考一下,上面的優化好像已經是極致了,不僅滿足了一致性,而且效能方面也做了足夠的考量,無從下手啊!

這時候,只能犧牲一些東西了。

06. 魚與熊掌不可兼得

6.1 優化的思路

效能和一致性常常同時出現,卻又相互排斥。剛才我們為了解決一致性問題帶入了效能問題。現在我們又要為了效能而犧牲一致性了。

這裡想提高效能的話,就要去掉事務了。那麼一旦去掉事務,一致性就沒辦法保證了,但有些一致性的問題並不是那麼地嚴重。

所以,這裡最關鍵的就是要想清楚,你的業務場景對什麼不能容忍,對什麼可以容忍。不同業務場景最後的方案一定是不同的。

6.2 秒殺可以容忍什麼

本文標題說的是秒殺,因為這個業務場景很常見,那麼我們就來說說秒殺。

秒殺最怕的是超賣,但卻可以接受少賣。什麼是少賣?我有一萬份,賣了9999份,但資料庫裡卻說已經買完了。

這個嚴重嗎?只要我們能把這個錯誤的量控制在一定比例以內並且可以後續修復,那這在秒殺中就不是一個問題了。

7. 為了效能犧牲一致性的設計方案

7.1 去掉了事務會發生什麼

在上述的方案中,如果去掉了事務,單使用者重複購買是不會有問題的,因為這個是通過唯一索引來實現的。

所以這邊我們主要是去解決超賣問題。

既然去掉了事務,那麼 for update 鎖行就無效了,我們可以另闢蹊徑,來解決這個問題。

7.2 修改表結構

剛才一直沒有提 Deal 表,其實它就是存了一下基本資訊,包括最大售賣量。

之前我們是通過對關聯表進行 sum(buy_count) 操作來得到已經賣掉的數量的,然後進行判斷後再進行插入資料。

現在沒了事務,這樣的操作就不是原子性的了。

所以讓我們來修改一下 Deal 表,把已經售賣的量也存放在 Deal 表中,然後巧妙地把操作轉換成一行 update 語句。

7.3 修改執行過程

如果你繼續先把資料查出來到記憶體中然後再操作,那就不是原子性的了,必定會出問題。

這時候,神奇的 update 語句來了:

update Deal set buy_count = buy_count + 1 where id = ? and buy_count + 1 <= buy_max

如果一單的 buy_max 是1000,如果有2000個使用者同時操作會發生什麼?

雖然沒有事務,但是 update 語句天然會有行鎖,前1000個使用者都會執行成功,返回生效行數1。而剩下的1000人不會報錯,但是生效行數為0。

所以程式中只要判斷 update 語句的生效行數就知道是否搶購成功了。

7.4 還沒有結束

問題解決了?好像也沒犧牲一致性啊,使用者根本不會超賣啊?

但是,購買的時候有兩個關鍵資訊,“剩餘多少”和“誰買了”,剛才的執行過程只處理了第一個資訊,它根本沒存“誰買了”這個資訊。

而這兩個資訊其實也是原子性的,但是為了效能,我們不得不犧牲一下了。

剛才說到如果 update 的生效行數是1,就代表購買成功。所以,如果一個使用者購買成功了,那麼就再去 UserDeal 表中插入一下資料。

可如果一個使用者重複購買了,那麼這裡也會出錯,所以如果這裡出錯的話還需要去操作一下 Deal 表把剛才購買的還回去:

update Deal set buy_count = buy_count - 1 where id = ? and buy_count - 1 >= 0

這邊理論上不會出現 buy_count - 1 < 0 的情況,除非你實現的不對。

…… 無圖無真相,完全混亂了

只看文字不清晰,還是來張完整的流程圖吧!

毫無破綻啊!不是說要犧牲一致性嗎?為什麼沒看到?因為上面的流程圖還沒有考慮資料庫故障或者網路故障,最後還是來一張最完整的流程圖吧:

仔細看一下整張流程圖,最終就這幾種情況:

  1. 執行成功

  2. 無庫存

  3. 回滾成功

  4. 損失庫存

前三種是正常的,只有“損失庫存”是有問題的。其實,“損失庫存”這種情況其實很難出現,只有在網路故障或者資料庫的情況下才可能偶爾。

那你的業務可以容忍它嗎?最終還是具體問題具體分析了。

8. 不要過度優化

最後還是提醒一句,千萬不要過度優化,第一個使用事務的方案其實已經夠好了!

除非你的業務特殊,全中國幾十萬人幾百萬人會同時來買,那才有必要犧牲一下一致性提升效能。

對了,如果是像雙十一或者小米這樣子的搶購,上面的方案也是不夠的…