限流的非正式用途 - 解決重復提交問題


問題

在業務應用程序開發中,經常遇到用戶重復提交的問題。

比如有一個報名的表單,如果用戶不小心連續點擊了提交按鈕多次,數據庫中就可能產生多條報名記錄;再或者正常提交后,因為網絡或者服務端的原因,前端沒有及時收到提交結果,則用戶可能認為自己沒有提交成功,然后再次甚至多次提交,數據庫中也可能產生多條此用戶的報名記錄。

這個例子中的情況還不會對業務造成多大影響,如果是涉及到資源增減的場景,比如賬戶、庫存等的操作,可能就比較麻煩了。

對於解決問題的辦法,你可能會說,前端提交后鎖住按鈕不就可以了嘛!確實能夠解決一部分問題。為什么是一部分呢?因為調用者可能繞過前端界面,直接訪問后端服務。那你也可能會說,在服務端加上判斷不就可以了嘛!

好的,我們先來看一下怎么判斷:

在上面報名的例子中,我們假設用戶的身份是用手機號來區分的,那么服務端判斷是否重復提交的時候,可以用一條SQL查一下:

select count(*) from table where mobile = 'xxx'

如果查詢出的數量大於0,我們就認為已經報名過,從而中斷程序的執行,返回錯誤。

我們再來看兩個例子:

  • 員工提交報銷,一不小心提交了兩次,這時服務端或許可以通過 “用戶Id+提交時間” 的方式來判斷。

  • 給用戶發放積分的處理,第一次請求超時了,又重試了一次,這時服務端或許可以通過 “用戶Id+事務Id” 的方式判斷是否重復提交。

還有很多的重復提交的場景,我們也都可以通過在服務端增加類似重復判斷的方式來解決。但是每次都要編寫這些大體類似的業務判斷邏輯,軟件開發不是經常說: Don't repeat yourself 嗎?

再者增加判斷也不能完美解決問題,為什么呢?因為后端服務一般是多線程處理的,甚至可能是分布式的,只是寫個判斷的邏輯還不夠,還要處理數據一致的問題,這個就有點技術含量了。

有什么通用的簡單辦法嗎?

方案

問題歸納

這里先來看下服務端可能收到重復請求的場景,我歸納如下:

  • 前端把關不嚴,用戶 “提交中” 時沒有禁用提交按鈕,導致用戶多次點擊,向服務端發起多次請求。
  • 調用者直接訪問服務,因程序錯誤(如死循環)或者攻擊行為(如重放攻擊),導致對同一業務多次發起服務請求。
  • 程序重試,可能會在前端或者后端的代碼中使用重試邏輯,發生某些異常或者超時的時候自動重試,導致一次業務多次請求服務。
  • 多線程或分布式環境下,加了重復判斷,但是因為數據一致性問題導致判斷失效,業務被重復處理。

前兩種場景比較好理解,這里不做過多說明。重點說明一下后兩種是如何發生的。

先來看重試導致的重復提交,客戶端第一次請求后沒有正常收到返回,判斷超時后,再次發起第二次業務請求,此時服務端執行了兩次相同的業務處理。

WX20211130-221617@2x

再來看多線程環境下的重復提交,線程1訪問數據庫查詢數據,然后判斷沒有提交過,在線程1寫入數據前,線程2也來訪問數據庫查詢數據,然后判斷也沒有提交過,於是線程1和線程2都向數據庫寫入相同的數據。

WX20211130-221644@2x

限流方案

現在到重點了,限流為何能夠應用到解決重復提交的問題?

重復提交滿足限流的基本要素

關於限流,這里定義如下幾個基本要素(個人總結):

  • 限流有一個針對的目標,比如限制IP、限制用戶等。

  • 限流有一個時間周期,比如1秒之內、1分鍾之內等。

  • 限流有一個對應時間周期的閾值,比如每秒10次、每分鍾100次等。

再回到重復提交上,我們可以分析得出:

  • 重復提交可以通過某些數據進行識別,這個就可以看作是限流目標。

  • 重復提交天然的存在一個時間緯度,可以對應到限流的時間周期上。

  • 重復提交即提交一次之后繼續提交,可以使用限流的閾值進行控制,並固定閾值為1。

看着有戲,再通過兩個例子驗證下:

報名重復提交問題

  • 限流目標:手機號
  • 限流周期:從用戶首次提交到報名結束

手機號可以從報名信息中提取,用戶第一次提交時會使用手機號創建一條限流計數記錄,報名結束之前,用戶再次提交時,限流計數超過1,從而觸發限流邏輯,向調用方返回錯誤;用戶報名結束后再次提交,服務可能已經關停,或者前端已關停入口,后端也有報名截止時間的判斷,即使還可以提交,已經沒有什么意義,對業務沒有影響。

員工提交報銷問題

  • 限流目標:員工Id + 提交分鍾數
  • 限流周期:從用戶提交 到 其后的1分鍾之內

員工Id可以從會話中提取到,提交分鍾數可以用 yyyyMMddHHmm 表達,用戶第一次提交報銷時會創建 “用戶Id + 提交分鍾數” 的限流計數記錄,用戶在1分鍾內再次提交時,限流計數會超過1,觸發限流處理邏輯,向調用方返回錯誤;用戶1分鍾后再次提交時,會創建新的限流計數記錄,同時不會觸發限流處理邏輯,可以正常提交。

從以上分析不難看出,重復提交可以滿足限流的幾個基本要素,那限流可以解決所有重復提交的問題嗎?

限流用於重復提交的限制

不過你也許已經發現,這里有一個隱含的假設:所有第一次業務提交都得到了正確的處理。所以限流計數1才能代表已經提交過一次。這在實際運行中很難保證,因為限流計數和業務處理往往不在一個事務中,限流計數一般更靠前一些,所以限流計數可能沒問題,但是業務處理並沒有成功,比如超時、斷網、宕機等基礎設施問題,甚至是業務條件不滿足等業務邏輯問題。那么限流又要被一棍子打死了嗎?

在遇到比較棘手的問題的時候,我經常想之前是否出現過呢?

在網絡論壇比較流行的年代,發帖或者回帖后,都會先進入到一個數秒的倒計時跳轉頁面,倒計時結束后再跳轉到正常的頁面。

這不就是一種限流並有效防止了重復提交的方式嘛!這個設計給到的一個啟示就是:系統可以在很短的一個時間之內,通過限流這種低成本的方式,限制用戶的重復操作,正常用戶可能不會感覺到或者只有輕微的影響,但卻很大程度上能夠避免重復提交帶來的數據問題,也可以屏蔽某些惡意行為。

基於這個認識,我們再來看下前文提到的幾個重復請求場景:

  • 前端把關不嚴,導致用戶多次點擊,向服務端發起多次請求。

    服務端可以對某一個用戶的提交使用短時間跨度的限流,比如5秒1次,正常用戶填寫1個表單耗費的時間應在5秒以上,假如用戶在5s內又提交了,則前端可以根據服務端返回的錯誤碼提示用戶,並跳轉到提交結果查詢頁面,用戶可以看到自己的提交結果。如果第一次提交真的沒有處理成功,則用戶可以再重新填寫提交表單,因為這時距離第一次提交超過了5s,因此用戶不會被限流。因為絕大部分提交都應該是正常的,所以這種概率比較小,但是也給了補救的機會,用戶可能會抱怨幾句。

    這里也可能出現服務端處理過慢,查詢結果的時候查不到的問題,解決這個問題或許可以在服務端設置一個盡可能短的超時時間,在前端多查詢幾次,其出現的概率一般不高,而且也可以通過技術手段降低。

  • 直接訪問接口時,因程序錯誤或者攻擊行為,導致同一業務多次發起服務請求。

    服務端可以對同一個訪問者的提交使用短時間跨度的限流,比如5秒1次,如果觸發限流,同時給予一個限流懲罰,30秒內都不能提交,還可以對這個限流懲罰時間采用指數遞增的方式。這樣可以盡量降低外部程序異常行為對服務的影響,同時調用方正常處理后又能自動恢復正常。

    在某些接口中可能會定義時間戳、驗證碼、SessionId之類的參數,也可以把它們加到限流目標中,用以准確識別重復提交。

  • 程序重試導致重復提交,發生某些異常或者超時的時候自動重試,導致一次業務多次請求服務。

    這可能是個設計問題。應該避免在中間服務發起提交行為的重試操作,因為很多的業務處理可能都不是冪等的,中間服務的重試行為因為訪問者看不到,所以很可能被忽略掉,從而導致數據問題。如果需要重試,應該僅在業務的發起處進行重試,發起者應該清楚重試邏輯可能導致的問題,並盡量降低影響。

    可以在最上層服務引入限流處理,選擇合適的限流目標,限流時間跨度和限流閾值,內部服務一般認為相對可靠,沒必要引入限流。

  • 多線程或分布式環境下,加了重復判斷,但是因為數據一致性問題導致判斷失效,業務被重復處理。

    通過選擇合適的限流目標,使用分布式一致性的限流算法,比如使用Redis,也可以實現提交操作在某個時間范圍內只能被執行一次,從而讓重復判斷的結果有效,避免業務重復處理。

通過對這幾種重復提交場景的分析,可以看到:限流並不能完美的避免重復提交,但是它可以提供一種通用的機制很大程度的降低重復提交,而且這種機制的成本可以很低,相比每個方法中硬編碼重復數據的判斷、查詢數據庫、使用分布式鎖等帶來的成本可能都要低不少。當然為了處理的更好,還可能需要前后端的一些其它配合。

其實也可以在業務處理中增加對重復數據的判斷,因為前邊已經被限流攔截了一道,重復執行的機會可以大為減少,重復數據判斷的邏輯帶來的影響也將很低。特別是一些關鍵業務中,重復提交導致的麻煩可能比較大,不過這時候可能要解決數據庫的查詢性能、分布式的數據一致問題。兩害相權取其輕。

實現

分析清楚了限流對於限制重復提交的意義,就可以在合適的場景來應用它。

比如存在一個前后端分離的系統,用戶都通過一個前端界面來處理業務,用戶同時一般只能操作一個界面,為了盡可能避免重復提交問題,我們在后端API中增加對用戶提交行為的限流操作,對於每個獨立的用戶限制5秒之內只能提交1次。

這里還是使用 FireflySoft.RateLimit 來做限流,后端API基於ASP.NET Core WebAPI實現。

安裝 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,這里假設它是從HTTP Header中傳遞過來的
                    return (context as HttpContext).Request.GetTypedHeaders().Get<string>("userId");
                },
                CheckRuleMatching = context =>
                {
                  	// 在這里判斷當前請求是否 “提交行為”,提交行為才進行限流處理
                    var path = (context as HttpContext).Request.Path.Value;
                    if(path == "/Comapny/Add"
                      ||path == "/Comapny/Update"
                      ||path == "/Goods/Purchase"
                      ||path == "/Goods/ChangePrice"
                      ||path == "/Order/Pay"
                      ||path == "/Order/Cancel"){
                        return true;
                    }
                    return false;
                },
                Name = "用戶提交行為限流",
                LimitNumber = 1, // 限流閾值
                StatWindow = TimeSpan.FromSeconds(5), //限流的時間窗口,這里是5秒
                StartTimeType = StartTimeType.FromNaturalPeriodBeign
            }
        })
    );

    ...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseRateLimit();

    ...
}

只需要上邊這些簡單的代碼就可以用來限制重復提交了。可以跑起來試試。

不過如果你要在分布式環境下使用,還需要准備一個Redis,將 InProcessFixedWindowAlgorithm 換成 RedisFixedWindowAlgorithm ,除了多傳遞一個Redis連接對象,其它的代碼都是一樣的。

FireflySoft.RateLimit 是一個開源的.NET Standard限流類庫,其使用靈活輕巧,可以在 GitHub 或者 Gitte 上訪問到最新的代碼。


好了,這就是這篇文章的主要內容了。對於用限流解決重復提交的問題,你有什么想說的呢?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM