Jackson 序列化的隱性成本
我們常以為接口的瓶頸在數據庫或業務邏輯,但在高并發、海量請求下,真正吞噬 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<Order> orders;
@OneToMany(fetch = FetchType.EAGER)
private List<Address> addresses;
@ManyToMany(fetch = FetchType.EAGER)
private List<Role> 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<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 每次序列化一個對象時:
- 檢查類結構(有哪些字段)
- 通過反射調用 getter
- 把值轉換成 JSON 字符串
- 處理空值和類型轉換
對于一個簡單的 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 了。
我們的實際做法
我們采用了混合方案:
- 對 90% 的接口仍用 Jackson(流量低、響應復雜)
- 對中等流量的接口使用
@JsonView(簡單優化) - 對 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 的瓶頸,這里是一些選擇:
-
Protocol Buffers(protobuf)
- 二進制格式,極快
- 需要定義 schema
- 不可讀
-
MessagePack
- 二進制 JSON,通常比文本 JSON 快
- 很多場景可作為替代
-
FastJSON
- 號稱比 Jackson 更快
- 但歷史上有過安全問題
-
自定義序列化器
- 可能是最快的
- 維護成本高
-
好好用 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 萬請求時遇到了這個時刻。你可能更早、也可能更晚,甚至永遠不會遇到。
但當你遇到時,希望你能記起這篇文章。

浙公網安備 33010602011771號