高性能 JSON:System.Text.Json Source Generator vs 手写 Span(Utf8JsonReader/Writer)
高性能 JSON:System.Text.Json Source Generator vs 手写 Span(Utf8JsonReader/Writer)⚡️
📚 目录
- 高性能 JSON:System.Text.Json Source Generator vs 手写 Span(Utf8JsonReader/Writer)⚡️
- 1. 为什么比较 & 本文目标 🎯
- 2. 参赛选手与实现基线 🧑💻
- A) 反射默认(Baseline)
- B) Source Generator(生成器)
- C) 手写 Span(Reader/Writer)
- 2.1 选型一图流 🧭
- 3. 测试样本与评测矩阵 🧪
- 4. 工程要点(把分配与拷贝压到最低)🛠️
- 4.1 零拷贝数据流🔬
- 5. 可复现仓库骨架(.NET 8/9)🧱
- 5.1 统一接口与模型(`JsonPerf.Codecs`)🧩
- 5.2 反射默认实现(**构造期选择只读 Options**)🧱
- 5.3 Source Generator 实现 🧬
- 5.4 手写 Span:Reader/Writer + 更稳的属性名比较 🧵
- 5.5 BenchmarkDotNet(`JsonPerf.Bench`)📈
- 5.6 Minimal API(`JsonPerf.Api`)——**三端点路径彻底隔离 & “收齐再解析” 修复** 🧵
- 5.7 /order/span 时序图🕰️
- 5.8 三路实现的可替换关系 🧩
- 6. 端到端压测脚本 🧪
- 7. 结果呈现与经验解读(建议)📈
- 8. 选型建议表 🧭
- 9. 反模式清单 🚫✅
- 10. 复现步骤 🧪➡️🏁
- 11. FAQ(工程化小贴士)📝
1. 为什么比较 & 本文目标 🎯
- 反射默认(JsonSerializer + Options):易用灵活,但运行期反射与元数据访问有开销;在 Trim/NativeAOT 下可能禁用反射序列化,需要 Source Generator(SG)或
TypeInfoResolver
才能工作。 - Source Generator(SG):编译期生成类型元数据/专用代码,冷启动更快、AOT/Trim 友好;调用入口三选一:传
JsonTypeInfo<T>
、传JsonSerializerContext
、或设置JsonSerializerOptions.TypeInfoResolver
(.NET 7+)。 - 手写 Span:
Utf8JsonReader/Writer
直读直写 UTF-8,配IBufferWriter<byte>
与管道实现低分配;适合顶级热路径、大批量与严格内存预算场景。
目标:在冷/热路径、S/M/B 对象规模与批量场景下,给出可复现的性能结论与可运维的选型标准。🧰
2. 参赛选手与实现基线 🧑💻
A) 反射默认(Baseline)
使用 JsonSerializer.Serialize/Deserialize(obj, options)
;全局复用一个 JsonSerializerOptions
(线程安全),避免频繁创建导致缓存失效与性能回退。♻️
B) Source Generator(生成器)
定义 partial class XxxJsonContext : JsonSerializerContext
,在类上用 [JsonSerializable(typeof(...))]
标注需要的类型。调用时三选一:
- 传
JsonTypeInfo<T>
;或 - 传
JsonSerializerContext
;或 - 将
XxxJsonContext.Default
赋给JsonSerializerOptions.TypeInfoResolver
(.NET 7+)后用常规重载。
⚠️ 不要把
options
与context/typeinfo
混用到错误重载上。
C) 手写 Span(Reader/Writer)
Utf8JsonReader
/Utf8JsonWriter
+ IBufferWriter<byte>
直读直写 UTF-8;端点用 HttpContext.Request.BodyReader
(PipeReader
)循环读取(或采用本文默认的“收齐再解析”零拷贝方案),更稳更快。🔥
2.1 选型一图流 🧭
3. 测试样本与评测矩阵 🧪
数据模型
- S(小对象):10–15 属性(数值/字符串/枚举/
DateTimeOffset
) - M(中对象):3 层嵌套 + 数组(50–100 属性)
- B(批量):
List<S>
1k/10k(模拟埋点/日志批量)
工况维度
- 冷启动 vs 热路径;序列化/反序列化分别测
- 指标:吞吐(MB/s 或 req/s)、延迟分位(P50/P95/P99)、分配(B/op)、GC 次数、CPU 利用
指标权重(示例)
工具
- 微基准:BenchmarkDotNet(
[MemoryDiagnoser]
;可选线程/硬件计数器) - 端到端:
wrk
/wrk2
(吞吐+延迟分布)、hey
(summary 含直方图/百分位,支持 CSV)
4. 工程要点(把分配与拷贝压到最低)🛠️
- Reader/Writer 直读直写:数值/布尔/枚举走
GetInt32()/GetDecimal()/GetBoolean()
与WriteNumber()/WriteBoolean()
,避免string
中转。 - 属性名匹配:优先
ValueTextEquals("id")
(自动反转义);不要用ValueSpan
直接比较文本。 - Buffer 复用:写出侧采用
Utf8JsonWriter(IBufferWriter<byte>, …)
;需要池化可用ArrayPoolBufferWriter<T>
。 - BodyReader:端点读取使用
BodyReader.ReadAsync()
循环(或收齐再解析),避免MemoryStream
中转。 - 编码/转义:
UnsafeRelaxedJsonEscaping
不转义<
,>
,&
等 HTML 敏感字符,仅在非 HTML/JS 注入语境使用。 - 多态:.NET 7+ 推荐
JsonPolymorphic
/JsonDerivedType
标注层次;SG 与运行时均可识别。 - AOT/Trim:Trim/NativeAOT 下可能禁止反射序列化;需用 SG 或把
TypeInfoResolver
配好。 - Options 复用:
JsonSerializerOptions
建议静态复用(线程安全),避免频繁 new。
4.1 零拷贝数据流🔬
5. 可复现仓库骨架(.NET 8/9)🧱
JsonPerfPlayground/
├─ src/
│ ├─ JsonPerf.Codecs/ # 模型 & 三路实现(统一 IJsonCodec<T>)
│ ├─ JsonPerf.Bench/ # BenchmarkDotNet 基准
│ └─ JsonPerf.Api/ # Minimal API(端到端对比)
├─ scripts/
│ ├─ wrk.sh # wrk/wrk2 压测脚本(已修正 BODY 传递)
│ └─ hey.sh # hey 压测脚本
└─ Directory.Build.props # TreatWarningsAsErrors, LangVersion 等
5.1 统一接口与模型(JsonPerf.Codecs
)🧩
// IJsonCodec.cs
public interface IJsonCodec<T>
{byte[] Serialize(T value);T Deserialize(ReadOnlySpan<byte> utf8Json);
}// Models.cs(S/M/B 示例)
public enum OrderState : byte { New, Paid, Shipped, Cancelled }public sealed class OrderItem
{public int Id { get; set; }public string Sku { get; set; } = "";public int Qty { get; set; }public decimal Price { get; set; }
}public sealed class Order
{public int Id { get; set; }public string Customer { get; set; } = "";public DateTimeOffset CreatedAt { get; set; }public OrderState State { get; set; }public List<OrderItem> Items { get; set; } = new();public Dictionary<string, string> Tags { get; set; } = new();
}
5.2 反射默认实现(构造期选择只读 Options)🧱
// ReflectionCodec.cs
using System.Text.Json;
using System.Text.Encodings.Web;public sealed class ReflectionCodec<T> : IJsonCodec<T>
{private static readonly JsonSerializerOptions s_default = new(){PropertyNamingPolicy = JsonNamingPolicy.CamelCase,WriteIndented = false};private static readonly JsonSerializerOptions s_relaxed = new(){PropertyNamingPolicy = JsonNamingPolicy.CamelCase,WriteIndented = false,Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping // 仅非 HTML/JS 语境};private readonly JsonSerializerOptions _opt;public ReflectionCodec(bool relaxedEscaping = false)=> _opt = relaxedEscaping ? s_relaxed : s_default;public byte[] Serialize(T value) => JsonSerializer.SerializeToUtf8Bytes(value, _opt);public T Deserialize(ReadOnlySpan<byte> utf8) => JsonSerializer.Deserialize<T>(utf8, _opt)!;
}
5.3 Source Generator 实现 🧬
// PerfJsonContext.cs
using System.Text.Json.Serialization;[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
public partial class PerfJsonContext : JsonSerializerContext { }
// SourceGenCodec.cs —— 直接传 JsonSerializerContext
using System.Text.Json;public sealed class SourceGenCodec<T> : IJsonCodec<T>
{public byte[] Serialize(T value) =>JsonSerializer.SerializeToUtf8Bytes(value!, typeof(T), PerfJsonContext.Default);public T Deserialize(ReadOnlySpan<byte> utf8) =>(T)JsonSerializer.Deserialize(utf8, typeof(T), PerfJsonContext.Default)!;
}
5.4 手写 Span:Reader/Writer + 更稳的属性名比较 🧵
// OrderMan.cs(片段)
using System.Text.Json;public static class OrderMan
{public static void Write(ref Utf8JsonWriter w, in Order o){w.WriteStartObject();w.WriteNumber("id", o.Id);w.WriteString("customer", o.Customer);w.WriteString("createdAt", o.CreatedAt);w.WriteNumber("state", (byte)o.State);w.WritePropertyName("items");w.WriteStartArray();foreach (var it in o.Items){w.WriteStartObject();w.WriteNumber("id", it.Id);w.WriteString("sku", it.Sku);w.WriteNumber("qty", it.Qty);w.WriteNumber("price", it.Price);w.WriteEndObject();}w.WriteEndArray();w.WritePropertyName("tags");w.WriteStartObject();foreach (var kv in o.Tags) w.WriteString(kv.Key, kv.Value);w.WriteEndObject();w.WriteEndObject();}public static Order Read(ref Utf8JsonReader r){var o = new Order();// 若入口不确定:推进到 StartObjectif (r.TokenType != JsonTokenType.StartObject){while (r.Read() && r.TokenType != JsonTokenType.StartObject) { }if (r.TokenType != JsonTokenType.StartObject)throw new JsonException("Expected StartObject for Order.");}while (r.Read()){if (r.TokenType == JsonTokenType.EndObject) break;if (r.TokenType != JsonTokenType.PropertyName) continue;if (r.ValueTextEquals("id")) { r.Read(); o.Id = r.GetInt32(); }else if (r.ValueTextEquals("customer")) { r.Read(); o.Customer = r.GetString()!; }else if (r.ValueTextEquals("createdAt")){ r.Read(); o.CreatedAt = r.GetDateTimeOffset(); }else if (r.ValueTextEquals("state")) { r.Read(); o.State = (OrderState)r.GetByte(); }else if (r.ValueTextEquals("items")){r.Read();if (r.TokenType == JsonTokenType.Null) { o.Items = new(); continue; }if (r.TokenType != JsonTokenType.StartArray)throw new JsonException("Expected StartArray for 'items'.");var list = new List<OrderItem>();while (r.Read() && r.TokenType != JsonTokenType.EndArray)list.Add(ReadItem(ref r));o.Items = list;}else if (r.ValueTextEquals("tags")){r.Read();if (r.TokenType == JsonTokenType.Null) { o.Tags = new(); continue; }if (r.TokenType != JsonTokenType.StartObject)throw new JsonException("Expected StartObject for 'tags'.");var dict = new Dictionary<string,string>();while (r.Read() && r.TokenType != JsonTokenType.EndObject){if (r.TokenType != JsonTokenType.PropertyName)throw new JsonException("Expected PropertyName in 'tags'.");var key = r.GetString()!;r.Read();dict[key] = r.GetString()!;}o.Tags = dict;}else r.Skip(); // 跳未知字段}return o;}private static OrderItem ReadItem(ref Utf8JsonReader r){if (r.TokenType != JsonTokenType.StartObject)throw new JsonException("Expected StartObject for OrderItem.");var it = new OrderItem();while (r.Read()){if (r.TokenType == JsonTokenType.EndObject) break;if (r.TokenType != JsonTokenType.PropertyName) continue;if (r.ValueTextEquals("id")) { r.Read(); it.Id = r.GetInt32(); }else if (r.ValueTextEquals("sku")) { r.Read(); it.Sku = r.GetString()!; }else if (r.ValueTextEquals("qty")) { r.Read(); it.Qty = r.GetInt32(); }else if (r.ValueTextEquals("price")) { r.Read(); it.Price = r.GetDecimal(); }else r.Skip();}return it;}
}
// SpanCodec.cs —— IBufferWriter 直写;Deserialize 加轻量守卫
using System.Buffers;
using System.Text.Json;public sealed class SpanCodec<T> : IJsonCodec<T>
{public byte[] Serialize(T value){var buffer = new ArrayBufferWriter<byte>(1024);using var w = new Utf8JsonWriter(buffer /*, new JsonWriterOptions { SkipValidation = false }*/);switch (value){case Order o: OrderMan.Write(ref w, o); break;default: throw new NotSupportedException(typeof(T).FullName);}w.Flush();return buffer.WrittenSpan.ToArray(); // demo 返回 byte[]}public T Deserialize(ReadOnlySpan<byte> utf8){var r = new Utf8JsonReader(utf8);// 轻量 guard:推进到 StartObject(与 OrderMan.Read 策略一致)if (r.TokenType != JsonTokenType.StartObject){while (r.Read() && r.TokenType != JsonTokenType.StartObject) { }if (r.TokenType != JsonTokenType.StartObject)throw new JsonException("Expected StartObject.");}object result = typeof(T) == typeof(Order)? OrderMan.Read(ref r): throw new NotSupportedException(typeof(T).FullName);return (T)result;}
}
5.5 BenchmarkDotNet(JsonPerf.Bench
)📈
// Bench.cs
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
// using BenchmarkDotNet.Jobs; // 可选:指定 Runtime/Job[MemoryDiagnoser]
// [SimpleJob(RuntimeMoniker.Net80, warmupCount: 3, iterationCount: 8, invocationCount: 1, baseline: true)]
public class JsonBench
{private Order _order = SampleData.SmallOrder();private byte[] _json = default!;private readonly IJsonCodec<Order> _ref = new ReflectionCodec<Order>();private readonly IJsonCodec<Order> _sg = new SourceGenCodec<Order>();private readonly IJsonCodec<Order> _sp = new SpanCodec<Order>();[GlobalSetup] public void Setup() => _json = _ref.Serialize(_order);[Benchmark(Baseline = true)] public byte[] Ser_Reflection() => _ref.Serialize(_order);[Benchmark] public byte[] Ser_SourceGen() => _sg.Serialize(_order);[Benchmark] public byte[] Ser_Span() => _sp.Serialize(_order);[Benchmark] public Order Deser_Reflection() => _ref.Deserialize(_json);[Benchmark] public Order Deser_SourceGen() => _sg.Deserialize(_json);[Benchmark] public Order Deser_Span() => _sp.Deserialize(_json);
}public class Program
{public static void Main(string[] args) => BenchmarkRunner.Run<JsonBench>();
}
5.6 Minimal API(JsonPerf.Api
)——三端点路径彻底隔离 & “收齐再解析” 修复 🧵
var builder = WebApplication.CreateBuilder(args);// 可选:全局绑定 SG(注意会影响默认 Results.Json 路径)
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(opt =>
{opt.SerializerOptions.TypeInfoResolver = PerfJsonContext.Default; // .NET 7+
});var app = builder.Build();// 反射 Baseline —— 显式传“独立 options”(不带 TypeInfoResolver)
app.MapPost("/order/ref", (Order o) =>
{var opts = new JsonSerializerOptions(JsonSerializerDefaults.Web);return Results.Json(o, opts); // 保证走反射默认路径
});// Source Generator —— 显式传 JsonTypeInfo 或 Context
app.MapPost("/order/sg", (Order o) =>Results.Json(o, PerfJsonContext.Default.Order)); // 或 Results.Json(o, PerfJsonContext.Default)// 手写 Span —— “收齐再解析”(零拷贝,解析完成后再 AdvanceTo)
app.MapPost("/order/span", async (HttpContext ctx) =>
{var pipe = ctx.Request.BodyReader;while (true){var result = await pipe.ReadAsync(ctx.RequestAborted);var buffer = result.Buffer;if (result.IsCompleted){// ✅ 先解析var reader = new Utf8JsonReader(buffer, isFinalBlock: true, state: default);var order = OrderMan.Read(ref reader);ctx.Response.ContentType = "application/json";using var w = new Utf8JsonWriter(ctx.Response.BodyWriter /*, new JsonWriterOptions { SkipValidation = false }*/);OrderMan.Write(ref w, order);await ctx.Response.BodyWriter.FlushAsync(ctx.RequestAborted);// ✅ 再 AdvanceTo,避免悬空引用pipe.AdvanceTo(buffer.End);break;}// 未结束:保留全部数据,继续累积pipe.AdvanceTo(buffer.Start, buffer.End);}
});app.Run();
5.7 /order/span 时序图🕰️
5.8 三路实现的可替换关系 🧩
6. 端到端压测脚本 🧪
scripts/wrk.sh
(Linux/Mac):
#!/usr/bin/env bash
URL=${1:-http://127.0.0.1:5000/order/sg} # 传 /ref 或 /span 即可切换
export BODY='{"id":1,"customer":"A","createdAt":"2024-01-01T00:00:00Z","state":1,"items":[{"id":1,"sku":"X","qty":2,"price":3.14}],"tags":{"k":"v"}}'wrk -t12 -c400 -d30s --latency -s <(cat <<'LUA'
wrk.method = "POST"
wrk.body = os.getenv("BODY")
wrk.headers["Content-Type"] = "application/json"
LUA
) "$URL"
scripts/hey.sh
:
#!/usr/bin/env bash
URL=${1:-http://127.0.0.1:5000/order/sg}
BODY='{"id":1,"customer":"A","createdAt":"2024-01-01T00:00:00Z","state":1,"items":[{"id":1,"sku":"X","qty":2,"price":3.14}],"tags":{"k":"v"}}'
hey -n 200000 -c 200 -m POST -T 'application/json' -D <(echo "$BODY") "$URL"
# hey 的 summary 含直方图与百分位(可提取 P99)
7. 结果呈现与经验解读(建议)📈
- 图表:三方案在 S/M/B 下的 MB/s(或 req/s)、P95/P99、B/op
- 表格:开启
UnsafeRelaxedJsonEscaping
、忽略条件、数字处理方式后的相对变化
常见趋势(仍需用你的数据验证):
- 小对象/常规 API:Source Generator 在吞吐、分配与冷启动方面稳定优于反射,改动成本小且 AOT/Trim 友好。
- 批量/顶级热路径:手写 Span 反序列化优势明显(尤其避免中转字符串/装箱);但维护/容错成本更高。
- 动态结构/键不固定:反射默认或“SG +
JsonExtensionData
/自定义转换器”更务实;多态优先用.NET 7+
的JsonPolymorphic
/JsonDerivedType
。
8. 选型建议表 🧭
场景 | 首选 | 备注 |
---|---|---|
常规 API / 中等吞吐 | Source Generator | 维护成本低、AOT/Trim 友好;冷/热路径均优于反射 |
大批量埋点 / 网关编解码 | 手写 Span | 搭配 IBufferWriter /管道/池化,做好单测与 Fuzz |
动态结构 / 字段不确定 | 反射默认(热点局部 SG) | 灵活;识别热点后迁移到 SG |
AOT/NativeAOT/严苛 Trim | Source Generator 或 TypeInfoResolver | 反射路径可能被禁用,需要 SG 或显式配置 |
安全对外输出需最严转义 | 默认编码器 | 谨慎使用 UnsafeRelaxedJsonEscaping (仅非 HTML/JS 注入语境) |
9. 反模式清单 🚫✅
- ❌
Utf8JsonReader
读数值经GetString()
再Parse
✅ 直接GetInt32()/GetDecimal()/GetBoolean()
- ❌ 使用
ValueSpan
直接比较属性名
✅ 用ValueTextEquals("name")
(自动反转义) - ❌
MemoryStream
中转请求体
✅ 用BodyReader
+Utf8JsonReader(ReadOnlySequence<byte>, …)
(本文默认“收齐再解析”更稳) - ❌ 每次
new JsonSerializerOptions()
✅ 缓存/复用同一实例(线程安全) - ❌ 在不安全语境全局开启
UnsafeRelaxedJsonEscaping
✅ 保持默认编码器或仅在确定安全的纯 JSON 语境启用
10. 复现步骤 🧪➡️🏁
-
新建解决方案与三个项目(ClassLib/Console/Web),按 §5 代码分别放入
JsonPerf.Codecs
、JsonPerf.Bench
、JsonPerf.Api
;添加 NuGet 包BenchmarkDotNet
。 -
启用生成器:在
JsonPerf.Codecs
中定义PerfJsonContext
并[JsonSerializable]
标注;在 API 项(如需全局)将JsonOptions.SerializerOptions.TypeInfoResolver = PerfJsonContext.Default
。 -
微基准:
dotnet run -c Release -p src/JsonPerf.Bench
(观察吞吐与 B/op;如需固定 Job,取消注释[SimpleJob(...)]
)。 -
启动 API:
dotnet run -c Release -p src/JsonPerf.Api
。 -
压测:
./scripts/wrk.sh http://127.0.0.1:5000/order/sg
(或/ref
、/span
)取吞吐/延迟分布;./scripts/hey.sh http://127.0.0.1:5000/order/sg
取 P50/P95/P99(可导 CSV)。
-
扰动实验:切换编码器/忽略条件/数字处理等,记录变化。
-
AOT/Trim 验证(可选):在 csproj 添加:
<PropertyGroup><PublishTrimmed>true</PublishTrimmed><TrimMode>full</TrimMode><PublishAot>true</PublishAot> </PropertyGroup>
若出现“反射序列化被禁用”类错误,切换到 SG 或配置
TypeInfoResolver
。
11. FAQ(工程化小贴士)📝
- SG 一定比反射快吗? 多数场景下冷启动与热路径都更优,但收益与模型复杂度相关;务必用你的数据基准化。
- 手写 Span 如何保证健壮性? 增强 Token 验证、越界/类型不符检查;对不可信输入做 Fuzz;异常统一转
ProblemDetails
。 - Minimal API 全局 JSON 设置:
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>
;控制器项目用AddControllers().AddJsonOptions(...)
。 - 进一步极限:可在可控环境试验
new JsonWriterOptions { SkipValidation = true }
与JsonReaderOptions
(MaxDepth
、尾逗号等)——性能与风险需权衡。⚖️