【秒殺系統】零基礎上手秒殺系統(三):搶購介面隱藏 + 單使用者限制頻率
前言
時光飛逝,兩週過去了,是時候繼續填坑了,不然又要被網友噴了。
本文是秒殺系統的第三篇,通過實際程式碼講解,幫助你瞭解秒殺系統設計的關鍵點,上手實際專案。
本篇主要講解秒殺系統中,關於搶購(下單)介面相關的單使用者防刷措施,主要說兩塊內容:
- 搶購介面隱藏
- 單使用者限制頻率(單位時間內限制訪問次數)
當然,這兩個措施放在任何系統中都有用,嚴格來說並不是秒殺系統獨特的設計,所以今天的內容也會比較的通用。
此外,我做了一張流程圖,描述了目前我們實現的秒殺介面下單流程:
前文回顧和文章規劃
- 零基礎上手秒殺系統(一):防止超賣
- 零基礎上手秒殺系統(二):令牌桶限流 + 再談超賣
- 零基礎上手秒殺系統(三):搶購介面隱藏 + 單使用者限制頻率(本篇)
- 零基礎上手秒殺系統:使用Redis快取熱點資料
- 零基礎上手秒殺系統:訊息佇列非同步處理訂單
- ...
歡迎關注我的個人公眾號獲取最全的原創文章:後端技術漫談(二維碼見文章底部)
專案原始碼在這裡
媽媽再也不用擔心只會看文章不會實現啦:
https://github.com/qqxx6661/miaosha
正文
秒殺系統介紹
可以翻閱該系列的第一篇文章,這裡不再回顧:
搶購介面隱藏
在前兩篇文章的介紹下,我們完成了防止超賣商品和搶購介面的限流,已經能夠防止大流量把我們的伺服器直接搞炸,這篇文章中,我們要開始關心一些細節問題。
對於稍微懂點電腦的,又會動歪腦筋的人來說,點選F12開啟瀏覽器的控制檯,就能在點選搶購按鈕後,獲取我們搶購介面的連結。(手機APP等其他客戶端可以抓包來拿到)
一旦壞蛋拿到了搶購的連結,只要稍微寫點爬蟲程式碼,模擬一個搶購請求,就可以不通過點選下單按鈕,直接在程式碼中請求我們的介面,完成下單。所以就有了成千上萬的薅羊毛軍團,寫一些指令碼搶購各種秒殺商品。
他們只需要在搶購時刻的000毫秒,開始不間斷髮起大量請求,覺得比大家在APP上點搶購按鈕要快,畢竟人的速度又極限,更別說APP說不定還要經過幾層前端驗證才會真正發出請求。
所以我們需要將搶購介面進行隱藏,搶購介面隱藏(介面加鹽)的具體做法:
- 每次點選秒殺按鈕,先從伺服器獲取一個秒殺驗證值(介面內判斷是否到秒殺時間)。
- Redis以快取使用者ID和商品ID為Key,秒殺地址為Value快取驗證值
- 使用者請求秒殺商品的時候,要帶上秒殺驗證值進行校驗。
大家先停下來仔細想想,通過這樣的辦法,能夠防住通過指令碼刷介面的人嗎?
能,也不能。
可以防住的是直接請求介面的人,但是隻要壞蛋們把指令碼寫複雜一點,先去請求一個驗證值,再立刻請求搶購,也是能夠搶購成功的。
不過壞蛋們請求驗證值介面,也需要在搶購時間開始後,才能請求介面拿到驗證值,然後才能申請搶購介面。理論上來說在訪問介面的時間上受到了限制,並且我們還能通過在驗證值介面增加更復雜的邏輯,讓獲取驗證值的介面並不快速返回驗證值,進一步拉平普通使用者和壞蛋們的下單時刻。所以介面加鹽還是有用的!
下面我們就實現一種簡單的加鹽介面程式碼,拋磚引玉。
程式碼邏輯實現
程式碼還是使用之前的專案,我們在其上面增加兩個介面:
- 獲取驗證值介面
- 攜帶驗證值下單介面
由於之前我們只有兩個表,一個stock表放庫存商品,一個stockOrder訂單表,放訂購成功的記錄。但是這次涉及到了使用者,所以我們新增使用者表,並且新增一個使用者張三。並且在訂單表中,不僅要記錄商品id,同時要寫入使用者id。
整個SQL結構如下,講究一個簡潔,暫時不加入別的多餘欄位:
-- ----------------------------
-- Table structure for stock
-- ----------------------------
DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '名稱',
`count` int(11) NOT NULL COMMENT '庫存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '樂觀鎖,版本號',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of stock
-- ----------------------------
INSERT INTO `stock` VALUES ('1', 'iphone', '50', '0', '0');
INSERT INTO `stock` VALUES ('2', 'mac', '10', '0', '0');
-- ----------------------------
-- Table structure for stock_order
-- ----------------------------
DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL COMMENT '庫存ID',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名稱',
`user_id` int(11) NOT NULL DEFAULT '0',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '建立時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of stock_order
-- ----------------------------
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_name` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES ('1', '張三');
SQL檔案在開原始碼裡也放了,不用擔心。
獲取驗證值介面
該介面要求傳使用者id和商品id,返回驗證值,並且該驗證值
Controller中新增方法:
/**
* 獲取驗證值
* @return
*/
@RequestMapping(value = "/getVerifyHash", method = {RequestMethod.GET})
@ResponseBody
public String getVerifyHash(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId) {
String hash;
try {
hash = userService.getVerifyHash(sid, userId);
} catch (Exception e) {
LOGGER.error("獲取驗證hash失敗,原因:[{}]", e.getMessage());
return "獲取驗證hash失敗";
}
return String.format("請求搶購驗證hash值為:%s", hash);
}
UserService中新增方法:
@Override
public String getVerifyHash(Integer sid, Integer userId) throws Exception {
// 驗證是否在搶購時間內
LOGGER.info("請自行驗證是否在搶購時間內");
// 檢查使用者合法性
User user = userMapper.selectByPrimaryKey(userId.longValue());
if (user == null) {
throw new Exception("使用者不存在");
}
LOGGER.info("使用者資訊:[{}]", user.toString());
// 檢查商品合法性
Stock stock = stockService.getStockById(sid);
if (stock == null) {
throw new Exception("商品不存在");
}
LOGGER.info("商品資訊:[{}]", stock.toString());
// 生成hash
String verify = SALT + sid + userId;
String verifyHash = DigestUtils.md5DigestAsHex(verify.getBytes());
// 將hash和使用者商品資訊存入redis
String hashKey = CacheKey.HASH_KEY.getKey() + "_" + sid + "_" + userId;
stringRedisTemplate.opsForValue().set(hashKey, verifyHash, 3600, TimeUnit.SECONDS);
LOGGER.info("Redis寫入:[{}] [{}]", hashKey, verifyHash);
return verifyHash;
}
一個Cache常量列舉類CacheKey:
package cn.monitor4all.miaoshadao.utils;
public enum CacheKey {
HASH_KEY("miaosha_hash"),
LIMIT_KEY("miaosha_limit");
private String key;
private CacheKey(String key) {
this.key = key;
}
public String getKey() {
return key;
}
}
程式碼解釋:
可以看到在Service中,我們拿到使用者id和商品id後,會檢查商品和使用者資訊是否在表中存在,並且會驗證現在的時間(我這裡為了簡化,只是寫了一行LOGGER,大家可以根據需求自行實現)。在這樣的條件過濾下,才會給出hash值。並且將Hash值寫入了Redis中,快取3600秒(1小時),如果使用者拿到這個hash值一小時內沒下單,則需要重新獲取hash值。
下面又到了動小腦筋的時間了,想一下,這個hash值,如果每次都按照商品+使用者的資訊來md5,是不是不太安全呢。畢竟使用者id並不一定是使用者不知道的(就比如我這種用自增id儲存的,肯定不安全),而商品id,萬一也洩露了出去,那麼壞蛋們如果再知到我們是簡單的md5,那直接就把hash算出來了!
在程式碼裡,我給hash值加了個字首,也就是一個salt(鹽),相當於給這個固定的字串撒了一把鹽,這個鹽是HASH_KEY("miaosha_hash")
,寫死在了程式碼裡。這樣黑產只要不猜到這個鹽,就沒辦法算出來hash值。
這也只是一種例子,實際中,你可以把鹽放在其他地方, 並且不斷變化,或者結合時間戳,這樣就算自己的程式設計師也沒法知道hash值的原本字串是什麼了。
攜帶驗證值下單介面
使用者在前臺拿到了驗證值後,點選下單按鈕,前端攜帶著特徵值,即可進行下單操作。
Controller中新增方法:
/**
* 要求驗證的搶購介面
* @param sid
* @return
*/
@RequestMapping(value = "/createOrderWithVerifiedUrl", method = {RequestMethod.GET})
@ResponseBody
public String createOrderWithVerifiedUrl(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId,
@RequestParam(value = "verifyHash") String verifyHash) {
int stockLeft;
try {
stockLeft = orderService.createVerifiedOrder(sid, userId, verifyHash);
LOGGER.info("購買成功,剩餘庫存為: [{}]", stockLeft);
} catch (Exception e) {
LOGGER.error("購買失敗:[{}]", e.getMessage());
return e.getMessage();
}
return String.format("購買成功,剩餘庫存為:%d", stockLeft);
}
OrderService中新增方法:
@Override
public int createVerifiedOrder(Integer sid, Integer userId, String verifyHash) throws Exception {
// 驗證是否在搶購時間內
LOGGER.info("請自行驗證是否在搶購時間內,假設此處驗證成功");
// 驗證hash值合法性
String hashKey = CacheKey.HASH_KEY.getKey() + "_" + sid + "_" + userId;
String verifyHashInRedis = stringRedisTemplate.opsForValue().get(hashKey);
if (!verifyHash.equals(verifyHashInRedis)) {
throw new Exception("hash值與Redis中不符合");
}
LOGGER.info("驗證hash值合法性成功");
// 檢查使用者合法性
User user = userMapper.selectByPrimaryKey(userId.longValue());
if (user == null) {
throw new Exception("使用者不存在");
}
LOGGER.info("使用者資訊驗證成功:[{}]", user.toString());
// 檢查商品合法性
Stock stock = stockService.getStockById(sid);
if (stock == null) {
throw new Exception("商品不存在");
}
LOGGER.info("商品資訊驗證成功:[{}]", stock.toString());
//樂觀鎖更新庫存
saleStockOptimistic(stock);
LOGGER.info("樂觀鎖更新庫存成功");
//建立訂單
createOrderWithUserInfo(stock, userId);
LOGGER.info("建立訂單成功");
return stock.getCount() - (stock.getSale()+1);
}
程式碼解釋:
可以看到service中,我們需要驗證了:
- 商品資訊
- 使用者資訊
- 時間
- 庫存
如此,我們便完成了一個擁有驗證的下單介面。
試驗一下介面
我們先讓使用者1,法外狂徒張三登場,發起請求:
http://localhost:8080/getVerifyHash?sid=1&userId=1
得到結果:
控制檯輸出:
別急著下單,我們看一下redis裡有沒有儲存好key:
木偶問題,接下來,張三可以去請求下單了!
http://localhost:8080/createOrderWithVerifiedUrl?sid=1&userId=1&verifyHash=d4ff4c458da98f69b880dd79c8a30bcf
得到輸出結果:
法外狂徒張三搶購成功了!
單使用者限制頻率
假設我們做好了介面隱藏,但是像我上面說的,總有無聊的人會寫一個複雜的指令碼,先請求hash值,再立刻請求購買,如果你的app下單按鈕做的很差,大家都要開搶後0.5秒才能請求成功,那可能會讓指令碼依然能夠在大家前面搶購成功。
我們需要在做一個額外的措施,來限制單個使用者的搶購頻率。
其實很簡單的就能想到用redis給每個使用者做訪問統計,甚至是帶上商品id,對單個商品做訪問統計,這都是可行的。
我們先實現一個對使用者的訪問頻率限制,我們在使用者申請下單時,檢查使用者的訪問次數,超過訪問次數,則不讓他下單!
使用Redis/Memcached
我們使用外部快取來解決問題,這樣即便是分散式的秒殺系統,請求被隨意分流的情況下,也能做到精準的控制每個使用者的訪問次數。
Controller中新增方法:
/**
* 要求驗證的搶購介面 + 單使用者限制訪問頻率
* @param sid
* @return
*/
@RequestMapping(value = "/createOrderWithVerifiedUrlAndLimit", method = {RequestMethod.GET})
@ResponseBody
public String createOrderWithVerifiedUrlAndLimit(@RequestParam(value = "sid") Integer sid,
@RequestParam(value = "userId") Integer userId,
@RequestParam(value = "verifyHash") String verifyHash) {
int stockLeft;
try {
int count = userService.addUserCount(userId);
LOGGER.info("使用者截至該次的訪問次數為: [{}]", count);
boolean isBanned = userService.getUserIsBanned(userId);
if (isBanned) {
return "購買失敗,超過頻率限制";
}
stockLeft = orderService.createVerifiedOrder(sid, userId, verifyHash);
LOGGER.info("購買成功,剩餘庫存為: [{}]", stockLeft);
} catch (Exception e) {
LOGGER.error("購買失敗:[{}]", e.getMessage());
return e.getMessage();
}
return String.format("購買成功,剩餘庫存為:%d", stockLeft);
}
UserService中增加兩個方法:
- addUserCount:每當訪問訂單介面,則增加一次訪問次數,寫入Redis
- getUserIsBanned:從Redis讀出該使用者的訪問次數,超過10次則不讓購買了!不能讓張三做法外狂徒。
@Override
public int addUserCount(Integer userId) throws Exception {
String limitKey = CacheKey.LIMIT_KEY.getKey() + "_" + userId;
String limitNum = stringRedisTemplate.opsForValue().get(limitKey);
int limit = -1;
if (limitNum == null) {
stringRedisTemplate.opsForValue().set(limitKey, "0", 3600, TimeUnit.SECONDS);
} else {
limit = Integer.parseInt(limitNum) + 1;
stringRedisTemplate.opsForValue().set(limitKey, String.valueOf(limit), 3600, TimeUnit.SECONDS);
}
return limit;
}
@Override
public boolean getUserIsBanned(Integer userId) {
String limitKey = CacheKey.LIMIT_KEY.getKey() + "_" + userId;
String limitNum = stringRedisTemplate.opsForValue().get(limitKey);
if (limitNum == null) {
LOGGER.error("該使用者沒有訪問申請驗證值記錄,疑似異常");
return true;
}
return Integer.parseInt(limitNum) > ALLOW_COUNT;
}
試一試介面
使用前文用的JMeter做併發訪問介面30次,可以看到下單了10次後,不讓再購買了:
大功告成了。
能否不用Redis/Memcached實現使用者訪問頻率統計
且慢,如果你說你不願意用redis,有什麼辦法能夠實現訪問頻率統計嗎,有呀,如果你放棄分散式的部署服務,那麼你可以在記憶體中儲存訪問次數,比如:
- Google Guava的記憶體快取
- 狀態模式
不知道大家的設計模式複習的怎麼樣了,如果沒有複習到狀態模式,可以先去看看狀態模式的定義。狀態模式很適合實現這種訪問次數限制場景。
我的部落格和公眾號(後端技術漫談)裡,寫了個《設計模式自習室》系列,詳細介紹了每種設計模式,大家有興趣可可以看看。【設計模式自習室】開篇:為什麼要有設計模式?
這裡我就不實現了,畢竟咱們還是分散式秒殺服務為主,不過引用一個部落格的例子,大家感受下狀態模式的實際應用:
https://www.cnblogs.com/java-my-life/archive/2012/06/08/2538146.html
考慮一個線上投票系統的應用,要實現控制同一個使用者只能投一票,如果一個使用者反覆投票,而且投票次數超過5次,則判定為惡意刷票,要取消該使用者投票的資格,當然同時也要取消他所投的票;如果一個使用者的投票次數超過8次,將進入黑名單,禁止再登入和使用系統。
public class VoteManager {
//持有狀體處理物件
private VoteState state = null;
//記錄使用者投票的結果,Map<String,String>對應Map<使用者名稱稱,投票的選項>
private Map<String,String> mapVote = new HashMap<String,String>();
//記錄使用者投票次數,Map<String,Integer>對應Map<使用者名稱稱,投票的次數>
private Map<String,Integer> mapVoteCount = new HashMap<String,Integer>();
/**
* 獲取使用者投票結果的Map
*/
public Map<String, String> getMapVote() {
return mapVote;
}
/**
* 投票
* @param user 投票人
* @param voteItem 投票的選項
*/
public void vote(String user,String voteItem){
//1.為該使用者增加投票次數
//從記錄中取出該使用者已有的投票次數
Integer oldVoteCount = mapVoteCount.get(user);
if(oldVoteCount == null){
oldVoteCount = 0;
}
oldVoteCount += 1;
mapVoteCount.put(user, oldVoteCount);
//2.判斷該使用者的投票型別,就相當於判斷對應的狀態
//到底是正常投票、重複投票、惡意投票還是上黑名單的狀態
if(oldVoteCount == 1){
state = new NormalVoteState();
}
else if(oldVoteCount > 1 && oldVoteCount < 5){
state = new RepeatVoteState();
}
else if(oldVoteCount >= 5 && oldVoteCount <8){
state = new SpiteVoteState();
}
else if(oldVoteCount > 8){
state = new BlackVoteState();
}
//然後轉調狀態物件來進行相應的操作
state.vote(user, voteItem, this);
}
}
public class Client {
public static void main(String[] args) {
VoteManager vm = new VoteManager();
for(int i=0;i<9;i++){
vm.vote("u1","A");
}
}
}
結果:
總結
本專案的程式碼開源在了Github,大家隨意使用:
https://github.com/qqxx6661/miaosha
最後,感謝大家的喜愛。
希望大家多多支援我的公主號:後端技術漫談。
參考
- https://cloud.tencent.com/developer/article/1488059
- https://juejin.im/post/5dd09f5af265da0be72aacbd
- https://zhenganwen.top/posts/30bb5ce6/
- https://www.cnblogs.com/java-my-life/archive/2012/06/08/2538146.html
關注我
我是一名後端開發工程師。
主要關注後端開發,資料安全,物聯網,邊緣計算方向,歡迎交流。
各大平臺都可以找到我
- 微信公眾號:後端技術漫談
- Github:@qqxx6661
- CSDN:@Rude3knife
- 知乎:@後端技術漫談
- 簡書:@蠻三刀把刀
- 掘金:@蠻三刀把刀
原創部落格主要內容
- 後端開發技術
- Java面試知識點
- 設計模式/資料結構
- LeetCode/劍指offer 演算法題解析
- SpringBoot/SpringCloud入門實戰系列
- 資料分析/資料爬蟲
- 逸聞趣事/好書分享/個人生活
個人公眾號:後端技術漫談
如果文章對你有幫助,不妨收藏,轉發,在看起來~