Jackson 序列化的隐性成本

2025-12-12 0 771

我们常以为接口的瓶颈在数据库或业务逻辑,但在高并发、海量请求下,真正吞噬 CPU 的,可能是“把对象变成 JSON”的那一步。当监控把序列化时间单独拆出来,你会惊讶它能让账单失控。这篇《The Hidden Cost of Jackson Serialization》对我启发很大:默认好用的 Jackson,在某些场景可能成为热路径的成本中心。下面顺手分享给大家参考,以下内容翻译整理自 《The Hidden Cost of Jackson Serialization》。

Jackson 很强大,直到你看到它真正让你付出了什么代价。我们的 REST API 正在大把大把的花钱。每个 JSON 响应要消耗 3–5ms 的 CPU 时间。把它乘以每天 5000 万次请求,你就会得到一张能让 CTO 掉眼泪的 AWS 账单。罪魁祸首?Jackson。Java 生态里最流行的 JSON 库,那个大家几乎不假思索就会用的默认选项。

事情是怎么开始的?

我们有一个标准的 Spring Boot 微服务,很普通。

@RestController
public class UserController {
    
    @GetMapping(\"/users/{id}\")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

干净、简单,跟每篇 Spring Boot 教程教你的几乎一样。

Spring Boot 默认用 Jackson 把 Java 对象转换成 JSON。你不用配置什么,它就能工作。

直到你看了指标数据。

当头棒喝

我们的监控面板显示出一些奇怪的东西:

  • 数据库查询时间:8ms
  • 业务逻辑:2ms
  • JSON 序列化:47ms

等等,什么?

实际工作只花了 10ms。把结果转换成 JSON 花了 47ms。就像你做饭用了 2 分钟,装盘却花了 10 分钟。

我以为是测量误差,于是跑了一个 profiler。

Method                          Time    Calls
-------------------------------- ------- -------
Jackson.writeValueAsString()     47ms    1
UserService.findById()           8ms     1

不是。Jackson 确实在每次请求里,用 47ms 序列化一个简单的 User 对象。

排查开始

我抓起我们的 User 实体,看了看:

@Entity
public class User {
    private Long id;
    private String email;
    private String firstName;
    private String lastName;
    
    @OneToMany(fetch = FetchType.EAGER)
    private List orders;
    
    @OneToMany(fetch = FetchType.EAGER)
    private List addresses;
    
    @ManyToMany(fetch = FetchType.EAGER)
    private List roles;
}

哦。我们把整张对象图都返回出去了。每个用户对象附带:

  • 50+ 个订单(每个订单还有行项目)
  • 3–4 个地址
  • 多个角色

Jackson 在每次请求中序列化上千个对象。难怪它慢。

但关键是:我们只需要用户的基本信息。邮箱和姓名,仅此而已。

“用 DTO 就好”的论调

每个资深开发看到这,都会大喊:“用 DTO 啊!”

是的,我们本来就该从第一天起就用数据传输对象(DTO)。但我们没有。

为什么?因为 Spring Boot 返回实体太容易了。在快速迭代出功能时,你会走捷径。

这些捷径会迅速累积。

我们有 73 个 REST 接口。都直接返回 JPA 实体。把它们全部重构成 DTO 要花上几周。

我们需要一个更快的修复方式。

快速优化一:@JsonView

Jackson 有个叫 @JsonView 的特性,可以控制被序列化的字段:

public class Views {
    public static class Basic {}
    public static class Detailed {}
}

@Entity
public class User {
    @JsonView(Views.Basic.class)
    private Long id;
    
    @JsonView(Views.Basic.class)
    private String email;
    
    @JsonView(Views.Detailed.class)
    private List orders;
}
@RestController
public class UserController {
    
    @JsonView(Views.Basic.class)
    @GetMapping(\"/users/{id}\")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

结果:序列化时间从 47ms 降到 12ms。

好一些,但对我们的规模仍然太慢。

快速优化二:禁用用不到的功能

Jackson 默认启用了很多特性,其中不少你并不需要:

@Configuration
public class JacksonConfig {
    
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        
        // 禁用开销较大的特性
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
        mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
        
        // 启用/调整更快的行为
        mapper.disable(MapperFeature.USE_GETTERS_AS_SETTERS);
        mapper.disable(MapperFeature.AUTO_DETECT_GETTERS);
        mapper.disable(MapperFeature.AUTO_DETECT_IS_GETTERS);
        
        return mapper;
    }
}

结果:再省 3ms,降到 9ms。

真正的问题:反射

Jackson 用反射去检查你的对象、决定如何序列化。

反射很慢。非常慢。

Jackson 每次序列化一个对象时:

  1. 检查类结构(有哪些字段)
  2. 通过反射调用 getter
  3. 把值转换成 JSON 字符串
  4. 处理空值和类型转换

对于一个简单的 User 对象,这也许没问题。但当你每天要序列化复杂的对象图上千万次,这些毫秒就会变成钱。

核选项:手写序列化

如果我们……自己把 JSON 拼出来呢?

@RestController
public class UserController {
    
    @GetMapping(\"/users/{id}\")
    public String getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        
        return String.format(
            \"{\"id\":%d,\"email\":\"%s\",\"firstName\":\"%s\",\"lastName\":\"%s\"}\",
            user.getId(),
            user.getEmail(),
            user.getFirstName(),
            user.getLastName()
        );
    }
}

结果:0.8ms。

从 47ms 到 0.8ms,提升了 58 倍。

但是……这是不是太疯狂了?我们仿佛又回到了 1999 年的字符串拼接时代。

被忽略的争论

这里会有点争议。

  • A 队:手写序列化不可维护。用 Jackson + DTO 才对。
  • B 队:Jackson 是性能瓶颈。写自定义序列化器。

两边都对,也都不完全对。

真正的答案取决于你的规模:

如果每天请求 < 100 万

用 Jackson。开发效率值得那点性能代价。

如果每天请求 1000 万+

在热路径上考虑自定义序列化。维护成本能被 AWS 账单的节省抵消。

如果每天请求 1 亿+

你大概应该用 Protocol Buffers 或 FlatBuffers 了。

我们的实际做法

我们采用了混合方案:

  1. 对 90% 的接口仍用 Jackson(流量低、响应复杂)
  2. 对中等流量的接口使用 @JsonView(简单优化)
  3. 对 5 个关键接口编写自定义序列化器(高流量、响应简单)

这 5 个接口占了我们 80% 的流量。只优化这几个就每月给我们省了约 4200 美元的 AWS 成本。

你应该跑的基准测试

别信我的数字。用你的代码测试:

@Test
public void benchmarkSerialization() {
    ObjectMapper mapper = new ObjectMapper();
    User user = createComplexUser();
    
    long start = System.nanoTime();
    for (int i = 0; i < 10000; i++) {
        mapper.writeValueAsString(user);
    }
    long end = System.nanoTime();
    
    System.out.println(\"Time per serialization: \" + 
        (end - start) / 10000 / 1_000 + \"μs\");
}

用你的真实领域对象跑。如果结果 > 100μs,那你就有问题需要关注。

有帮助的工具

  • JProfiler:精确展示时间花在了哪里
  • Spring Boot Actuator 指标:按接口统计序列化时间
  • JMH(Java 微基准测试框架):更准确的性能测试
  • Jackson 的 @JsonView:不用大改就能有快速收益

我们犯过的常见错误

  • 错误 1:过度信任默认
    Spring Boot 的默认值更偏向开发体验,而非性能。多数应用这没问题。但在规模化场景下,默认会“害人”。
  • 错误 2:不测量
    我们的 API 跑了 8 个月,没人做过性能剖析。8 个月的冤枉钱,只因为我们以为“应该没问题”。
  • 错误 3:直接返回实体
    JPA 实体用于持久化,DTO 用于 API。混用不仅有性能问题,还会带来安全风险(不小心暴露敏感字段)。
  • 错误 4:过早优化
    问题解决后,团队有人想“把所有地方都优化一下”。这是坏主意。先优化热路径,测量,再决定是否继续。

不那么舒服的真相

Jackson 并不慢。

Jackson 正在做它被设计要做的事:在零配置的情况下,处理任意结构的 Java 对象。

这种灵活性是有代价的。反射、类型检查、空值处理、循环引用检测——这些都要时间。

问题不在 Jackson,而在“把一切都交给 Jackson”。

替代方案

如果你遇到 Jackson 的瓶颈,这里是一些选择:

  1. Protocol Buffers(protobuf)

    • 二进制格式,极快
    • 需要定义 schema
    • 不可读
  2. MessagePack

    • 二进制 JSON,通常比文本 JSON 快
    • 很多场景可作为替代
  3. FastJSON

    • 号称比 Jackson 更快
    • 但历史上有过安全问题
  4. 自定义序列化器

    • 可能是最快的
    • 维护成本高
  5. 好好用 DTO

    • 认真点,这能解决 90% 的问题

真正的教训

你没有 Jackson 问题,你有架构问题。

如果 Jackson 慢,那是因为你序列化了太多数据。修的是数据,不是库。

用 DTO、用投影、用 @JsonView,如果需要用户自定义响应结构可以用 GraphQL。

别把责任推给 Jackson,它只是忠实地序列化了你让它序列化的庞大对象图。

行动计划

你应该这样做:

步骤 1: 加指标,跟踪序列化时间

@Around(\"@annotation(org.springframework.web.bind.annotation.GetMapping)\")
public Object measureSerialization(ProceedingJoinPoint joinPoint) throws Throwable {
    long start = System.nanoTime();
    Object result = joinPoint.proceed();
    long serializationTime = System.nanoTime() - start;
    
    metrics.recordSerializationTime(serializationTime);
    return result;
}

步骤 2: 剖析你最热的 10 个接口

步骤 3: 若序列化时间 > 响应时间的 20%,继续调查

步骤 4: 先修最严重的几个

步骤 5: 再次测量

不要盲目优化。不要盲目相信框架。测量一切。

我预期会看到的评论

  • “用 gRPC/GraphQL/REST 替代就好!”
    可以,如果你能重构整个 API。多数团队做不到。
  • “DTO 能解决所有问题!”
    它能解决很多。但即便用了 DTO,如果你还在序列化巨大的列表,Jackson 仍会慢。
  • “手写序列化是技术债!”
    50K 的 AWS 账单也是。择其轻。
  • “这是过早优化!”
    当你每月在无谓的 CPU 周期上花 4K 美元时,就不是了。

尾声

Jackson 很好,Spring Boot 也很棒。

但“好”不代表“适用于所有规模”。

在某个时刻,你需要质疑默认值;在某个时刻,你需要测量;在某个时刻,你需要在开发效率与运行成本之间做艰难取舍。

我们在每天 5000 万请求时遇到了这个时刻。你可能更早、也可能更晚,甚至永远不会遇到。

但当你遇到时,希望你能记起这篇文章。

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

申明:本文由第三方发布,内容仅代表作者观点,与本网站无关。对本文以及其中全部或者部分内容的真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。本网发布或转载文章出于传递更多信息之目的,并不意味着赞同其观点或证实其描述,也不代表本网对其真实性负责。

左子网 编程相关 Jackson 序列化的隐性成本 https://www.zuozi.net/35659.html

常见问题
  • 1、自动:拍下后,点击(下载)链接即可下载;2、手动:拍下后,联系卖家发放即可或者联系官方找开发者发货。
查看详情
  • 1、源码默认交易周期:手动发货商品为1-3天,并且用户付款金额将会进入平台担保直到交易完成或者3-7天即可发放,如遇纠纷无限期延长收款金额直至纠纷解决或者退款!;
查看详情
  • 1、描述:源码描述(含标题)与实际源码不一致的(例:货不对板); 2、演示:有演示站时,与实际源码小于95%一致的(但描述中有”不保证完全一样、有变化的可能性”类似显著声明的除外); 3、发货:不发货可无理由退款; 4、安装:免费提供安装服务的源码但卖家不履行的; 5、收费:价格虚标,额外收取其他费用的(但描述中有显著声明或双方交易前有商定的除外); 6、其他:如质量方面的硬性常规问题BUG等。 注:经核实符合上述任一,均支持退款,但卖家予以积极解决问题则除外。
查看详情
  • 1、左子会对双方交易的过程及交易商品的快照进行永久存档,以确保交易的真实、有效、安全! 2、左子无法对如“永久包更新”、“永久技术支持”等类似交易之后的商家承诺做担保,请买家自行鉴别; 3、在源码同时有网站演示与图片演示,且站演与图演不一致时,默认按图演作为纠纷评判依据(特别声明或有商定除外); 4、在没有”无任何正当退款依据”的前提下,商品写有”一旦售出,概不支持退款”等类似的声明,视为无效声明; 5、在未拍下前,双方在QQ上所商定的交易内容,亦可成为纠纷评判依据(商定与描述冲突时,商定为准); 6、因聊天记录可作为纠纷评判依据,故双方联系时,只与对方在左子上所留的QQ、手机号沟通,以防对方不承认自我承诺。 7、虽然交易产生纠纷的几率很小,但一定要保留如聊天记录、手机短信等这样的重要信息,以防产生纠纷时便于左子介入快速处理。
查看详情

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务