Python技术迷

别再无脑用JSON了!把一个接口的序列化方案换掉,响应速度直接提升5倍

接口压测一跑,CPU 先红了,带宽也跟着抖。

最离谱的是,业务没多复杂,就是一个“查详情然后返回列表”的普通接口。SQL 不慢,缓存命中率也还行,trace 里真正长的那一段,不在业务逻辑,在序列化。你看着像查库 8ms、拼装 5ms,结果最后 json.dumps() 一把干到了 40ms 往上,这种账我一般不认。

很多团队一提接口返回,条件反射就是 JSON。能用,兼容性也好,但高频接口、内部服务调用、字段结构稳定的场景,还闭眼上 JSON,基本就是拿 CPU 当柴烧。

我前段时间就碰过一次。一个 Python 服务,给下游风控模块吐批量结果,单次返回大概几百条记录。字段不算多,但请求量上来以后,P99 明显往上拱。

日志里当时很直接:

import time
import json
import logging

log = 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 msgpack

defencode_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 Response

defbuild_resp(rows: list[dict]) -> Response:
    body = encode_response_v2(rows)
return Response(
        body,
        content_type="application/x-msgpack"
    )

客户端解包也很直白:

import requests
import msgpack

resp = requests.get("http://svc/detail/batch", timeout=1)
data = msgpack.unpackb(resp.content, raw=False)

别小看这点改动。压测对比很实在,没动 SQL,没调线程池,单改序列化方案:

import time
import json
import msgpack

sample = {
"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 了:

  1. 服务内部高频调用;
  2. 批量返回,重复字段多;
  3. 结构稳定,调用方可控;
  4. 序列化已经在 trace 里占明显大头。

还有个坑得提前说。序列化方案一换,排查方式也得跟着换。以前抓包一眼能看懂 JSON,现在二进制报文肉眼不可读,日志里最好补长度、版本号、摘要,不然线上出问题你会查得很烦。

import hashlib
import msgpack
import logging

log = 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。