ASP.NET Core API如何以流分块传输替代大型JSON数组返回
ASP.NET Core 实现大列表分块流式返回方案
你现有代码的核心性能问题是ToListAsync()会一次性将全量数据加载到服务端内存,完成全部序列化后才会向客户端发送响应,首包延迟高、内存占用大。ASP.NET Core 没有PushStreamContent不代表做不了流传输,下面两个方案都可以实现边查边发、分块传输的效果,首包延迟可以降到百毫秒以内。
方案一:普通Web API 流式JSON返回(改造成本最低,兼容现有HTTP客户端)
这个方案不需要改接口协议,返回的还是标准JSON格式,普通HTTP客户端无需特殊改造即可调用,支持流式读取的客户端可以做到边接收边处理。
实现要点
- 必须关闭响应缓冲,否则ASP.NET Core会默认缓存全量响应后再发送
- 用EF Core的
AsAsyncEnumerable()实现数据库流式读取,不要用ToListAsync()一次性加载全量数据 - 可以灵活控制每批发送的数据块大小,比如你要的100条/块
最简实现(自动分块序列化)
[HttpGet] [ResponseCache(NoStore = true)] public async IAsyncEnumerable<Price> GetPrices() { // 关闭响应缓冲,数据生成一块就发送一块 Response.Buffer = false; // 流式读取数据库,不会一次性加载全量数据到内存 await foreach (var price in _context.Prices .AsAsyncEnumerable() .WithCancellation(HttpContext.RequestAborted)) { yield return price; } }
严格控制100条/块的手动实现
如果需要明确按100条为单位分块发送,可以直接操作响应流手动控制序列化节奏:
[HttpGet] public async Task GetPrices() { Response.Buffer = false; Response.ContentType = "application/json"; Response.Headers["Transfer-Encoding"] = "chunked"; await using var jsonWriter = new Utf8JsonWriter(Response.Body, new JsonWriterOptions { SkipValidation = true }); jsonWriter.WriteStartArray(); var batchBuffer = new List<Price>(100); await foreach (var price in _context.Prices .AsAsyncEnumerable() .WithCancellation(HttpContext.RequestAborted)) { batchBuffer.Add(price); if (batchBuffer.Count == 100) { // 凑够100条就序列化刷入响应流,发送给客户端 foreach (var item in batchBuffer) { JsonSerializer.Serialize(jsonWriter, item); } await jsonWriter.FlushAsync(HttpContext.RequestAborted); batchBuffer.Clear(); } } // 发送最后不足100条的剩余数据 foreach (var item in batchBuffer) { JsonSerializer.Serialize(jsonWriter, item); } jsonWriter.WriteEndArray(); await jsonWriter.FlushAsync(HttpContext.RequestAborted); }
方案二:gRPC 服务端流(性能最优,适合内部服务场景)
如果客户端和服务端都是内部可控系统,gRPC服务端流是更优选择,原生支持分块消息传输,用Protobuf二进制序列化,比JSON体积小、速度快3~5倍,更适合数据量持续增长的场景。
实现步骤
- 先定义proto协议文件,声明服务端流接口:
syntax = "proto3"; option csharp_namespace = "PriceGrpcService"; import "google/protobuf/timestamp.proto"; service PriceGrpc { // 服务端流返回,关键字stream标记 rpc GetAllPrices (GetPricesRequest) returns (stream PriceMessage); } message GetPricesRequest {} message PriceMessage { int32 id = 1; string product_code = 2; double price = 3; google.protobuf.Timestamp update_at = 4; }
- 实现服务端逻辑:
public class PriceGrpcImpl : PriceGrpc.PriceGrpcBase { private readonly AppDbContext _dbContext; public PriceGrpcImpl(AppDbContext dbContext) { _dbContext = dbContext; } public override async Task GetAllPrices(GetPricesRequest request, IServerStreamWriter<PriceMessage> responseStream, ServerCallContext context) { await foreach (var price in _dbContext.Prices .AsAsyncEnumerable() .WithCancellation(context.CancellationToken)) { // 数据库实体转Protobuf消息 var msg = new PriceMessage { Id = price.Id, ProductCode = price.ProductCode, Price = (double)price.Price, UpdateAt = Timestamp.FromDateTime(price.UpdateTime.ToUniversalTime()) }; // 写入流,客户端实时收到 await responseStream.WriteAsync(msg); } } }
- 客户端可以直接遍历流边收边处理,不需要等全量响应:
using var call = grpcClient.GetAllPrices(new GetPricesRequest()); await foreach (var priceMsg in call.ResponseStream.ReadAllAsync(cancellationToken: cts.Token)) { // 收到一条处理一条 Console.WriteLine($"商品{priceMsg.ProductCode} 当前价格:{priceMsg.Price}"); }
避坑说明
- 不要忘记关闭响应缓冲,否则所有流操作都会被ASP.NET Core缓存,达不到分块发送的效果
- 数据库查询必须用
AsAsyncEnumerable(),不要用ToList()/ToListAsync(),否则还是会全量加载数据到内存 - 一定要传入取消令牌,客户端断开连接时可以及时终止数据库查询,避免无效资源占用
- 手动写JSON流时必须补全数组结束符,否则客户端会收到格式非法的JSON
内容的提问来源于stack exchange,提问作者totallynotatallno
相关产品推荐
相关产品推荐

