别再无脑用JSON了!把一个接口的序列化方案换掉,响应速度直接提升5倍
接口压测一跑,CPU 先红了,带宽也跟着抖。
最离谱的是,业务没多复杂,就是一个“查详情然后返回列表”的普通接口。SQL 不慢,缓存命中率也还行,trace 里真正长的那一段,不在业务逻辑,在序列化。你看着像查库 8ms、拼装 5ms,结果最后 json.dumps() 一把干到了 40ms 往上,这种账我一般不认。
很多团队一提接口返回,条件反射就是 JSON。能用,兼容性也好,但高频接口、内部服务调用、字段结构稳定的场景,还闭眼上 JSON,基本就是拿 CPU 当柴烧。
我前段时间就碰过一次。一个 Python 服务,给下游风控模块吐批量结果,单次返回大概几百条记录。字段不算多,但请求量上来以后,P99 明显往上拱。
日志里当时很直接:
import time
import json
import logginglog = logging.getLogger(__name__)
defencode_response(rows: list[dict]) -> bytes:
start = time.perf_counter()
body = json.dumps(
{"code": 0, "data": rows},
ensure_ascii=False,
separators=(",", ":")
).encode("utf-8")
cost = (time.perf_counter() - start) * 1000
log.info("serialize=json rows=%s size=%s cost=%.2fms",
len(rows), len(body), cost)
return body
看起来没毛病。压测一上来,问题就出来了。同样的数据,查库 12ms,组装 6ms,序列化 38ms,网络发送再加一点,接口总耗时轻轻松松破 60ms。你说数据库慢,我还能去看执行计划;你说是 JSON 慢,这种锅很多人一开始不愿意背。
但这地方我第一眼就不太信 JSON 了。因为它有几个老问题,平时不明显,一上量就全冒头:
第一,字段名重复传。 1000 条记录,user_id、rule_code、hit 这些 key 反复写 1000 次,纯属搬砖。
第二,类型都是文本化。 整数、浮点、布尔,全得转成字符串格式再落字节。
第三,Python 标准库 JSON 编码并不算快。 能用,不代表适合高频内部 RPC。
这种场景,尤其是服务间内部通信,我一般会先问一句:真需要“人人看得懂”的格式吗?如果调用方就两三个,协议完全可控,那我宁可换成二进制序列化。
我们最后没走 protobuf 那套编译 schema 的路子,先做了个成本更低的版本:消息头 + msgpack。改动不大,但够用。
import msgpackdefencode_response_v2(rows: list[dict]) -> bytes:
payload = {
"code": 0,
"ts": 1710000000,
"data": rows,
}
return msgpack.packb(payload, use_bin_type=True)
服务端返回的时候也顺手把 Content-Type 换了:
from flask import Responsedefbuild_resp(rows: list[dict]) -> Response:
body = encode_response_v2(rows)
return Response(
body,
content_type="application/x-msgpack"
)
客户端解包也很直白:
import requests
import msgpackresp = requests.get("http://svc/detail/batch", timeout=1)
data = msgpack.unpackb(resp.content, raw=False)
别小看这点改动。压测对比很实在,没动 SQL,没调线程池,单改序列化方案:
import time
import json
import msgpacksample = {
"code": 0,
"data": [
{"user_id": i, "rule_code": "A12", "hit": True, "score": i % 100}
for i in range(1000)
]
}
for name, fn in {
"json": lambda: json.dumps(sample).encode(),
"msgpack": lambda: msgpack.packb(sample, use_bin_type=True),
}.items():
start = time.perf_counter()
for _ in range(2000):
body = fn()
print(name, "cost=", round((time.perf_counter() - start) * 1000, 2),
"size=", len(body))
我们线上同结构数据压出来,序列化耗时差不多从 35ms 掉到 7ms 左右,接口整体响应接近提了 5 倍。这里我不想吹成什么银弹,它不是所有场景都值 5 倍,但内部接口、固定结构、请求量大,收益通常都很直接。
当然,也别又从一个极端走到另一个极端。JSON 不是不能用,它适合:
对外开放接口,调试友好; 跨语言、跨团队联调频繁; 字段变化快,协议不想绑太死。
但下面这几种,我真不建议再无脑 JSON 了:
服务内部高频调用; 批量返回,重复字段多; 结构稳定,调用方可控; 序列化已经在 trace 里占明显大头。
还有个坑得提前说。序列化方案一换,排查方式也得跟着换。以前抓包一眼能看懂 JSON,现在二进制报文肉眼不可读,日志里最好补长度、版本号、摘要,不然线上出问题你会查得很烦。
import hashlib
import msgpack
import logginglog = logging.getLogger(__name__)
defpack_with_log(payload: dict) -> bytes:
body = msgpack.packb(payload, use_bin_type=True)
log.info(
"serialize=msgpack size=%s md5=%s",
len(body),
hashlib.md5(body).hexdigest()[:8]
)
return body
所以这事最后复盘下来,不复杂,就是很多人默认把 JSON 当“接口返回的唯一答案”了。其实它只是一个默认选项,不是性能场景下的最佳选项。
接口慢,别上来就盯数据库。trace 里如果序列化那一段已经冒头了,这地方就该动刀了。很多时候不是你业务太重,是你把不该走文本协议的流量,硬塞进了 JSON。