轉自:https://www.cnblogs.com/xiangkejin/p/9351501.html
3. 深入優化設計
3.1 初始方案問題分析
在前面針對數據庫的優化中,由於數據庫行級鎖存在競爭造成大量的串行阻塞,我們使用了存儲過程(或者觸發器)等技術綁定操作,整個事務在MySQL端完成,把整個熱點執行放在一個過程當中一次性完成,可以屏蔽掉網絡延遲時間,減少行級鎖持有時間,提高事務並發訪問速度。
可是問題時並發的流量實際上都是直接穿透讓MYSQL自己去抗,比如說庫存是否賣完以及用戶是否重復秒殺都完全是靠查詢數據庫去判斷,造成數據庫不必要的負擔非常大,然而這些都可以放在緩存做一個標記在服務層進行攔截,對於中小規模的並發還可以,但是真正的超高並發,顯然這個還不完善。
3.2 優化的方向和思路
方向:將請求盡量攔截在系統上游
傳統秒殺系統之所以掛,請求都壓倒了后端數據層,數據讀寫鎖沖突嚴重,並發高響應慢,幾乎所有請求都超時,流量雖大,下單成功的有效流量甚小【一趟火車其實只有2000張票,200w個人來買,基本沒有人能買成功,請求有效率為0】

思路:限流和削峰
限流:屏蔽掉無用的流量,允許少部分流量流向后端。 https://blog.csdn.net/fanrenxiang/article/details/80683378(某ip 的 某段時間 只讓請求 m次 rquest)
削峰:瞬時大流量峰值容易壓垮系統,解決這個問題是重中之重。常用的消峰方法有異步處理、緩存和消息中間件等技術。
消息隊列就像“水庫”一樣,攔蓄上游的洪水,削減進入下游河道的洪峰流量,進而減免洪流災害。
什么是流量削峰? https://www.jianshu.com/p/6746140bbb76 https://blog.csdn.net/hixiaoxiaoniao/article/details/89285564#%E6%8E%92%E9%98%9F https://www.cnblogs.com/yanggb/p/11117165.html
springboot集成rabbitmq商品秒殺業務實戰(流量削峰): https://blog.csdn.net/weixin_44001965/article/details/105557610?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-4.channel_param&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-4.channel_param
異步處理:秒殺系統是一個高並發系統,采用異步處理模式可以極大地提高系統並發量,其實異步處理就是削峰的一種實現方式。
緩存:秒殺系統本身是一個典型的讀多寫少的應用場景【一趟火車其實只有2000張票,200w個人來買,最多2000個人下單成功,其他人都是查詢庫存,寫比例只有0.1%,讀比例占99.9%】,非常適合使用緩存。
消息隊列:消息隊列可以削峰,將攔截大量並發請求,這也是一個異步處理過程,后台業務根據自己的處理能力,從消息隊列中主動的拉取請求消息進行業務處理。
3.3 前端優化
3.3.1 靜態資源緩存
1. 頁面靜態化
對商品詳情和訂單詳情進行頁面靜態化處理,頁面是存在html,動態數據是通過接口從服務端獲取,實現前后端分離,靜態頁面無需連接數據庫打開速度較動態頁面會有明顯提高。
2.頁面緩存
通過CDN緩存靜態資源,來抗峰值。不使用CDN的話也可以通過在手動渲染得到的html頁面緩存到redis。
3.3.2 限流手段
1. 使用數學公式驗證碼
描述:點擊秒殺前,先讓用戶輸入數學公式驗證碼,驗證正確才能進行秒殺。
好處:
1)防止惡意的機器人和爬蟲
2)分散用戶的請求
實現:
1)前端通過把商品id作為參數調用服務端創建驗證碼接口
2)服務端根據前端傳過來的商品id和用戶id生成驗證碼,並將商品id+用戶id作為key,生成的驗證碼作為value存入redis,同時將生成的驗證碼輸入圖片寫入imageIO讓前端展示。
3)將用戶輸入的驗證碼與根據商品id+用戶id從redis查詢到的驗證碼對比,相同就返回驗證成功,進入秒殺;不同或從redis查詢的驗證碼為空都返回驗證失敗,刷新驗證碼重試
2. 禁止重復提交
用戶提交之后按鈕置灰,禁止重復提交
3.4 中間代理層
可利用負載均衡(例如反響代理Nginx等)使用多個服務器並發處理請求,減小服務器壓力。
3.5 后端優化
3.5.1 控制層(網關層)
限制同一UserID訪問頻率:盡量攔截瀏覽器請求,但針對某些惡意攻擊或其它插件,在服務端控制層需要針對同一個訪問uid,限制訪問頻率。
1. 利用緩存
設置緩存有效時間,在緩存中計數,如果在緩存的有效時間內請求的次數超了的話,就返回請求訪問太頻繁。
2. 利用RateLimiter
RateLimiter是guava提供的基於令牌桶算法的限流實現類,通過調整生成token的速率來限制用戶頻繁訪問秒殺頁面,從而達到防止超大流量沖垮系統。(令牌桶算法的原理是系統會以一個恆定的速度往桶里放入令牌,而如果請求需要被處理,則需要先從桶里獲取一個令牌,當桶里沒有令牌可取時,則拒絕服務。
3.5.2 服務層
當用戶量非常大的時候,攔截流量后的請求訪問量還是非常大,此時仍需進一步優化。
1. 業務分離:將秒殺業務系統和其他業務分離,單獨放在高配服務器上,可以集中資源對訪問請求抗壓。——應用的拆分
2. 采用消息隊列緩存請求:將大流量請求寫到消息隊列緩存,利用服務器根據自己的處理能力主動到消息緩存隊列中抓取任務處理請求,數據庫層訂閱消息減庫存,減庫存成功的請求返回秒殺成功,失敗的返回秒殺結束。
3. 利用緩存應對讀請求:對於讀多寫少業務,大部分請求是查詢請求,所以可以讀寫分離,利用緩存分擔數據庫壓力。
4. 利用緩存應對寫請求:緩存也是可以應對寫請求的,可把數據庫中的庫存數據轉移到Redis緩存中,所有減庫存操作都在Redis中進行,然后再通過后台進程把Redis中的用戶秒殺請求同步到數據庫中。
可以將緩存和消息中間件 組合起來,Redis緩存系統(接收記錄用戶Request),消息中間件(將redis中的Request 同步到 db)。
方案:本地標記 + redis預處理 + RabbitMQ異步下單 + 客戶端輪詢
描述:通過三級緩沖保護,1、本地標記 2、redis預處理 3、RabbitMQ異步下單,最后才會訪問數據庫,這樣做是為了最大力度減少對數據庫的訪問。
實現:
- 在秒殺階段 使用 本地標記 對 用戶秒殺過的商品 做標記,若被標記過直接返回重復秒殺,未被標記才查詢redis,通過本地標記來減少對redis的訪問
- 搶購開始前,將商品和庫存數據同步到redis中,所有的搶購操作都在redis中進行處理,通過Redis庫存預減 來減少數據庫訪問壓力
- 為了保護系統不受高流量的沖擊而導致系統崩潰的問題,使用RabbitMQ用 (異步隊列) 處理下單,實際做了一層緩沖保護,做了一個窗口模型,窗口模型會實時的刷新用戶秒殺的狀態。
- client端用js輪詢一個接口,用來獲取處理狀態
3.5.3 數據庫層
數據庫層是最脆弱的一層,一般在應用設計時在上游就需要把請求攔截掉,數據庫層只承擔“能力范圍內”的訪問請求。所以,上面通過在服務層引入隊列和緩存,讓最底層的數據庫高枕無憂。但依然可以進行如下方向的優化:
對於秒殺系統,直接訪問數據庫的話,存在一個【事務競爭優化】問題,可使用存儲過程(或者觸發器)等技術綁定操作,整個事務在MySQL端完成,把整個熱點執行放在一個過程當中一次性完成,可以屏蔽掉網絡延遲時間,減少行級鎖持有時間,提高事務並發訪問速度。
3.7 優化秒殺流程
- 秒殺活動開始之前有個活動倒計時,時間到了則會放開秒殺的權限,並生成一個驗證碼展示在前面頁面,並把驗證結果存在redis中,這里利用redis有過期時間的特性,也給驗證碼的緩存加了個過期時間。這里的redis緩存用的是redis的string類型。
- 在秒殺之前先要填一個驗證碼verifyCode,點擊秒殺按鈕時,先發送ajax請求到后台獲取真實的秒殺地址path,這里秒殺地址是隱藏的,目的是防止有人惡意刷秒殺接口。所謂隱藏地址,其實是在請求地址中加一段隨機字符串,這段字符串是變化的,因此秒殺請求地址是動態的; @RequestMapping(value = "/{seckillId}/{md5}/execution" ... )
- 先說下如何獲取真實的秒殺地址,后台先訪問redis,驗證一下這個驗證碼有沒有過期以及這個verifyCode是不是正確,驗證碼驗證通過后,先刪除這個驗證碼緩存,然后生成真實地址;
- 真實地址隨機字符串由uuid以及md5加密生成,並且保存在redis中,並且設置了有效期;
- 從瀏覽器端向秒殺地址發起請求,帶上path參數去后台調用真正的秒殺接口,下面是秒殺接口的邏輯;
- 訪問redis,驗證path有沒有過期,以及是不是正確。這里驗證path以及上面的校驗驗證碼,都是用userId對應生成的一個key值去取redis中的數據;
- path驗證通過后,先訪問內存標識,看秒殺的這個商品有沒有賣完,減少對redis的不必要訪問。每一種參與秒殺活動的商品都在內存里用HashMap設置了一個標識,標識某個商品id商品是否賣完了。這里的是否賣完的內存標識設置以及每種參與秒殺商品的庫存存入redis是在系統啟動時做的;
- 如果內存標識中這個商品沒有賣完,則要看這個用戶在這次活動中是否重復秒殺,因為我們的秒殺規則是一個用戶id對於某個商品id的商品只能秒殺一件。如何判斷該用戶有沒有秒殺過這件商品呢,秒殺記錄也保存在redis緩存中;
- 如果判斷秒殺過則返回提示,如果沒有秒殺過,繼續;
- 上面說過系統加載時redis中保存了各商品對應的庫存,這里用到redis的原子操作的方法decr,將對應商品的庫存減1,此時數據庫時的庫存還沒有減,因此是預減庫存;
- desc方法返回該商品此時的庫存,如果小於0,說明商品已經賣完了,此次秒殺無效,並且設置該商品的內存標識為true,表示已賣完;
- 正確地預減庫存后,然后就要真正操作數據庫了,數據庫一般是性能瓶頸,比較耗時,因此決定用異步方式處理。對於每一條秒殺請求存入消息隊列RabbitMQ中,消息體中要包含哪個用戶秒殺哪個商品的信息,這里是封裝了一個消息體類,這樣一個秒殺請求就進入了消息隊列,一個秒殺請求還沒有完成,真正的秒殺請求的完成得要持久化到數據庫,生成訂單,減了數據庫的庫存才能算數,這時在客戶端顯示的一般是排隊中,比如以前在搶購小米手機時,我就看到這樣的展示,過一會再刷新頁面就顯示沒搶到;
- 消息隊列處理秒殺請求。先從消息體中解析出用戶id和商品id,查數據庫看這個商品是否賣完了,查數據庫看該用戶對於這個商品是否有過秒殺記錄;數據庫減庫存,數據庫生成訂單,這兩項持久化地寫數據庫操作放在同一個事務中,要么都執行成功,要么都失敗。並把秒殺記錄對象,包括秒殺單號、訂單號、用戶id、商品id,存入redis中。如果數據庫減庫存失敗,表明商品賣完了,則要在redis中設置該商品已賣完的標識。消息隊列處理秒殺請求。先從消息體中解析出用戶id和商品id,查數據庫看這個商品是否賣完了,查數據庫看該用戶對於這個商品是否有過秒殺記錄;
- 數據庫減庫存,數據庫生成訂單,這兩項持久化地寫數據庫操作放在同一個事務中,要么都執行成功,要么都失敗。並把秒殺記錄對象,包括秒殺單號、訂單號、用戶id、商品id,存入redis中。如果數據庫減庫存失敗,表明商品賣完了,則要在redis中設置該商品已賣完的標識。
- ajax發起秒殺請求,秒殺請求的處理邏輯最后也只是把這條請求放入消息隊列,並不能返回是否秒殺成功的結果。因此,當秒殺請求正確響應后,即請求放入消息隊列后,需要另外一個請求去輪詢秒殺結果,秒殺成功的標志是生成秒殺訂單,並把秒殺訂單對象放入redis中。所以輪詢秒殺結果,只用去 【輪詢】 redis中是否有對應於 該用戶 的該商品 的秒殺訂單對象,如果有,則表明秒殺成功,並在前台給出提示。
上面的秒殺流程對應的流程圖如下:
步驟1到12,主體是redis預減庫存,生成消息隊列:

步驟13到14是處理消息隊列:

步驟15,是客戶端請求秒殺結果:

4. 問題解析
1. 如何解決庫存的超賣問題?
賣超原因:
(1)一個用戶同時發出了多個請求,如果庫存足夠,沒加限制,用戶就可以下多個訂單。(2)減庫存的sql上沒有加庫存數量的判斷,並發的時候也會導致把庫存減成負數。
解決辦法:
(1):在后端的秒殺表中,對user_id和goods_id加唯一索引,確保一個用戶對一個商品絕對不會生成兩個訂單。
(2):我們的減庫存的sql上應該加上庫存數量的判斷
數據庫自身是有行級鎖的,每次減庫存的時候判斷count>0,它實際上是串行的執行update的,因此絕對不會賣超!。
UPDATE seckill
SET number = number-1
WHERE seckill_id=#{seckillId}
AND start_time <#{killTime}
AND end_time >= #{killTime}
AND number > 0;
2. 如何解決少賣問題—Redis預減成功而DB扣庫存失敗?
前面的方案中會出現一個少賣的問題。Redis在預減庫存的時候,在初始化的時候就放置庫存的大小,redis的原子減操作保證了多少庫存就會減多少,也就會在消息隊列中放多少。
現在考慮兩種情況:
1)數據庫那邊出現非庫存原因比如網絡等造成減庫存失敗,而這時redis已經減了。
2)萬一一個用戶發出多個請求,而且這些請求恰巧比別的請求更早到達服務器,如果庫存足夠,redis就會減多次,redis提前進入賣空狀態,並拒絕。不過這兩種情況出現的概率都是非常低的。
兩種情況都會出現少賣的問題,實際上也是緩存和數據庫出現不一致的問題!
但是我們不是非得解決不一致的問題,本身使用緩存就難以保證強一致性:
在redis中設置庫存比真實庫存多一些就行。
3. 秒殺過程中怎么保證redis緩存和數據庫的一致性?
在其他一般讀大於寫的場景,一般處理的原則是:緩存只做失效,不做更新。
采用Cache-Aside pattern:
失效:應用程序先從cache取數據,沒有得到,則從數據庫中取數據,成功后,放到緩存中。
更新:先把數據存到數據庫中,成功后,再讓緩存失效。
4. Redis中的庫存如何與DB中的庫存保持一致?
Redis中的數量不是庫存,它的作用僅僅時候只是為了阻擋多余的請求透傳到db,起到一個保護DB的作用。因為秒殺商品的數量是有限的,比如只有10個,讓1萬個請求去訪問DB是沒有意義的,因為最多只有10個請求會下單成功,剩余的9990個請求都是無效的,是可以不用去訪問db而直接失敗的。
因此,這是一個偽問題,我們是不需要保持一致的。
5. 為什么要隱藏秒殺接口?
html是可以被右鍵->查看源代碼,如果秒殺地址寫死在源文件中,是很容易就被惡意用戶拿到的,就可以被機器人利用來刷接口,這對於其他用戶來說是不公平的,我們也不希望看到這種情況。所以我們可以控制讓用戶在沒有到秒殺時間的時候不能獲取到秒殺地址,只返回秒殺的開始時間。
當到秒殺時間的時候才返回秒殺地址即seckill_id以及根據seckill_id和salt加密的MD5,前端再次拿着seckill_id和MD5才能執行秒殺。假如用戶在秒殺開始前猜測到秒殺地址seckill_id去請求秒殺,也是不會成功的,因為它拿不到需要驗證的MD5。這里的MD5相當於是用戶進行秒殺的憑證。
6. 一個秒殺系統,500用戶同時登陸訪問服務器A,服務器B如何快速利用登錄名(假設是電話號碼或者郵箱)做其他查詢?
主從復制,讀寫分離
