使用JsonSerializer.DeserializeAsyncEnumerable处理大JSON性能缓慢如何解决?
问题
使用.NET 8、System.Text.Json 8、HttpClient发起请求,以HttpConnection.ChunkedEncodingReadStream作为响应输入流,通过JsonSerializer.DeserializeAsyncEnumerable异步反序列化枚举大型JSON对象时性能极差。
代码示例
await using var fileStream = File.OpenRead(filePath); using var content = new MultipartFormDataContent(); using var streamContent = new StreamContent(fileStream); streamContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "file", FileName = Path.GetFileName(filePath) }; content.Add(streamContent); var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "api/v1/streaming") { Content = content }; var response = await httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); var streamWrapper = new StreamWrapper(responseStream); var bufferSize = 10 * 1024 * 1024; var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = {new JsonElementToObjectConverter()}, DefaultBufferSize = bufferSize }; await foreach (var batchPart in JsonSerializer.DeserializeAsyncEnumerable<BatchPart>(streamWrapper, options, cancellationToken)) { }
排查结果
JsonSerializer尝试读取DefaultBufferSize大小的数据,但服务器/输入流无法提供足够数据填满缓冲区,随后尝试反序列化对象,数千次迭代下导致性能极差。读取日志显示每次仅读取少量字节却尝试填充大缓冲区:
Time Iteration #N Buffer to read Read bytes 12:35:45.145 1 16777216 535 12:35:49.327 2 16777216 4090 12:35:49.33 3 16773126 10671 12:35:49.33 4 16762455 4088 12:35:49.331 5 16758367 10673 ... 12:36:04.751 882 24051345 4088 12:36:04.789 883 24047257 10658 12:36:04.827 884 24036599 4088 12:36:04.871 885 24032511 10667 ...
整体耗时约15分钟,但将HttpCompletionOption改为ResponseContentRead(不使用分块读取)仅需约2秒。问题根源在于反序列化时内部使用fillBuffer:false,导致每次读取都无法高效利用缓冲区。
解决方案
1. 调整DefaultBufferSize为合理小值
过大的缓冲区(如10MB)会导致每次读取都等待填充大缓冲区,改为匹配实际读取字节数的大小,比如4KB或8KB:
var bufferSize = 4 * 1024; // 4KB var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = {new JsonElementToObjectConverter()}, DefaultBufferSize = bufferSize };
2. 使用BufferedStream包装响应流
给HttpConnection.ChunkedEncodingReadStream套一层BufferedStream,让小批量分块数据先缓冲到内存,再提供给JsonSerializer,减少频繁的小读取操作:
var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); // 使用64KB左右的缓冲大小平衡内存占用和读取效率 await using var bufferedStream = new BufferedStream(responseStream, 64 * 1024); // 若StreamWrapper无特殊必要逻辑,直接使用bufferedStream即可 await foreach (var batchPart in JsonSerializer.DeserializeAsyncEnumerable<BatchPart>(bufferedStream, options, cancellationToken)) { // 处理数据 }
3. 移除不必要的StreamWrapper
如果StreamWrapper没有特殊业务逻辑,直接使用原始响应流或包装后的BufferedStream,避免额外的流包装开销。
4. 优化自定义转换器JsonElementToObjectConverter
检查该转换器的实现是否存在不必要的内存分配或重复序列化操作,若业务允许,改用强类型反序列化代替JsonElement转换,减少转换开销。
5. 手动控制Utf8JsonReader处理流
若上述方法仍不满足需求,可绕过DeserializeAsyncEnumerable,直接使用Utf8JsonReader手动控制读取逻辑:
await using var bufferedStream = new BufferedStream(responseStream, 64 * 1024); var reader = new Utf8JsonReader(bufferedStream, options); while (reader.Read()) { if (reader.TokenType == JsonTokenType.StartObject) { var batchPart = JsonSerializer.Deserialize<BatchPart>(ref reader, options); // 处理batchPart } }
6. 协调服务器调整分块大小
若服务器每次仅返回几百字节的极小分块,也会导致客户端频繁读取。可协调服务器调整分块输出的批量大小,减少IO往返次数。
内容的提问来源于stack exchange,提问作者Oleh Hrechukh

