教你从0到1搭建秒杀系统-抢购接口隐藏与单用户限制频率
在前兩篇文章的介紹下,我們完成了防止超賣商品和搶購接口的限流,已經能夠防止大流量把我們的服務器直接搞炸,這篇文章中,我們要開始關心一些細節問題。對于稍微懂點電腦的,點擊F12打開瀏覽器的控制臺,就能在點擊搶購按鈕后,獲取我們搶購接口的鏈接(手機APP等其他客戶端可以抓包來拿到)。一旦拿到了搶購的鏈接,只要稍微寫點爬蟲代碼,模擬一個搶購請求,就可以不通過點擊下單按鈕,直接在代碼中請求我們的接口,完成下單。他們只需要在搶購時刻的0毫秒,開始不間斷發起大量請求,絕對比大家在APP上點搶購按鈕要快,畢竟人的速度有限,更別說APP說不定還要經過幾層前端驗證才會真正發出請求。本篇我們從兩個方面對所出現的問題來進行相關限制:搶購接口隱藏和單用戶限制頻率。
搶購接口隱藏
如果在秒殺活動活動開始前不知道具體的接口,那是不是就不能發起請求了?所以我們可以將搶購接口進行隱藏,搶購接口隱藏(接口加鹽)的具體做法:
其實這種方式可以防住的是直接請求接口的人,但是只要把腳本寫復雜一點,先去請求一個驗證值,再立刻請求搶購,也是能夠搶購成功的。不過請求驗證值接口,也需要在搶購時間開始后,才能請求接口拿到驗證值,然后才能申請搶購接口。理論上來說在訪問接口的時間上受到了限制,并且我們還能通過在驗證值接口增加更復雜的邏輯,讓獲取驗證值的接口并不快速返回驗證值,進一步拉平普通用戶和惡意請求的下單時刻。所以接口加鹽還是有用的!下面我們就實現一種簡單的加鹽接口代碼,拋磚引玉。
接口加鹽實現
代碼還是使用之前的項目,我們在其上面增加兩個接口:獲取驗證值接口和攜帶驗證值下單接口。之前我們只有兩個表,一個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', '王二');獲取驗證值接口
該接口要求傳用戶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());// 生成hashString verify = SALT + sid + userId;String verifyHash = DigestUtils.md5DigestAsHex(verify.getBytes());// 將hash和用戶商品信息存入redisString hashKey = CacheKey.HASH_KEY.getKey() + "_" + sid + "_" + userId;stringRedisTemplate.opsForValue().set(hashKey, verifyHash, 3600, TimeUnit.SECONDS);LOGGER.info("Redis寫入:[{}] [{}]", hashKey, verifyHash);return verifyHash; }Redis的Cache常量枚舉類CacheKey如下:
public enum CacheKey {HASH_KEY("miaosha_v1_user_hash"),LIMIT_KEY("miaosha_v1_user_limit");private String key;private CacheKey(String key) {this.key = key;}public String getKey() {return key;} }代碼整體來說比較簡單,拿到用戶id和商品id后,檢查商品和用戶信息是否在表中存在,并且會驗證現在的時間(我這里為了簡化,只是寫了一行LOGGER,大家可以根據需求自行實現)。在這樣的條件過濾下,才會給出hash值,并且將Hash值寫入了Redis中,緩存3600秒(1小時),如果用戶拿到這個hash值一小時內沒下單,則需要重新獲取hash值。
這里有一個問題需要大家思考一下:為什么verify 除了使用商品id和用戶id還要額外加一個SALT 再使用MD5加密得到verifyHash ?
其實用戶id并不一定是用戶不知道的(就比如我這種用自增id存儲的,肯定不安全),而商品id,萬一也泄露了出去,那么別人就可以通過md5直接就hash算出來。隨意這里給前面加了前綴,也就是一個salt(鹽),相當于給這個固定的字符串撒了一把鹽,寫死在了代碼里。這樣只要不猜到這個鹽,就沒辦法算出來verifyHash值。當然,我這里只是其中一種方式,大家在使用的時候可以依據自己的方式,比如可以結合時間戳等來保證verifyHash不容易被計算出來。
攜帶驗證值下單接口
用戶在前臺拿到了驗證值后,點擊下單按鈕,前端攜帶著特征值,即可進行下單操作。Controller中添加要求驗證的搶購接口:
@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); }在上面的方法中,我們驗證了時間,驗證值匹配,用戶信息,商品信息和庫存。這樣一個簡單的帶有驗證的下單接口就完成了。接下里讓我們來實際進行調用看一下情況。
接口測試
我們使用postman調用getVerifyHash獲取請求搶購驗證hash值:
我們去redis看一下這個值已經存在其中:
同時在控制臺看出各種驗證都是通過的:
有了這個驗證值,接下來進行下單操作看是否可以下單成功:
可以看出我們去拿不驗證通過并且成功下單。
單用戶限制頻率時間
假設我們做好了接口隱藏,但是像我上面說的,總有無聊的人會寫一個復雜的腳本,先請求hash值,再立刻請求購買,如果你的app下單按鈕做的很差,大家都要開搶后0.5秒才能請求成功,那可能會讓腳本依然能夠在大家前面搶購成功。這個時候我們需要在做一個額外的措施,來限制單個用戶的搶購頻率。實現方式其實也很簡單:用redis給每個用戶做訪問統計。
我們使用外部緩存來解決問題,這樣即便是分布式的秒殺系統,請求被隨意分流的情況下,也能做到精準的控制每個用戶的訪問次數。這里選擇redis。同樣在Controller中添加要求驗證的搶購接口 + 單用戶限制訪問頻率的方法:
@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和getUserIsBanned。addUserCount進行每當訪問訂單接口,則增加一次訪問次數,寫入Redis;getUserIsBanned從Redis讀出該用戶的訪問次數,超過一定次數則不讓購買了,假設我們這里規定最多11次。代碼如下:
@Overridepublic 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;}@Overridepublic 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進行同一用戶20次的訪問量,看一下執行結果:
可以看到到第十一次的時候就不允許搶購了。所以我們實現了統一用戶訪問頻率的攔截。
猜你感興趣:
教你從0到1搭建秒殺系統-防超賣
教你從0到1搭建秒殺系統-限流
教你從0到1搭建秒殺系統-搶購接口隱藏與單用戶限制頻率
教你從0到1搭建秒殺系統-緩存與數據庫雙寫一致
教你從0到1搭建秒殺系統-Canal快速入門(番外篇)
教你從0到1搭建秒殺系統-訂單異步處理
更多文章請點擊:更多…
參考文章:
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
總結
以上是生活随笔為你收集整理的教你从0到1搭建秒杀系统-抢购接口隐藏与单用户限制频率的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 教你从0到1搭建秒杀系统-限流
- 下一篇: 教你从0到1搭建秒杀系统-缓存与数据库双