之前在 Java-Interview 中提到過秒殺架構的設計,這次基于其中的理論簡單實作了一下。
本次采用循序漸進的方式逐漸提高性能達到并發秒殺的效果,文章較長請準備好瓜子闆凳(liushuizhang😂)。
本文所有涉及的代碼:
https://github.com/crossoverJie/SSM
https://github.com/crossoverJie/distributed-redis-tool
最終架構圖:

先簡單根據這個圖談下請求的流轉,因為後面不管怎麼改進這個都是沒有變的。
前端請求進入 <code>web</code> 層,對應的代碼就是 <code>controller</code>。
之後将真正的庫存校驗、下單等請求發往 <code>Service</code> 層(其中 RPC 調用依然采用的 <code>dubbo</code>,隻是更新為最新版本,本次不會過多讨論 dubbo 相關的細節,有興趣的可以檢視 基于dubbo的分布式架構)。
<code>Service</code> 層再對資料進行落地,下單完成。
其實抛開秒殺這個場景來說正常的一個下單流程可以簡單分為以下幾步:
校驗庫存
扣庫存
建立訂單
支付
基于上文的架構是以我們有了以下實作:
先看看實際項目的結構:
還是和以前一樣:
提供出一個 <code>API</code> 用于 <code>Service</code> 層實作,以及 <code>web</code> 層消費。
web 層簡單來說就是一個 <code>SpringMVC</code>。
<code>Service</code> 層則是真正的資料落地。
<code>SSM-SECONDS-KILL-ORDER-CONSUMER</code> 則是後文會提到的 <code>Kafka</code> 消費。
資料庫也是隻有簡單的兩張表模拟下單:
web 層 <code>controller</code> 實作:
其中 web 作為一個消費者調用看 <code>OrderService</code> 提供出來的 dubbo 服務。
Service 層,<code>OrderService</code> 實作:
首先是對 API 的實作(會在 API 提供出接口):
這裡隻是簡單調用了 <code>DBOrderService</code> 中的實作,DBOrderService 才是真正的資料落地,也就是寫資料庫了。
DBOrderService 實作:
預先初始化了 10 條庫存。
手動調用下 <code>createWrongOrder/1</code> 接口發現:
庫存表:
訂單表:
一切看起來都沒有問題,資料也正常。
但是當用 <code>JMeter</code> 并發測試時:
測試配置是:300個線程并發,測試兩輪來看看資料庫中的結果:
請求都響應成功,庫存确實也扣完了,但是訂單卻生成了 124 條記錄。
這顯然是典型的超賣現象。
其實作在再去手動調用接口會傳回庫存不足,但為時晚矣。
怎麼來避免上述的現象呢?
最簡單的做法自然是樂觀鎖了,這裡不過多讨論這個,不熟悉的朋友可以看下這篇。
來看看具體實作:
其實其他的都沒怎麼改,主要是 Service 層。
對應的 XML:
同樣的測試條件,我們再進行上面的測試 <code>/createOptimisticOrder/1</code>:
這次發現無論是庫存訂單都是 OK 的。
檢視日志發現:
很多并發請求會響應錯誤,這就達到了效果。
為了進一步提高秒殺時的吞吐量以及響應效率,這裡的 web 和 Service 都進行了橫向擴充。
web 利用 Nginx 進行負載。
Service 也是多台應用。
再用 JMeter 測試時可以直覺的看到效果。
由于我是在阿裡雲的一台小水管伺服器進行測試的,加上配置不高、應用都在同一台,是以并沒有完全展現出性能上的優勢( <code>Nginx</code> 做負載轉發時候也會增加額外的網絡消耗)。
由于應用多台部署之後,手動發版測試的痛苦相信經曆過的都有體會。
這次并沒有精力去搭建完整的 CI CD,隻是寫了一個簡單的腳本實作了自動化部署,希望對這方面沒有經驗的同學帶來一點啟發:
之後每當我有更新,隻需要執行這兩個腳本就可以幫我自動建構。
都是最基礎的 Linux 指令,相信大家都看得明白。
上文的結果看似沒有問題,其實還差得遠呢。
這裡隻是模拟了 300 個并發沒有問題,但是當請求達到了 3000 ,3W,300W 呢?
雖說可以橫向擴充可以支撐更多的請求。
但是能不能利用最少的資源解決問題呢?
其實仔細分析下會發現:
假設我的商品一共隻有 10 個庫存,那麼無論你多少人來買其實最終也最多隻有 10 人可以下單成功。
是以其中會有 <code>99%</code> 的請求都是無效的。
大家都知道:大多數應用資料庫都是壓倒駱駝的最後一根稻草。
通過 <code>Druid</code> 的監控來看看之前請求資料庫的情況:
因為 Service 是兩個應用。
資料庫也有 20 多個連接配接。
怎麼樣來優化呢?
其實很容易想到的就是分布式限流。
我們将并發控制在一個可控的範圍之内,然後快速失敗這樣就能最大程度的保護系統。
為此還對 https://github.com/crossoverJie/distributed-redis-tool 進行了小小的更新。
因為加上該元件之後所有的請求都會經過 Redis,是以對 Redis 資源的使用也是要非常小心。
修改之後的 API 如下:
這裡建構器改用了 <code>JedisConnectionFactory</code>,是以得配合 Spring 來一起使用。
并在初始化時顯示傳入 Redis 是以叢集方式部署還是單機(強烈建議叢集,限流之後對 Redis 還是有一定的壓力)。
既然 API 更新了,實作自然也要修改:
如果是原生的 Spring 應用得采用 <code>@SpringControllerLimit(errorCode = 200)</code> 注解。
實際使用如下:
web 端:
Service 端就沒什麼更新了,依然是采用的樂觀鎖更新資料庫。
再壓測看下效果 <code>/createOptimisticLimitOrderByRedis/1</code>:
首先是看結果沒有問題,再看資料庫連接配接以及并發請求數都有明顯的下降。
其實仔細觀察 Druid 監控資料發現這個 SQL 被多次查詢:
其實這是實時查詢庫存的 SQL,主要是為了在每次下單之前判斷是否還有庫存。
這也是個優化點。
這種資料我們完全可以放在記憶體中,效率比在資料庫要高很多。
由于我們的應用是分布式的,是以堆内緩存顯然不合适,Redis 就非常适合。
這次主要改造的是 Service 層:
每次查詢庫存時走 Redis。
扣庫存時更新 Redis。
需要提前将庫存資訊寫入 Redis(手動或者程式自動都可以)。
主要代碼如下:
壓測看看實際效果 <code>/createOptimisticLimitOrderByRedis/1</code>:
最後發現資料沒問題,資料庫的請求與并發也都下來了。
最後的優化還是想如何來再次提高吞吐量以及性能的。
我們上文所有例子其實都是同步請求,完全可以利用同步轉異步來提高性能啊。
這裡我們将寫訂單以及更新庫存的操作進行異步化,利用 <code>Kafka</code> 來進行解耦和隊列的作用。
每當一個請求通過了限流到達了 Service 層通過了庫存校驗之後就将訂單資訊發給 Kafka ,這樣一個請求就可以直接傳回了。
消費程式再對資料進行入庫落地。
因為異步了,是以最終需要采取回調或者是其他提醒的方式提醒使用者購買完成。
這裡代碼較多就不貼了,消費程式其實就是把之前的 Service 層的邏輯重寫了一遍,不過采用的是 SpringBoot。
感興趣的朋友可以看下。
https://github.com/crossoverJie/SSM/tree/master/SSM-SECONDS-KILL/SSM-SECONDS-KILL-ORDER-CONSUMER
其實經過上面的一頓優化總結起來無非就是以下幾點:
盡量将請求攔截在上遊。
還可以根據 UID 進行限流。
最大程度的減少請求落到 DB。
多利用緩存。
同步操作異步化。
fail fast,盡早失敗,保護應用。
碼字不易,這應該是我寫過字數最多的了,想想當年高中 800 字的作文都憋不出來😂,可想而知是有多難得了。
以上内容歡迎讨論。
最近在總結一些 Java 相關的知識點,感興趣的朋友可以一起維護。
位址: https://github.com/crossoverJie/Java-Interview
作者:
crossoverJie
出處:
https://crossoverjie.top
歡迎關注部落客公衆号與我交流。
本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出,
如有問題, 可郵件(crossoverJie#gmail.com)咨詢。