Asp.Net Core API返回IAsyncEnumerable<T>未实现流式传输问题
问题背景
搭建了一个Asp.Net Core API控制器,意图流式返回277000+条LocationDto数据,但实际响应被缓冲,客户端调用时抛出OutOfMemoryException。
API控制器代码
[HttpPost] public async IAsyncEnumerable<LocationDto> GetLocations([FromBody] LocationReportQueryDto locationReportQuery, CancellationToken token = default) { var result = await locationReportDataAccess.GetFilteredLocationsAsync(locationReportQuery, token); foreach (var location in result) { yield return location; } }
客户端调用代码
public async Task<List<ItemLocDto>> GetFilteredLocationsAsync(LocationReportQueryDto locationReportQuery, CancellationToken token = default) { var httpClient = httpClientFactory.CreateClient("DataAccess"); var response = await httpClient.PostAsJsonAsync("/reports/LocationReport/GetFilteredLocations", locationReportQuery, token); response.EnsureSuccessStatusCode(); var list = await response.Content.ReadFromJsonAsync<List<ItemLocDto>>(cancellationToken: token); if (list == null) throw new HttpRequestException("LocationReportDataAccess GetFilteredLocationsAsync HTTP Call - Response is null"); return list; }
抛出的异常信息
blazor.server.js:1 [2022-12-18T04:51:19.544Z] Error: System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown. at System.IO.MemoryStream.set_Capacity(Int32 value) at System.IO.MemoryStream.EnsureCapacity(Int32 value) at System.IO.MemoryStream.Write(Byte[] buffer, Int32 offset, Int32 count) at System.Net.Http.HttpContent.LimitMemoryStream.Write(Byte[] buffer, Int32 offset, Int32 count) at System.IO.MemoryStream.WriteAsync(ReadOnlyMemory`1 buffer, CancellationToken cancellationToken) --- End of stack trace from previous location --- at System.Net.Http.HttpConnection.ChunkedEncodingReadStream.CopyToAsyncCore(Stream destination, CancellationToken cancellationToken) at System.Net.Http.HttpConnectionResponseContent.SerializeToStreamAsync(Stream stream, TransportContext context, CancellationToken cancellationToken) at System.Net.Http.HttpContent.LoadIntoBufferAsyncCore(Task serializeToStreamTask, MemoryStream tempBuffer) at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken) at Portal.Infrastructure.DataAccess.Reports.LocationReportDataAccess.GetFilteredLocationsAsync(LocationReportQueryDto locationReportQuery, CancellationToken token) in C:\Users\LOFT\RiderProjects\Portal\src\Libraries\Portal.Infrastructure\DataAccess\Reports\LocationReportDataAccess.cs:line 30 at Portal.Services.Reporting.LocationResultsService.GetResultsAsync(LocationInputConfig config, CancellationToken token) in C:\Users\LOFT\RiderProjects\Portal\src\Libraries\Portal.Services\Reporting\LocationResultsService.cs:line 137 at Portal.Web.Pages.Reports.LocationList.OnGenerateClick() in C:\Users\LOFT\RiderProjects\Portal\src\Presentation\Portal.Web\Pages\Reports\LocationList.razor:line 679 at Microsoft.AspNetCore.Components.ComponentBase.CallStateHasChangedOnAsyncCompletion(Task task) at Microsoft.AspNetCore.Components.RenderTree.Renderer.GetErrorHandledTask(Task taskToHandle, ComponentState owningComponentState)
核心原因分析
服务端未实现真正的流式读取:
locationReportDataAccess.GetFilteredLocationsAsync返回的是List<LocationDto>,这意味着执行await时,所有27万+条数据已被一次性加载到服务器内存。后续的foreach + yield return只是遍历内存中的列表,并未实现数据库层面的流式传输,ASP.NET Core无法对已加载到内存的数据进行流式输出,最终仍会缓冲整个响应。客户端一次性加载全量数据:
客户端使用ReadFromJsonAsync<List<ItemLocDto>>会将整个HTTP响应内容全部读取到内存,再反序列化为List<ItemLocDto>,直接触发内存溢出。
解决方案
服务端调整:实现数据库级流式读取
修改locationReportDataAccess.GetFilteredLocationsAsync,使其返回IAsyncEnumerable<LocationDto>而非List<LocationDto>,确保从数据库查询时采用流式读取(以EF Core为例):
// 数据访问层方法修改示例 public async IAsyncEnumerable<LocationDto> GetFilteredLocationsAsync(LocationReportQueryDto query, [EnumeratorCancellation] CancellationToken token) { // 避免使用ToListAsync(),改用AsAsyncEnumerable()实现流式查询 var queryable = dbContext.Locations .Where(/* 你的过滤条件 */) .Select(l => new LocationDto { /* 映射字段 */ }); await foreach (var location in queryable.AsAsyncEnumerable().WithCancellation(token)) { yield return location; } }
控制器方法可简化为直接返回异步枚举:
[HttpPost] public async IAsyncEnumerable<LocationDto> GetLocations([FromBody] LocationReportQueryDto locationReportQuery, [EnumeratorCancellation] CancellationToken token = default) { return locationReportDataAccess.GetFilteredLocationsAsync(locationReportQuery, token); }
注意:ASP.NET Core 3.0+默认支持
IAsyncEnumerable的流式JSON序列化(使用System.Text.Json),无需额外配置。
客户端调整:流式读取并处理响应
客户端不再一次性将所有数据加载为List,而是流式读取响应内容、逐条处理数据:
public async IAsyncEnumerable<ItemLocDto> GetFilteredLocationsAsync(LocationReportQueryDto locationReportQuery, [EnumeratorCancellation] CancellationToken token = default) { var httpClient = httpClientFactory.CreateClient("DataAccess"); var response = await httpClient.PostAsJsonAsync( "/reports/LocationReport/GetFilteredLocations", locationReportQuery, token, // 关键:指定ResponseHeadersRead,避免缓冲整个响应 HttpCompletionOption.ResponseHeadersRead ); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(token); // 流式反序列化逐条数据 await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<ItemLocDto>(stream, cancellationToken: token)) { if (item != null) { yield return item; } } }
调用该方法时,需用await foreach逐条处理,避免一次性存入List:
// 示例调用 await foreach (var loc in GetFilteredLocationsAsync(query, token)) { // 逐条处理数据,比如添加到分页集合、写入文件等 ProcessLocation(loc); }
关键注意点
- 数据库查询必须避免一次性加载全量数据,使用异步枚举从数据库流式读取。
- 客户端需指定
HttpCompletionOption.ResponseHeadersRead,让HttpClient在获取响应头后就开始读取响应流,而非等待整个响应完成。 - 若使用第三方ORM或数据访问库,需确认其支持异步流式读取,避免底层仍全量加载数据。
内容的提问来源于stack exchange,提问作者AtomicallyBeyond

