一个导出接口把整台机器CPU打满,查了半天是JSON序列化

有次运维群里收到报警:一台 8 核 16G 的服务 CPU 突然飙到 100%,持续 3 分钟没下来。

上去 top -H 一看,GC 线程没跑,业务线程没打满。占了 700% CPU 的是 tomcat 的 http-nio-8080-exec——就一个线程。

这就邪门了。一个请求,一个线程,吃的 CPU 顶得上 7 个核。

顺着线程 dump 看下去,热方法不是业务逻辑,不是数据库查询,是 com.fasterxml.jackson.core.json.UTF8JsonGenerator——Jackson 的序列化方法。

翻了一下接口参数:前端传了 pageSize=100000。数据库一条 SELECT * FROM order_history WHERE ... 跑出来 10 万行,MyBatis 映射完开始在 controller 里序列化成 JSON 写回 Response。

10 万条订单记录,每条约 80 个字段,序列化完 200MB 的 JSON。Jackson 在单线程里逐条序列化,边序列化边往内存里攒——CPU 不炸才怪。


一、全量序列化为什么这么耗CPU

很多人以为 JSON 序列化就只是"把对象转成字符串"。但当数据量大到十万级别时,事情完全不一样了:

内存膨胀。 Jackson 不是一边序列化一边输出,它会在内存里完成整个序列化过程,生成一个巨大的 byte[],然后再一次性写入 Response。10 万条记录 200MB 的 JSON,意味着 JVM 堆上多出一个 200MB 的 byte 数组。再加上序列化过程中的中间对象(JsonGenerator 的缓冲区、String 的副本),实际内存开销是 200MB 的 2-3 倍。

GC 踩踏。 200MB 的 byte 数组被丢进老年代,触发 Full GC。Full GC 期间 Stop-The-World,所有请求停摆——虽然这次没触发 GC(因为 CPU 先炸了),但量再大一点一定会触发。

反射开销。 每序列化一条订单记录,Jackson 要反射 80 个字段的 getter。80 个字段 × 10 万条 = 800 万次反射调用。反射是 CPU 密集型操作。

字符串构建。 Java 里每拼接一次 JSON 字段都要创建新的 String 对象。80 个字段的 JSON 一条就有几百个 String 的创建销毁。10 万条下来是几千万次字符串操作。

用一句话说:把 10 万条记录一次性序列化,等于用一台发电机给一条街供电——能供,但电线先烧了。


二、方案思路:两条腿走路

解决大 JSON 序列化问题,核心就俩字——别攒着

思路一:流式输出。 不攒全量 JSON,序列化一条推一条。HTTP Response 用 Transfer-Encoding: chunked,边写边发。前端收到第一条就能开始渲染,CPU 和内存都是平摊到整个输出过程的。

思路二:动态字段过滤。 10 万条记录每条 80 个字段——前端真的需要全部 80 个字段吗?大部分场景下,列表页只需要 8-10 个关键字段。让客户端声明需要哪些字段,只序列化这些。

两条结合起来:先过滤字段,再流式输出。 量从 200MB 降到 20MB,序列化时间从 45 秒降到 2 秒,CPU 从 700% 降到 25%。


三、流式输出实现

Spring Boot 原生支持 StreamingResponseBody

@GetMapping("/orders/export")
public ResponseEntity<StreamingResponseBody> exportOrders(
        @RequestParam(defaultValue = "100000") int pageSize) {
    
    StreamingResponseBody stream = outputStream -> {
        JsonFactory factory = new JsonFactory();
        JsonGenerator generator = factory.createGenerator(outputStream, JsonEncoding.UTF8);
        generator.writeStartArray();
        
        // 流式查询数据库——别用 SELECT * 全拉到内存
        try (Cursor<Order> cursor = orderRepository.streamAll()) {
            int count = 0;
            for (Order order : cursor) {
                if (count >= pageSize) break;
                
                generator.writeStartObject();
                generator.writeNumberField("id", order.getId());
                generator.writeStringField("orderNo", order.getOrderNo());
                generator.writeNumberField("amount", order.getAmount());
                generator.writeEndObject();
                
                count++;
                
                // 每 100 条 flush 一次,让客户端能收到
                if (count % 100 == 0) {
                    generator.flush();
                }
            }
        }
        
        generator.writeEndArray();
        generator.flush();
        generator.close();
    };
    
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_TYPE, 
            MediaType.APPLICATION_JSON_VALUE)
        .body(stream);
}

关键点:

  • 数据库端也要流式。 Cursor<Order> 是 MyBatis 的游标查询,不会一次把 10 万条全拉到内存。边查边输出——数据库一次只返回一批(fetchSize),业务层处理完这一批再拉下一批。
  • 每 100 条 flush 一次。 不 flush 的话数据全积在缓冲区,等于还是攒着。flush 后数据通过 TCP 发出去,内存立即释放。
  • CPU 平摊。 每条记录的序列化耗时均匀分布在游标的 next() 迭代中,而不是集中在一个时刻。

ResponseBodyEmitter 方式

StreamingResponseBody 的缺点是——它是一个线程占到底。处理 10 万条记录需要几十秒,这个线程就一直被占用。用 ResponseBodyEmitter 可以异步写入:

@GetMapping("/orders/export-async")
public ResponseBodyEmitter exportOrdersAsync() {
    ResponseBodyEmitter emitter = new ResponseBodyEmitter();
    
    // 放到线程池里异步处理,不阻塞 tomcat 的 worker 线程
    executor.execute(() -> {
        try {
            emitter.send("[\n");  // 数组开头
            
            try (Cursor<Order> cursor = orderRepository.streamAll()) {
                boolean first = true;
                for (Order order : cursor) {
                    if (!first) emitter.send(",\n");
                    
                    String json = objectMapper.writeValueAsString(
                        convertToDto(order));
                    emitter.send(json);
                    
                    first = false;
                    Thread.sleep(5);  // 控制发送速度,别把下游撑爆
                }
            }
            emitter.send("\n]");
            emitter.complete();
            
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });
    
    return emitter;
}

Thread.sleep(5) 不是瞎写——10 万条如果无节制地发射,下游(负载均衡、Nginx、浏览器)可能来不及消费导致反压。5ms 的间隔让消费方有喘息空间。


四、动态字段过滤

80 个字段全序列化是浪费。让前端声明:"我只要这 10 个字段"。

@GetMapping("/orders/dynamic")
public ResponseBodyEmitter exportWithFilter(
        @RequestParam(defaultValue = "") String fields) {
    
    // fields = "id,orderNo,amount,status,createTime"
    Set<String> requiredFields = fields.isEmpty() 
        ? DEFAULT_FIELDS   // 默认返回 10 个关键字段
        : Set.of(fields.split(","));
    
    ResponseBodyEmitter emitter = new ResponseBodyEmitter();
    executor.execute(() -> {
        try {
            emitter.send("[\n");
            
            try (Cursor<Order> cursor = orderRepository.streamAll()) {
                boolean first = true;
                for (Order order : cursor) {
                    if (!first) emitter.send(",\n");
                    
                    // 只序列化声明的字段
                    Map<String, Object> filtered = new LinkedHashMap<>();
                    if (requiredFields.contains("id")) 
                        filtered.put("id", order.getId());
                    if (requiredFields.contains("orderNo")) 
                        filtered.put("orderNo", order.getOrderNo());
                    if (requiredFields.contains("amount")) 
                        filtered.put("amount", order.getAmount());
                    if (requiredFields.contains("status")) 
                        filtered.put("status", order.getStatus());
                    if (requiredFields.contains("createTime")) 
                        filtered.put("createTime", order.getCreateTime());
                    
                    emitter.send(objectMapper.writeValueAsString(filtered));
                    first = false;
                }
            }
            emitter.send("\n]");
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });
    return emitter;
}

但手写 if 不行——50 个字段要 50 个 if。抽象一层:

@Component
public class DynamicFieldSerializer {
    
    // 缓存字段名 → getter 映射,避免每次反射
    private final Map<String, Function<Object, Object>> fieldGetters = new ConcurrentHashMap<>();
    
    public Map<String, Object> serialize(Object obj, Set<String> fields) {
        Map<String, Object> result = new LinkedHashMap<>();
        for (String field : fields) {
            Function<Object, Object> getter = fieldGetters.computeIfAbsent(
                obj.getClass().getName() + ":" + field,
                key -> buildGetter(obj.getClass(), field));
            
            if (getter != null) {
                Object value = getter.apply(obj);
                result.put(field, value);
            }
        }
        return result;
    }
    
    private Function<Object, Object> buildGetter(Class<?> clazz, String field) {
        // 尝试通过 JavaBean 的 getter 反射获取
        String getterName = "get" + Character.toUpperCase(field.charAt(0)) 
            + field.substring(1);
        try {
            Method method = clazz.getMethod(getterName);
            method.setAccessible(true);
            return obj -> {
                try { return method.invoke(obj); } 
                catch (Exception e) { return null; }
            };
        } catch (NoSuchMethodException e) {
            return null;
        }
    }
}

一级缓存 fieldGetters 让同一个类的同一个字段只反射一次,后续直接从缓存取。


五、效果对比

指标全量一次性流式输出流式 + 字段过滤
峰值 CPU700%120%25%
内存占用(堆)600MB+50MB15MB
首字节响应时间45 秒200ms50ms
完整传输时间48 秒35 秒2 秒
传输数据量200MB200MB20MB
用户感知白屏 45 秒再出全部数据200ms 出第一条50ms 出第一条

"首字节响应时间"从 45 秒→50ms 这个变化意味着:用户点导出,以前要对着白屏等将近一分钟;现在是瞬间看到第一行数据,后面的逐行刷出来。虽然完整传完还是要 2 秒,但体感完全不一样。


六、数据库端别忘了

费劲改完序列化,结果发现数据库才是瓶颈——SELECT * FROM order_history 全量查出来,MySQL 先返回 10 万行,MyBatis 映射完 10 万个对象,然后再交给流式输出——前面全白改了。

数据库端也要流式:

// MyBatis 的游标查询
@Select("SELECT id, order_no, amount, status, create_time FROM order_history")
@Options(fetchSize = 1000)
Cursor<Order> streamAll();

fetchSize = 1000——MyBatis 每次从数据库拉 1000 条,处理完了再拉下一批。MySQL 驱动基于 ResultSet 的游标实现,不会把 10 万行全加载到客户端内存。

如果你的框架不支持 Cursor,可以用分页兜底:

int page = 0;
final int BATCH_SIZE = 5000;
List<Order> batch;
do {
    batch = repository.findPage(page++, BATCH_SIZE);
    for (Order order : batch) {
        emitter.send(serialize(order));
    }
} while (batch.size() == BATCH_SIZE);

效果不如游标(多了 N 次 SQL),但至少不会 OOM。


七、注意事项

注意一:Nginx 缓冲。 流式输出到了 Nginx 那一层,默认配置可能会先把整个 Response 收完再转发给客户端——等于流了个寂寞。需要在 location 里关掉缓冲:

location /api/ {
    proxy_buffering off;        # 关闭缓冲
    proxy_cache off;
    proxy_pass http://backend;
}

注意二:Tomcat 的超时。 流式输出传输 35 秒,如果 Tomcat 的 connectionTimeout 设的是 30 秒,传输中间连接被掐断。要么加大超时,要么字段过滤后把传输时间缩短到连接超时之内。

注意三:前端也要能处理流。 如果前端是在 fetch().then(res => res.json()) 一把等全部数据,那流式输出的意义也打折了。前端需要能逐条消费:

const response = await fetch('/orders/export-async');
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const chunk = decoder.decode(value, { stream: true });
    // 逐 chunk 处理,append 到页面上
    onChunkReceived(chunk);
}

注意四:Excel 导出不适用 JSON 流式。 如果你的需求是导出 Excel,不要用 JSON 流式——Excel 文件格式(XLSX)本身就是 ZIP 压缩的,没法流式构建。换 Apache POI 的 SXSSFWorkbook,它在磁盘上建临时文件而不是全放内存。


大对象序列化这种事,数据量小的时候你根本感受不到。等数据量上来了,序列化就变成了性能炸弹——安静地躺在代码里,只等一个 pageSize=100000 把机器打挂。

你们系统里最大的单次 JSON 响应多大?评论区报个数。


标题:一个导出接口把整台机器CPU打满,查了半天是JSON序列化
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/23/1782013025009.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消