最近把SSM框架的基礎知識擼了一遍,跟着github上的這個項目 ,實現了一下秒殺系統,並對這個項目中存在的問題進行了 一些小小的改進,記錄一下。整個項目用到了Spring+SpringMVC+Mybatis+Redis框架,如果是剛學SSM框架希望找個小項目練手的,可以跟着這個項目來練練手,感覺涉及的知識還是很全面的。項目源碼已上傳到GitHub:https://github.com/MeteorCh/SecKill,需要的同學自取。
一、項目功能及涉及知識點
項目的整體業務流程如下(感覺自己畫的可能不是很標準,表達意思即可)
1.數據庫
項目涉及三個數據表
(1)用戶表user
(2)商品表seckill
(3)秒殺成功記錄表success_seckilled
2.登錄模塊
2.1 功能描述
在這裏使用了Spring的攔截器對訪問的網址進行攔截,如果沒登錄,就跳轉到登錄頁面登錄。如果輸入的用戶名和密碼都正確,則將用戶名和用戶名用MD5加密的token寫入Cookie,下次登錄時,首先判斷Cookie的登錄信息正不正確,正確的話自動登錄。此外,當用戶訪問登錄頁面時,如果存在cookie,則直接跳轉到列表頁,這些邏輯都寫在LoginInterceptor類中,具體內容下載源碼查看。登錄頁面如下:
2.2 涉及知識點
登錄模塊涉及的知識點主要有:Cookie、Session、SpringMVC攔截器、MD5驗證
3.秒殺商品列表模塊
3.1功能描述
展示所有的秒殺商品,需要注意的是,從數據庫中查找商品時,應把庫存數量爲0的過濾掉。秒殺商品列表頁面如下:
3.2 涉及知識
這個主要就涉及一些簡單的JSP和MyBatis的操作知識。沒有什麼複雜的。
4.商品詳情頁
4.1功能描述
點擊秒殺商品列表頁中的詳情頁,顯示秒殺商品的詳情並提供秒殺通道。這裏,是整個項目的核心,也是高併發的地方之一。所以,這裏使用了Redis作爲緩存。查詢商品詳情時,先去Redis中查找,如果沒有,則去數據庫中查並將結果寫入到Redis,以便下次查找的時候直接從Redis中查找。界面如下:
4.2涉及知識
這裏涉及的知識有:Redis緩存、緩存穿透的處理
5.秒殺
5.1功能描述
用戶點擊秒殺的時候,會首先寫入一條記錄到success_seckilled表中,如果寫入失敗,則說明是重複秒殺。如果寫入成功,再去seckill表中,將對應商品的數量-1,此處爲了防止超賣,需要限制-1時,剩餘商品的數量要大於0。
此處,爲了防止數據被篡改,同樣也需要同時攜帶商品ID用MD5加密的密文,並在後端判斷數據是否被篡改。最後,需要將秒殺的結果以Json的形式返回給瀏覽器,並在客戶端進行顯示。秒殺結果界面如下:
5.2涉及知識
這裏面也是涉及到Redis的 一些知識。具體在第二節討論。
二、問題及解決
原項目中存在這以下問題,我解決了一下,具體如下:
1.自動登錄
可以利用Cookie和Session來實現自動登錄。我這裏保存的Cookie有兩個:
其中UserKey爲用戶名,SsID爲UserKey通過MD5加密的結果。這樣,在後端,就可以通過判斷UserKey通過MD5加密的結果是否和SsID相等,來判斷是否能自動登錄。
2.緩存穿透的問題
緩存穿透是指,請求一條不可能存在的數據,請求時先去緩存中找,不存在,再去數據庫中找,數據庫中也不存在。這樣的話,緩存就沒有意義了。我這裏的解決方案是,一個商品詳情的請求,如果從數據庫中找不存在,首先去數據庫中找,如果結果爲空,仍然把這個數據存儲到Redis中,下次請求的時候,就直接從Redis緩存中找了。但是,用這種方法,這種數據的緩存有效期要儘量短一些,防止增加了數據一直查不到的情況。(我設置的是,有效商品的緩存有效期爲1小時,無效請求的有效期爲3分鐘)。
3.庫存爲0仍然可以顯示秒殺頁
我感覺這個是原項目特別需要完善的一個地方。因爲Redis緩存存儲的商品信息,自從寫入就一直沒有變化。當商品搶購完了以後,用戶點擊詳情頁,仍然可以進入到秒殺頁面,體驗不是很好。所以,我這裏的處理方法是,當商品庫存爲0後,去主動更新一下Redis緩存中對象的庫存值。在請求時,判斷從Redis中取到的商品庫存是否爲0,如果爲0,則跳轉到另一個頁面。
4.錯誤頁面的處理
有時候,我們再請求的過程中會出現異常,然後會在瀏覽器顯示異常的信息,看起來不是很好。我們可以通過簡單的配置web.xml文件,讓出現錯誤時,跳轉到我們自定義的錯誤頁面,配置信息如下:
<error-page>
<error-code>404</error-code>
<location>/error</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/error</location>
</error-page>
<error-page>
<error-code>400</error-code>
<location>/error</location>
</error-page>
三、併發測試
既然是秒殺系統,那我們需要做一個高併發的測試,看系統的性能到底怎麼樣。我這裏用多線程去模擬請求,測試高併發。代碼如下(我這裏是寫在測試類中):
@RunWith(SpringJUnit4ClassRunner.class)
//告訴junit spring的配置文件
@ContextConfiguration({"classpath:spring-dao.xml",
"classpath:spring-service.xml"})
public class Tester {
@Autowired
SecKillService secKillService;
@Autowired
UserService userService;
/**
* 插入用戶
* @throws InterruptedException
*/
//@Test
public void insertUser() throws InterruptedException {
List<User> users=new ArrayList<>(2000);
for (int i=500;i<2000;++i){
User user=new User("user"+i,"12345");
users.add(user);
}
userService.insertUsers(users);
}
/**
*測試併發的入口
*/
@Test
public void simulateConcurrency(){
try {
calculateTime(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
/**
* 模擬請求
* @param userNum 併發數
* @throws InterruptedException
*/
public void calculateTime(int userNum) throws InterruptedException {
long startTime=System.currentTimeMillis();
ExecutorService service= Executors.newFixedThreadPool(userNum);
for (int i=0;i<userNum;++i){
final int num=i;
service.execute(new Thread(new Runnable() {
@Override
public void run() {
try {
request(num);
} catch (IOException e) {
e.printStackTrace();
}
}
}));
}
service.shutdown();
service.awaitTermination(1, TimeUnit.HOURS);
long endTime=System.currentTimeMillis();
System.out.println("耗費時間:"+(endTime-startTime)/1000);
}
/**
* 請求
* @param i
* @throws IOException
*/
public void request(int i) throws IOException {
/**
* 耗時統計:500併發10秒
*/
//高併發請求測試
int secID=1001;
String urlPath = "http://localhost:8080/SecondKill/meteor/"+secID+"/"
+CookieUtility.getMd5(secID)+"/execution";
String userKey = "user"+i;
String result = "";
CookieStore cookieStore = new BasicCookieStore();
CloseableHttpClient httpClient = HttpClients.custom()
.setDefaultCookieStore(cookieStore)
.build();
RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(1000000).setConnectTimeout(1000000).build();
try {
HttpPost post = new HttpPost(urlPath);//這裏發送post請求
post.setConfig(requestConfig);
List<BasicClientCookie> cookies=createCookie(userKey);
for (BasicClientCookie cookie:cookies)
cookieStore.addCookie(cookie);
// 通過請求對象獲取響應對象
HttpResponse response = httpClient.execute(post);
// 判斷網絡連接狀態碼是否正常(0--200都數正常)
result = EntityUtils.toString(response.getEntity(), "utf-8");
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
List<BasicClientCookie> createCookie(String userName){
List<BasicClientCookie> cookies=new ArrayList<>();
//用戶名
BasicClientCookie cookie = new BasicClientCookie(ConstValue.USER_KEY, userName);
cookie.setDomain("localhost");
cookie.setPath("/SecondKill/");
cookies.add(cookie);
//ssid
BasicClientCookie ssID = new BasicClientCookie(ConstValue.SS_ID,CookieUtility.getMd5(userName));
ssID.setDomain("localhost");
ssID.setPath("/SecondKill/");
cookies.add(cookie);
return cookies;
}
}
使用時,首先通過insertUser插入2000個用戶,再調用simulateConcurrency函數,開多線程去模擬高併發,計算請求的時間。我這裏的測試是,500的併發量,耗時是10秒左右。