ASP.NET Web API GZip/Deflat 資料遺失迷航記

ASP.NET Web API GZip/Deflat 資料遺失迷航記

前情提要:參考(1)(2),之前測試發現 ASP.NET Web API 有個查詢資料量不小(1.92 MB),並且會有同等級的上傳行為,在 ASP.NET Web API 加入 GZIP/Deflate (解)壓縮來改善效能,資料由 1.92 MB → 50 KB 壓縮率約 40 倍,這是用一點 CPU 來改善效能的好範例。

同事回報,在測試區當資料量提升至 3.3 MB(3338506 bytes) 後,程式有機會(隨機)出錯(Exception)。

先來看 HTTP POST 流程:

RestSharp (RawData: Over 3.3 MB) → GZIP → GZIP ByteArray (79515 bytes) → HTTP POST → ASP.NET Web API

這是個 VSTO 的 Excel 應用程式,在讀取 Web API 資料之後,原封不同進行上傳(HTTP POST)動作,由側錄取得離線 RawData 約 3.3 MB,經過 GZIP 壓縮後得到約 78 KB 的 Byte Array 資料,最後使用參考(2)程式碼送出:

request.AddParameter("application/json", content.ToArray(), ParameterType.RequestBody);
檔案大小

接收流程:

ASP.NET Web API → DecompressionHandler → RawData → Web API life cycle

Web API 收到請求後安排一個 Message Handler 進行解壓縮後取得 RawData 之後,按 Web API 生命流程往下進行。

程式穩定運作,經過許多分析與測試後,在終於在 Web API 側錄到錯誤發生時的資訊:

lose data

在 DecompressionHandler 取得的 GZIP ByteArray 大小為 77889,很明顯小於原始的 79515,把解壓縮的 RawData 資料 Dump 出來後確實資料短少被截斷了,造成後續 Web API 在進行 ModelBinding 時的不正常 Exception。


錯誤方向一:JSON.NET

一開始會很順(很習慣)的從日誌來解問題,在 ModelStatu.Isvalid取得的日誌如下:

{
  "args.JsonParam": {
    "Errors": [
      {
        "ErrorMessage": "",
        "Exception": {
          "ClassName": "Newtonsoft.Json.JsonReaderException",
          "Data": null,
          "ExceptionMethod": "8\nReadStringIntoBuffer\nNewtonsoft.Json, Version=10.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed\nNewtonsoft.Json.JsonTextReader\nVoid ReadStringIntoBuffer(Char)",
          "HResult": -2146233088,
          "HelpURL": null,
          "InnerException": null,
          "Message": "Unterminated string. Expected delimiter: \". Path 'JsonParam', line 1, position 1918469.",
          "RemoteStackIndex": 0,
          "RemoteStackTraceString": null,
          "Source": "Newtonsoft.Json",
          "StackTraceString": " 於 Newtonsoft.Json.JsonTextReader.ReadStringIntoBuffer(Char quote)\r\n 於 Newtonsoft.Json.JsonTextReader.ReadStringValue(ReadType readType)\r\n 於 Newtonsoft.Json.JsonTextReader.ReadAsString()\r\n 於 Newtonsoft.Json.JsonReader.ReadForType(JsonContract contract, Boolean hasConverter)\r\n 於 Newtonsoft.Json.Serialization.JsonSerializerInternalReader.PopulateObject(Object newObject, JsonReader reader, JsonObjectContract contract, JsonProperty member, String id)",
          "WatsonBuckets": null
        }
      },
      {
        "ErrorMessage": "",
        "Exception": {
          "ClassName": "Newtonsoft.Json.JsonSerializationException",
          "Data": null,
          "ExceptionMethod": "8\nThrowUnexpectedEndException\nNewtonsoft.Json, Version=10.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed\nNewtonsoft.Json.Serialization.JsonSerializerInternalReader\nVoid ThrowUnexpectedEndException(Newtonsoft.Json.JsonReader, Newtonsoft.Json.Serialization.JsonContract, System.Object, System.String)",
          "HResult": -2146233088,
          "HelpURL": null,
          "InnerException": null,
          "Message": "Unexpected end when deserializing object. Path 'JsonParam', line 1, position 1918469.",
          "RemoteStackIndex": 0,
          "RemoteStackTraceString": null,
          "Source": "Newtonsoft.Json",
          "StackTraceString": " 於 Newtonsoft.Json.Serialization.JsonSerializerInternalReader.ThrowUnexpectedEndException(JsonReader reader, JsonContract contract, Object currentObject, String message)",
          "WatsonBuckets": null
        }
      }
    ],
    "Value": null
  }
}

Message 進行了解,在 https://github.com/JamesNK/Newtonsoft.Json/releases 11.0.1 可以找到疑似的說明:

  • New feature - Optimized internal buffering when writing very large strings

不過套件升級對我們而言,不是件小工程(這又是另一個故事了)。一開始的測試結果而言,好像也沒解到問題,後面才確定資料面的問題。


錯誤方向二:HTTP POST

那會不會是 RestSharp 的問題?也就是,當我們執行以下程式碼時,我們以為放入了 79515 ByteArray,但 Web API 收到卻是 77889 ByteArray。

request.AddParameter("application/json", content.ToArray(), ParameterType.RequestBody);

查遍了 HTTP 的文件,HTTP Header、HTTP Message(Body) 並無特定限制大小。查看 RestSharp 的 Source Code,AddParameter 底層是呼叫 AddBody 來加入 content。未 GZIP/Deflate 的 3.3 MB 資料能正常 HTTP POST 上去,GZIP/Deflate 壓縮後的 78 KB 資料會短少截斷?

更好玩的是,我改用使用參考(2) HTTPClient 程式,居然可以重現一模一樣的錯誤。

想了好久,實在摸不到頭緒。GZIP/Deflate 壓縮後的 ByteArray 資料到底有何秘密,能讓 HTTP POST 就這樣失效!


錯誤方向三:IIS / Web.config

茅頭開始亂了,腦筋動到了 IIS 與專案的 Web.config 身上。資深一些開發人員都知道,當有大檔案上傳需求時,需要把 IIS 或 Web.config 裡的水管開大些,但 3.3 MB 能正常,78 KB 不正常,理論、實務都說不過去。心態已經是死馬亂醫,測試一下:

<system.web>
    <!--50MB-->
    <httpRuntime targetFramework="4.6.1" maxRequestLength="51200" />
</system.web>

<security>
  <requestFiltering>
    <!--50MB-->
    <requestLimits maxAllowedContentLength="52428800" />
  </requestFiltering>
</security>

沒效。那會不會 IIS 限制 HTTP Header 大小(這還沒查 RestSharp 的 Source Code 之前),還真的有還東西 <headerLimits>(參考:Header Limits <headerLimits>)。

沒效,沒效啦。


亂槍打鳥:直到膝蓋中了一槍!

在無計可施,一陣亂槍打鳥後,程式突然正常運作了,而且是在令人想不到的地方。

private HttpContent DecompressGzipContent(HttpRequestMessage request)
{
    MemoryStream outputStream = new MemoryStream();
    var inputStream = request.Content.ReadAsStreamAsync().Result;
    Trace.TraceInformation($"InputStream Length: {inputStream.Length}");
    using (var decompressor = new GZipStream(inputStream, CompressionMode.Decompress))
    {
        decompressor.CopyToAsync(outputStream);
    }
    outputStream.Seek(0, SeekOrigin.Begin);
    Trace.TraceInformation($"OutputStream Length: {outputStream.Length}");
    HttpContent newContent = AddHeadersToNewContent(request, outputStream);
    return newContent;
}

在 DecompressionHandler 我想追蹤每一次進出 Stream 大小,加入了 Trace.TraceInformation 卻意外讓程式正常運作,不再爆 Exception 了。測試後主要是受 outputStream 影響,只要在 outputStream.Seek() 後有被取出的動作,就能讓原本會被截斷的 ByteArray 都正常了。

以下程式也能正常:

outputStream.Seek(0, SeekOrigin.Begin);
var output = outputStream;
HttpContent newContent = AddHeadersToNewContent(request, outputStream);

七月又還沒到,心裡只有「見鬼了」,這是什麼鬼解法!不過,最少它提供了一絲線索,問題可能在 MemoryStream 身上。


方向:MemoryStream 類別

從文件 Stream.CopyToAsync 方法 (Stream, Int32) 看到一個關鍵值,緩衝區的大小 (以位元組為單位)。 這個值必須大於零。 預設大小為 81920。這一點從 Reference Source 得到證實,也可以從 .NET API Browser 查得。

//We pick a value that is the largest multiple of 4096 that is still smaller than the large object heap threshold (85K).
// The CopyTo/CopyToAsync buffer is short-lived and is likely to be collected at Gen0, and it offers a significant
// improvement in Copy performance.
private const int _DefaultCopyBufferSize = 81920;

從 Reference Source 看一下 CopyToAsyncCopyTo 實作:

private async Task CopyToAsyncInternal(Stream destination, Int32 bufferSize, CancellationToken cancellationToken)
{
    byte[] buffer = new byte[bufferSize];
    int bytesRead;
    while ((bytesRead = await ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0)
    {
        await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
    }
}

將程式碼修改為:

using (var decompressor = new GZipStream(inputStream, CompressionMode.Decompress))
{
    decompressor.CopyToAsync(outputStream, 81920 * 4);
}

有改善 Exception 發生的次數。對,只是改善但還是會發生 Exception!


同步非同步版本

如果你有認真看,.CopyToAsync() 其實是非同步版本,但之前文章裡的實作是同步的。把幾個關鍵字補上。

/// <summary>
/// Decompresses the content of the gzip.
/// </summary>
/// <param name="request">The request.</param>
/// <returns></returns>
private async Task<HttpContent> DecompressGzipContent(HttpRequestMessage request)
{
    MemoryStream outputStream = new MemoryStream(81920 * 4);
    var inputStream = await request.Content.ReadAsStreamAsync();
    using (var decompressor = new GZipStream(inputStream, CompressionMode.Decompress))
    {
        await decompressor.CopyToAsync(outputStream, 81920 * 4).ConfigureAwait(false);
    }
    outputStream.Seek(0, SeekOrigin.Begin);
    HttpContent newContent = AddHeadersToNewContent(request, outputStream);
    return newContent;
}

未結案的小結

其實追的方向與猜的次數比文章還多很多,雖然很開心越來越接進問題核心,不過我要老實說,目前我還是無法確定資料會被截斷真正原因,是 buffer 造成?或是非同步造成?我送進去的大小並無大於預設 81920 < 79515,接近但不超過,而且並不是每一次都會造成 Exception。最好玩的還是 Trace.TraceInformation 可以讓程式正常運作,我猜想,有可能它在讀取 MemoryStream 的動作觸發了如 Stream.Flush() 動作,將所有 buffer 強制進行了輸出(另一個 Stream.Write())的動作。

不過,這個案例最難的是還原真像,我在 GitHub 上有做一個可以讓大家玩玩看的專案,核心 DecompressionHandler 和我們正式環境會出問題程式碼一樣,測試資料量放大至 4.2 MB 左右,內含 40000 筆資料,就資料壓力而言,比我們目前正式環境還大。在測試專案,我盡量模擬各種 GZip / Deflate 的可能使用情境,並同時採用 RestSharp 與 HttpClient 來測試,但可能是本機執行效能快又好的關係,都無法重現資料遺失的情境。

這種隨機發生的問題,有夠難查,第一次如此沒有把握說結案的。如果大大知道一二,也請務必指教指教。

2 則留言:

  1. 在做串流相關處裡望藉Flush真的很容易出狀況,
    現在每次做串流處裡都會記得Flush、Position
    HttpRespone的Stream還要記得Close

    回覆刪除
  2. var inputStream = request.Content.ReadAsStreamAsync().Result; // line 1
    using (var decompressor = new GZipStream(inputStream, CompressionMode.Decompress))
    {
    decompressor.CopyToAsync(outputStream); // line 2
    }
    outputStream.Seek(0, SeekOrigin.Begin);

    我非 .net 的開發者, 從程式碼看
    line 1 與 line 2 都是非同步
    意即 line 1 執行了 要將資料讀到 input stream, 由於採取非同步的方式, 並非資料讀完就執行下一行,
    所以在 line 2 時, input steam 未必有資料, 因此在資料量變大時, 有機率性遇到問題。


    雖然加了 Trace.TraceInformation 不會出錯, 個人認為在資料量往上升, 以及壓力測試下可能會再發生
    以上為個人見解, 僅供參考









    Trace.TraceInformation($"InputStream Length: {inputStream.Length}");

    回覆刪除

感謝您的留言,如果我的文章你喜歡或對你有幫助,按個「讚」或「分享」它,我會很高興的。