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 側錄到錯誤發生時的資訊:
在 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 看一下 CopyToAsync
與 CopyTo
實作:
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 來測試,但可能是本機執行效能快又好的關係,都無法重現資料遺失的情境。
這種隨機發生的問題,有夠難查,第一次如此沒有把握說結案的。如果大大知道一二,也請務必指教指教。
在做串流相關處裡望藉Flush真的很容易出狀況,
回覆刪除現在每次做串流處裡都會記得Flush、Position
HttpRespone的Stream還要記得Close
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}");