限流的非常規用途 – 緩解搶購壓力
這兩年因為疫情,節假日都不怎麼外出了,以前每逢節假日都要提前一個月或者半個月搶火車票,人太多的時候會把12306整崩潰。當時很多技術人員指點江山,激揚想法,糞土當年鐵科院。
前幾年小米手機還很火爆,特別搶手,每每新機發布,人山人海、萬人空巷,卻往往都在米兔排隊的身影后收穫失望的表情。
俱往矣!數風流套路,還看今朝!
幾句笑談,引出搶購這回事兒,下邊開始正文。
怎麼理解搶購?
搶購,大部分人應該都經歷過,沒搶過火車票,還沒搶過口罩嗎?沒搶過食鹽,還沒搶過米面糧油嗎?沒搶過618,難道沒搶過雙11?總有一款搶購適合你。
「購」大家都能夠理解,就是花錢買東西。「搶」就很有意思了,首先是因為商品數量有限(這裡不講套路了),然後需要的人特別多,所以怎麼分配就成了一個問題,也就是人們常說的僧多粥少。
不知道大家是否見過這樣的場景,一群人圍在商店門口,大門還沒完全打開,就咆哮著擠了進去,搶著拿貨架上的商品,誰跑得快擠得凶,誰就能拿到,誰拿到誰就可以去付款帶走,這就是搶的最真實寫照了。如果你不曾體驗過,那麼擠公交也可以代替,要麼擠上去,刷卡走人,要麼被擠下來,等待下一輛車。雖然體驗不好,但也確實解決了分配問題。
這裡的所謂「搶」就是看誰先獲得了有利位置,誰先鎖定了商品。那麼鎖定了商品就一定會購買嗎?也不見得,說不定你突然發現囊中羞澀,大家應該聽過很多棄購的新聞。所以對於搶購來說,搶的是「購」的機會,誰先到了誰就有 「購」 的機會。至於會不會真的買回家,還真不好下定論。
搶購程式的問題
理解了搶購,再來看看程式怎麼實現它?
根據前面的理解,我們可以將搶購分為兩個階段,「搶」映射為下單,「購」映射為支付訂單。下單的操作就是鎖定商品,又可以分為鎖定庫存、創建訂單兩個階段,鎖定庫存就相當於你在商店中把商品拿到了手裡,創建訂單就相當於你和商家關於商品的價格達成協議。
操作庫存有一個很有名的「超售」問題,說的是扣減庫存時出錯了,庫存數量扣成了負數,那麼這個問題是如何發生的呢?
後端服務處理請求時一般都是多執行緒的,為了高可用,還可能是分散式的,這個問題就是多執行緒和分散式環境帶來的並行處理造成的。扣減庫存時,一般有兩個步驟,先檢查庫存是否足夠,然後再從庫存中減去相應的數量。這兩個步驟如果不是原子的,那麼在多執行緒或分散式環境下,就會存在多個執行緒同時查詢庫存,單從每個請求處理的上下文看庫存是足夠,但是庫存總量不能滿足所有這些扣減加在一起,結果就扣成了負數。
那怎麼解決呢?方法很多,比如把查詢和更新放到同一個資料庫事務中就可以了,或者使用一個鎖(分散式部署時為分散式鎖)來鎖定對某件商品庫存的查詢+扣減同時只能有一個在處理。
這樣是不是就沒有問題了呢?還是有的。需要注意這裡說的是搶購,意味著會有很多的人來嘗試,在現實生活中,如果人特別多,商店中承載不了這麼多人,很可能會出現安全事故。在電腦程式中也可能會出現耗盡資源導致崩潰的問題,特別是資料庫,IO請求突然爆發,很可能就會被壓垮,上文提到的資料庫事務無疑會加重這一負擔。而鎖也會導致吞吐量的下降,請求堆積無法得到及時處理,一樣會加重伺服器負擔,出現頻繁超時甚至無法服務的問題。
還有什麼辦法呢?以前寫程式的時候有一個觀點,大概是不用太關注程式碼運行效率,增加硬體就好了,比如增加記憶體、升級CPU、換固態硬碟等等。不過在搶購這裡不太可行,還是因為並發量可能太大了,不得不考慮計算資源的成本,而動態資源調配的速度也是個問題。
現實生活中我們還有一個文明的處理方式:排隊,先到先得,售完為止。程式先把請求接下來,保存到隊列中,然後再按照先後順序一個個處理,處理完一個回復一個,這就是隊列的處理方式。這樣做的優點是不用再去協調那些跨執行緒跨進程的資源訪問衝突,計算資源需求也會大幅降低;缺點是用戶要多等一會才能看到結果,體驗略差,不過本來就是很緊俏的東西。
限流之於搶購
上邊已經分析了搶購可能會遇到的問題,那麼限流能幹什麼呢?
在軟體系統中庫存就是一個數字,限流呢也有一個數字,我們可以把限流的閾值作為庫存的數量,請求過來的時候,先用限流處理,能夠通過的就進入下一步,如果被限流了,則說明庫存已經耗盡了,返回錯誤就行了。示意圖如下:
這樣做的好處是什麼呢?減輕後續其它處理的壓力。如果庫存已經耗盡,也再無必要去查詢資料庫,白白浪費寶貴的資料庫資源,甚至分散式鎖、顯式的資料庫事務都不需要了,因為能夠通過限流檢查的就是可以扣減庫存的,直接使用Update就可以了;不過此時扣減庫存和下訂單時的壓力仍舊存在,比如3W個請求同時進來,限流可以攔截其中的27000,剩下的3000會進入下一環節,影響還是需要仔細考慮的。再看使用隊列的方式,加了限流,隊列中也只接收能夠下單的請求,隊列壓力小,後續處理隊列時的資料庫操作同樣也減少很多。
這裡可能還會出現一些問題,比如限流檢查通過了,但是後續的處理因為某種原因失敗了,庫存不能回收,實際上就浪費了一次機會,不過這不是搶購的主要矛盾,一般會忽略這個問題。
當然對於限購來說還有很多其它問題,限流就很難發揮作用了,比如前端高並發查詢庫存數,這裡就不多講了。
技術實現
這裡以ASP.NET Core Web API為例,限流組件選擇 FireflySoft.RateLimit ,演算法選擇簡單的固定窗口也就是計數器方式,進程內計數。這裡沒有選擇Redis,是因為進程內計數比較簡單,不需要外部依賴,演示方便;即使在分散式環境下,一般也可以應對,比如總的限流閾值是 1000/小時,部署了5份服務實例,只需要在程式中使用 200/小時 的閾值就可以了;但是如果負載均衡不均勻,某些情況下可能不太合適,此時可以選擇Redis全局一致限流。
安裝 Nuget 包
使用包管理器控制台:
Install-Package FireflySoft.RateLimit.AspNetCore
或者使用 .NET CLI:
dotnet add package FireflySoft.RateLimit.AspNetCore
或者直接添加到項目文件中:
<ItemGroup>
<PackageReference Include="FireflySoft.RateLimit.AspNetCore" Version="2.*" />
</ItemGroup>
編寫限流規則
在Startup.cs中註冊限流服務並使用限流中間件。裡邊添加了一些注釋,你可以仔細看看。
public void ConfigureServices(IServiceCollection services)
{
...
services.AddRateLimit(new InProcessFixedWindowAlgorithm(
new[] {
new FixedWindowRule()
{
Id = "1",
ExtractTarget = context =>
{
// 限流的目標:商品Id,這裡假設它是從Url參數中傳遞過來的
// 實際使用可能還需要很多的安全檢查,這裡為了演示簡單處理
return (context as HttpContext).Request.Query["GoodsId"].ToString();;
},
CheckRuleMatching = context =>
{
// 在這裡判斷當前請求是否 「搶購下單」,搶購下單才進行限流處理
// 實際使用可能還需要很多的檢查,這裡為了演示簡單處理
var path = (context as HttpContext).Request.Path.Value;
if(path == "/Order/PanicBuying"){
return true;
}
return false;
},
Name = "緩解搶購壓力限流",
LimitNumber = 1000, // 限流閾值,等於庫存數量
StatWindow = TimeSpan.FromSeconds(3600), //限流的時間窗口,這裡是3600秒
StartTimeType = StartTimeType.FromNaturalPeriodBeign
}
}),
new HttpErrorResponse()
{
BuildHttpContent = (context, ruleCheckResult) =>
{
return "同學!你來晚了,已經售罄!";
}
},
);
...
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseRateLimit();
...
}
只需要上邊這些簡單的程式碼就可以跑起來了,你可以用Postman測試一下。
如果你不想在Web程式中使用,或者需要更多自定義的設置,也可以直接安裝 FireflySoft.RateLimit.Core 這個包,它可以用於各種.NET程式。點擊查看示例: bosima/FireflySoft.RateLimit(github.com)
如果你想要使用Redis,只需將 InProcessFixedWindowAlgorithm 換成 RedisFixedWindowAlgorithm ,除了多傳遞一個Redis連接對象,其它的程式碼都是一樣的。
好了,這就是本文的主要內容了。對於使用限流緩解搶購壓力,你有什麼想說的呢?
FireflySoft.RateLimit 是一個開源的.NET Standard限流類庫,其使用靈活輕巧,可以在 GitHub 上訪問到最新的程式碼。其主要特點包括:
- 多種限流演算法:內置固定窗口、滑動窗口、漏桶、令牌桶四種演算法,還可自定義擴展。
- 多種計數存儲:目前支援記憶體、Redis兩種存儲方式。
- 分散式友好:通過Redis存儲支援分散式程式統一計數。
- 限流目標靈活:可以從請求中提取各種數據用於設置限流目標。
- 支援限流懲罰:可以在客戶端觸發限流後鎖定一段時間不允許其訪問。
- 動態更改規則:支援程式運行時動態更改限流規則。
- 自定義錯誤:可以自定義觸發限流後的錯誤碼和錯誤消息。
- 普適性:原則上可以滿足任何需要限流的場景。