一个导出接口把整台机器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 让同一个类的同一个字段只反射一次,后续直接从缓存取。
五、效果对比
| 指标 | 全量一次性 | 流式输出 | 流式 + 字段过滤 |
|---|---|---|---|
| 峰值 CPU | 700% | 120% | 25% |
| 内存占用(堆) | 600MB+ | 50MB | 15MB |
| 首字节响应时间 | 45 秒 | 200ms | 50ms |
| 完整传输时间 | 48 秒 | 35 秒 | 2 秒 |
| 传输数据量 | 200MB | 200MB | 20MB |
| 用户感知 | 白屏 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
公众号:服务端技术精选
评论