你的API为什么慢?我花了一周排查,结果是个空格惹的祸

2026-05-02 10 0

你的API为什么慢?我花了一周排查,结果是个空格惹的祸

说出来你可能不信,我上周被一个接口性能问题折腾了整整一周,最后发现罪魁祸首是一个空格

不是在代码里的空格,是一个JSON序列化时多出来的空格。

事情是这样的——我们有个数据导出接口,导出1000条数据,耗时居然要8秒。数据库查询只要200毫秒,剩下的时间全消失在「序列化」这个环节。

今天就把这个排查过程和中间学到的JSON序列化反模式分享出来,保证比你看那些"JSON入门教程"有用一万倍。

第一坑:字符串拼接是最慢的序列化方式

先问个问题:下面两种JSON构建方式,哪个更快?

// 方式一:字符串拼接
let json = '{';
json += '"name": "张三",';
json += '"age": 25';
json += '}';

// 方式二:对象序列化
let data = { name: "张三", age: 25 };
let json = JSON.stringify(data);

你的直觉可能是方式一更快——看起来更"可控",没有抽象层。

但实际上,方式二比方式一快5到10倍

原因很简单:JSON.stringify是C++实现的,而字符串拼接是JavaScript引擎的逐字符操作。对于小于1KB的数据差异不明显,但当你处理大型数据集时,这个差异会放大到难以忍受的程度。

我们那个导出接口,最初的实现就是用字符串拼接拼出来的「自定义JSON」。当时觉得这样可以控制格式,结果——8秒。

第二坑:toJSON方法里的陷阱

很多人知道可以给对象定义toJSON方法来定制序列化行为,但不知道这里有个坑:toJSON里别再调用JSON.stringify了

// 错误示例
const user = {
  name: "张三",
  profile: { bio: "程序员", skills: ["JavaScript", "Python"] },
  toJSON() {
    return JSON.stringify({
      name: this.name,
      ...this.profile
    });
  }
};
// 序列化结果:profile字段变成了字符串,而不是对象!

// 正确示例
const user = {
  name: "张三",
  profile: { bio: "程序员", skills: ["JavaScript", "Python"] },
  toJSON() {
    return {
      name: this.name,
      bio: this.profile.bio,
      skills: this.profile.skills
    };
  }
};

第一眼看到这个bug的时候,我人傻了。profile字段在序列化后从对象变成了字符串「{"bio":"程序员","skills":["JavaScript","Python"]}」。

这直接导致前端解析出错,因为代码里假设profile是个对象。这属于那种「语法完全正确,逻辑完全错误」的bug,极难排查。

第三坑:循环引用和undefined的序列化

JSON.stringify有两个特性很多人不知道:

  • 循环引用的对象会抛出异常
  • undefined、Symbol、函数会被自动忽略

第一个坑比较明显,第二个坑就比较隐蔽了:

const data = {
  name: "张三",
  age: undefined,       // 序列化后消失
  gender: null,         // 序列化为 null
  phone: Symbol("phone"), // 序列化后消失
  greet: function() {}   // 序列化后消失
};

console.log(JSON.stringify(data));
// 输出: {"name":"张三","gender":null}

你以为你传了一个完整的对象给前端,其实中间有些字段已经默默消失了。如果前端代码假设某个字段一定存在,轻则页面异常,重则数据库写入失败。

更阴的是,这种问题在测试环境可能不会出现——因为测试数据往往比较「干净」,而生产数据可能有各种边缘值。

第四坑:大数字精度丢失

JSON规范里没有整数和浮点数的区别,只有「数字」。而JavaScript的Number类型使用IEEE 754双精度浮点数,能安全表示的整数范围是 -(2^53-1) 到 (2^53-1)。

换句话说:如果你有个ID是 9007199254740993,JSON序列化后会变成 9007199254740992

const bigId = 9007199254740993;
console.log(JSON.parse(JSON.stringify({ id: bigId })).id);
// 输出: 9007199254740992 —— 少了1!

这个问题在高并发系统中很常见——订单ID、交易ID、用户ID都可能超过这个范围。

解决方案有两个:

  1. 用字符串传递大数字:数据库里存字符串,前端也用字符串
  2. 用JSONBig库:支持大数字的序列化
import JSONBig from "json-bigint";

const data = { orderId: 9007199254740993 };
const json = JSONBig.stringify(data);
// 输出: {"orderId":"9007199254740993"}

const parsed = JSONBig.parse(json);
// 输出: { orderId: BigInt "9007199254740993" }

如果你用的是Node.js,还有一个更狠的问题:雪花算法生成的ID在JSON序列化时会精度丢失,而很多团队用了雪花算法很久都不知道这个问题,直到某天客服开始收到用户反馈「订单号查不到」。

第五坑:空格——我这次踩的坑王

好了,终于到正题了。

我们那个8秒的导出接口,序列化用了6秒。代码是这么写的:

function buildJson(rows) {
  let json = '[';
  rows.forEach((row, index) => {
    json += ' { ';
    json += '"id": ' + row.id + ', ';
    json += '"name": "' + row.name + '", ';
    json += '"email": "' + row.email + '" ';
    json += '}';
    if (index < rows.length - 1) json += ', ';
  });
  json += ']';
  return json;
}

注意到了吗?每个对象里的字段之间有空格:「 "id": 1, 」。

对于1000条数据,每条数据10个字段,就是10000个空格。而每行代码里的+操作符和字符串拼接,会产生大量的中间字符串对象。

修复后的代码:

function buildJson(rows) {
  return '[' + rows.map(row =>
    `{"id":${row.id},"name":"${row.name}","email":"${row.email}"}`
  ).join(',') + ']';
}

时间从8秒降到了400毫秒。20倍提升,就因为少了一些空格和一次JOIN操作。

当然,这个例子的空格问题是人为制造的。但真实场景中,JSON序列化慢的原因往往是:

  • 不必要的嵌套结构
  • 序列化不需要返回给前端的字段
  • 大字段(如text类型的文章内容)没有单独处理

第六坑:循环中的序列化

这是一个经典反模式:

// 错误:循环中每次都序列化
const users = [{ name: "张三" }, { name: "李四" }];
users.forEach(user => {
  console.log(JSON.stringify(user));
});

// 正确:批量处理
console.log(JSON.stringify(users));

原理很简单:JSON序列化的启动开销不小,循环调用会重复这个开销。如果你要处理1000个对象,循环序列化可能比一次性序列化慢几十倍。

第七坑:不知道有替代方案

JSON不是唯一的序列化格式。在特定场景下,有更快或更小的选择:

  • MessagePack:二进制格式,比JSON小30-50%,解析快2-3倍
  • Protocol Buffers:Google的序列化协议,需要定义schema,但性能极高
  • CBOR:类似MessagePack,专为嵌入式设计
import msgpack from "@msgpack/msgpack";

const data = { name: "张三", age: 25 };
const packed = msgpack.encode(data);
const unpacked = msgpack.decode(packed);

如果你在处理海量数据的场景,MessagePack能给你带来显著的网络传输和解析性能提升。

总结:JSON序列化的七条军规

写到这里,给你七条我在血泪教训中总结的规则:

  1. 永远用JSON.stringify,而不是字符串拼接——除非你在写面试题
  2. toJSON方法返回对象,不是字符串——这是个很容易犯的错
  3. 注意undefined和函数会消失——测试时用边缘值
  4. 大数字用字符串或JSONBig——雪花ID必看
  5. 避免循环中序列化——批量处理是基本素养
  6. 选择合适的序列化格式——JSON不是万能的
  7. 永远不要手动拼接JSON——除非你想体验8秒的恐惧

JSON看起来简单,但里面的坑足够你喝一壶。希望我这一周的排查经历能让你少走点弯路。

如果你也有类似的踩坑经历,欢迎来comck.com和我聊聊。

相关文章

写API这事儿:为毛你的接口总是被吐槽?
微服务:看上去很美,用起来很贵
一次诡异的数据库死锁,帮你彻底搞懂事务隔离级别
WebSocket连接总是断?可能是你打开方式不对
为什么你的API总被吐槽?这份避坑指南让你少走三年弯路
你的接口不快,不是代码的问题——是你测量姿势错了

发布评论