專案簡介
在慕課網上發現了一個JavaWeb專案,內容講的是高併發秒殺,覺得挺有意思的,就進去學習了一番。
記錄在該專案中學到了什麼玩意..
該專案原始碼對應的gitHub地址(由觀看其視訊的人編寫,並非視訊原始碼):github.com/codingXiaxw…
我結合其資料和觀看視訊的時候整理出從該專案學到了什麼...
專案Dao層
日誌記錄工具:
<!--1.日誌 java日誌有:slf4j,log4j,logback,common-logging
slf4j:是規範/介面
日誌實現:log4j,logback,common-logging
使用:slf4j+logback
-->
複製程式碼
Mybatis之前沒注意到的配置屬性:
使用jdbc的getGeneratekeys獲取自增主鍵值,這個屬性還是挺有用的。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!--配置全域性屬性-->
<settings>
<!--使用jdbc的getGeneratekeys獲取自增主鍵值-->
<setting name="useGeneratedKeys" value="true"/>
<!--使用列別名替換列名  預設值為true
select name as title(實體中的屬性名是title) form table;
開啟後mybatis會自動幫我們把表中name的值賦到對應實體的title屬性中
-->
<setting name="useColumnLabel" value="true"/>
<!--開啟駝峰命名轉換Table:create_time到 Entity(createTime)-->
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
</configuration>
複製程式碼
Mybatis返回的物件如果有關聯欄位,除了使用resultMap還有下面這種方式(雖然我還是覺得resultMap會方便一點)
<select id="queryByIdWithSeckill" resultType="SuccessKilled">
<!--根據seckillId查詢SuccessKilled物件,並攜帶Seckill物件-->
<!--如何告訴mybatis把結果對映到SuccessKill屬性同時對映到Seckill屬性-->
<!--可以自由控制SQL語句-->
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",
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=#{seckillId}
AND sk.user_phone=#{userPhone}
</select>
複製程式碼
資料庫連線池可能用到的屬性:
<!--c3p0私有屬性-->
<property name="maxPoolSize" value="30"/>
<property name="minPoolSize" value="10"/>
<!--關閉連線後不自動commit-->
<property name="autoCommitOnClose" value="false"/>
<!--獲取連線超時時間-->
<property name="checkoutTimeout" value="1000"/>
<!--當獲取連線失敗重試次數-->
<property name="acquireRetryAttempts" value="2"/>
複製程式碼
spring與Junit整合:
/**
* Created by codingBoy on 16/11/27.
* 配置spring和junit整合,這樣junit在啟動時就會載入spring容器
*/
@RunWith(SpringJUnit4ClassRunner.class)
//告訴junit spring的配置檔案
@ContextConfiguration({"classpath:spring/spring-dao.xml"})
public class SeckillDaoTest {
//注入Dao實現類依賴
@Resource
private SeckillDao seckillDao;
@Test
public void queryById() throws Exception {
long seckillId=1000;
Seckill seckill=seckillDao.queryById(seckillId);
System.out.println(seckill.getName());
System.out.println(seckill);
}
}
複製程式碼
Mybatis引數為一個以上時
之前在學習MyBatis的時候,如果引數超過了一個,那麼是使用Map集合來進行裝載的!
在這次教程中發現,可以不用Map集合(如果都是基本資料型別)!
例子:使用@Param就行了!
int reduceNumber(@Param("seckillId") long seckillId, @Param("killTime") Date killTime);
複製程式碼
在XML檔案中可以直接忽略parameterType了!
避免重複插入資料時丟擲異常
如果主鍵重複插入資料的時候,Mybatis正常是會丟擲異常的,我們又不希望它丟擲異常,那麼我們可以這樣做:
寫ignore..
Service層
tdo
一個dto包作為傳輸層,dto和entity的區別在於:entity用於業務資料的封裝,而dto用於完成web和service層的資料傳遞。
對於dto這個概念,在之前我是接觸過一次的,但是沒有好好地實踐起來。這次看到了它的用法了。
我的理解是:Service與Web層資料傳遞資料的再包裝了一個物件而已。因為很多時候Service層返回的資料如果使用的是POJO,POJO很多的屬性是多餘的,還有一些想要的資料又包含不了。此時,dto又可以再一次對要傳輸的資料進行抽象,封裝想獲取的資料。
定義多個異常物件
之前的異常物件都是針對整個業務的,其實還是可以細分多個異常類的出來的。比如“重複秒殺”,”秒殺關閉“這些都是屬於秒殺的業務。
這樣做的好處就是看到丟擲的異常就能夠知道是具體哪部分錯了。
對於視訊中在Service層就catch住了很多異常,我覺得可以在Service層直接丟擲,在Controller也能丟擲,直接使用統一異常處理器類來管理會更加方便!
提倡使用註解方式使用事務
我覺得就是程式碼更加清晰吧,使用註解的話。
在視訊下面還有同學說如果在Service中呼叫事務方法會有些坑,我暫時還沒遇到過。先存起來吧:
併發性上不去是因為當多個執行緒同時訪問一行資料時,產生了事務,因此產生寫鎖,每當一個獲取了事務的執行緒把鎖釋放,另一個排隊執行緒才能拿到寫鎖,QPS和事務執行的時間有密切關係,事務執行時間越短,併發性越高,這也是要將費時的I/O操作移出事務的原因。
關於同類中呼叫事務方法的時候有個坑,同學們需要注意下AOP切不到呼叫事務方法。事務不會生效,解決辦法有幾種,可以搜一下,找一下適合自己的方案。本質問題時類內部呼叫時AOP不會用代理呼叫內部方法。
“關於同類中呼叫事務方法的時候有個坑” 解決方案 1、如果是基於介面動態代理 是沒有問題的,直接使用介面呼叫 2、如果是基於class的動態代理 可以用 AopContext.currentProxy() 解決,注意剝離方法一定是public 修飾 !!
MD5暴露介面
其實我也在想MD5暴露出去的url是不是真的有用,也見到有人提問了。
回答者:
不能說沒作用,如果不加密,使用者擷取了你的訪問地址,他看到了當前秒殺ID為1000,他完全可以推測出其他的秒殺地址,或者說他可以造出一批地址;視訊中秒殺在資料庫中判斷了秒殺時間,其他時間他自然是秒殺不到,但是對資料庫也有一定的衝擊,如果他用定時器或者迴圈秒殺軟體,你的系統承受力是個問題;另一方面對於一些還沒開始的秒殺,他模擬地址以後,完全可以用定時器一直訪問。加密以後由於他拿不到混淆碼,就只能通過點選連結進行秒殺……
簡單理解:通過MD5加密以後,使用者在秒殺之前模擬不出真實的地址,還是有一定作用的。
列舉類
在return new SeckillExecution(seckillId,1,"秒殺成功",successKilled);程式碼中**,我們返回的state和stateInfo引數資訊應該是輸出給前端的,但是我們不想在我們的return程式碼中硬編碼這兩個引數,所以我們應該考慮用列舉的方式將這些常量封裝起來**,
public enum SeckillStatEnum {
SUCCESS(1,"秒殺成功"),
END(0,"秒殺結束"),
REPEAT_KILL(-1,"重複秒殺"),
INNER_ERROR(-2,"系統異常"),
DATE_REWRITE(-3,"資料篡改");
private int state;
private String info;
SeckillStatEnum(int state, String info) {
this.state = state;
this.info = info;
}
public int getState() {
return state;
}
public String getInfo() {
return info;
}
public static SeckillStatEnum stateOf(int index)
{
for (SeckillStatEnum state : values())
{
if (state.getState()==index)
{
return state;
}
}
return null;
}
}
複製程式碼
Web層開發技巧
Restful介面設計學習
之前就已經接觸過RESTful這樣的思想理念的,可是在第一個專案中是沒有用起來的。因為還是不大習慣,怕寫成不倫不類的RESTful介面,打算在第二個專案中將RESTful全部應用起來。
參考博文:kb.cnblogs.com/page/512047…
SpringMVC之前不知道的細節
@DateTimeFormat註解對時間進行格式化!(這個我暫時沒有試驗)
<!--配置spring mvc-->
<!--1,開啟springmvc註解模式
a.自動註冊DefaultAnnotationHandlerMapping,AnnotationMethodHandlerAdapter
b.預設提供一系列的功能:資料繫結,數字和日期的format@NumberFormat,@DateTimeFormat
c:xml,json的預設讀寫支援-->
<mvc:annotation-driven/>
<!--2.靜態資源預設servlet配置-->
<!--
1).加入對靜態資源處理:js,gif,png
2).允許使用 "/" 做整體對映
-->
<mvc:default-servlet-handler/>
複製程式碼
返回統一格式的JSON
之前在Web層與Service中封裝了dto來進行這兩層的資料進行傳輸,而我們一般都是在Controller返回JSON給前端進行解析。
最好的做法就是將JSON的格式也統一化。這樣做就能夠很好地形成規範了!
//將所有的ajax請求返回型別,全部封裝成json資料
public class SeckillResult<T> {
private boolean success;
private T data;
private String error;
public SeckillResult(boolean success, T data) {
this.success = success;
this.data = data;
}
public SeckillResult(boolean success, String error) {
this.success = success;
this.error = error;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
}
複製程式碼
獲取JSON資料方式
之前獲取JSON都是使用object.properties
的方式來獲取的,這次還看到了另一種方式:
JavaScript模組化
之前在專案中寫JS程式碼都是要什麼功能,寫到哪裡的。看了這次視訊,發現JS都可以模組化!!!
JS模組化起來可讀性還是比之前要好的,這是我之前沒有接觸過的,以後寫JS程式碼就要注意了!
下面貼上一段程式碼來感受一下:
/**
* 模組化javaScript
* Created by jianrongsun on 17-5-25.
*/
var seckill = {
// 封裝秒殺相關的ajax的url
URL: {
now: function () {
return "/seckill/time/now";
},
exposer: function (seckillId) {
return "/seckill/" + seckillId + "/exposer";
},
execution: function (seckillId, md5) {
return "/seckill/" + seckillId + "/" + md5 + "/execution";
}
},
// 驗證手機號碼
validatePhone: function (phone) {
return !!(phone && phone.length === 11 && !isNaN(phone));
},
// 詳情頁秒殺業務邏輯
detail: {
// 詳情頁開始初始化
init: function (params) {
console.log("獲取手機號碼");
// 手機號驗證登入,計時互動
var userPhone = $.cookie('userPhone');
// 驗證手機號
if (!seckill.validatePhone(userPhone)) {
console.log("未填寫手機號碼");
// 驗證手機控制輸出
var killPhoneModal = $("#killPhoneModal");
killPhoneModal.modal({
show: true, // 顯示彈出層
backdrop: 'static', // 靜止位置關閉
keyboard: false // 關閉鍵盤事件
});
$("#killPhoneBtn").click(function () {
console.log("提交手機號碼按鈕被點選");
var inputPhone = $("#killPhoneKey").val();
console.log("inputPhone" + inputPhone);
if (seckill.validatePhone(inputPhone)) {
// 把電話寫入cookie
$.cookie('userPhone', inputPhone, {expires: 7, path: '/seckill'});
// 驗證通過 重新整理頁面
window.location.reload();
} else {
// todo 錯誤文案資訊寫到前端
$("#killPhoneMessage").hide().html("<label class='label label-danger'>手機號碼錯誤</label>").show(300);
}
});
} else {
console.log("在cookie中找到了電話號碼,開啟計時");
// 已經登入了就開始計時互動
var startTime = params['startTime'];
var endTime = params['endTime'];
var seckillId = params['seckillId'];
console.log("開始秒殺時間=======" + startTime);
console.log("結束秒殺時間========" + endTime);
$.get(seckill.URL.now(), {}, function (result) {
if (result && result['success']) {
var nowTime = seckill.convertTime(result['data']);
console.log("伺服器當前的時間==========" + nowTime);
// 進行秒殺商品的時間判斷,然後計時互動
seckill.countDown(seckillId, nowTime, startTime, endTime);
} else {
console.log('結果:' + result);
console.log('result' + result);
}
});
}
}
},
handlerSeckill: function (seckillId, mode) {
// 獲取秒殺地址
mode.hide().html('<button class="btn btn-primary btn-lg" id="killBtn">開始秒殺</button>');
console.debug("開始進行秒殺地址獲取");
$.get(seckill.URL.exposer(seckillId), {}, function (result) {
if (result && result['success']) {
var exposer = result['data'];
if (exposer['exposed']) {
console.log("有秒殺地址介面");
// 開啟秒殺,獲取秒殺地址
var md5 = exposer['md5'];
var killUrl = seckill.URL.execution(seckillId, md5);
console.log("秒殺的地址為:" + killUrl);
// 繫結一次點選事件
$("#killBtn").one('click', function () {
console.log("開始進行秒殺,按鈕被禁用");
// 執行秒殺請求,先禁用按鈕
$(this).addClass("disabled");
// 傳送秒殺請求
$.post(killUrl, {}, function (result) {
var killResult = result['data'];
var state = killResult['state'];
var stateInfo = killResult['stateInfo'];
console.log("秒殺狀態" + stateInfo);
// 顯示秒殺結果
mode.html('<span class="label label-success">' + stateInfo + '</span>');
});
});
mode.show();
} else {
console.warn("還沒有暴露秒殺地址介面,無法進行秒殺");
// 未開啟秒殺
var now = seckill.convertTime(exposer['now']);
var start = seckill.convertTime(exposer['start']);
var end = seckill.convertTime(exposer['end']);
console.log("當前時間" + now);
console.log("開始時間" + start);
console.log("結束時間" + end);
console.log("開始倒數計時");
console.debug("開始進行倒數計時");
seckill.countDown(seckillId, now, start, end);
}
} else {
console.error("伺服器端查詢秒殺商品詳情失敗");
console.log('result' + result.valueOf());
}
});
},
countDown: function (seckillId, nowTime, startTime, endTime) {
console.log("秒殺的商品ID:" + seckillId + ",伺服器當前時間:" + nowTime + ",開始秒殺的時間:" + startTime + ",結束秒殺的時間" + endTime);
// 獲取顯示倒數計時的文字域
var seckillBox = $("#seckill-box");
// 獲取時間戳進行時間的比較
nowTime = new Date(nowTime).valueOf();
startTime = new Date(startTime).valueOf();
endTime = new Date(endTime).valueOf();
console.log("轉換後的Date型別當前時間戳" + nowTime);
console.log("轉換後的Date型別開始時間戳" + startTime);
console.log("轉換後的Date型別結束時間戳" + endTime);
if (nowTime < endTime && nowTime > startTime) {
// 秒殺開始
console.log("秒殺可以開始,兩個條件符合");
seckill.handlerSeckill(seckillId, seckillBox);
}
else if (nowTime > endTime) {
alert(nowTime > endTime);
// console.log(nowTime + ">" + startTime);
console.log(nowTime + ">" + endTime);
// 秒殺結束
console.warn("秒殺已經結束了,當前時間為:" + nowTime + ",秒殺結束時間為" + endTime);
seckillBox.html("秒殺結束");
} else {
console.log("秒殺還沒開始");
alert(nowTime < startTime);
// 秒殺未開啟
var killTime = new Date(startTime + 1000);
console.log(killTime);
console.log("開始計時效果");
seckillBox.countdown(killTime, function (event) {
// 事件格式
var format = event.strftime("秒殺倒數計時: %D天 %H時 %M分 %S秒");
console.log(format);
seckillBox.html(format);
}).on('finish.countdown', function () {
// 事件完成後回撥事件,獲取秒殺地址,控制業務邏輯
console.log("準備執行回撥,獲取秒殺地址,執行秒殺");
console.log("倒數計時結束");
seckill.handlerSeckill(seckillId, seckillBox);
});
}
},
cloneZero: function (time) {
var cloneZero = ":00";
if (time.length < 6) {
console.warn("需要拼接時間");
time = time + cloneZero;
return time;
} else {
console.log("時間是完整的");
return time;
}
},
convertTime: function (localDateTime) {
var year = localDateTime.year;
var monthValue = localDateTime.monthValue;
var dayOfMonth = localDateTime.dayOfMonth;
var hour = localDateTime.hour;
var minute = localDateTime.minute;
var second = localDateTime.second;
return year + "-" + monthValue + "-" + dayOfMonth + " " + hour + ":" + minute + ":" + second;
}
};
複製程式碼
高併發效能優化
前三篇已經做好了這個系統了,但是作為一個秒殺系統而言,它能支援的併發量是很低的。那我們現在要考慮怎麼調優。
分析
秒殺的地址介面可以藉助redis來進行優化,不用多次訪問資料庫。
秒殺操作是與資料庫的事務相關的,不能使用快取來替代了。下面給出的方案是需要修改原始碼的,難度是比較難的。
下面分析瓶頸究竟在哪:
- Mysql執行單條的SQL語句其實是非常快的。
- 主要是行級鎖事務的等待,網路的延遲和GC回收!
解決思路:
解決秒殺介面
對於秒殺介面而言,需要使用到Redis將資料進行快取起來。那麼使用者就訪問就不用去訪問資料庫了,我們給Redis快取的資料就好了。
這次使用Jedis來操作Redis.
還有值得 注意的地方:我們可以使用ProtostuffIOUtil來代替JDK的序列化,因為這個的序列化功能比JDK的要做得好很多!
package com.suny.dao.cache;
import com.dyuproject.protostuff.LinkedBuffer;
import com.dyuproject.protostuff.ProtostuffIOUtil;
import com.dyuproject.protostuff.runtime.RuntimeSchema;
import com.suny.entity.Seckill;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
/**
* 操作Redis的dao類
* Created by 孫建榮 on 17-5-27.下午4:44
*/
public class RedisDao {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final JedisPool jedisPool;
private RuntimeSchema<Seckill> schema = RuntimeSchema.createFrom(Seckill.class);
public RedisDao(String ip, int port) {
jedisPool = new JedisPool(ip, port);
}
public Seckill getSeckill(long seckillId) {
// redis操作業務邏輯
try (Jedis jedis = jedisPool.getResource()) {
String key = "seckill:" + seckillId;
// 並沒有實現內部序列化操作
//get->byte[]位元組陣列->反序列化>Object(Seckill)
// 採用自定義的方式序列化
// 快取獲取到
byte[] bytes = jedis.get(key.getBytes());
if (bytes != null) {
// 空物件
Seckill seckill = schema.newMessage();
ProtostuffIOUtil.mergeFrom(bytes, seckill, schema);
// seckill被反序列化
return seckill;
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return null;
}
public String putSeckill(Seckill seckill) {
// set Object(Seckill) -> 序列化 -> byte[]
try (Jedis jedis = jedisPool.getResource()) {
String key = "seckill:" + seckill.getSeckillId();
byte[] bytes = ProtostuffIOUtil.toByteArray(seckill, schema,
LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));
// 超時快取
int timeout=60*60;
return jedis.setex(key.getBytes(), timeout, bytes);
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
return null;
}
}
複製程式碼
<!--匯入連線redis的JAR包-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
<!--新增序列化依賴-->
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.1.1</version>
</dependency>
複製程式碼
RedisDao並不受Mybatis的代理影響,於是是需要我們自己手動建立的。
最終,我們的service邏輯就會變成這樣子:
秒殺操作優化
再次回到我們的秒殺操作,其實需要優化的地方就是我們的GC和行級鎖等待的時間。
我們之前的邏輯是這樣的:先執行減庫存操作,再插入購買成功的記錄
其實,我們可以先插入成功購買的記錄,再執行減庫存的操作!
- 那這兩者有啥區別呢???減庫存的操作會導致行級鎖的等待,而我們先進行insert的話,那麼就不會被行級鎖所干擾了。並且,我們這中兩個操作是在同一個事物中的,並不會出現“超賣”的情況!
關於先執行insert與先執行update的區別,兩個事務同時insert的情況下,沒有鎖競爭,執行速度會快,當兩個事務先update同一行資料,會有一個事務獲得行鎖,鎖在事務提交之前都不會釋放,所以讓鎖被持有的時間最短能提升效率
所以我們service層的邏輯可以改成這樣:
這不是最終的方案,如果為了效能的優化我們還可以將SQL在Mysql中執行,不受Spring的事務來管理。在Mysql使用儲存過程來進行提交效能
-- 秒殺執行儲存過程
DELIMITER $$ -- console ; 轉換為
$$
-- 定義儲存過程
-- 引數: in 引數 out輸出引數
-- row_count() 返回上一條修改型別sql(delete,insert,update)的影響行數
-- row_count:0:未修改資料 ; >0:表示修改的行數; <0: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;
$$
-- 儲存過程定義結束
DELIMITER ;
SET @r_result = -3;
-- 執行儲存過程
CALL execute_seckill(1003, 13502178891, now(), @r_result);
-- 獲取結果
SELECT @r_result;
複製程式碼
Mybatis呼叫儲存過程其實和JDBC是一樣的:
在使用儲存過程的時候,我們需要4個引數,其實result是在儲存過程中被賦值的。我們可以通過MapUtils來獲取相對應的值。這是我之前沒有接觸過的。
最後,對於部署的系統架構應該是這樣子的:
總結
花了點時間看了該視訊教程,覺得還是學到了不少的東西的。之前沒有接觸過優化的相關問題,現在給我開啟了思路,以及學到了不少的開發規範的問題,也是很讚的。如果是初學者的話是可以去學學的。
如果文章有錯的地方歡迎指正,大家互相交流。習慣在微信看技術文章,想要獲取更多的Java資源的同學,可以關注微信公眾號:Java3y