热血生死狙击战场官方版
77.11MB · 2025-12-02
我们常以为接口的瓶颈在数据库或业务逻辑,但在高并发、海量请求下,真正吞噬 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。你不用配置什么,它就能工作。
直到你看了指标数据。
我们的监控面板显示出一些奇怪的东西:
等等,什么?
实际工作只花了 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<Order> orders;
@OneToMany(fetch = FetchType.EAGER)
private List<Address> addresses;
@ManyToMany(fetch = FetchType.EAGER)
private List<Role> roles;
}
哦。我们把整张对象图都返回出去了。每个用户对象附带:
Jackson 在每次请求中序列化上千个对象。难怪它慢。
但关键是:我们只需要用户的基本信息。邮箱和姓名,仅此而已。
每个资深开发看到这,都会大喊:“用 DTO 啊!”
是的,我们本来就该从第一天起就用数据传输对象(DTO)。但我们没有。
为什么?因为 Spring Boot 返回实体太容易了。在快速迭代出功能时,你会走捷径。
这些捷径会迅速累积。
我们有 73 个 REST 接口。都直接返回 JPA 实体。把它们全部重构成 DTO 要花上几周。
我们需要一个更快的修复方式。
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<Order> 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 每次序列化一个对象时:
对于一个简单的 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 年的字符串拼接时代。
这里会有点争议。
两边都对,也都不完全对。
真正的答案取决于你的规模:
用 Jackson。开发效率值得那点性能代价。
在热路径上考虑自定义序列化。维护成本能被 AWS 账单的节省抵消。
你大概应该用 Protocol Buffers 或 FlatBuffers 了。
我们采用了混合方案:
@JsonView(简单优化)这 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,那你就有问题需要关注。
@JsonView:不用大改就能有快速收益Jackson 并不慢。
Jackson 正在做它被设计要做的事:在零配置的情况下,处理任意结构的 Java 对象。
这种灵活性是有代价的。反射、类型检查、空值处理、循环引用检测——这些都要时间。
问题不在 Jackson,而在“把一切都交给 Jackson”。
如果你遇到 Jackson 的瓶颈,这里是一些选择:
Protocol Buffers(protobuf)
MessagePack
FastJSON
自定义序列化器
好好用 DTO
你没有 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: 再次测量
不要盲目优化。不要盲目相信框架。测量一切。
Jackson 很好,Spring Boot 也很棒。
但“好”不代表“适用于所有规模”。
在某个时刻,你需要质疑默认值;在某个时刻,你需要测量;在某个时刻,你需要在开发效率与运行成本之间做艰难取舍。
我们在每天 5000 万请求时遇到了这个时刻。你可能更早、也可能更晚,甚至永远不会遇到。
但当你遇到时,希望你能记起这篇文章。