時光飛逝,兩週過去了,是時候繼續填坑了,否則又要被網友噴了。html
本文是秒殺系統的第三篇,經過實際代碼講解,幫助你瞭解秒殺系統設計的關鍵點,上手實際項目。前端
本篇主要講解秒殺系統中,關於搶購(下單)接口相關的單用戶防刷措施,主要說兩塊內容:java
固然,這兩個措施放在任何系統中都有用,嚴格來講並非秒殺系統獨特的設計,因此今天的內容也會比較的通用。git
此外,我作了一張流程圖,描述了目前咱們實現的秒殺接口下單流程:程序員
歡迎關注個人我的公衆號獲取最全的原創文章:後端技術漫談(二維碼見文章底部)github
媽媽不再用擔憂只會看文章不會實現啦:面試
https://github.com/qqxx6661/miaosharedis
在前兩篇文章的介紹下,咱們完成了防止超賣商品和搶購接口的限流,已經可以防止大流量把咱們的服務器直接搞炸,這篇文章中,咱們要開始關心一些細節問題。算法
對於稍微懂點電腦的,又會動歪腦筋的人來講,點擊F12打開瀏覽器的控制檯,就能在點擊搶購按鈕後,獲取咱們搶購接口的連接。(手機APP等其餘客戶端能夠抓包來拿到)後端
一旦壞蛋拿到了搶購的連接,只要稍微寫點爬蟲代碼,模擬一個搶購請求,就能夠不經過點擊下單按鈕,直接在代碼中請求咱們的接口,完成下單。因此就有了成千上萬的薅羊毛軍團,寫一些腳本搶購各類秒殺商品。
他們只須要在搶購時刻的000毫秒,開始不間斷髮起大量請求,以爲比你們在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', '張三');
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,對單個商品作訪問統計,這都是可行的。
咱們先實現一個對用戶的訪問頻率限制,咱們在用戶申請下單時,檢查用戶的訪問次數,超過訪問次數,則不讓他下單!
咱們使用外部緩存來解決問題,這樣即使是分佈式的秒殺系統,請求被隨意分流的狀況下,也能作到精準的控制每一個用戶的訪問次數。
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中增長兩個方法:
@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,有什麼辦法可以實現訪問頻率統計嗎,有呀,若是你放棄分佈式的部署服務,那麼你能夠在內存中存儲訪問次數,好比:
不知道你們的設計模式複習的怎麼樣了,若是沒有複習到狀態模式,能夠先去看看狀態模式的定義。狀態模式很適合實現這種訪問次數限制場景。
個人博客和公衆號(後端技術漫談)裏,寫了個《設計模式自習室》系列,詳細介紹了每種設計模式,你們有興趣可能夠看看。【設計模式自習室】開篇:爲何要有設計模式?
這裏我就不實現了,畢竟我們仍是分佈式秒殺服務爲主,不過引用一個博客的例子,你們感覺下狀態模式的實際應用:
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
最後,感謝你們的喜好。
但願你們多多支持個人公主號:後端技術漫談。
我是一名後端開發工程師。
主要關注後端開發,數據安全,物聯網,邊緣計算方向,歡迎交流。
公衆號:後端技術漫談.jpg
若是文章對你有幫助,不妨收藏,轉發,在看起來~