只读查询的“零分配”之路:EF Core + Dapper + MemoryPack 的组合优化

发布于:2025-09-13 ⋅ 阅读:(18) ⋅ 点赞:(0)

🚀 只读查询的“零分配”之路:EF Core + Dapper + MemoryPack 的组合优化

目标:在只读接口的热路径上,把关键路径上的临时对象与复制降至最低,降低 GC 压力与 p95/p99 尾延迟,提升吞吐。
主线(“三轨并行”)

  1. EF Core:编译查询 + AsNoTracking() + 显式投影(中低 QPS 的默认路径);
  2. Dapper:手写 SQL + 非缓冲流式(buffered:false)+ 扁平 DTO(热点/大结果集);
  3. 序列化MemoryPack + HttpResponse.BodyWriterIBufferWriter<byte>直写;浏览器/通用生态 → 回退 System.Text.Json(配 Source Generator)。

ℹ️ 术语澄清:“零分配”是工程目标,即尽量将关键路径上的中间对象/复制去除或显著降低;受字符串、网络缓冲、运行库内部对象等影响,端到端“绝对零分配”不可达。



🏗️ 架构鸟瞰(三轨并行总览)

序列化轨
Dapper 轨
EF Core 轨
EF Core 轨
Dapper 轨
DTO 流
Row 流
PipeWriter.FlushAsync
MemoryPack / JSON 写出
IBufferWriter
Dapper
buffered:false
🗄️ PostgreSQL
EF Core 编译查询
AsNoTracking + 投影
👤 Client
ASP.NET Core Minimal API

1) 🧭 适用与边界

  • 适用:只读 API、列表页、导出、报表快照;一致性级别以“读已提交/快照读”为主。
  • 暂不讨论:复杂对象图(建议显式投影 DTO,避免 Include 拉整图)、强事务读写混合。

2) ⚙️ 三轨并行:总体设计

  • EF Core 轨(可维护)
    AsNoTracking() + 投影 DTO + 编译查询;用 TagWith("hotpath:...") 标注,便于日志/执行计划定位。编译查询把 LINQ 预编译为委托,适合高重复度查询(是否采用以基准评估为准)。

  • Dapper 轨(性能优先)
    稳定 SQL + 扁平 DTO,必要时 buffered:false 非缓冲流式,明显降低大结果集峰值内存(连接在枚举全过程必须保持打开)。

  • 序列化/输出轨
    MemoryPackIBufferWriter<byte>/PipeWriter 直写Accept 不支持时回退 System.Text.Json(建议启用 Source Generation 以减少反射、兼容 AOT/Trim)。对 BodyWriter调用 FlushAsync 才会把缓冲推入响应体。


3) 🧩 EF Core:编译查询 + 禁跟踪 + 显式投影

准则

  1. 编译查询EF.CompileQuery/CompileAsyncQuery 将 LINQ 表达式编译为委托,绕过查询缓存查找,在高重复度场景更优(先做基准)。
  2. 只读禁跟踪AsNoTracking() 是只读查询的常规选择;AsNoTrackingWithIdentityResolution 会在无跟踪下做身份解析(去重相同主键实例),仅在确需语义时使用。
  3. 查询标签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 CreatedAt
        from orders
        order by created_at desc
        limit @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 非缓冲序列化时序

Client ASP.NET Core IDbConnection PostgreSQL Utf8JsonWriter GET /orders/dapper-json Open() Execute Query (buffered:false) 非缓冲=逐行枚举,避免一次性物化 Row Serialize(row) loop [rows] writer.Flush() 将数据刷入管道缓冲 BodyWriter.FlushAsync() 推到响应体(网络) Client ASP.NET Core IDbConnection PostgreSQL Utf8JsonWriter

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 CoreHttpResponse.BodyWriterPipeWriter,缓冲写;调用 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);
    }
});

🧠 读路径选择决策树

开始
QPS ≥ 1k/s
且查询字段稳定?
EF Core 编译查询
+ AsNoTracking + 投影
结果集 > 100k rows
或 p95 > 200ms?
Dapper 非缓冲 + 扁平 DTO
带宽敏感/内网对接?
MemoryPack + BodyWriter 直写
System.Text.Json (SourceGen)

注:阈值为示例,请依据你的基准与线上指标设团队门槛。


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 仅在确需去重实例时使用。
⚔️ wrk / bombardier
🟦 ASP.NET Core 应用
🧮 dotnet-counters
🧵 dotnet-trace
📊 Allocation Rate/GC Gen
🔧 修复: 去 ToList/启用非缓冲/直写等

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>();
}
🧰 GlobalSetup: 预置 DB/数据
Case A: EF 普通 vs 编译查询
Case B: EF 投影 vs Dapper
Case C: STJ vs MemoryPack
BenchmarkDotNet
MemoryDiagnoser
📈 ops/s, p95/p99, Allocated B/op, GC 次数
🧭 调整热路径与配置

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:16
    environment:
      POSTGRES_PASSWORD: devpass
      POSTGRES_USER: dev
      POSTGRES_DB: demo
    ports: ["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 简化配置。

网站公告

今日签到

点亮在社区的每一天
去签到