动态

详情 返回 返回

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 每次序列化一個對象時:

  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 萬請求時遇到了這個時刻。你可能更早、也可能更晚,甚至永遠不會遇到。

但當你遇到時,希望你能記起這篇文章。

user avatar king_wenzhinan 头像 u_16297326 头像 lenglingx 头像 u_11365552 头像 devlive 头像 xiaoxiansheng_5e75673e1ae30 头像 ruozxby 头像 wxweven 头像 chengxy 头像 enaium 头像 jeecg 头像 zzger 头像
点赞 20 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.