JSON序列化:那个被你忽视的性能杀手
我曾经排查过一个诡异的性能问题:接口响应时间莫名其妙地卡在200ms,怎么优化SQL、加缓存、换负载策略——通通没用。后来用dotTrace一查,70%的时间居然花在了JSON序列化上。当场裂开。
这不是个例。JSON序列化是后端开发中最容易被忽视的瓶颈之一。大家都盯着数据库慢查询、Redis缓存命中率、GC调优,但没几个人会想到——你每次JsonResponse(data)这个简单操作,可能正在慢慢拖垮你的服务。
先说个真实案例
某电商平台做了一次大促压测,QPS目标1万。服务本身写得很干净,数据库也优化到位,结果压到5000QPS就顶不住了。Profiling工具一看:序列化和反序列化占总CPU的42%。
他们用的是System.Text.Json,默认配置,一个嵌套很深的订单对象(包含商品列表、用户信息、地址、优惠卷等)。业务逻辑本身只需要15ms,但序列化要80ms。这不是bug,这是特性——默认的JSON库就是为通用设计的,而通用往往意味着慢。
序列化到底慢在哪?
很多人觉得JSON序列化就是一个字符串拼接操作,那真是太天真了。真正的性能杀手是这三个:
1. 反射(Reflection)
大多数JSON库在序列化时,需要在运行时通过反射遍历对象的每个属性。反射的本质是:每次访问属性都要做一次运行时类型检查。你以为你在访问order.User.Name,实际上底层做了:
// 伪代码,实际比这还复杂
PropertyInfo prop = order.GetType().GetProperty("User");
PropertyInfo nameProp = prop.PropertyType.GetProperty("Name");
var value = nameProp.GetValue(order.User);
一个嵌套对象序列化下来,这种查找可能执行几万次。对于一个复杂的DTO,这个开销是惊人的。
2. 动态分发(Dynamic Dispatch)
当你序列化一个多态对象(比如object类型或接口类型)时,库需要做运行时类型判断来决定用哪个序列化器。这个决策本身就有成本,而且会阻止JIT对代码做激进优化。
3. 字符串分配(String Allocation)
JSON生成过程中会创建大量临时字符串对象。这不仅带来GC压力,还会触发内存分配,而内存分配在高性能场景下是一笔不小的账。
数字说话
我在一台8核机器上,用BenchmarkDotNet跑了几个主流JSON库的序列化性能。被测对象是一个典型的API响应对象,包含列表、嵌套对象、日期等常见字段:
// 序列化对象结构(简化)
public class OrderDto
{
public Guid Id { get; set; }
public string OrderNo { get; set; }
public DateTime CreatedAt { get; set; }
public UserDto User { get; set; }
public List<OrderItemDto> Items { get; set; }
public AddressDto ShippingAddress { get; set; }
public decimal TotalAmount { get; set; }
public List<CouponDto> AppliedCoupons { get; set; }
}
// 序列化性能(10000次迭代取中位数)
// System.Text.Json (默认) : 1.84 ms/次
// Newtonsoft.Json : 2.31 ms/次
// SpanJson (no reflection) : 0.72 ms/次
// System.Text.Json + SourceGen: 0.58 ms/次
// Utf8Json : 0.41 ms/次
看到了吗?最慢和最快之间差了4倍。如果你每天处理1000万次序列化,这省下来的时间可以跑好几天。
怎么破?
方案一:换库(最简单粗暴)
如果不是特别依赖某个库的特性,直接换更快的序列化器。推荐两个:
Utf8Json:直接操作UTF-8字节,零反射,零动态分发。性能几乎和手写序列化代码一个级别。缺点是功能没有 Newtonsoft 那么全,但胜在稳定。
System.Text.Json + Source Generator:这是 .NET 6 之后的官方方案。通过编译时生成序列化代码,运行时完全不需要反射。我之前把一个高并发接口从默认的System.Text.Json切换到Source Generator,序列化时间从80ms降到了12ms,而代码改动不超过10行。
// 只需要加这个特性,编译器自动生成最优代码
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
public partial class MyJsonContext : JsonSerializerContext { }
// 使用时指定Context
var json = JsonSerializer.Serialize(order, MyJsonContext.Default.OrderDto);
方案二:减少序列化对象复杂度
有时候最大的问题不是怎么序列化,而是为什么要序列化这么多东西。
我见过太多祖传代码,一个接口返回的JSON对象嵌套了七八层,包含各种冗余字段。很多字段前端根本不用,后端却每次都老老实实地序列化。
原则:DTO要精准,不要贪多。
按需返回字段,比什么优化都管用。可以引入Response DTO分层:
// 最精简版 - 列表页用
public class OrderListItemDto { ... }
// 详情版 - 详情页用
public class OrderDetailDto { ... }
// 使用时
if (needFullDetail) {
return Ok(OrderDetailDto.From(order));
} else {
return Ok(OrderListItemDto.From(order)); // 少序列化80%的字段
}
方案三:字段黑名单/白名单
如果换库代价太大,至少别序列化不该序列化的字段。很多人不知道,主流库都支持在运行时排除特定字段:
// Newtonsoft
var settings = new JsonSerializerSettings {
ContractResolver = new DefaultContractResolver(),
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.None
};
// System.Text.Json
var options = new JsonSerializerOptions {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
一个NullValueHandling.Ignore可能帮你省掉20%的序列化开销,因为大量字段在业务里就是null,干嘛还要花CPU去写个null?
方案四:流式序列化(Streaming)
如果你的JSON很大(比如导出一个巨大的列表),不要一次性序列化成字符串再发出去。用流式API,边序列化边写Socket:
// 反面教材:全部加载到内存再返回
var json = JsonSerializer.Serialize(orders);
return Content(json, "application/json");
// 正面教材:流式写入
await using var stream = Response.Body;
await JsonSerializer.SerializeAsync(stream, orders, MyJsonContext.Default.ListOrderDto);
这个改动让我服务在大文件导出场景下的内存占用从1.2GB降到了40MB。
怎么判断序列化是不是你的瓶颈?
方法很简单,用System.Diagnostics.Stopwatch埋点,或者直接用IDE的性能探查器。重点看这三个信号:
- 序列化占总CPU时间超过30%
- GC Gen0/Gen1频繁触发,但堆内存并不大(说明大量临时对象)
- 接口RT高,但数据库和业务逻辑加起来时间却很短
如果你中了以上任意一条,别犹豫,先把序列化优化了再说。这往往是最快出成果的一步。
最后说点真心话
很多后端开发有个误区:性能优化就是要优化核心逻辑。但真实生产环境里,往往是这些基础设施代码——序列化、日志、反射——在暗处捅了你一刀。
你的业务逻辑可能只占10%的CPU,但一个烂的JSON库可能占用40%。把40%优化到10%,业务逻辑反而变成了那10%里的10%——这就是杠杆效应。
下次接口又卡了,别光盯着SQL和Redis。先问问自己:我的JSON,够快吗?
如果你有具体的序列化性能问题,欢迎留言。选几个典型案例,下期可以深度展开。