限流的非常規用途 – 緩解搶購壓力

這兩年因為疫情,節假日都不怎麼外出了,以前每逢節假日都要提前一個月或者半個月搶火車票,人太多的時候會把12306整崩潰。當時很多技術人員指點江山,激揚想法,糞土當年鐵科院。

前幾年小米手機還很火爆,特別搶手,每每新機發布,人山人海、萬人空巷,卻往往都在米兔排隊的身影后收穫失望的表情。

俱往矣!數風流套路,還看今朝!

幾句笑談,引出搶購這回事兒,下邊開始正文。


怎麼理解搶購?

搶購,大部分人應該都經歷過,沒搶過火車票,還沒搶過口罩嗎?沒搶過食鹽,還沒搶過米面糧油嗎?沒搶過618,難道沒搶過雙11?總有一款搶購適合你。

「購」大家都能夠理解,就是花錢買東西。「搶」就很有意思了,首先是因為商品數量有限(這裡不講套路了),然後需要的人特別多,所以怎麼分配就成了一個問題,也就是人們常說的僧多粥少。

不知道大家是否見過這樣的場景,一群人圍在商店門口,大門還沒完全打開,就咆哮著擠了進去,搶著拿貨架上的商品,誰跑得快擠得凶,誰就能拿到,誰拿到誰就可以去付款帶走,這就是搶的最真實寫照了。如果你不曾體驗過,那麼擠公交也可以代替,要麼擠上去,刷卡走人,要麼被擠下來,等待下一輛車。雖然體驗不好,但也確實解決了分配問題。

搶購場景

這裡的所謂「搶」就是看誰先獲得了有利位置,誰先鎖定了商品。那麼鎖定了商品就一定會購買嗎?也不見得,說不定你突然發現囊中羞澀,大家應該聽過很多棄購的新聞。所以對於搶購來說,搶的是「購」的機會,誰先到了誰就有 「購」 的機會。至於會不會真的買回家,還真不好下定論。

搶購程式的問題

理解了搶購,再來看看程式怎麼實現它?

根據前面的理解,我們可以將搶購分為兩個階段,「搶」映射為下單,「購」映射為支付訂單。下單的操作就是鎖定商品,又可以分為鎖定庫存、創建訂單兩個階段,鎖定庫存就相當於你在商店中把商品拿到了手裡,創建訂單就相當於你和商家關於商品的價格達成協議。
WX20211203-224134@2x

操作庫存有一個很有名的「超售」問題,說的是扣減庫存時出錯了,庫存數量扣成了負數,那麼這個問題是如何發生的呢?

後端服務處理請求時一般都是多執行緒的,為了高可用,還可能是分散式的,這個問題就是多執行緒和分散式環境帶來的並行處理造成的。扣減庫存時,一般有兩個步驟,先檢查庫存是否足夠,然後再從庫存中減去相應的數量。這兩個步驟如果不是原子的,那麼在多執行緒或分散式環境下,就會存在多個執行緒同時查詢庫存,單從每個請求處理的上下文看庫存是足夠,但是庫存總量不能滿足所有這些扣減加在一起,結果就扣成了負數。

那怎麼解決呢?方法很多,比如把查詢和更新放到同一個資料庫事務中就可以了,或者使用一個鎖(分散式部署時為分散式鎖)來鎖定對某件商品庫存的查詢+扣減同時只能有一個在處理。

這樣是不是就沒有問題了呢?還是有的。需要注意這裡說的是搶購,意味著會有很多的人來嘗試,在現實生活中,如果人特別多,商店中承載不了這麼多人,很可能會出現安全事故。在電腦程式中也可能會出現耗盡資源導致崩潰的問題,特別是資料庫,IO請求突然爆發,很可能就會被壓垮,上文提到的資料庫事務無疑會加重這一負擔。而鎖也會導致吞吐量的下降,請求堆積無法得到及時處理,一樣會加重伺服器負擔,出現頻繁超時甚至無法服務的問題。

還有什麼辦法呢?以前寫程式的時候有一個觀點,大概是不用太關注程式碼運行效率,增加硬體就好了,比如增加記憶體、升級CPU、換固態硬碟等等。不過在搶購這裡不太可行,還是因為並發量可能太大了,不得不考慮計算資源的成本,而動態資源調配的速度也是個問題。

現實生活中我們還有一個文明的處理方式:排隊,先到先得,售完為止。程式先把請求接下來,保存到隊列中,然後再按照先後順序一個個處理,處理完一個回復一個,這就是隊列的處理方式。這樣做的優點是不用再去協調那些跨執行緒跨進程的資源訪問衝突,計算資源需求也會大幅降低;缺點是用戶要多等一會才能看到結果,體驗略差,不過本來就是很緊俏的東西。

限流之於搶購

上邊已經分析了搶購可能會遇到的問題,那麼限流能幹什麼呢?

在軟體系統中庫存就是一個數字,限流呢也有一個數字,我們可以把限流的閾值作為庫存的數量,請求過來的時候,先用限流處理,能夠通過的就進入下一步,如果被限流了,則說明庫存已經耗盡了,返回錯誤就行了。示意圖如下:

WX20211203-224148@2x

這樣做的好處是什麼呢?減輕後續其它處理的壓力。如果庫存已經耗盡,也再無必要去查詢資料庫,白白浪費寶貴的資料庫資源,甚至分散式鎖、顯式的資料庫事務都不需要了,因為能夠通過限流檢查的就是可以扣減庫存的,直接使用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存儲支援分散式程式統一計數。
  • 限流目標靈活:可以從請求中提取各種數據用於設置限流目標。
  • 支援限流懲罰:可以在客戶端觸發限流後鎖定一段時間不允許其訪問。
  • 動態更改規則:支援程式運行時動態更改限流規則。
  • 自定義錯誤:可以自定義觸發限流後的錯誤碼和錯誤消息。
  • 普適性:原則上可以滿足任何需要限流的場景。