1. 程式人生 > >Seckill系統高併發優化

Seckill系統高併發優化

絕大多數秒殺系統都需要實現高併發,這樣就必須在原來的專案基礎上進行優化。簡單的優化很有可能就會很大地提高系統的併發效能,但是這些優化往往是系統開發人員很少注意的,或者直接被人們忽略。因此要成為一個出色的開發人員,學會優化技巧與時刻具備系統優化的意識是必須的。

專案原始碼地址:

注意:在看本部落格之前建議先大致看明白專案結構

本專案秒殺業務核心SQL操作:

先是UPDATE貨存(貨存減1),再是INSERT購買明細。中間可能會出現重複秒殺,秒殺結束,系統內部錯誤等異常,只要出現異常,事務就會回滾。

事務行為分析:

當一個事務開啟的時候拿到了資料庫表中某一行的行級鎖,另一個事務進來資料庫時發現鎖住了同一行,若之前的事務不提交或回滾,這個行級鎖不會被釋放,後面進來的那個事務就要等待行級鎖。當第一個事務提交或回滾後,行級鎖被釋放,第二個事務就能獲得這個行級鎖進行資料操作,多個事務以此類推,這些過程是一個序列化的操作,也是一個含有大量阻塞的操作。這是mysql資料庫或是絕大多數關係型資料庫事務實現的方案。

秒殺系統瓶頸分析:

  1. 現在的事務實現方案是通過Spring的事務對秒殺業務核心進行管理。
  2. 系統目前的秒殺邏輯:java客戶端傳送UPDATE語句至MySQL服務端(雖然有網路延遲,但是各個事務並行),各事務開始競爭行級鎖(阻塞開始),UPDATE執行後將UPDATE結果返回至java客戶端(存在網路延遲與可能的GC操作),客戶端判斷如果執行成功,則傳送INSERT購買明細的SQL語句至MySQL服務端再執行(存在網路延遲與可能的GC操作),將執行結果返回至java客戶端(存在網路延遲與可能的GC操作),客戶端再判斷是否執行成功,如果成功,就告知MySQL提交事務(存在網路延遲)。
  3. 因此,阻塞的時間即從各事務在MySQL服務端競爭行級鎖開始,一直到最後的事務提交,中間有4次的網路延遲以及java客戶端的各種邏輯判斷。這樣事務的執行週期就會比較長。當排隊的事務比較多的時候,系統性能就會呈指數級下降。

注:java的GC操作:專案中DAO層各資料庫操作類通過MyBatis實現的生成相應物件注入Spring容器中,當使用後不再被使用時,就會進行垃圾回收。

專案優化分析:

通過分析事務的行為與秒殺系統瓶頸可以知道,要減少事務等待的時間,削弱阻塞的過程,就要想辦法減少行級鎖持有的時間。

  1. 優化思路一:持有行級鎖是在UPDATE上(INSERT不涉及行級鎖),釋放鎖是在Commit(客戶端Spring控制),也就是鎖持有時間是UPDATE和Commit之間。這個過程網路請求越少,鎖持有時間就越短。
  2. 優化思路二:把客戶端邏輯放在MySQL服務端(使用儲存過程,整個事務在MySQL端完成),避免網路延遲與GC的影響,也沒有java客戶端的邏輯判斷。

簡單的併發優化(優化思路一):

這裡寫圖片描述

分析:

參照優化思路一,持有行級鎖在UPDATE上,INSERT不涉及行級鎖(沒INSERT之前根本不存在相應的行,更不可能會有行級鎖)。因此可以先插入購買明細,這個過程雖然存在網路延遲,但是各個事務之間是可以並行的所以不需要等待,這樣就可以減少各個事務一部分的等待與阻塞。實現減少MySQL row lock的持有時間。(但還是要把UPDATE庫存的結果返回給客戶端,客戶端再決定是否提交事務,即還有2次網路延遲)

修改秒殺業務核心程式碼順序後:

int insertCount = successKilledDao.insertSuccessKilled(seckillId,userPhone,nowTime);
            //唯一:seckillId,userPhone(聯合主鍵)
            if(insertCount<=0){
                //重複秒殺
                throw new RepeatKillException("seckill repeated");
            }
            else {
                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, SeckillStateEnums.SUCCESS, successKilled);  //列舉
                }
            }

深度優化(利用儲存過程實現事務SQL在MySQL端執行):

  1. 參照優化思路二,利用儲存過程將秒殺業務核心事務SQL放在MySQL端執行,這樣就可以避免事務執行過程中的網路延遲與GC影響,事務行級鎖持有時間幾乎就是資料庫資料操作的時間。大大削弱了事務等待的阻塞效應。

秒殺核心SQL事務儲存過程:

DELIMITER //
CREATE PROCEDURE excuteSeckill(IN fadeSeckillId INT,IN fadeUserPhone VARCHAR (15),IN fadeKillTime TIMESTAMP ,OUT fadeResult INT)
  BEGIN
    DECLARE insertCount INT DEFAULT 0;
    START TRANSACTION ;
    INSERT IGNORE success_killed(seckill_id,user_phone,state,create_time) VALUES(fadeSeckillId,fadeUserPhone,0,fadeKillTime);  --先插入購買明細
    SELECT ROW_COUNT() INTO insertCount;
    IF(insertCount = 0) THEN
      ROLLBACK ;
      SET fadeResult = -1;   --重複秒殺
    ELSEIF(insertCount < 0) THEN
      ROLLBACK ;
      SET fadeResult = -2;   --內部錯誤
    ELSE   --已經插入購買明細,接下來要減少庫存
      UPDATE seckill SET number = number -1 WHERE seckill_id = fadeSeckillId AND start_time < fadeKillTime AND end_time > fadeKillTime AND number > 0;
      SELECT ROW_COUNT() INTO insertCount;
      IF (insertCount = 0)  THEN
        ROLLBACK ;
        SET fadeResult = 0;   --庫存沒有了,代表秒殺已經關閉
      ELSEIF (insertCount < 0) THEN
        ROLLBACK ;
        SET fadeResult = -2;   --內部錯誤
      ELSE
        COMMIT ;    --秒殺成功,事務提交
        SET  fadeResult = 1;   --秒殺成功返回值為1
      END IF;
    END IF;
  END
//

DELIMITER ;

SET @fadeResult = -3;
CALL excuteSeckill(8,13813813822,NOW(),@fadeResult);
SELECT @fadeResult;

Java客戶端(MyBatis)呼叫資料庫儲存過程:

首先,在Dao層新建一個介面:void killByProcedure(Map [泛型:String,Object] paramMap); 然後在相應的XML中配置實現(注意:jdbcType沒有INT型別的列舉,要使用BIGINT;同樣沒有VARCHAR的列舉,要使用BIGINT代替。):

    <!--MyBatis呼叫儲存過程 -->
    <select id="killByProcedure" statementType="CALLABLE">
        CALL executeSeckill(
          #{ seckillId , jdbcType = BIGINT , mode= IN },
          #{ phone ,jdbcType = BIGINT , mode= IN },
          #{ killTime , jdbcType = TIMESTAMP , mode= IN },
          #{ result , jdbcType = BIGINT , mode= OUT }
        )
    </select>

然後,Service層重新寫入一個方法SeckillExecution executeSeckillProcedure(int seckillId, String userPhone, String md5);(注意:在使用MapUtils時要注入commons-collections 3.2依賴)

public SeckillExecution executeSeckillProcedure(int seckillId, String userPhone, String md5) {

        if( md5==null || !md5.equals(getMD5(seckillId)) ){
            return new SeckillExecution(seckillId,SeckillStateEnums.DATA_REWRITE);
        }

        Timestamp nowTime = new Timestamp(System.currentTimeMillis());
        Map<String,Object> map = new HashMap<String,Object>();
        map.put("seckillId",seckillId);
        map.put("phone",userPhone);
        map.put("killTime",nowTime);
        map.put("result", null);

        try{
            seckillDao.killByProcedure(map);
            int result = MapUtils.getInteger(map,"result",-2);
            if(result == 1){
                SuccessKilled sk = successKilledDao.queryByIdWithSeckill(seckillId,userPhone);
                return new SeckillExecution(seckillId,SeckillStateEnums.SUCCESS,sk);
            }
            else{
                return new SeckillExecution(seckillId,SeckillStateEnums.stateOf(result));
            }
        }
        catch (Exception e){
            logger.error(e.getMessage(),e);
            return new SeckillExecution(seckillId,SeckillStateEnums.INNER_ERROR);
        }
    }

再者,在web-control層將呼叫方法改成executeSeckillProcedure,同時因為executeSeckillProcedure已經將重複秒殺,秒殺結束(無庫存)合併到返回的SeckillExecution中,所以不用再捕獲這兩個異常(原本在service層要丟擲這兩個異常,是為了告訴Spring宣告式事務該程式出錯要進行事務回滾)

try{
      SeckillExecution seckillExecution = seckillService.executeSeckillProcedure(seckillId,phone,md5);
      return new SeckillResult<SeckillExecution>(true,seckillExecution);
}
catch (Exception e){
      logger.error(e.getMessage(),e);
      SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStateEnums.INNER_ERROR);
      return  new SeckillResult<SeckillExecution>(true,seckillExecution);
}

最後,整合測試web層:

這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述

可見秒殺成功,重複秒殺,秒殺結束都正常進行!

測試是否有併發效能提升:

寫到這裡,最關心的應該是:我思考了那麼多東西,修改了一部分專案結構,究竟有沒有起到效果呢?下面就來進行測試。

測試方法:將新寫的executeSeckillProcedure方法與原本的executeSeckill方法進行多次比較

第一次測試executeSeckill:

11:12:55.602 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
11:12:55.616 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Registering transaction synchronization for SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.629 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@60856961] will be managed by Spring
11:12:55.635 [main] DEBUG d.S.insertSuccessKilled - ==>  Preparing: INSERT ignore INTO success_killed(seckill_id,user_phone,create_time) VALUE (?,?,?) 
11:12:55.664 [main] DEBUG d.S.insertSuccessKilled - ==> Parameters: 8(Integer), 11111111111(String), 2016-07-14 11:12:55.596(Timestamp)
11:12:55.666 [main] DEBUG d.S.insertSuccessKilled - <==    Updates: 1
11:12:55.677 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.678 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5] from current transaction
11:12:55.679 [main] DEBUG daoPackage.SeckillDao.reduceNumber - ==>  Preparing: UPDATE seckill SET number = number - 1 WHERE seckill_id = ? AND start_time <= ? AND end_time >= ? AND number > 0 
11:12:55.680 [main] DEBUG daoPackage.SeckillDao.reduceNumber - ==> Parameters: 8(Integer), 2016-07-14 11:12:55.596(Timestamp), 2016-07-14 11:12:55.596(Timestamp)
11:12:55.682 [main] DEBUG daoPackage.SeckillDao.reduceNumber - <==    Updates: 1
11:12:55.682 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.683 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Fetched SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5] from current transaction
11:12:55.685 [main] DEBUG d.S.queryByIdWithSeckill - ==>  Preparing: SELECT sk.seckill_id, sk.user_phone, sk.create_time, sk.state, s.seckill_id "seckill.seckill_id", s.name "seckill.name", s.number "seckill.number", s.start_time "seckill.start_time", s.end_time "seckill.end_time", s.create_time "seckill.create_time" FROM success_killed sk INNER JOIN seckill s ON sk.seckill_id = s.seckill_id WHERE sk.seckill_id = ? AND sk.user_phone=? 
11:12:55.686 [main] DEBUG d.S.queryByIdWithSeckill - ==> Parameters: 8(Integer), 11111111111(String)
11:12:55.705 [main] DEBUG d.S.queryByIdWithSeckill - <==      Total: 1
11:12:55.712 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Releasing transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.713 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Transaction synchronization committing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.714 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.714 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Transaction synchronization closing SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@416c58f5]
11:12:55.720 [main] INFO  SeckillServiceTest - execution=SeckillExecution{seckillId=8, state=1, stateInfo='Seckill success!', successKilled=SuccessKilled{seckillId=8, userPhone='11111111111', state=0, createTime=Thu Jul 14 11:12:56 GMT+08:00 2016}}

分析:

我們只需要看時間為11:12:55.68011:12:55.713的部分,因為在11:12:55.680時間點java客戶端將UPDATE庫存的SQL操作以及引數傳送到MySQL服務端,這個時間正是該事務開始拿到行級鎖的時間!在11:12:55.713時間點事務被提交,行級鎖被釋放。這段時間0.033s就是其他併發事務需要等待與阻塞的時間。而其他的時間段(Jdbc連線或傳送SQL或傳送引數),各個事務是可以並行的。

第一次測試executeSeckillProcedure:

11:22:34.318 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@1800a575] will not be managed by Spring
11:22:34.346 [main] DEBUG daoPackage.SeckillDao.queryById - ==>  Preparing: SELECT seckill_id,name,number,start_time,end_time,create_time FROM seckill WHERE seckill_id = ? 
11:22:34.385 [main] DEBUG daoPackage.SeckillDao.queryById - ==> Parameters: 8(Integer)
11:22:34.409 [main] DEBUG daoPackage.SeckillDao.queryById - <==      Total: 1
11:22:34.417 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@413f69cc]
put into redis
11:22:34.432 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
11:22:34.433 [main] DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@42b02722] was not registered for synchronization because synchronization is not active
11:22:34.434 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@5ce8d869] will not be managed by Spring
11:22:34.435 [main] DEBUG d.SeckillDao.killByProcedure - ==>  Preparing: CALL executeSeckill( ?, ?, ?, ? ) 
11:22:34.457 [main] DEBUG d.SeckillDao.killByProcedure - ==> Parameters: 8(Integer), 11111111112(String), 2016-07-14 11:22:34.432(Timestamp)
11:22:34.463 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@42b02722]
11:22:34.473 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
11:22:34.473 [main] DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@19b93fa8] was not registered for synchronization because synchronization is not active
11:22:34.473 [main] DEBUG o.m.s.t.SpringManagedTransaction - JDBC Connection [com.mchange.v2.c3p0.impl.NewProxyConnection@40db2a24] will not be managed by Spring
11:22:34.474 [main] DEBUG d.S.queryByIdWithSeckill - ==>  Preparing: SELECT sk.seckill_id, sk.user_phone, sk.create_time, sk.state, s.seckill_id "seckill.seckill_id", s.name "seckill.name", s.number "seckill.number", s.start_time "seckill.start_time", s.end_time "seckill.end_time", s.create_time "seckill.create_time" FROM success_killed sk INNER JOIN seckill s ON sk.seckill_id = s.seckill_id WHERE sk.seckill_id = ? AND sk.user_phone=? 
11:22:34.474 [main] DEBUG d.S.queryByIdWithSeckill - ==> Parameters: 8(Integer), 11111111112(String)
11:22:34.480 [main] DEBUG d.S.queryByIdWithSeckill - <==      Total: 1
11:22:34.480 [main] DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@19b93fa8]
11:22:34.481 [main] INFO  SeckillServiceTest - Seckill success!

分析:

  1. 我們先來看看幾個重要的時間點:11:22:34.435是為了請求儲存過程;11:22:34.457將儲存過程的引數傳送至MySQL服務端;11:22:34.463關閉了一個SqlSession,這說明什麼?這說明在儲存過程的事務已經執行完並有返回結果了,不然不會關掉當前連線資料庫的會話。接下來再新建一個SqlSession連線資料庫來queryByIdWithSeckill,即獲得秒殺成功後的Seckill物件。

  2. 所以,真正在資料庫中事務的行級鎖持有時間(其他事務等待與阻塞的時間),為11:22:34.45711:22:34.463的時間,竟然只有0.006s!在其他時間段,各個事務的操作是可以並行的。

第2次,第3次及更多的測試與分析如上:

executeSeckill:11:35:36.621到11:35:36.677(0.056s
executeSeckillProcedure:11:39:42.655到11:39:42.660(0.005s

executeSeckill:11:42:14.273到11:42:14.319(0.046s
executeSeckillProcedure:11:44:10.239到11:44:10.246(0.007s

executeSeckill:11:45:35.829到11:45:35.861(0.032s
executeSeckillProcedure:11:46:47.685到11:46:47.691(0.006s

通過多組測試可以知道:用儲存過程事務的行級鎖持有時間大約為0.006s,而Java客戶端託管的事務行級鎖持有時間大約為0.040s,相差0.034s。如果一個熱點商品在同一秒同一毫秒內競爭的人數是500,這樣事務排隊時間要多出0.034s * 500 = 17s。很不幸運的排在最後的事務要多阻塞17s的時間!這樣使用者的體驗是很不好的。因此,高併發優化對使用者體驗有舉足輕重的作用。