整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          Java秒殺系統實戰系列-待秒殺商品列表與詳情功能開

          Java秒殺系統實戰系列-待秒殺商品列表與詳情功能開發

          文是“Java秒殺系統實戰系列文章”的第四篇,從這篇文章開始我們將進入該秒殺系統相關業務模塊的代碼實戰!本文將首先從最簡單的業務模塊入手,即如何實現“獲取待秒殺商品的列表以及查看待秒殺的商品詳情”功能!

          對于“待秒殺商品列表及其詳情的展示”這一功能,我們將采用目前比較流行的mvc開發模式來實現!值得一提的是,這一功能模塊涉及的主要數據庫表為“商品信息表item”、“待秒殺商品信息item_kill”。

          “待秒殺商品列表”代碼實戰

          1、首先是在 ItemController控制器中開發“獲取待秒殺商品列表”的請求方法,其源代碼如下所示:

          //獲取商品列表
          @RequestMapping(value={"/","/index",prefix+"/list",prefix+"/index.html"},method=RequestMethod.GET)
          public String list(ModelMap modelMap){
           try {
           //獲取待秒殺商品列表
           List<ItemKill> list=itemService.getKillItems();
           modelMap.put("list",list);
           
           log.info("獲取待秒殺商品列表-數據:{}",list);
           }catch(Exception e){
           log.error("獲取待秒殺商品列表-發生異常:",e.fillInStackTrace());
           return "redirect:/base/error";
           }
           return"list";
          }
          

          控制器的這一方法在獲取到待秒殺商品的列表信息后,將通過modelMap的形式將數據列表返回給到前端的頁面list.jsp中進行渲染!其中,itemService.getKillItems() 主要用于獲取待秒殺商品的列表信息,其源代碼如下所示:

          @Autowired
          private ItemKillMapper itemKillMapper;
          //獲取待秒殺商品列表
          @Override
          public List<ItemKill> getKillItems() throwsException {
           return itemKillMapper.selectAll();
          }
          

          2、緊接著是開發 itemKillMapper.selectAll() 方法,其主要是基于Mybatis在配置文件中寫動態Sql,該Sql的作用在于“獲取待秒殺商品的列表”,其源代碼如下所示:

           <!--查詢待秒殺的活動商品列表-->
           <select id="selectAll"resultType="com.debug.kill.model.entity.ItemKill">
           SELECT
           a.*,
           b.name AS itemName,
           (
           CASE WHEN (now() BETWEEN a.start_time AND a.end_time AND a.total > 0)
           THEN 1
           ELSE 0
           END
           ) AS canKill
           FROM item_kill AS a LEFT JOIN item AS b ON b.id=a.item_id
           WHERE a.is_active=1
           </select>
          

          在這里的Sql,Debug是采用了Left Join左關聯查詢的方式獲取列表信息,目的是為了獲取“商品信息表”中的商品信息,如“商品名稱”等等。

          值得一提的是,在這里Debug還使用了一個小技巧,即采用一個字段 canKill 來表示當前“待秒殺的商品”是否可以被秒殺/被搶購!其判斷的標準為:

          當待秒殺的商品的剩余數量/庫存,即 total 字段的取值大于0時,并且 “當前的服務器時間now()處于待秒殺商品的搶購開始時間 和 搶購結束時間的范圍內”時,canKill的取值將為1,即代表可以被搶購或者被秒殺。否則canKill的取值將為0。

          3、至此,“待秒殺商品列表”這一功能模塊的后端代碼開發已經完成了!前端發起請求后,請求將首先到達controller,通過請求路徑url映射到某個方法進行調用,controller的方法首先會進行最基本的數據校驗,然后通過調用service提供的接口獲取真正的業務數據,最后是在service中執行真正的dao層層面的數據查詢或者數據操作邏輯,最終完成整個業務流的操作。

          4、接下來是開發一個頁面list.jsp用于展示“待秒殺商品列表的信息”,下面展示了該頁面的部分核心源碼,如下圖所示:

          從該代碼中可以看出,當canKill字段取值為1時,將可以點擊“詳情”進行查看;否則,將會提示相應的信息!即“判斷是否可以秒殺”的邏輯Debug是將其放在了后端來實現!

          5、至此,“獲取待秒殺商品列表”這一功能模塊的前后端代碼實戰已經完畢了,點擊運行整個項目,將整個系統運行在外置的tomcat服務器中,觀察控制臺的輸出信息,如果沒有報錯,這說明整個系統的代碼在語法級別層面是木有問題的。如下圖所示為整個秒殺系統、項目在運行起來之后的首頁:

          雖然不是很美觀,但是Debug覺得還是湊合著用吧 哈哈!!


          “待秒殺商品詳情”代碼實戰

          1、接下來是點擊“詳情”,查看“待秒殺商品的詳情信息”,對于這個功能模塊,其實還是比較簡單的,其核心主要是根據“主鍵”進行查詢。

          同樣的道理,首先需要在ItemController控制器中開發接收前端請求的功能方法,其源代碼如下所示:

          /**
           * 獲取待秒殺商品的詳情
           * @return
           */
          @RequestMapping(value=prefix+"/detail/{id}",method=RequestMethod.GET)
          public String detail(@PathVariable Integer id,ModelMap modelMap){
           if(id==null || id<=0){
           return "redirect:/base/error";
           }
           try {
           ItemKill detail=itemService.getKillDetail(id);
           modelMap.put("detail",detail);
           }catch(Exception e){
           log.error("獲取待秒殺商品的詳情-發生異常:id={}",id,e.fillInStackTrace());
           return "redirect:/base/error";
           }
           return"info";
          }
          

          該控制器的方法在獲取到待秒殺商品的詳情后,將通過modelMap把詳情信息塞回info.jsp前端頁面中進行渲染展示!

          2、緊接著是itemService.getKillDetail(id) 的開發,即用于獲取“待秒殺商品的詳情”,其源代碼如下所示:

          /**
           * 獲取待秒殺商品詳情
           */
          @Override
          public ItemKill getKillDetail(Integer id) throws Exception{
           ItemKill entity=itemKillMapper.selectById(id);
           if(entity==null){
           throw new Exception("獲取秒殺詳情-待秒殺商品記錄不存在");
           }
           return entity;
          }
          

          其中,itemKillMapper.selectById(id);主要是基于Mybatis在配置文件中寫動態Sql,該Sql的主要功能為根據主鍵查詢待秒殺商品的詳情,其源代碼如下所示:


          <!--獲取秒殺詳情-->
          <select id="selectById" resultType="com.debug.kill.model.entity.ItemKill">
           SELECT
           a.*,
           b.name AS itemName,
           (
           CASE WHEN (now() BETWEEN a.start_time AND a.end_time AND a.total > 0)
           THEN 1
           ELSE 0
           END
           ) AS canKill
           FROM item_kill AS a LEFT JOIN item AS b ON b.id=a.item_id
           WHERE a.is_active=1 AND a.id=#{id}
          </select>
          

          從該Sql中不難看出,其實就是在“獲取待秒殺商品列表”的Sql中加入“主鍵的精準查詢”!

          3、最后是在頁面info.jsp渲染展示該詳情信息,如下圖所示為該頁面的部分核心源代碼:

          從該頁面的部分核心源代碼中可以看出,為了避免有人“跳過頁面的請求,直接惡意刷后端接口”,在該頁面仍然再次進行了一次判斷(在后面執行“搶購/秒殺”請求時,后端接口還會再次進行判斷的,所有這些都是為了安全考慮!)

          4、至此,關于“待秒殺商品的詳情展示”的功能的前后端代碼實戰已經完成了!再次將整個系統/項目運行在外置的tomcat服務器中,點擊列表頁中的“詳情”按鈕,可以看到待秒殺商品的詳情信息,如下圖所示:

          至此,本文所要分享介紹的內容已經完成了,即主要分享介紹了“獲取待秒殺商品的列表”和“查看待秒殺商品的詳情”功能!

          Debug有話說

          1、目前,這一秒殺系統的整體構建與代碼實戰已經全部完成了,完整的源代碼數據庫地址可以來這里下載:

          https://gitee.com/steadyjack/SpringBoot-SecondKill

          記得Fork跟Star啊!!!

          2、由于相應的博客的更新可能并不會很快,故而如果有想要快速入門以及實戰整套系統的,可以考慮聯系Debug獲取這一“Java秒殺系統”的完整視頻教程(課程是收費的!),當然,大家也可以點擊下面這個鏈接 :

          https://gitee.com/steadyjack/SpringBoot-SecondKill

          聯系Debug或者加入相應的技術交流群進行交流!

          3、實戰期間有任何問題都可以留言或者與Debug聯系、交流。

          推薦閱讀

          Java商城秒殺系統的設計與實戰教程(SpringBoot版)

          Java秒殺系統實戰系列-構建SpringBoot多模塊項目

          Java秒殺系統實戰系列-整體業務流程介紹與數據庫設計

          Java秒殺系統實戰系列-待秒殺商品列表與詳情功能開發

          者:因為熱愛所以堅持ing來源: 蘇三說技術

          前言

          高并發下如何設計秒殺系統?這是一個高頻面試題。這個問題看似簡單,但是里面的水很深,它考查的是高并發場景下,從前端到后端多方面的知識。

          秒殺一般出現在商城的促銷活動中,指定了一定數量(比如:10個)的商品(比如:手機),以極低的價格(比如:0.1元),讓大量用戶參與活動,但只有極少數用戶能夠購買成功。這類活動商家絕大部分是不賺錢的,說白了是找個噱頭宣傳自己。

          雖說秒殺只是一個促銷活動,但對技術要求不低。下面給大家總結一下設計秒殺系統需要注意的9個細節。



          1. 瞬時高并發

          一般在秒殺時間點(比如:12點)前幾分鐘,用戶并發量才真正突增,達到秒殺時間點時,并發量會達到頂峰。

          但由于這類活動是大量用戶搶少量商品的場景,必定會出現狼多肉少的情況,所以其實絕大部分用戶秒殺會失敗,只有極少部分用戶能夠成功。

          正常情況下,大部分用戶會收到商品已經搶完的提醒,收到該提醒后,他們大概率不會在那個活動頁面停留了,如此一來,用戶并發量又會急劇下降。所以這個峰值持續的時間其實是非常短的,這樣就會出現瞬時高并發的情況,下面用一張圖直觀地感受一下流量的變化:



          像這種瞬時高并發的場景,傳統的系統很難應對,我們需要設計一套全新的系統。可以從以下幾個方面入手:

          1. 頁面靜態化
          2. CDN加速
          3. 緩存
          4. mq異步處理
          5. 限流
          6. 分布式鎖

          2. 頁面靜態化

          活動頁面是用戶流量的第一入口,所以是并發量最大的地方。

          如果這些流量都能直接訪問服務端,恐怕服務端會因為承受不住這么大的壓力,而直接掛掉。

          活動頁面絕大多數內容是固定的,比如:商品名稱、商品描述、圖片等。為了減少不必要的服務端請求,通常情況下,會對活動頁面做靜態化處理。用戶瀏覽商品等常規操作,并不會請求到服務端。只有到了秒殺時間點,并且用戶主動點了秒殺按鈕才允許訪問服務端。

          這樣能過濾大部分無效請求。

          但只做頁面靜態化還不夠,因為用戶分布在全國各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很遠,網速各不相同。

          如何才能讓用戶最快訪問到活動頁面呢?

          這就需要使用CDN,它的全稱是Content Delivery Network,即內容分發網絡。

          使用戶就近獲取所需內容,降低網絡擁塞,提高用戶訪問響應速度和命中率。

          3. 秒殺按鈕

          大部分用戶怕錯過秒殺時間點,一般會提前進入活動頁面。此時看到的秒殺按鈕是指紋,不可點擊的。只有到了秒殺時間點那一時刻,秒殺按鈕才會自動點亮,變成可點擊的。

          但此時很多用戶已經迫不及待了,通過不停刷新頁面,爭取在第一時間看到秒殺按鈕的點亮。

          從前面得知,該活動頁面是靜態的。那么我們在靜態頁面中如何控制秒殺按鈕,只在秒殺時間點時才點亮呢?

          沒錯,使用js文件控制。

          為了性能考慮,一般會將css、js和圖片等靜態資源文件提前緩存到CDN上,讓用戶能夠就近訪問秒殺頁面。

          看到這里,有些聰明的小伙伴,可能會問:CDN上的js文件是如何更新的?

          秒殺開始之前,js標志為false,還有另外一個隨機參數。

          當秒殺開始的時候系統會生成一個新的js文件,此時標志為true,并且隨機參數生成一個新值,然后同步給CDN。由于有了這個隨機參數,CDN不會緩存數據,每次都能從CDN中獲取最新的js代碼。

          此外,前端還可以加一個定時器,控制比如:10秒之內,只允許發起一次請求。如果用戶點擊了一次秒殺按鈕,則在10秒之內置灰,不允許再次點擊,等到過了時間限制,又允許重新點擊該按鈕。

          4 .讀多寫少

          在秒殺的過程中,系統一般會先查一下庫存是否足夠,如果足夠才允許下單,寫數據庫。如果不夠,則直接返回該商品已經搶完。

          由于大量用戶搶少量商品,只有極少部分用戶能夠搶成功,所以絕大部分用戶在秒殺時,庫存其實是不足的,系統會直接返回該商品已經搶完。

          這是非常典型的:讀多寫少 的場景。

          如果有數十萬的請求過來,同時通過數據庫查緩存是否足夠,此時數據庫可能會掛掉。因為數據庫的連接資源非常有限,比如:mysql,無法同時支持這么多的連接。

          而應該改用緩存,比如:redis。

          即便用了redis,也需要部署多個節點。



          5 .緩存問題

          通常情況下,我們需要在redis中保存商品信息,里面包含:商品id、商品名稱、規格屬性、庫存等信息,同時數據庫中也要有相關信息,畢竟緩存并不完全可靠。

          用戶在點擊秒殺按鈕,請求秒殺接口的過程中,需要傳入的商品id參數,然后服務端需要校驗該商品是否合法。

          大致流程如下圖所示:



          根據商品id,先從緩存中查詢商品,如果商品存在,則參與秒殺。如果不存在,則需要從數據庫中查詢商品,如果存在,則將商品信息放入緩存,然后參與秒殺。如果商品不存在,則直接提示失敗。

          這個過程表面上看起來是OK的,但是如果深入分析一下會發現一些問題。

          5.1 緩存擊穿

          比如商品A第一次秒殺時,緩存中是沒有數據的,但數據庫中有。雖說上面有如果從數據庫中查到數據,則放入緩存的邏輯。

          然而,在高并發下,同一時刻會有大量的請求,都在秒殺同一件商品,這些請求同時去查緩存中沒有數據,然后又同時訪問數據庫。結果悲劇了,數據庫可能扛不住壓力,直接掛掉。

          如何解決這個問題呢?

          這就需要加鎖,最好使用分布式鎖。

          當然,針對這種情況,最好在項目啟動之前,先把緩存進行預熱。即使先把所有的商品,同步到緩存中,這樣商品基本都能直接從緩存中獲取到,就不會出現緩存擊穿的問題了。

          是不是上面加鎖這一步可以不需要了?

          表面上看起來,確實可以不需要。但如果緩存中設置的過期時間不對,緩存提前過期了,或者緩存被不小心刪除了,如果不加速同樣可能出現緩存擊穿。

          其實這里加鎖,相當于買了一份保險。

          5.2 緩存穿透

          如果有大量的請求傳入的商品id,在緩存中和數據庫中都不存在,這些請求不就每次都會穿透過緩存,而直接訪問數據庫了。

          由于前面已經加了鎖,所以即使這里的并發量很大,也不會導致數據庫直接掛掉。

          但很顯然這些請求的處理性能并不好,有沒有更好的解決方案?

          這時可以想到布隆過濾器。

          系統根據商品id,先從布隆過濾器中查詢該id是否存在,如果存在則允許從緩存中查詢數據,如果不存在,則直接返回失敗。

          雖說該方案可以解決緩存穿透問題,但是又會引出另外一個問題:布隆過濾器中的數據如何更緩存中的數據保持一致?

          這就要求,如果緩存中數據有更新,則要及時同步到布隆過濾器中。如果數據同步失敗了,還需要增加重試機制,而且跨數據源,能保證數據的實時一致性嗎?

          顯然是不行的。

          所以布隆過濾器絕大部分使用在緩存數據更新很少的場景中。

          如果緩存數據更新非常頻繁,又該如何處理呢?

          這時,就需要把不存在的商品id也緩存起來。

          下次,再有該商品id的請求過來,則也能從緩存中查到數據,只不過該數據比較特殊,表示商品不存在。需要特別注意的是,這種特殊緩存設置的超時時間應該盡量短一點。

          6. 庫存問題

          對于庫存問題看似簡單,實則里面還是有些東西。

          真正的秒殺商品的場景,不是說扣完庫存,就完事了,如果用戶在一段時間內,還沒完成支付,扣減的庫存是要加回去的。

          所以,在這里引出了一個預扣庫存的概念,預扣庫存的主要流程如下:



          扣減庫存中除了上面說到的預扣庫存和回退庫存之外,還需要特別注意的是庫存不足和庫存超賣問題。

          6.1 數據庫扣減庫存

          使用數據庫扣減庫存,是最簡單的實現方案了,假設扣減庫存的sql如下:

          update product set stock=stock-1 where id=123; 

          這種寫法對于扣減庫存是沒有問題的,但如何控制庫存不足的情況下,不讓用戶操作呢?

          這就需要在update之前,先查一下庫存是否足夠了。

          偽代碼如下:

          int stock=mapper.getStockById(123); 
          if(stock > 0) {   
                int count=mapper.updateStock(123);   
                if(count > 0) {    
                     addOrder(123);   
                } 
          } 

          大家有沒有發現這段代碼的問題?

          沒錯,查詢操作和更新操作不是原子性的,會導致在并發的場景下,出現庫存超賣的情況。

          有人可能會說,這樣好辦,加把鎖,不就搞定了,比如使用synchronized關鍵字。

          確實,可以,但是性能不夠好。

          還有更優雅的處理方案,即基于數據庫的樂觀鎖,這樣會少一次數據庫查詢,而且能夠天然地保證數據操作的原子性。

          只需將上面的sql稍微調整一下:

          update product set stock=stock-1 where id=product and stock > 0; 

          在sql最后加上:stock > 0,就能保證不會出現超賣的情況。

          但需要頻繁訪問數據庫,我們都知道數據庫連接是非常昂貴的資源。在高并發的場景下,可能會造成系統雪崩。而且,容易出現多個請求,同時競爭行鎖的情況,造成相互等待,從而出現死鎖的問題。

          6.2 redis扣減庫存

          redis的incr方法是原子性的,可以用該方法扣減庫存。偽代碼如下:

           boolean exist=redisClient.query(productId,userId);   
          if(exist) {     
                 return -1; 
          }   
          int stock=redisClient.queryStock(productId);   
          if(stock <=0) {     return 0;   }   
          redisClient.incrby(productId, -1);   
          redisClient.add(productId,userId); 
          return 1; 

          代碼流程如下:

          1. 先判斷該用戶有沒有秒殺過該商品,如果已經秒殺過,則直接返回-1。
          2. 查詢庫存,如果庫存小于等于0,則直接返回0,表示庫存不足。
          3. 如果庫存充足,則扣減庫存,然后將本次秒殺記錄保存起來。然后返回1,表示成功。

          估計很多小伙伴,一開始都會按這樣的思路寫代碼。但如果仔細想想會發現,這段代碼有問題。

          有什么問題呢?

          如果在高并發下,有多個請求同時查詢庫存,當時都大于0。由于查詢庫存和更新庫存非原則操作,則會出現庫存為負數的情況,即庫存超賣。

          當然有人可能會說,加個synchronized不就解決問題?

          調整后代碼如下:

          boolean exist=redisClient.query(productId,userId);    
          if(exist) {     return -1;    }    
          synchronized(this) {
            int stock=redisClient.queryStock(productId);
            if(stock <=0) {
              return 0;
            }        
            redisClient.incrby(productId, -1); 
            redisClient.add(productId,userId);
          } 
          return 1; 

          加synchronized確實能解決庫存為負數問題,但是這樣會導致接口性能急劇下降,每次查詢都需要競爭同一把鎖,顯然不太合理。

          為了解決上面的問題,代碼優化如下:

          boolean exist=redisClient.query(productId,userId);
          if(exist) {   return -1; } 
          if(redisClient.incrby(productId, -1)<0) {
            return 0;
          } 
          redisClient.add(productId,userId); 
          return 1; 

          該代碼主要流程如下:

          1. 先判斷該用戶有沒有秒殺過該商品,如果已經秒殺過,則直接返回-1。
          2. 扣減庫存,判斷返回值是否小于0,如果小于0,則直接返回0,表示庫存不足。
          3. 如果扣減庫存后,返回值大于或等于0,則將本次秒殺記錄保存起來。然后返回1,表示成功。

          該方案咋一看,好像沒問題。

          但如果在高并發場景中,有多個請求同時扣減庫存,大多數請求的incrby操作之后,結果都會小于0。

          雖說,庫存出現負數,不會出現超賣的問題。但由于這里是預減庫存,如果負數值負得太多的話,后面萬一要回退庫存時,就會導致庫存不準。

          那么,有沒有更好的方案呢?

          6.3 lua腳本扣減庫存

          我們都知道lua腳本,是能夠保證原子性的,它跟redis一起配合使用,能夠完美解決上面的問題。

          lua腳本有段非常經典的代碼:

          StringBuilder lua=new StringBuilder();  
          lua.append("if (redis.call('exists', KEYS[1])==1) then");  
          lua.append("    local stock=tonumber(redis.call('get', KEYS[1]));");  
          lua.append("    if (stock==-1) then");  lua.append("        return 1;");  
          lua.append("    end;");  lua.append("    if (stock > 0) then");  
          lua.append("        redis.call('incrby', KEYS[1], -1);");  
          lua.append("        return stock;");  
          lua.append("    end;");  
          lua.append("    return 0;");  
          lua.append("end;");  
          lua.append("return -1;"); 

          該代碼的主要流程如下:

          1. 先判斷商品id是否存在,如果不存在則直接返回。
          2. 獲取該商品id的庫存,判斷庫存如果是-1,則直接返回,表示不限制庫存。
          3. 如果庫存大于0,則扣減庫存。
          4. 如果庫存等于0,是直接返回,表示庫存不足。

          7 .分布式鎖

          之前我提到過,在秒殺的時候,需要先從緩存中查商品是否存在,如果不存在,則會從數據庫中查商品。如果數據庫中,則將該商品放入緩存中,然后返回。如果數據庫中沒有,則直接返回失敗。

          大家試想一下,如果在高并發下,有大量的請求都去查一個緩存中不存在的商品,這些請求都會直接打到數據庫。數據庫由于承受不住壓力,而直接掛掉。

          那么如何解決這個問題呢?

          這就需要用redis分布式鎖了。

          7.1 setNx加鎖

          使用redis的分布式鎖,首先想到的是setNx命令。

          if (jedis.setnx(lockKey, val)==1) {
            jedis.expire(lockKey, timeout);
          } 

          用該命令其實可以加鎖,但和后面的設置超時時間是分開的,并非原子操作。

          假如加鎖成功了,但是設置超時時間失敗了,該lockKey就變成永不失效的了。在高并發場景中,該問題會導致非常嚴重的后果。

          那么,有沒有保證原子性的加鎖命令呢?

          7.2 set加鎖使

          用redis的set命令,它可以指定多個參數。

          String result=jedis.set(lockKey, requestId, "NX", "PX", expireTime); 
          if ("OK".equals(result)) { 
            return true;
          }
          return false; 

          其中:

          • lockKey:鎖的標識
          • requestId:請求id
          • NX:只在鍵不存在時,才對鍵進行設置操作。
          • PX:設置鍵的過期時間為 millisecond 毫秒。
          • expireTime:過期時間

          由于該命令只有一步,所以它是原子操作。

          7.3 釋放鎖

          接下來,有些朋友可能會問:在加鎖時,既然已經有了lockKey鎖標識,為什么要需要記錄requestId呢?

          答:requestId是在釋放鎖的時候用的。

          if (jedis.get(lockKey).equals(requestId)) {
            jedis.del(lockKey);
            return true; 
          }
          return false; 

          在釋放鎖的時候,只能釋放自己加的鎖,不允許釋放別人加的鎖。

          這里為什么要用requestId,用userId不行嗎?

          答:如果用userId的話,假設本次請求流程走完了,準備刪除鎖。此時,巧合鎖到了過期時間失效了。而另外一個請求,巧合使用的相同userId加鎖,會成功。而本次請求刪除鎖的時候,刪除的其實是別人的鎖了。

          當然使用lua腳本也能避免該問題:

          if redis.call('get', KEYS[1])==ARGV[1] then
          return redis.call('del', KEYS[1])
          else    return 0 
          end 

          它能保證查詢鎖是否存在和刪除鎖是原子操作。

          7.4 自旋鎖

          上面的加鎖方法看起來好像沒有問題,但如果你仔細想想,如果有1萬的請求同時去競爭那把鎖,可能只有一個請求是成功的,其余的9999個請求都會失敗。

          在秒殺場景下,會有什么問題?

          答:每1萬個請求,有1個成功。再1萬個請求,有1個成功。如此下去,直到庫存不足。這就變成均勻分布的秒殺了,跟我們想象中的不一樣。

          如何解決這個問題呢?

          答:使用自旋鎖。

          try {   Long start=System.currentTimeMillis();   
               while(true) {
                 String result=jedis.set(lockKey, requestId, "NX", "PX", expireTime);
                 if ("OK".equals(result)) {
                   return true;
                 }
                 long time=System.currentTimeMillis() - start;
                 if (time>=timeout) { 
                   return false; 
                 } 
                 try {
                   Thread.sleep(50); 
                 } 
                 catch (InterruptedException e) {
                   e.printStackTrace();
                 }
               }
              } 
          finally{
            unlock(lockKey,requestId); 
          }
          return false; 

          在規定的時間,比如500毫秒內,自旋不斷嘗試加鎖,如果成功則直接返回。如果失敗,則休眠50毫秒,再發起新一輪的嘗試。如果到了超時時間,還未加鎖成功,則直接返回失敗。

          7.5 redisson

          除了上面的問題之外,使用redis分布式鎖,還有鎖競爭問題、續期問題、鎖重入問題、多個redis實例加鎖問題等。

          這些問題使用redisson可以解決,由于篇幅的原因,在這里先保留一點懸念,有疑問的私聊給我。后面會出一個專題介紹分布式鎖,敬請期待。

          8. mq異步處理

          我們都知道在真實的秒殺場景中,有三個核心流程:

          而這三個核心流程中,真正并發量大的是秒殺功能,下單和支付功能實際并發量很小。所以,我們在設計秒殺系統時,有必要把下單和支付功能從秒殺的主流程中拆分出來,特別是下單功能要做成mq異步處理的。而支付功能,比如支付寶支付,是業務場景本身保證的異步。

          于是,秒殺后下單的流程變成如下:

          如果使用mq,需要關注以下幾個問題:

          8.1 消息丟失問題

          秒殺成功了,往mq發送下單消息的時候,有可能會失敗。原因有很多,比如:網絡問題、broker掛了、mq服務端磁盤問題等。這些情況,都可能會造成消息丟失。

          那么,如何防止消息丟失呢?

          答:加一張消息發送表。

          在生產者發送mq消息之前,先把該條消息寫入消息發送表,初始狀態是待處理,然后再發送mq消息。消費者消費消息時,處理完業務邏輯之后,再回調生產者的一個接口,修改消息狀態為已處理。

          如果生產者把消息寫入消息發送表之后,再發送mq消息到mq服務端的過程中失敗了,造成了消息丟失。

          這時候,要如何處理呢?

          答:使用job,增加重試機制。

          用job每隔一段時間去查詢消息發送表中狀態為待處理的數據,然后重新發送mq消息。

          8.2 重復消費問題

          本來消費者消費消息時,在ack應答的時候,如果網絡超時,本身就可能會消費重復的消息。但由于消息發送者增加了重試機制,會導致消費者重復消息的概率增大。

          那么,如何解決重復消息問題呢?

          答:加一張消息處理表。

          消費者讀到消息之后,先判斷一下消息處理表,是否存在該消息,如果存在,表示是重復消費,則直接返回。如果不存在,則進行下單操作,接著將該消息寫入消息處理表中,再返回。

          有個比較關鍵的點是:下單和寫消息處理表,要放在同一個事務中,保證原子操作。

          8.3 垃圾消息問題

          這套方案表面上看起來沒有問題,但如果出現了消息消費失敗的情況。比如:由于某些原因,消息消費者下單一直失敗,一直不能回調狀態變更接口,這樣job會不停地重試發消息。最后,會產生大量的垃圾消息。

          那么,如何解決這個問題呢?

          每次在job重試時,需要先判斷一下消息發送表中該消息的發送次數是否達到最大限制,如果達到了,則直接返回。如果沒有達到,則將次數加1,然后發送消息。

          這樣如果出現異常,只會產生少量的垃圾消息,不會影響到正常的業務。

          8.4 延遲消費問題

          通常情況下,如果用戶秒殺成功了,下單之后,在15分鐘之內還未完成支付的話,該訂單會被自動取消,回退庫存。

          那么,在15分鐘內未完成支付,訂單被自動取消的功能,要如何實現呢?

          我們首先想到的可能是job,因為它比較簡單。

          但job有個問題,需要每隔一段時間處理一次,實時性不太好。

          還有更好的方案?

          答:使用延遲隊列。

          我們都知道rocketmq,自帶了延遲隊列的功能。

          下單時消息生產者會先生成訂單,此時狀態為待支付,然后會向延遲隊列中發一條消息。達到了延遲時間,消息消費者讀取消息之后,會查詢該訂單的狀態是否為待支付。如果是待支付狀態,則會更新訂單狀態為取消狀態。如果不是待支付狀態,說明該訂單已經支付過了,則直接返回。

          還有個關鍵點,用戶完成支付之后,會修改訂單狀態為已支付。



          9. 如何限流?

          通過秒殺活動,如果我們運氣爆棚,可能會用非常低的價格買到不錯的商品(這種概率堪比買福利彩票中大獎)。

          但有些高手,并不會像我們一樣老老實實,通過秒殺頁面點擊秒殺按鈕,搶購商品。他們可能在自己的服務器上,模擬正常用戶登錄系統,跳過秒殺頁面,直接調用秒殺接口。

          如果是我們手動操作,一般情況下,一秒鐘只能點擊一次秒殺按鈕。

          但是如果是服務器,一秒鐘可以請求成上千接口。

          這種差距實在太明顯了,如果不做任何限制,絕大部分商品可能是被機器搶到,而非正常的用戶,有點不太公平。

          所以,我們有必要識別這些非法請求,做一些限制。那么,我們該如何現在這些非法請求呢?

          目前有兩種常用的限流方式:

          基于nginx限流

          基于redis限流

          9.1 對同一用戶限流

          為了防止某個用戶,請求接口次數過于頻繁,可以只針對該用戶做限制。

          限制同一個用戶id,比如每分鐘只能請求5次接口。

          9.2 對同一ip限流

          有時候只對某個用戶限流是不夠的,有些高手可以模擬多個用戶請求,這種nginx就沒法識別了。

          這時需要加同一ip限流功能。

          限制同一個ip,比如每分鐘只能請求5次接口。

          但這種限流方式可能會有誤殺的情況,比如同一個公司或網吧的出口ip是相同的,如果里面有多個正常用戶同時發起請求,有些用戶可能會被限制住。

          9.3 對接口限流

          別以為限制了用戶和ip就萬事大吉,有些高手甚至可以使用代理,每次都請求都換一個ip。

          這時可以限制請求的接口總次數。

          在高并發場景下,這種限制對于系統的穩定性是非常有必要的。但可能由于有些非法請求次數太多,達到了該接口的請求上限,而影響其他的正常用戶訪問該接口。看起來有點得不償失。

          9.4 加驗證碼

          相對于上面三種方式,加驗證碼的方式可能更精準一些,同樣能限制用戶的訪問頻次,但好處是不會存在誤殺的情況。

          通常情況下,用戶在請求之前,需要先輸入驗證碼。用戶發起請求之后,服務端會去校驗該驗證碼是否正確。只有正確才允許進行下一步操作,否則直接返回,并且提示驗證碼錯誤。

          此外,驗證碼一般是一次性的,同一個驗證碼只允許使用一次,不允許重復使用。

          普通驗證碼,由于生成的數字或者圖案比較簡單,可能會被破解。優點是生成速度比較快,缺點是有安全隱患。

          還有一個驗證碼叫做:移動滑塊,它生成速度比較慢,但比較安全,是目前各大互聯網公司的首選。

          9.5 提高業務門檻

          上面說的加驗證碼雖然可以限制非法用戶請求,但是有些影響用戶體驗。用戶點擊秒殺按鈕前,還要先輸入驗證碼,流程顯得有點繁瑣,秒殺功能的流程不是應該越簡單越好嗎?

          其實,有時候達到某個目的,不一定非要通過技術手段,通過業務手段也一樣。

          12306剛開始的時候,全國人民都在同一時刻搶火車票,由于并發量太大,系統經常掛。后來,重構優化之后,將購買周期放長了,可以提前20天購買火車票,并且可以在9點、10、11點、12點等整點購買火車票。調整業務之后(當然技術也有很多調整),將之前集中的請求,分散開了,一下子降低了用戶并發量。

          回到這里,我們通過提高業務門檻,比如只有會員才能參與秒殺活動,普通注冊用戶沒有權限。或者,只有等級到達3級以上的普通用戶,才有資格參加該活動。

          這樣簡單的提高一點門檻,即使是黃牛黨也束手無策,他們總不可能為了參加一次秒殺活動,還另外花錢充值會員吧?

          時光飛逝,兩周過去了,是時候繼續填坑了,不然又要被網友噴了。

          本文是秒殺系統的第三篇,通過實際代碼講解,幫助你了解秒殺系統設計的關鍵點,上手實際項目。

          本篇主要講解秒殺系統中,關于搶購(下單)接口相關的單用戶防刷措施,主要說兩塊內容:

          • 搶購接口隱藏
          • 單用戶限制頻率(單位時間內限制訪問次數)

          當然,這兩個措施放在任何系統中都有用,嚴格來說并不是秒殺系統獨特的設計,所以今天的內容也會比較的通用。

          此外,我做了一張流程圖,描述了目前我們實現的秒殺接口下單流程:

          前文回顧和文章規劃

          • 零基礎上手秒殺系統(一):防止超賣
          • 零基礎上手秒殺系統(二):令牌桶限流 + 再談超賣
          • 零基礎上手秒殺系統(三):搶購接口隱藏 + 單用戶限制頻率(本篇)
          • 零基礎上手秒殺系統:使用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入門實戰系列
          • 數據分析/數據爬蟲
          • 逸聞趣事/好書分享/個人生活

          個人公眾號:后端技術漫談

          公眾號:后端技術漫談.jpg

          如果文章對你有幫助,不妨收藏,轉發,在看起來~


          主站蜘蛛池模板: 日韩视频免费一区二区三区| 91精品一区二区三区在线观看| 亚洲国产视频一区| 国产激情一区二区三区四区| 香蕉免费看一区二区三区| 波多野结衣一区视频在线| 国产精品高清视亚洲一区二区| 亚洲av色香蕉一区二区三区| 中文字幕亚洲一区二区三区| 女女同性一区二区三区四区| 熟女少妇丰满一区二区| 日韩一区二区三区在线| 亚洲av高清在线观看一区二区| 无码国产精品一区二区免费3p| 国产日韩精品视频一区二区三区| 亚洲无码一区二区三区| 国产在线无码视频一区二区三区| 日韩人妻无码免费视频一区二区三区| 日本一区高清视频| 麻豆AV无码精品一区二区| 日韩免费无码视频一区二区三区| 国产品无码一区二区三区在线| 性色A码一区二区三区天美传媒 | 无码人妻精品一区二区三18禁| 日韩视频在线一区| 亚洲综合在线一区二区三区| 无码国产精品一区二区免费16| 国产福利电影一区二区三区,免费久久久久久久精 | 深田咏美AV一区二区三区| 久久久精品一区二区三区| 一区二区在线视频观看| 亚洲日本乱码一区二区在线二产线 | 香蕉一区二区三区观| 久久精品国内一区二区三区| 日韩欧国产精品一区综合无码| 97精品国产福利一区二区三区| A国产一区二区免费入口| 韩日午夜在线资源一区二区 | 亚洲成AV人片一区二区| 在线精品一区二区三区电影| 伊人色综合一区二区三区影院视频|