1. 程式人生 > 其它 >Java秒殺系統四:高併發優化

Java秒殺系統四:高併發優化

本文為Java高併發秒殺API之高併發優化課程筆記。

編輯器:IDEA

java版本:java8

前文:

一、秒殺系統環境搭建與DAO層設計

二、 秒殺系統Service層

三、秒殺系統web層

目錄

高併發優化分析

併發發生在哪?對一件商品秒殺,自然在具體商品詳情頁下面,會存在高併發瓶頸。

紅色部分為高併發發生點。

為什麼要單獨獲取系統時間?因為資源不都是從伺服器獲取的。

所以需要單獨獲取時間來明確伺服器的當前時間。

CDN:內容分發網路,加速使用者獲取資料的系統。部署在離使用者最近的網路結點上。命中CDN不需要訪問後端伺服器。

獲取系統時間不需要優化。訪問一次記憶體大概10ns,沒有後端訪問。

獲取秒殺地址:無法使用CDN快取,適合服務端快取:redis等。一致性維護成本低。

執行秒殺操作:無法使用CDN,後端快取困難:庫存問題,一行資料競爭:熱點商品。

秒殺方案

成本分析:運維成本和穩定:nosql,mq等。開發成本:資料一致性,回滾方案。冪等性難保證:重複秒殺問題。不適合新手的架構。

為什麼不用MySQL解決?

一條update,MySQL可以QPS很高。

java控制事務行為分析:

瓶頸分析:

優化分析:行級鎖在commit之後釋放,所以優化方向在於減少行級鎖的保持時間。

延遲分析:本地機房,可能1ms,異地機房:

往返可能20ms,那併發最多就50QPS。

優化思路:

把客戶端邏輯放端MySQL服務端,避免網路延遲和GC影響。

兩種方案:

  • 定製SQL方案:update /*+[auto_commit]*/,成功就成功,不成功就回滾,需要修改MySQL原始碼。
  • 使用儲存過程:整個事務在MySQL端完成。

優化總結

  • 前端控制:暴露介面,按鈕防重複
  • 動靜態資料分離:CDN快取(靜態資源),後端快取(如redis)
  • 事務競爭優化:減少事務鎖時間

redis後端優化快取編碼

使用redis優化地址暴露介面

下載安裝redis。

D:\Program Files (x86)\Renren.io\Redis>redis-cli.exe -h 127.0.0.1 -p 6379
127.0.0.1:6379> set myKey abc
OK
127.0.0.1:6379> get myKey
"abc"

配合java使用,pom.xml加入依賴:

<!--redis依賴引入-->
<dependency>
  <groupid>redis.clients</groupid>
  <artifactid>jedis</artifactid>
  <version>3.5.0</version>
</dependency>

<!--序列化操作-->
<dependency>
    <groupid>com.dyuproject.protostuff</groupid>
    <artifactid>protostuff-core</artifactid>
    <version>1.1.6</version>
</dependency>

<dependency>
    <groupid>com.dyuproject.protostuff</groupid>
    <artifactid>protostuff-runtime</artifactid>
    <version>1.1.6</version>
</dependency>

在SecKillServiceImpl.java檔案中原本暴露url的程式碼為:

/**
 * 秒殺開啟時,輸出秒殺介面地址
 * 否則輸出系統時間和秒殺時間
 *
 * @param seckillId
 */
@Override
public Exposer exportSecKillUrl(long seckillId) {
    // 查資料庫
    SecKill secKill = secKillDao.queryById(seckillId);
    if(secKill == null) {
        // 查不到id,false
        return new Exposer(false,seckillId);
    }
    Date startTime = secKill.getStartTime();
    Date endTime = secKill.getEndTime();
    Date nowTime = new Date();
    if(nowTime.getTime()<starttime.gettime() ||="" nowtime.gettime()="">endTime.getTime()) {
        return new Exposer(false,seckillId, nowTime.getTime(),
                startTime.getTime(),endTime.getTime());
    }
    // 不可逆
    String md5 = getMD5(seckillId);
    return new Exposer(true,md5,seckillId);
}

在DAO資料夾下新建RedisDao.java,因為它也是和資料打交道的。

RedisDao.java

public class RedisDao {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private JedisPool jedisPool;
    public RedisDao(String ip,int port) {
        jedisPool = new JedisPool(ip,port);
    }

    private RuntimeSchema<seckill> schema = RuntimeSchema.createFrom(SecKill.class);

    public SecKill getSeckill(long seckillId) {
        // redis操作邏輯
        try {
            Jedis jedis = jedisPool.getResource();
            try {
                String key = "seckill:"+seckillId;
                // redis並沒有實現內部序列化操作
                // get得到的是一個二進位制陣列byte[],通過反序列化-> Object(SecKill)
                // 採用自定義序列化 protostuff
                // protostuff:pojo 有get set這些方法
                byte[] bytes = jedis.get(key.getBytes(StandardCharsets.UTF_8));
                // 獲取到了,需要protostuff轉化
                // 需要位元組陣列和schema
                if (bytes != null) {
                    // 建立一個空物件來放反序列化生成的物件
                    SecKill secKill = schema.newMessage();
                    ProtostuffIOUtil.mergeFrom(bytes,secKill,schema);
                    // seckill被反序列化
                    return secKill;
                }
            } finally {
                jedis.close();
            }

        } catch (Exception e) {
            logger.error(e.getMessage(),e);
        }
        return null;
    }

    public String putSeckill(SecKill secKill) {
        // set: objest(SecKill) -> bytes[] 序列化操作
        try {
            Jedis jedis = jedisPool.getResource();
            try {
                String key = "seckill:"+secKill.getSeckillId();
                byte[] bytes = ProtostuffIOUtil.toByteArray(secKill,schema,
                        LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));
                // 超時快取
                int timeout = 60*60; // 1小時
                String result = jedis.setex(key.getBytes(StandardCharsets.UTF_8),timeout,bytes);
                return result; //加入快取資訊,成功還是失敗
            } finally {
                jedis.close();
            }
        } catch (Exception e) {
            logger.error(e.getMessage(),e);
        }
        return null;
    }
}

這裡面有兩個方法,put和get,中間還牽扯到序列化,用的是protostuff。

單元測試之前,需要注入RedisDao,在spring-dao.xml中注入:

<!--需要自己配置redis dao-->
<bean id="redusDao" class="cn.orzlinux.dao.cache.RedisDao">
    <constructor-arg index="0" value="localhost">
    <constructor-arg index="1" value="6379">
</constructor-arg></constructor-arg></bean>

進行單元測試:

@RunWith(SpringJUnit4ClassRunner.class)
//告訴junit spring配置檔案
@ContextConfiguration({"classpath:spring/spring-dao.xml"})
public class RedisDaoTest {
    private long id = 1001;
    @Autowired
    private RedisDao redisDao;

    @Autowired
    private SecKillDao secKillDao;

    @Test
    public void testSeckill() {
        // get and put
        // 從快取中拿
        SecKill secKill = redisDao.getSeckill(id);
        // 沒有就從資料庫中查
        // 查到後放回redis
        if(secKill==null) {
            secKill = secKillDao.queryById(id);
            if(secKill != null) {
                String result = redisDao.putSeckill(secKill);
                System.out.println(result);
                secKill = redisDao.getSeckill(id);
                System.out.println(secKill);
            }
        }
    }
}

可以通過在命令列查詢redis驗證一下,可以看出的確是放入了:

127.0.0.1:6379> get seckill:1001
"\b\xe9\a\x12\x11500\xe7\xa7\x92\xe6\x9d\x80iphone12\x18\xbc\x84=!\x00evL|\x01\x00\x00)\x00\xe4\xcd\xb3\xc5\x01\x00\x001\xf8z/N|\x01\x00\x00"
127.0.0.1:6379> get seckill:1002
(nil)

這裡面能用redis快取是因為秒殺一件商品可能有成千上萬人,這些人訪問這件商品的URL都是一樣的,不需要頻繁查詢資料庫,直接存快取中拿就可以。

秒殺操作併發優化

事務執行:

簡單優化

insert插入操作衝突概率低。服務端根據insert結果判斷是否執行update,排除重複秒殺,不再update加鎖。然後再是update行級鎖,可以減少行級鎖的持有時間。

原始碼更改資料庫操作時間:

@Override
@Transactional
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws
        SeckillException, RepeatKillException, SeckillException {
    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) {
                // 沒有更新記錄,秒殺結束
                throw new SeckillCloseException("seckill is closed");
            } else {
                //秒殺成功
                SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId,userPhone);
                return new SeckillExecution(seckillId, SecKillStatEnum.SUCCESS,successKilled);
            }
        }

    } catch (SeckillCloseException | RepeatKillException e1){
        throw e1;
    } catch (Exception e) {
        logger.error(e.getMessage(),e);
        // 所有編譯期異常轉化為執行期異常,這樣spring才能回滾
        throw new SeckillException("seckill inner error"+e.getMessage());
    }
}

深度優化

事務SQL在MySQL端執行(儲存過程)。

儲存過程

儲存過程(Stored Procedure)是在大型資料庫系統中,一組為了完成特定功能的SQL 語句集,它儲存在資料庫中,一次編譯後永久有效,使用者通過指定儲存過程的名字並給出引數(如果該儲存過程帶有引數)來執行它。儲存過程是資料庫中的一個重要物件。

優點很明顯,說一下缺點:難除錯、可移植性差、如果業務資料模型變動,大型專案的儲存過程更改很大。

儲存過程優化的是事務行級鎖的持有時間。不要過度依賴儲存過程,簡單的邏輯可以依靠儲存過程。

定義一個儲存過程:

-- 秒殺執行儲存過程
DELIMITER $$ -- console ;轉化為\$\$ 表示sql可以執行操作了
-- 定義儲存過程
-- 引數:in 輸入引數; out 輸出引數
-- row_count(): 返回上一條修改型別sql的影響行數
-- row_count: 0未修改資料,>0 修改的行數,<0 sql錯誤或未執行

# SUCCESS(1,"秒殺成功"),
# END(0,"秒殺結束"),
# REPEAT_KILL(-1,"重複秒殺"),
# INNER_ERROR(-2,"系統異常"),
# DATA_REWRITE(-3,"資料篡改")
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;
$$ -- 儲存過程定義結束
delimiter ;

-- console定義變數
set @r_result=-3;
-- 執行儲存過程
call execute_seckill(1001,19385937587,now(),@r_result);
-- 獲取結果
select @r_result;

這樣,在伺服器端完成插入和update的操作。

要想使用這個儲存過程,需要在SeckillDao.java加入新的方法:

// 使用儲存過程執行秒殺
void killByProcedure(Map<string,object> paramMap);

然後通過xml實現sql語句:

<!--mybatis呼叫儲存過程-->
<select id="killByProcedure" statementtype="CALLABLE">
    call execute_seckill(
        #{seckillId,jdbcType=BIGINT,mode=IN},
        #{phone,jdbcType=BIGINT,mode=IN},
        #{killTime,jdbcType=TIMESTAMP,mode=IN},
        #{result,jdbcType=INTEGER,mode=OUT}
        )
</select>

SecKillService介面加入新的方法,然後在SecKillServiceImpl.java實現:

/**
 * 儲存過程執行秒殺
 *
 * @param seckillId
 * @param userPhone
 * @param md5
 * @return
 * @throws SeckillException
 * @throws RepeatKillException
 * @throws SeckillCloseException
 */
@Override
public SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5) {
    if(md5==null || !md5.equals(getMD5(seckillId))) {
        // 秒殺的資料被重寫修改了
        throw new SeckillException("seckill data rewrite");
    }
    Date nowTime = new Date();
    Map<string,object> map = new HashMap<>();
    map.put("seckillId",seckillId);
    map.put("phone",userPhone);
    map.put("killTime",nowTime);
    map.put("result",null);
    // 執行儲存過程只有,result被賦值
    try {
        secKillDao.killByProcedure(map);
        // 獲取result
        int result = MapUtils.getInteger(map,"result",-2);
        if(result == 1) {
            SuccessKilled sk = successKilledDao.queryByIdWithSeckill(seckillId,userPhone);
            return new SeckillExecution(seckillId,SecKillStatEnum.SUCCESS,sk);
        } else {
            return new SeckillExecution(seckillId,SecKillStatEnum.stateOf(result));
        }
    } catch (Exception e) {
        logger.error(e.getMessage(),e);
        return new SeckillExecution(seckillId,SecKillStatEnum.INNER_ERROR);
    }
}

最後再controller層將原有的執行秒殺方法換成這個。

本文同步釋出於orzlinux.cn