教你从0到1搭建秒杀系统-限流
本文是秒殺系統的第二篇,主要講解接口限流措施。接口限流其實定義也非常廣,接口限流本身也是系統安全防護的一種措施,在面臨高并發的請購請求時,我們如果不對接口進行限流,可能會對后臺系統造成極大的壓力,尤其是對于下單的接口,過多的請求打到數據庫會對系統的穩定性造成影響。所以對于秒殺系統:
- 會盡量選擇獨立于公司其他后端系統之外進行單獨部署,以免秒殺業務崩潰影響到其他系統
- 除了獨立部署秒殺業務之外,我們能夠做的就是盡量讓后臺系統穩定優雅的處理大量請求。
列舉幾種容易理解的接口限流的措施:
- 令牌桶限流
- 單用戶訪問頻率限流
- 搶購接口隱藏
因為篇幅會比較長,所以會分兩篇文章來進行講解,本篇主要講令牌桶限流,后面兩種我們一并在后面的一篇文章介紹。
令牌桶限流
令牌桶限流算法
令牌桶算法最初來源于計算機網絡。在網絡傳輸數據時,為了防止網絡擁塞,需限制流出網絡的流量,使流量以比較均勻的速度向外發送。令牌桶算法就實現了這個功能,可控制發送到網絡上數據的數目,并允許突發數據的發送。
如圖,大小固定的令牌桶可自行以恒定的速率源源不斷地產生令牌。如果令牌不被消耗,或者被消耗的速度小于產生的速度,令牌就會不斷地增多,直到把桶填滿。后面再產生的令牌就會從桶中溢出。最后桶中可以保存的最大令牌數永遠不會超過桶的大小。如果請求需要被處理,則需要先從桶里獲取一個令牌,當桶里沒有令牌可取時,則拒絕服務。
令牌桶算法與漏桶算法
漏桶算法思路很簡單,水(請求)先進入到漏桶里,漏桶以一定的速度出水,當水流入速度過大會直接溢出,可以看出漏桶算法能強行限制數據的傳輸速率。
漏桶算法與令牌桶算法在表面看起來類似,很容易將兩者混淆。但事實上,這兩者具有截然不同的特性,且為不同的目的而使用。漏桶算法與令牌桶算法的區別在于:
- 漏桶算法能夠強行限制數據的傳輸速率,令牌桶算法能夠在限制數據的平均傳輸速率的同時還允許某種程度的突發傳輸;
- 在某些情況下,漏桶算法不能夠有效地使用網絡資源。因為漏桶的漏出速率是固定的,所以即使網絡中沒有發生擁塞,漏桶算法也不能使某一個單獨的數據流達到端口速率。因此,漏桶算法對于存在突發特性的流量來說缺乏效率。而令牌桶算法則能夠滿足這些具有突發特性的流量。
并不能說明令牌桶一定比漏洞好,它們使用場景不一樣:
- 令牌桶可以用來保護自己,主要用來對調用者頻率進行限流,為的是讓自己不被打垮。所以如果自己本身有處理能力的時候,如果流量突發(實際消費能力強于配置的流量限制),那么實際處理速率可以超過配置的限制。
- 漏桶算法用來保護他人,也就是保護他所調用的系統。主要場景是,當調用的第三方系統本身沒有保護機制,或者有流量限制的時候,我們的調用速度不能超過他的限制,由于我們不能更改第三方系統,所以只有在主調方控制。這個時候,即使流量突發,也必須舍棄。
限流工具類RateLimiter
Google開源工具包Guava提供了限流工具類RateLimiter,該類基于令牌桶算法來完成限流,非常易于使用。我們利用它在之前講過的樂觀鎖搶購接口上增加該令牌桶限流代碼:
@Controller public class OrderController {private static final Logger LOGGER = LoggerFactory.getLogger(OrderController.class);@Autowiredprivate StockService stockService;@Autowiredprivate OrderService orderService;//每秒放行10個請求RateLimiter rateLimiter = RateLimiter.create(10);/*** 樂觀鎖更新庫存 + 令牌桶限流* @param sid* @return*/@RequestMapping("/createOptimisticOrder/{sid}")@ResponseBodypublic String createOptimisticOrder(@PathVariable int sid) {// 阻塞式獲取令牌//LOGGER.info("等待時間" + rateLimiter.acquire());// 非阻塞式獲取令牌if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {LOGGER.warn("你被限流了,真不幸,直接返回失敗");return "購買失敗,庫存不足";}int id;try {id = orderService.createOptimisticOrder(sid);LOGGER.info("購買成功,剩余庫存為: [{}]", id);} catch (Exception e) {LOGGER.error("購買失敗:[{}]", e.getMessage());return "購買失敗,庫存不足";}return String.format("購買成功,剩余庫存為:%d", id);} }在代碼中做了相關的解釋。使用RateLimiter rateLimiter = RateLimiter.create(10)初始化令牌桶類,每秒放行10個請求。使用rateLimiter 獲取令牌的方式主要有兩種:
- 阻塞式獲取令牌:使用rateLimiter.acquire()實現。請求進來后,若令牌桶里沒有足夠的令牌,就在這里阻塞住,等待令牌的發放;
- 非阻塞式獲取令牌:使用rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)實現。請求進來后,若令牌桶里沒有足夠的令牌,會嘗試等待設置好的時間(這里寫了1000ms),其會自動判斷在1000ms后,這個請求能不能拿到令牌,如果不能拿到,直接返回搶購失敗。如果timeout設置為0,則等于阻塞時獲取令牌。
我們使用JMeter設置200個線程,來同時搶購數據庫里庫存100個的iphone。相關結構和數據可以看教你從0到1搭建秒殺系統-防超賣。
令牌桶算法實踐
首先使用非阻塞式獲取令牌的方式進行操作,請求完以后來看看購買結果:
有數據可以看到,最終只有11個被賣出去了。在這種情況下請求能夠沒被限流的比率在11%左右。
可以看到,200個請求中沒有被限流的請求里,由于樂觀鎖的原因,會出現一些并發更新數據庫失敗的問題,導致商品沒有被賣出。我們再試一試令牌桶算法的阻塞式使用,我們將代碼換成rateLimiter.acquire();,然后將數據庫恢復成100個庫存,訂單表清零。開始請求:
可以看到,100個全部賣出。這里首先看一下操作結果和打印日志:
對照著請求的打印日志,有幾個問題需要說明一下:
- 首先,所有請求進入了處理流程,但是被限流成每秒處理10個請求。
- 在剛開始的請求里,令牌桶里一下子被取了10個令牌,所以出現了第二張圖中的,樂觀鎖并發更新失敗,然而在后面的請求中,由于令牌一旦生成就被拿走,所以請求進來的很均勻,沒有再出現并發更新庫存的情況。這也符合“令牌桶”的定義,可以應對突發請求(只是由于樂觀鎖,所以購買沖突了)。而非“漏桶”的永遠恒定的請求限制。
- 200個請求,在樂觀鎖的情況下,賣出了全部100個商品,如果沒有該限流,而請求又過于集中的話,會賣不出去幾個。
令牌桶限流算法說完了,我們再回頭思考超賣的問題,在海量請求的場景下使用樂觀鎖,會導致大量的請求返回搶購失敗,用戶體驗極差。然而使用悲觀鎖,比如數據庫事務,則可以讓數據庫一個個處理庫存數修改,修改成功后再迎接下一個請求,所以在不同情況下,應該根據實際情況使用悲觀鎖和樂觀鎖。兩種鎖各有優缺點,不能單純的定義哪個好于哪個:
- 樂觀鎖比較適合數據修改比較少,讀取比較頻繁的場景,即使出現了少量的沖突,這樣也省去了大量的鎖的開銷,故而提高了系統的吞吐量;
- 但是如果經常發生沖突(寫數據比較多的情況下),上層應用不不斷的retry,這樣反而降低了性能,對于這種情況使用悲觀鎖就更合適。
悲觀鎖實踐
我們為了在高流量下,能夠更好更快的賣出商品,我們實現一個悲觀鎖(事務for update更新庫存),看看悲觀鎖的結果如何。在Controller中,增加一個悲觀鎖賣商品接口:
/*** 事務for update更新庫存* @param sid* @return*/ @RequestMapping("/createPessimisticOrder/{sid}") @ResponseBody public String createPessimisticOrder(@PathVariable int sid) {int id;try {id = orderService.createPessimisticOrder(sid);LOGGER.info("購買成功,剩余庫存為: [{}]", id);} catch (Exception e) {LOGGER.error("購買失敗:[{}]", e.getMessage());return "購買失敗,庫存不足";}return String.format("購買成功,剩余庫存為:%d", id); }在Service中,給該賣商品流程加上事務:
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) @Override public int createPessimisticOrder(int sid){//校驗庫存(悲觀鎖for update)Stock stock = checkStockForUpdate(sid);//更新庫存saleStock(stock);//創建訂單int id = createOrder(stock);return stock.getCount() - (stock.getSale()); }/*** 檢查庫存 ForUpdate* @param sid* @return*/ private Stock checkStockForUpdate(int sid) {Stock stock = stockService.getStockByIdForUpdate(sid);if (stock.getSale().equals(stock.getCount())) {throw new RuntimeException("庫存不足");}return stock; }/*** 更新庫存* @param stock*/ private void saleStock(Stock stock) {stock.setSale(stock.getSale() + 1);stockService.updateStockById(stock); }/*** 創建訂單* @param stock* @return*/ private int createOrder(Stock stock) {StockOrder order = new StockOrder();order.setSid(stock.getId());order.setName(stock.getName());int id = orderMapper.insertSelective(order);return id; }這里使用Spring的事務,@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED),如果遇到回滾,則返回Exception,并且事務傳播使用PROPAGATION_REQUIRED–支持當前事務,如果當前沒有事務,就新建一個事務。
我們依然設置100個商品,清空訂單表,開始用JMeter更改請求的接口/createPessimisticOrder/1,發起200個請求:
可以看到,200個請求,100個返回了搶購成功,100個返回了搶購失敗。并且商品賣給了前100個進來的請求,十分的有序。所以,悲觀鎖在大量請求的請求下,有著更好的賣出成功率。但是需要注意的是,如果請求量巨大,悲觀鎖會導致后面的請求進行了長時間的阻塞等待,用戶就必須在頁面等待,很像是“假死”,可以通過配合令牌桶限流,或者是給用戶顯著的等待提示來優化。
猜你感興趣:
教你從0到1搭建秒殺系統-防超賣
教你從0到1搭建秒殺系統-限流
教你從0到1搭建秒殺系統-搶購接口隱藏與單用戶限制頻率
教你從0到1搭建秒殺系統-緩存與數據庫雙寫一致
教你從0到1搭建秒殺系統-Canal快速入門(番外篇)
教你從0到1搭建秒殺系統-訂單異步處理
更多文章請點擊:更多…
總結
以上是生活随笔為你收集整理的教你从0到1搭建秒杀系统-限流的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 教你从0到1搭建秒杀系统-防超卖
- 下一篇: 教你从0到1搭建秒杀系统-抢购接口隐藏与