前一段時間好好研究了秒殺的問題,我把里面的問題好好總結了,可以說是比較全面的了,真的是吐血整理了。
由於我先是在word中整理的,格式都整理得比較好,放到博客上格式挺難調,暫時按word的格式來吧,有時間了在好好排版下。
主要需要解決的問題有兩個:
- 高並發對數據庫產生的壓力
- 競爭狀態下如何解決庫存的正確減少(超賣問題)
優化的思路:
1) 盡量將請求攔截在系統上游
2)讀多寫少經量多使用緩存
3) redis緩存 +RabbitMQ+ mysql 批量入庫
1. 初始秒殺設計
1.1 業務分析
秒殺系統業務流程如下:
由圖可以發現,整個系統其實是針對庫存做的系統。用戶成功秒殺商品,對於我們系統的操作就是:1.減庫存。2.記錄用戶的購買明細。下面看看我們用戶對庫存的業務分析:
記錄用戶的秒殺成功信息,我們需要記錄:1.誰購買成功了。2.購買成功的時間/有效期。這些數據組成了用戶的秒殺成功信息,也就是用戶的購買行為。
為什么我們的系統需要事務?
1.若是用戶成功秒殺商品我們記錄了其購買明細卻沒有減庫存。導致商品的超賣。
2.減了庫存卻沒有記錄用戶的購買明細。導致商品的少賣。對於上述兩個故障,若是沒有事務的支持,損失最大的無疑是我們的用戶和商家。在MySQL中,它內置的事務機制,可以准確的幫我們完成減庫存和記錄用戶購買明細的過程。
1.2 難點分析
當用戶A秒殺id為10的商品時,此時MySQL需要進行的操作是:
1.開啟事務。2.更新商品的庫存信息。3.添加用戶的購買明細,包括用戶秒殺的商品id以及唯一標識用戶身份的信息如電話號碼等。4.提交事務。
若此時有另一個用戶B也在秒殺這件id為10的商品,他就需要等待,等待到用戶A成功秒殺到這件商品,然后MySQL成功的提交了事務他才能拿到這個id為10的商品的鎖從而進行秒殺,而同一時間是不可能只有用戶B在等待,肯定是有很多很多的用戶都在等待競爭行級鎖。秒殺的難點就在這里,如何高效的處理這些競爭?如何高效的完成事務?
1.3 功能實現
我們只是實現秒殺的一些功能:1.秒殺接口的暴露。2.執行秒殺的操作。3.相關查詢,比如說列表查詢,詳情頁查詢。我們實現這三個功能即可。
1.4 數據庫設計
Seckill秒殺表單
Success_seckill購買明細表
在購買明細表中seckill_id和user_phone是聯合主鍵,當重復秒殺的時候,加入ignore防止報錯,只是會返回0,表示重復秒殺。
INSERT ignore INTO success_killed(seckill_id,user_phone,state)
VALUES (#{seckillId},#{userPhone},0)
在購買明細表中seckill_id和user_phone是聯合主鍵,當重復秒殺的時候,加入ignore防止報錯,只是會返回0,表示重復秒殺。
INSERT ignore INTO success_killed(seckill_id,user_phone,state)
VALUES (#{seckillId},#{userPhone},0)
1.5 DAO層設計
秒殺表的DAO:減庫存(id,nowtime)、由id查詢商品、由偏移量查詢商品
購買明細表的DAO:插入購買明細、根據商品id查詢明細SucceesKill對象(攜帶Seckill對象)—mybatis的復合查詢
減庫存和增加明細的sql
<update id="reduceNumber"> UPDATE seckill SET number = number-1 WHERE seckill_id=#{seckillId} AND start_time <![CDATA[ <= ]]> #{killTime} AND end_time >= #{killTime} AND number > 0; </update> <insert id="insertSuccessKilled"> <!--當出現主鍵沖突時(即重復秒殺時),會報錯;不想讓程序報錯,加入ignore--> INSERT ignore INTO success_killed(seckill_id,user_phone,state) VALUES (#{seckillId},#{userPhone},0) </insert>
1.6 Service層設計
暴露秒殺地址(接口)DTO
public class Exposer { //是否開啟秒殺 private boolean exposed; //加密措施 private String md5; private long seckillId; //系統當前時間(毫秒) private long now; //秒殺的開啟時間 private long start; //秒殺的結束時間 private long end;}
封裝執行秒殺后的結果:是否秒殺成功
public class SeckillExecution { private long seckillId; //秒殺執行結果的狀態 private int state; //狀態的明文標識 private String stateInfo; //當秒殺成功時,需要傳遞秒殺成功的對象回去 private SuccessKilled successKilled;}
秒殺過程
接口暴露:
public Exposer exportSeckillUrl(long seckillId) { //緩存優化 Seckill seckill = getById(seckillId); //若是秒殺未開啟 Date startTime = seckill.getStartTime(); Date endTime = seckill.getEndTime(); //系統當前時間 Date nowTime = new Date(); if (startTime.getTime() > nowTime.getTime() || endTime.getTime() < nowTime.getTime()) { return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime()); } //秒殺開啟,返回秒殺商品的id、用給接口加密的md5 String md5 = getMD5(seckillId); return new Exposer(true, md5, seckillId); }
如果當前時間還沒有到秒殺時間或者已經超過秒殺時間,秒殺處於關閉狀態,那么返回秒殺的開始時間和結束時間;如果當前時間處在秒殺時間內,返回暴露地址(秒殺商品的id、用給接口加密的md5)
為什么要進行MD5加密?
我們用MD5加密的方式對秒殺地址(seckill_id)進行加密,暴露給前端用戶。當用戶執行秒殺的時候傳遞seckill_id和MD5,程序拿着seckill_id根據設置的鹽值計算MD5,如果與傳遞的md5不一致,則表示地址被篡改了。
為什么要進行秒殺接口暴露的控制或者說進行秒殺接口的隱藏?
現實中有的用戶回通過瀏覽器插件提前知道秒殺接口,填入參數和地址來實現自動秒殺,這對於其他用戶來說是不公平的,我們也不希望看到這種情況。所以我們可以控制讓用戶在沒有到秒殺時間的時候不能獲取到秒殺地址,只返回秒殺的開始時間。當到秒殺時間的時候才
返回秒殺地址即seckill_id以及根據seckill_id和salt加密的MD5,前端再次拿着seckill_id和MD5才能執行秒殺。假如用戶在秒殺開始前猜測到秒殺地址seckill_id去請求秒殺,也是不會成功的,因為它拿不到需要驗證的MD5。這里的MD5相當於是用戶進行秒殺的憑證。
執行秒殺:
//秒殺是否成功,成功: 增加明細,減庫存;失敗:拋出異常,事務回滾 @Transactional public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException { if (md5 == null || !md5.equals(getMD5(seckillId))) { //秒殺數據被重寫了 throw new SeckillException("seckill data rewrite"); } //執行秒殺邏輯:增加購買明細+減庫存 Date nowTime = new Date(); try { //先增加明細,然后再執行減庫存的操作 int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone); //看是否該明細被重復插入,即用戶是否重復秒殺 if (insertCount <= 0) { throw new RepeatKillException("seckill repeated"); } else { //減庫存,熱點商品競爭 int updateCount = seckillDao.reduceNumber(seckillId, nowTime); if (updateCount <= 0) { //沒有更新庫存記錄,說明秒殺結束或者是已經賣完 rollback throw new SeckillCloseException("seckill is closed"); } else { //秒殺成功,得到成功插入的明細記錄,並返回成功秒殺的信息 commit SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone); return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled); } } } catch (SeckillCloseException e1) { throw e1; } catch (RepeatKillException e2) { throw e2; } catch (Exception e) { logger.error(e.getMessage(), e); //所以編譯期異常轉化為運行期異常 throw new SeckillException("seckill inner error :" + e.getMessage()); } }
首先檢查用戶是否已經登錄,查看cookie中是否有phone的信息,如果沒有,返回沒有注冊的錯誤信息。
接着執行秒殺,首先驗證md5,看地址是否被篡改。先增加明細(為什么要先增加明細見后面優化的過程),看是否該明細被重復插入,即用戶是否重復秒殺,如果是,拋異常。然后減庫存,因為sql在減庫存的時候判斷了當前時間和秒殺時間是否對應,如果數據庫update返回0沒有更新庫存記錄,說明秒殺結束;或者是庫存已經沒有主動拋出錯誤rollback。(前面在獲取秒殺地址的時候已經擋住了秒殺關閉的請求(沒到時間或者時間已過),然后從獲取到秒殺地址到執行秒殺還可能會在這段時間秒殺結束)
最后秒殺成功,得到購買明細信息,接着commit。
注意事務在這里的處理:
1.7 Web層設計
交互流程
2. 初始優化設計
紅色部分代表可能高並發的點,綠色表示沒有影響
2.1 詳情頁緩存
通過CDN緩存靜態資源,來抗峰值。
動靜態數據分離
詳情頁靜態資源是部署在CDN節點中,也就是說訪問靜態資源或者詳情頁是不用訪問我們的系統的。
限流小技巧:用戶提交之后按鈕置灰,禁止重復提交
為什么要單獨ajax請求獲取服務器的時間?
為了保持時間一致,因為詳情頁放在CDN上和系統存放的位置是分離的。
2.2 秒殺接口地址緩存
無法使用CDN是因為,CDN適合的請求的資源是不易變化的。
秒殺接口是變化的,可以使用redis服務端緩存可以用集群抗住非常大的並發。1秒鍾可以承受10萬qps。多個Redis組成集群,可以到100w個qps
一致性:當秒殺的對象改變的時候修改我們的數據庫同時修改緩存。
原本查詢秒殺商品時是通過主鍵直接去數據庫查詢的,選擇將數據緩存在Redis,在查詢秒殺商品時先去Redis緩存中查詢,以此降低數據庫的壓力。如果在緩存中查詢不到數據再去數據庫中查詢,再將查詢到的數據放入Redis緩存中,這樣下次就可以直接去緩存中直接查詢到。
這里有一個繼續優化的點:在redis中存放對象是將對象序列化成byte字節。
通過Jedis儲存對象的方式有大概三種
- 本項目采用的方式:將對象序列化成byte字節,最終存byte字節;
- 對象轉hashmap,也就是你想表達的hash的形式,最終存map;
- 對象轉json,最終存json,其實也就是字符串
其實如果你是平常的項目,並發不高,三個選擇都可以,這種情況下以hash的形式更加靈活,可以對象的單個屬性,但是問題來了,在秒殺的場景下,三者的效率差別很大。
10w數據 |
時間 |
內存占用 |
存json |
10s |
14M |
存byte |
6s |
6M |
存jsonMap |
10s |
20M |
存byteMap |
4s |
4M |
取json |
7s |
|
取byte |
4s |
|
取jsonmap |
7s |
|
取bytemap |
4s |
bytemap最快啊,為啥不用啊,因為項目用了超級高性能的自定義序列化工具protostuff。
2.3 秒殺操作優化
Mysql真的低效嗎?
在mysql端一條update壓力測試約4wQPS,即使是現在最好的秒殺產品應該也達不到這個數字。
然而實際上遠沒有這么高的QPS,那么時間消耗在哪呢?
串行化操作,大量的堵塞
2.3.1 瓶頸分析
客戶端執行update,當我們的sql通過網絡發送到mysql的時候,這本身就有網絡延遲在里面,並且還有GC的時間,GC又分為新生代GC和老年代GC,新生代會暫停所有的事務代碼,也就是我們的java代碼,一般在幾十毫秒
也即是說如果由java客戶端去控制這些事務的話,update減庫存,網絡延遲,update數據操作結果返回,然后執行GC;然后執行insert,發生網絡延遲,等待insert執行結果返回,也可能出現GC,最后commit或者rollback。當這些執行完了之后,第二個等待行鎖的線程才有可能拿到這個數據行的鎖,再去執行update減庫存。
不是我們的mysql慢,也不是java慢,可能存在我們的java客戶端執行這些sql,然后等待這些sql的結果,再去做判斷再去執行這些sql,這一長串的事務在java客戶端執行,但是java客戶端和數據庫之間會有網絡延遲,或者是GC這些時間也要加載事務的執行周期里面,而同一行的事務是串行化的。
那么我們的QPS分析就是所有的sql執行時間+網絡延遲時間+可能的GC,這就是當前執行一行數據的時間。
優化的方向
2.3.2 簡單優化
將原本先update(減庫存)再進行insert(插入購買明細)的步驟改成:先insert再update。
為什么要先insert后update?
首先是在更新操作的時候給行加鎖,插入並不會加鎖,如果更新操作在前,那么就需要執行完更新和插入以后事務提交或回滾才釋放鎖。而如果插入在前,更新在后,那么只有在更新時才會加行鎖,之后在更新完以后事務提交或回滾釋放鎖。
在這里,插入是可以並行的,而更新由於會加行級鎖是串行的。
也就是說是更新在前加鎖和釋放鎖之間兩次的網絡延遲和GC,如果插入在前則加鎖和釋放鎖之間只有一次的網絡延遲和GC,也就是減少的持有鎖的時間。
這里先insert並不是忽略了庫存不足的情況,而是因為insert和update是在同一個事務里,光是insert並不一定會提交,只有在update成功才會提交,所以並不會造成過量插入秒殺成功記錄。
2.3.3 深度優化
客戶端邏輯事務SQL在MYSQL端執行,完全屏蔽網絡延遲和GC,MYSQL只需告訴最終結果。
1. 阿里巴巴做了一個mysql源碼層的修改方案,當執行完update之后,它會自動做回滾,回滾的條件影響的記錄數是1,就會commit;如果是0就會rollback,不由java客戶端來控制commit或者rollback,不給java客戶端和mysql之間通信的網絡延遲,本質上減低了網絡延遲或者GC的干擾,但是這個成本高,要修改mysql源碼,只有大公司能做。
2.我們可以將執行秒殺操作時的insert和update放到MySQL服務端的存儲過程里,而Java客戶端直接調用這個存儲過程,這樣就可以避免網絡延遲和可能發生的GC影響。另外,由於我們使用了存儲過程,也就使用不到Spring的事務管理了,因為在存儲過程里我們會直接啟用一個事務。
2.3.4 優化總結
預知后事如何,請看下篇分解:秒殺系統優化方案(下)吐血整理