Java秒殺系統四:高併發優化
本文為Java高併發秒殺API之高併發優化課程筆記。
編輯器:IDEA
java版本:java8
前文:
二、 秒殺系統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