秒殺系統筆記

登陸

兩次MD5加密:前端

  1. 瀏覽器對密碼明文加固定的鹽來md5加密一次後傳輸(這一步感受加不加鹽都無所謂,由於前端js是對外的,有沒有加鹽別人是知道的)
  2. 服務器對接收的密文加鹽再作一次加密,第二次加密後的值來與數據庫中的數據對比。(這裏的鹽是註冊時,隨機生成的,與二次加密後的密碼都保存在數據庫中)

理解:java

       第一次是爲了防止傳輸過程當中,用戶明文密碼被截取獲取。mysql

       第二次是爲了防止數據庫被盜後,別人知道了密碼的md5值,而後直接僞造請求,傳遞這個md5值過來登陸成功;二次加密後,由於瀏覽器傳遞的值和數據庫中存放的值不一致,因此丟失後也不會形成密碼丟失問題。redis

頁面靜態化

生成請求地址一一對應的緩存,能防止瞬間併發高帶來的性能問題,但頁面的實時性就下降了,由於緩存所展現的內容老是固定的,過時從新渲染後,內容纔可能發生變化。算法

表結構設計

--建立秒殺庫存表
CREATE TABLE seckill(
  `seckill_id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品庫存id',
  `name` varchar (120) NOT NULL COMMENT '商品名稱',
  `number` int NOT NULL COMMENT '庫存數量',
  `start_time` timestamp NOT NULL COMMENT '秒殺開啓時間',
  `end_time` timestamp NOT NULL COMMENT '秒殺結束時間',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
  PRIMARY KEY(seckill_id),
  key idx_start_time(start_time),
  key idx_end_time(end_time),
  key idx_create_time(create_time)
)ENGINE=InnoDB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8 COMMENT='秒殺庫存表';

--秒殺成功明細表
CREATE TABLE success_killed(
  `seckill_id` bigint NOT NULL COMMENT '秒殺商品id',
  `user_phone` bigint NOT NULL COMMENT '用戶手機號,簡單起見經過手機來惟一對應用戶',
  `state` bigint NOT NULL DEFAULT -1 COMMENT '狀態表示:-1:無效,0:成功,1:已付款',
  `create_time` timestamp NOT NULL COMMENT '建立時間',
  PRIMARY KEY (seckill_id,user_phone),
  key idx_create_time(create_time)
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒殺成功明細表';

秒殺實現邏輯

客戶端刷新來訪問接口:以服務端的時間和數據庫中記錄的活動的時間進行對比,來判斷秒殺活動是否處於開啓中。開啓的話,則返回一個惟一令牌。sql

發送秒殺請求時,參數中須要有一個活動id加鹽md5計算得出的令牌,這個令牌與當前秒殺活動一一對應,之因此要傳令牌,而不是傳活動的id,是爲了防止別人在活動開始前作好工具來自動秒殺,活動id能提早拿獲得,而令牌是活動開始後才能拿獲得。這樣一來就算作好了秒殺工具,也要在活動開始以後來改參數,工具才能用。可是改參數的話,速度就比別人慢了,別人進入頁面,點一個按鈕就搶完了。這就從必定程度上防範了自動化秒殺工具的使用。數據庫

執行秒殺時,回傳秒殺地址中的md5令牌,服務端以相同的算法計算出實際令牌,校驗令牌準確性,準確才能夠執行後續操做。後端

理解:令牌僅僅在活動開始纔會給瀏覽器生成,而且秒殺操做必需要有令牌,就能保證秒殺的公平性。瀏覽器

以上令牌實際有兩種生成方式:緩存

1. md5(productId + 固定的鹽) => 令牌:每一個用戶的令牌都相同

2. md5(productId + userId) => 令牌 :每一個用戶的令牌都不一樣

驗證碼

不一樣人輸入驗證碼的速度不同,因此能夠下降(分散)一個短暫時間內的併發請求的壓力,同時還能防止機器人刷請求。

數學公式驗證碼,如1+2-3,輸入運算的結果

 

生成圖片背景:隨機的弧線、線段,用於干擾

生成公式:如生成三個隨機數,這三個隨機數之間插入兩個隨機的運算符,四則運算分別對應0-3這四個隨機數(生成這個公式注意/0的狀況)。

公式以字符串形式存在。使用ScriptEngineManager這個類對字符串公式進行運算,得出結果。而後結果對應用戶,保存到redis中,後續秒殺請求中,要對提交上來的驗證結果進行校驗。校驗經過以後,將redis中的結果移除掉,由於校驗經過一次後續就沒有必要再保留了

限流

限制同一個用戶一段時間(10秒內或一分鐘等)內對接口的訪問次數。

每次訪問接口,次數記錄到redis中,key=接口名+userid。使用incr進行增長,設置這個key的有效期,超過以後自動刪除。遞增以後,若是大於某個值,則接口返回拒絕訪問。

實現思路:攔截器中計算訪問次數,沒有超過次數,則放行請求。

 

第一次設置的時候,就設置有效期爲1s:

註解實現配置化,同時設置好後續要用的用戶信息

 

 

建立一個攔截器,獲取方法上的註解,並進行計數判斷,肯定是否放行(光標處未給出)

在攔截器中獲取好user對象,設置到threadlocal中,接着service方法中就能獲取到了。

或者使用【注入自定義對象】,在處理器的處理方法中從threadLocal中獲取對象,這樣service方法直接注入user對象就可使用了

併發瓶頸問題分析

某一個商品進入秒殺時,成爲熱點數據,同一個數據行的競爭變得很是大。

解決方案A:Redis預減庫存

服務啓動後,監聽bean生命週期,進行redis數據冗餘(productId->productCount)。

接收秒殺請求後,執行redis decr命令,返回數據自減以後的值,若是這個值大於0,說明秒殺成功,由於沒有操做mysql,實際的操做不多,一會兒就響應給客戶端了,提示客戶端正在排隊中,服務後臺往MQ中放一個消息,表示某個用戶秒殺到了某個商品。

客戶端輪訓服務器,以更新當前當前的排隊中的狀態,以得知是否成功,服務接口查詢mysql中的秒殺記錄,而後將當前的狀態返回給客戶端。

服務器不停地消費消息,實現數據落地(mysql中生成訂單、庫存數量減1、生成秒殺記錄)。這裏的邏輯相對於秒殺請求來講,是異步執行的,生成秒殺記錄用於給客戶端輪訓時更新客戶端的狀態。數據落地過程當中,可能還沒開始執行,則當前秒殺記錄爲空,執行成功、執行失敗時,都要記錄秒殺記錄,標記成功仍是失敗;失敗的狀況有:庫存數量減一時,發現沒得減了,則失敗。但這個狀況基本不會出現,由於已經提早冗餘了一份數據到redis中,redis能成功減一的話,mysql裏也能成功減一的,由於這兩份數據在最開始時,數值是同樣的。

 

以上的細節優化:

       redis預減庫存時,調用decr函數,這其實是一個阻塞的網絡請求。這個函數返回的是減了以後的結果,因此只要有一次出現負數,後續的全部調用都會是負數,這樣的網絡請求沒有必要。因此在服務器內存中,創建一個map(productId -> boolean),標記某個商品是否出現負數了,作好標記後,後續再出現redis預減狀況,先判斷這個map,而不用每次都調用decr函數了,少一次網絡請求,提高接口的響應速度。

 

對MQ的理解:

不用MQ以前,觸發某些邏輯是直接調用對應的函數的,同步的話,則會形成阻塞。

使用MQ後,觸發某些邏輯就不是調用函數了,而是往消息隊列中發送一個消息,而後當前函數就結束了;

另外一個地方提早註冊好了消息的處理函數,這個函數會被異步執行,也就是這時候才實際觸發了某些邏輯。經過消息隊列來解除邏輯之間的同步關係,實現異步調用。

 

經過MQ實現了異步調用。這提高了QPS,可是感受有點奇怪,是否是能夠認爲這個QPS意義不大,就好比一我的來找你辦事情,你還沒辦就直接跟他說我登記一下,你先回去等通知吧,一小時來了一百我的,你都這麼處理,而後跟別說,我這一小時內處理了一百我的的事情,說這句話看上去效率很是高,但實際上有變高嗎?由於實際要作的事情時延遲作了。不使用異步處理的話,對於一小時的秒殺活動,則全部的數據處理必須在這一小時內處理完,這會給服務器帶來大負荷。而使用異步處理後,這一小時內只須要執行redis預減就好了,而不用執行數據落地,這一小時內執行的是秒殺活動最關鍵的部分,就是誰能搶獲得,就只作這個工做,其餘有空在作,如實際的數據落地和狀態更新能夠在這一小時以後才執行,至關於把這一小時內的集中的工做量,平攤到後續的一段時間內了。實際能夠理解爲:一件事情的處理效率沒有本質變化,只不過異步的話,可讓完成這件事情有更多的時間,因此完成起來就更加遊刃有餘了。

 

缺點:分佈式redis、分佈式MQ的運維成本、保證數據一致性、如何數據回滾、又須要一個分佈式系統來記錄誰已經秒殺過了,而不能再屢次秒殺、不適合新手

 

解決方案B

mysql測試,同一行的高併發update,能達到4W QPS左右,因此純粹討論mysql,也不慢了。

 

常規實現的瓶頸分析,一次秒殺過程須要發送4次數據庫指令:

開啓事務、update減庫存、(期間還可能會存在GC耗時)、insert添加購買明細、提交事務【數據庫服務器與java客戶端之間也存在網絡延時】

佔用行級鎖的時間 = update語句開始佔用的時間(而不是事務開始時間)  至  提交事務/回滾事務,釋放行級鎖時間

以上這個時間內包含了 GC耗時、update語句結果返回耗時,insert語句和結果往返耗時。簡單來講就是行級鎖佔用時間=GC耗時 + java服務端與mysql服務端的網絡通訊耗時

 

而對同一行數據的秒殺操做,併發量與行級鎖佔用時間直接相關。以上步驟出現併發執行時,只能串行。由於每一個事務鎖住的都是同一行數據。

 

優化方式:把Java客戶端邏輯放到mysql服務端,避免網絡延遲和GC耗時

1.mysql源碼層的修改方案,定製update語句,使update語句更新行爲1時,自動提交,小公司基本沒有這樣的團隊實力去作這樣的事

2.使用存儲過程,存儲過程的目的是將一組sql組成一個事務,在數據庫服務端完成,避免客戶端去完成事務,從而下降性能的消耗(避免了java GC耗時和mysql與java服務器之間的通訊耗時)

 

簡單的優化方式:

以上佔用行級鎖的時間中,包含了insert操做,因此把insert操做移出來,能提高下降行級鎖佔用時間。

把insert和update的位置互換一下。

被insert的表使用聯合主鍵(用戶手機、秒殺商品id),若是成功插入數據,說明用戶之前沒秒殺過,插入失敗則說明是重複秒殺了。插入成功返回1,失敗返回0(這個實現經過insert ignore來實現:加了ignore,若是出現主鍵衝突,則不報錯,而是返回0);

僅當插入成功後,才進行update減庫存。若是update影響行數是1,則說明能秒殺到,不然影響行數爲0,說明沒秒殺到。沒秒殺到,則拋出異常,好讓事務回滾。

以上優化延遲獲取行級鎖,而且行級鎖的獲取到釋放之間(事務回滾或提交)的操做更少了,則佔用行級鎖的時間更少了。原來的邏輯,期間有一個insert操做,互換位置以後,insert操做就不佔用行級鎖時間了。

理解:行級鎖佔用從update開始至事務結束,期間的操做越少越好,因此把insert移出去,行鎖佔用時間,併發效率更高。

// 先使用insert ignore插入秒殺記錄,返回影響行數
int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
if (insertCount <= 0) {
    //出現重複秒殺
    throw new RepeatKillException("seckill repeated");
} else {
    // 庫存減一,這裏開始佔用行鎖: update table set num = num - 1 where num > 0
    int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
    if (updateCount <= 0) {
        //沒有更新到記錄,跑出異常,使事務回滾,還原以前的插入操做
        throw new SeckillCloseException("seckill is closed");
    } else {
        SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);
        
        // 返回秒殺成功
        return new SeckillExecution(seckillId, SeckillStateEnum.SUCCESS, successKilled);
    }
}

 

深度優化

使用存儲過程,避免GC耗時,以及最大程度下降了java服務端與mysql服務器之間的網絡通訊耗時。由於使用存儲過程,就只有一次請求往返耗時。

insert ignore:若是中已經存在相同的記錄(惟一索引、主鍵來判斷),則忽略當前新數據。【success_killed表使用id和phone做爲聯合主鍵】

-- 秒殺執行存儲過程
DELIMITER $$ -- console ; 轉換爲 $$
-- 定義存儲過程
-- 參數: in 輸入參數; out 輸出參數
-- row_count():返回上一條修改類型sql(delete,insert,update)的影響行數
-- row_count: 0:未修改數據; >0:表示修改的行數; <0:sql錯誤/未執行修改sql
CREATE PROCEDURE `seckill`.`execute_seckill`
  (in v_seckill_id bigint,in v_phone bigint,
    in v_kill_time timestamp,out r_result int)
  BEGIN
    DECLARE insert_count int DEFAULT 0;
    START TRANSACTION;
    insert ignore into success_killed
      (seckill_id,user_phone,create_time)
      values (v_seckill_id,v_phone,v_kill_time);
    select row_count() into insert_count;
    IF (insert_count = 0) THEN
      ROLLBACK;
      set r_result = -1;
    ELSEIF(insert_count < 0) THEN
      ROLLBACK;
      SET R_RESULT = -2;
    ELSE
      update seckill
      set number = number-1
      where seckill_id = v_seckill_id
        and end_time > v_kill_time
        and start_time < v_kill_time
        and number > 0;
      select row_count() into insert_count;
      IF (insert_count = 0) THEN
        ROLLBACK;
        set r_result = 0;
      ELSEIF (insert_count < 0) THEN
        ROLLBACK;
        set r_result = -2;
      ELSE
        COMMIT;
        set r_result = 1;
      END IF;
    END IF;
  END;
$$
-- 存儲過程定義結束

優化總結

前端控制,點擊秒殺以後,隱藏按鈕,防止再次點擊

動靜態數據分離,CDN緩存(將請求從咱們的服務中剝離出去),後端緩存(redis)

事務競爭優化,減小事務鎖的時間

 

序列化優化

redis緩存:實現對熱點數據的快速存取,分攤mysql的壓力

java對象存入redis:序列化爲 byte[] 存入,取出byte[] ,反序列爲實際對象

 

序列化最高效的是protostuff。這是谷歌protobuf的再次包裝,而不須要本身再寫描述文件來幫助序列化和反序列化了,包裝後能動態生成描述文件

從redis獲取數據,並反序列化

序列化對象,並存入redis

 

其餘

作好的java服務,不直接暴露出去,而是經過ngnix反向代理

完整附件:https://files.cnblogs.com/files/hellohello/seckill-master.7z

相關文章
相關標籤/搜索