只读查询的“零分配”之路:EF Core + Dapper + MemoryPack 的组合优化
🚀 只读查询的“零分配”之路:EF Core + Dapper + MemoryPack 的组合优化
目标:在只读接口的热路径上,把关键路径上的临时对象与复制降至最低,降低 GC 压力与 p95/p99 尾延迟,提升吞吐。
主线(“三轨并行”)
- EF Core:编译查询 +
AsNoTracking()
+ 显式投影(中低 QPS 的默认路径); - Dapper:手写 SQL + 非缓冲流式(
buffered:false
)+ 扁平 DTO(热点/大结果集); - 序列化:MemoryPack +
HttpResponse.BodyWriter
(IBufferWriter<byte>
)直写;浏览器/通用生态 → 回退 System.Text.Json(配 Source Generator)。
ℹ️ 术语澄清:“零分配”是工程目标,即尽量将关键路径上的中间对象/复制去除或显著降低;受字符串、网络缓冲、运行库内部对象等影响,端到端“绝对零分配”不可达。
📚 目录
- 🚀 只读查询的“零分配”之路:EF Core + Dapper + MemoryPack 的组合优化
- 🏗️ 架构鸟瞰(三轨并行总览)
- 1) 🧭 适用与边界
- 2) ⚙️ 三轨并行:总体设计
- 3) 🧩 EF Core:编译查询 + 禁跟踪 + 显式投影
- 4) 🧵 Dapper:扁平 DTO + **非缓冲**流式(热点/大结果集)
- 📡 Dapper 非缓冲序列化时序
- 5) 📦 EF → JSON 流式端点
- 6) 📤 输出零拷贝优先:**MemoryPack + BodyWriter** 直写(二进制优先,JSON 回退)
- 🧠 读路径选择决策树
- 7) 🔎 Hot Path 审计清单(把分配“看得见”)
- 8) 🧪 基准方法学(BenchmarkDotNet)
- 9) 🧰 可复现实验模板(最小工程)
- 10) 🔁 选择与回滚
🏗️ 架构鸟瞰(三轨并行总览)
1) 🧭 适用与边界
- 适用:只读 API、列表页、导出、报表快照;一致性级别以“读已提交/快照读”为主。
- 暂不讨论:复杂对象图(建议显式投影 DTO,避免
Include
拉整图)、强事务读写混合。
2) ⚙️ 三轨并行:总体设计
-
EF Core 轨(可维护):
AsNoTracking()
+ 投影 DTO + 编译查询;用TagWith("hotpath:...")
标注,便于日志/执行计划定位。编译查询把 LINQ 预编译为委托,适合高重复度查询(是否采用以基准评估为准)。 -
Dapper 轨(性能优先):
稳定 SQL + 扁平 DTO,必要时buffered:false
非缓冲流式,明显降低大结果集峰值内存(连接在枚举全过程必须保持打开)。 -
序列化/输出轨:
MemoryPack 以IBufferWriter<byte>
/PipeWriter
直写;Accept
不支持时回退 System.Text.Json(建议启用 Source Generation 以减少反射、兼容 AOT/Trim)。对BodyWriter
,调用FlushAsync
才会把缓冲推入响应体。
3) 🧩 EF Core:编译查询 + 禁跟踪 + 显式投影
准则
- 编译查询:
EF.CompileQuery/CompileAsyncQuery
将 LINQ 表达式编译为委托,绕过查询缓存查找,在高重复度场景更优(先做基准)。 - 只读禁跟踪:
AsNoTracking()
是只读查询的常规选择;AsNoTrackingWithIdentityResolution
会在无跟踪下做身份解析(去重相同主键实例),仅在确需语义时使用。 - 查询标签:
TagWith("hotpath:xxx")
写入 SQL 注释,帮助把 LINQ 与生成 SQL/日志对应。
示例
// EF Core 8/9
using Microsoft.EntityFrameworkCore;public sealed record OrderDto(int Id, string No, decimal Amount, DateTime CreatedAt);public static class Queries
{// 编译查询:只读 + 投影 + 限制条数public static readonly Func<AppDbContext, int, IAsyncEnumerable<OrderDto>>GetRecentOrders = EF.CompileAsyncQuery((AppDbContext db, int take) =>db.Orders.TagWith("hotpath:list-orders") // 起始处标注,便于日志/执行计划定位.AsNoTracking().OrderByDescending(x => x.CreatedAt).Select(x => new OrderDto(x.Id, x.No, x.Amount, x.CreatedAt)).Take(take));
}
4) 🧵 Dapper:扁平 DTO + 非缓冲流式(热点/大结果集)
- 列顺序与 DTO 对齐,降低映射开销;
- 非缓冲:
buffered:false
使结果延迟枚举,结合“边读边写”显著降低峰值内存;务必保证连接在枚举全过程保持打开; - AOT/裁剪友好:可评估 Dapper.AOT(构建期生成/拦截器),减少运行时反射/发射。
示例(流式 JSON:取消令牌 + 分段 Flush + STJ SourceGen + 统一限量 + 无 RegisterForDispose)
using System.Data;
using System.Text.Json;
using System.Text.Json.Serialization;
using Dapper;public readonly record struct OrderRow(int Id, string No, decimal Amount, DateTime CreatedAt);// System.Text.Json Source Generator 上下文(AOT/性能友好)
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization,WriteIndented = false)]
[JsonSerializable(typeof(OrderRow))]
[JsonSerializable(typeof(OrderRow[]))]
[JsonSerializable(typeof(OrderDto))]
public partial class SourceGenContext : JsonSerializerContext {}app.MapGet("/orders/dapper-json", async (IDbConnection cnn, HttpContext ctx, int take, CancellationToken ct) =>
{take = Math.Clamp(take, 0, 50_000); // 统一限量,防止误用拉爆内存/带宽const string sql = """select id as Id, no as No, amount as Amount, created_at as CreatedAtfrom ordersorder by created_at desclimit @take;""";// 非缓冲=延迟枚举:连接需保持打开(假设由 DI 托管生命周期,不额外注册释放)if (cnn.State != ConnectionState.Open) cnn.Open();ctx.Response.ContentType = "application/json";using var json = new Utf8JsonWriter(ctx.Response.BodyWriter,new JsonWriterOptions { SkipValidation = true });json.WriteStartArray();int counter = 0;foreach (var row in cnn.Query<OrderRow>(sql, new { take }, buffered: false)){if (ct.IsCancellationRequested) break; // 同步非缓冲无法把 CT 传入 DB 命令JsonSerializer.Serialize(json, row, SourceGenContext.Default.OrderRow);// 可选:分段 Flush,降低尾延迟(阈值可根据网络/代理调优;默认不必频繁刷)if ((++counter % 2000) == 0){json.Flush();await ctx.Response.BodyWriter.FlushAsync(ct);}}json.WriteEndArray();json.Flush(); // 刷到 PipeWriter 缓冲await ctx.Response.BodyWriter.FlushAsync(ct); // 推到响应体(网络)
});
📡 Dapper 非缓冲序列化时序
5) 📦 EF → JSON 流式端点
using System.Text.Json;app.MapGet("/orders/ef-json-stream", async (AppDbContext db, HttpContext ctx, int take, CancellationToken ct) =>
{take = Math.Clamp(take, 0, 50_000);ctx.Response.ContentType = "application/json";using var json = new Utf8JsonWriter(ctx.Response.BodyWriter);json.WriteStartArray();await foreach (var x in Queries.GetRecentOrders(db, take).WithCancellation(ct)){JsonSerializer.Serialize(json, x, SourceGenContext.Default.OrderDto);}json.WriteEndArray();json.Flush();await ctx.Response.BodyWriter.FlushAsync(ct);
});
6) 📤 输出零拷贝优先:MemoryPack + BodyWriter 直写(二进制优先,JSON 回退)
- MemoryPack:源码生成、AOT 友好,支持直接序列化到
IBufferWriter<byte>
/Stream
;适合大对象/高 QPS 返回体。 - ASP.NET Core:
HttpResponse.BodyWriter
为PipeWriter
,缓冲写;调用FlushAsync
控制何时把缓冲写进响应体。 - 内容协商:约定
Accept: application/x-memorypack
用二进制;否则回退 JSON(建议 STJ Source Generation 以减少反射与 AOT 风险)。MVC 场景可用 MemoryPack 的 Formatter 简化配置。
示例(媒体类型 application/x-memorypack
+ 统一限量)
using MemoryPack;
using System.Text.Json;[MemoryPackable]
public partial record OrderDto(int Id, string No, decimal Amount, DateTime CreatedAt);app.MapGet("/orders/ef-mpk", async (AppDbContext db, HttpContext ctx, int take, CancellationToken ct) =>
{take = Math.Clamp(take, 0, 50_000);var list = new List<OrderDto>(Math.Min(take, 8192));await foreach (var x in Queries.GetRecentOrders(db, take).WithCancellation(ct))list.Add(x);var accept = ctx.Request.Headers.Accept.ToString();if (accept.Contains("application/x-memorypack", StringComparison.OrdinalIgnoreCase)){ctx.Response.ContentType = "application/x-memorypack";MemoryPackSerializer.Serialize(ctx.Response.BodyWriter, list); // 直写 IBufferWriter<byte>await ctx.Response.BodyWriter.FlushAsync(ct);}else{ctx.Response.ContentType = "application/json";using var json = new Utf8JsonWriter(ctx.Response.BodyWriter);json.WriteStartArray();foreach (var x in list)JsonSerializer.Serialize(json, x, SourceGenContext.Default.OrderDto);json.WriteEndArray();json.Flush();await ctx.Response.BodyWriter.FlushAsync(ct);}
});
🧠 读路径选择决策树
注:阈值为示例,请依据你的基准与线上指标设团队门槛。
7) 🔎 Hot Path 审计清单(把分配“看得见”)
- 分配/GC 监控:
dotnet-counters monitor System.Runtime
观察 Allocation Rate、Gen0/1/2、堆大小。 - 执行期追踪:
dotnet-trace collect -- <command>
采集 EventPipe 事件,配 PerfView/SpeedScope 分析 Alloc Stacks/FlameGraph。 - 代码侧:避免中间
ToList()
/string.Format
/链式 LINQ 隐式分配;JSON 路径用Utf8JsonWriter(IBufferWriter<byte>)
;EF 默认AsNoTracking()
,AsNoTrackingWithIdentityResolution
仅在确需去重实例时使用。
8) 🧪 基准方法学(BenchmarkDotNet)
-
微基准
- Case A:EF 普通查询 vs 编译查询(高重复度);
- Case B:EF 投影 DTO vs Dapper(相同字段、相同筛选);
- Case C:STJ(SourceGen)vs MemoryPack(S/M/L 对象、批量规模)。
-
采集:
[MemoryDiagnoser]
输出 Allocated B/Op 与 GC 次数;[Benchmark(Baseline = true)]
设基线。 -
端到端:配合
wrk
/bombardier
进行吞吐/尾延迟对比,旁路跑dotnet-counters
。
基准骨架
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.EntityFrameworkCore;[MemoryDiagnoser] // 采集分配/GC 指标
public class EfCompiledVsNormal
{private AppDbContext _db = default!;[GlobalSetup]public void Setup() => _db = DbFactory.Create();[Benchmark(Baseline = true)]public async Task<List<OrderDto>> Normal()=> await _db.Orders.AsNoTracking().OrderByDescending(x => x.CreatedAt).Select(x => new OrderDto(x.Id, x.No, x.Amount, x.CreatedAt)).Take(1000).ToListAsync();[Benchmark]public async Task<List<OrderDto>> Compiled(){var list = new List<OrderDto>(1000);await foreach (var x in Queries.GetRecentOrders(_db, 1000))list.Add(x);return list;}
}public static class Program
{public static void Main() => BenchmarkRunner.Run<EfCompiledVsNormal>();
}
9) 🧰 可复现实验模板(最小工程)
1) 新建工程 & 依赖
dotnet new web -n ZeroAllocRead
cd ZeroAllocRead
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Dapper
dotnet add package MemoryPack
# 可选(AOT/Trim 友好)
dotnet add package Dapper.AOT
2) Docker 起库(PostgreSQL 16)
# docker-compose.yml
services:db:image: postgres:16environment:POSTGRES_PASSWORD: devpassPOSTGRES_USER: devPOSTGRES_DB: demoports: ["5432:5432"]
docker compose up -d
3) 初始化表与数据(psql 示例)
create table orders(id serial primary key,no text not null,amount numeric not null,created_at timestamp not null default now()
);insert into orders(no,amount,created_at)
select 'ORD-'||g, (random()*1000)::numeric, now() - (g||' minutes')::interval
from generate_series(1, 500000) as g;
4) 代码落地
-
建
Queries.cs
(第 3 节); -
在
Program.cs
添加第 4、5、6 节的 Minimal API; -
配置连接串与 DbContext 注册;
-
运行:
dotnet run
。 -
验证:
-
GET /orders/ef-mpk
:Accept: application/x-memorypack
→ MemoryPack 二进制;Accept: application/json
→ JSON(STJ Source Generation)。
-
GET /orders/dapper-json
:大分页下观察内存曲线更稳。 -
GET /orders/ef-json-stream
:EF 路径的 JSON 流式对照样例。
-
10) 🔁 选择与回滚
场景 | 首选路径 |
---|---|
变更频繁、中低 QPS | EF Core(编译查询 + 投影 + AsNoTracking) |
热点/字段固定/高 QPS | Dapper 非缓冲 + 扁平 DTO |
服务间/带宽敏感 | MemoryPack + BodyWriter 直写;公共 API → JSON 回退 |
风险与回滚
- AOT/Trim:普通 Dapper 依赖运行时反射/发射;Dapper.AOT 用生成代码替代,更适配 AOT。
- 身份解析:
AsNoTrackingWithIdentityResolution
仅在确需时使用。 - 协议版本化:MemoryPack 输出建议加魔数/版本;MVC 可用 MemoryPack 的 Formatter 简化配置。