前一段時間好好研究了秒殺的問題,我把裏面的問題好好總結了,能夠說是比較全面的了,真的是吐血整理了。html
因爲我先是在word中整理的,格式都整理得比較好,放到博客上格式挺難調,暫時按word的格式來吧,有時間了在好好排版下。前端
主要須要解決的問題有兩個:java
優化的思路:mysql
1) 儘可能將請求攔截在系統上游ajax
2)讀多寫少經量多使用緩存
3) redis緩存 +RabbitMQ+ mysql 批量入庫redis
秒殺系統業務流程以下:sql
由圖能夠發現,整個系統實際上是針對庫存作的系統。用戶成功秒殺商品,對於咱們系統的操做就是:1.減庫存。2.記錄用戶的購買明細。下面看看咱們用戶對庫存的業務分析:數據庫
記錄用戶的秒殺成功信息,咱們須要記錄:1.誰購買成功了。2.購買成功的時間/有效期。這些數據組成了用戶的秒殺成功信息,也就是用戶的購買行爲。json
爲何咱們的系統須要事務?瀏覽器
1.如果用戶成功秒殺商品咱們記錄了其購買明細卻沒有減庫存。致使商品的超賣。
2.減了庫存卻沒有記錄用戶的購買明細。致使商品的少賣。對於上述兩個故障,如果沒有事務的支持,損失最大的無疑是咱們的用戶和商家。在MySQL中,它內置的事務機制,能夠準確的幫咱們完成減庫存和記錄用戶購買明細的過程。
當用戶A秒殺id爲10的商品時,此時MySQL須要進行的操做是:
1.開啓事務。2.更新商品的庫存信息。3.添加用戶的購買明細,包括用戶秒殺的商品id以及惟一標識用戶身份的信息如電話號碼等。4.提交事務。
若此時有另外一個用戶B也在秒殺這件id爲10的商品,他就須要等待,等待到用戶A成功秒殺到這件商品,而後MySQL成功的提交了事務他才能拿到這個id爲10的商品的鎖從而進行秒殺,而同一時間是不可能只有用戶B在等待,確定是有不少不少的用戶都在等待競爭行級鎖。秒殺的難點就在這裏,如何高效的處理這些競爭?如何高效的完成事務?
咱們只是實現秒殺的一些功能:1.秒殺接口的暴露。2.執行秒殺的操做。3.相關查詢,好比說列表查詢,詳情頁查詢。咱們實現這三個功能便可。
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)
秒殺表的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>
暴露秒殺地址(接口)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。
注意事務在這裏的處理:
交互流程
紅色部分表明可能高併發的點,綠色表示沒有影響
經過CDN緩存靜態資源,來抗峯值。
動靜態數據分離
詳情頁靜態資源是部署在CDN節點中,也就是說訪問靜態資源或者詳情頁是不用訪問咱們的系統的。
限流小技巧:用戶提交以後按鈕置灰,禁止重複提交
爲何要單獨ajax請求獲取服務器的時間?
爲了保持時間一致,由於詳情頁放在CDN上和系統存放的位置是分離的。
沒法使用CDN是由於,CDN適合的請求的資源是不易變化的。
秒殺接口是變化的,可使用redis服務端緩存能夠用集羣抗住很是大的併發。1秒鐘能夠承受10萬qps。多個Redis組成集羣,能夠到100w個qps
一致性:當秒殺的對象改變的時候修改咱們的數據庫同時修改緩存。
本來查詢秒殺商品時是經過主鍵直接去數據庫查詢的,選擇將數據緩存在Redis,在查詢秒殺商品時先去Redis緩存中查詢,以此下降數據庫的壓力。若是在緩存中查詢不到數據再去數據庫中查詢,再將查詢到的數據放入Redis緩存中,這樣下次就能夠直接去緩存中直接查詢到。
這裏有一個繼續優化的點:在redis中存放對象是將對象序列化成byte字節。
經過Jedis儲存對象的方式有大概三種
其實若是你是日常的項目,併發不高,三個選擇均可以,這種狀況下以hash的形式更加靈活,能夠對象的單個屬性,可是問題來了,在秒殺的場景下,三者的效率差異很大。
10w數據 |
時間 |
內存佔用 |
存json |
10s |
14M |
存byte |
6s |
6M |
存jsonMap |
10s |
20M |
存byteMap |
4s |
4M |
取json |
7s |
|
取byte |
4s |
|
取jsonmap |
7s |
|
取bytemap |
4s |
bytemap最快啊,爲啥不用啊,由於項目用了超級高性能的自定義序列化工具protostuff。
Mysql真的低效嗎?
在mysql端一條update壓力測試約4wQPS,即便是如今最好的秒殺產品應該也達不到這個數字。
然而實際上遠沒有這麼高的QPS,那麼時間消耗在哪呢?
串行化操做,大量的堵塞
客戶端執行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,這就是當前執行一行數據的時間。
優化的方向
將本來先update(減庫存)再進行insert(插入購買明細)的步驟改爲:先insert再update。
爲何要先insert後update?
首先是在更新操做的時候給行加鎖,插入並不會加鎖,若是更新操做在前,那麼就須要執行完更新和插入之後事務提交或回滾才釋放鎖。而若是插入在前,更新在後,那麼只有在更新時纔會加行鎖,以後在更新完之後事務提交或回滾釋放鎖。
在這裏,插入是能夠並行的,而更新因爲會加行級鎖是串行的。
也就是說是更新在前加鎖和釋放鎖之間兩次的網絡延遲和GC,若是插入在前則加鎖和釋放鎖之間只有一次的網絡延遲和GC,也就是減小的持有鎖的時間。
這裏先insert並非忽略了庫存不足的狀況,而是由於insert和update是在同一個事務裏,光是insert並不必定會提交,只有在update成功纔會提交,因此並不會形成過量插入秒殺成功記錄。
客戶端邏輯事務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的事務管理了,由於在存儲過程裏咱們會直接啓用一個事務。
預知後事如何,請看下篇分解:秒殺系統優化方案(下)吐血整理