别再无脑用JSON了!把一个接口的序列化方案换掉,响应速度直接提升5倍
接口慢到 700ms,第一眼我不太信是业务代码的问题。
SQL 没慢,Redis 没抖,线程池也没打满。trace 拉出来看了一圈,最扎眼的是这一段:
GET /api/order/snapshot
db.query 38ms
cache.merge 12ms
json.dumps 421ms
response.write 96ms
total 688ms
这就有点恶心了。
业务只花了几十毫秒,最后卡在 json.dumps。这种接口还挺常见:订单快照、报表明细、设备状态列表、权限菜单树,看起来就是返回一坨结构化数据。大家习惯性用 JSON,前端也习惯性接 JSON,然后某一天数据量上来了,CPU 开始给你脸色看。
我当时先没动业务逻辑,只单独把序列化拆出来测了一下。
import json
import time
from decimal import Decimal
from datetime import datetimedefbuild_rows(n: int):
rows = []
for i in range(n):
rows.append({
"order_id": f"O{i:08d}",
"user_id": i % 3000,
"amount": float(Decimal("19.90") + Decimal(i % 17)),
"status": "PAID"if i % 3else"WAIT",
"sku_list": [f"SKU-{i}-{j}"for j in range(4)],
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
return rows
rows = build_rows(20000)
t1 = time.perf_counter()
body = json.dumps(rows, ensure_ascii=False).encode("utf-8")
t2 = time.perf_counter()
print("json bytes:", len(body))
print("json cost ms:", round((t2 - t1) * 1000, 2))
这个结果不用太精确,机器不同会有差异。但方向很明显:JSON 在大对象、大数组、字段重复很多的场景下,真不便宜。
我后来换的是 MessagePack。
不是说 MessagePack 天下无敌,而是这个接口有几个特点:内部接口、Python 调 Python、前端不直接调、字段结构稳定、响应体大。这个时候还硬上 JSON,就有点像明明走内网,非要每次把数据打印成一篇小作文再发出去。
改法其实不复杂,核心就两处。
import msgpack
from fastapi import FastAPI, Response, Requestapp = FastAPI()
defload_snapshot(user_id: int):
# 这里假装是查库+查缓存后的结果
return {
"user_id": user_id,
"items": [
{
"order_id": f"O{i:08d}",
"amount": 39.9 + i % 11,
"status": 2,
"tags": ["new", "paid", "risk_checked"]
}
for i in range(5000)
]
}
@app.get("/api/order/snapshot")
deforder_snapshot(request: Request, user_id: int):
data = load_snapshot(user_id)
accept = request.headers.get("accept", "")
if"application/x-msgpack"in accept:
packed = msgpack.packb(data, use_bin_type=True)
return Response(
content=packed,
media_type="application/x-msgpack",
headers={"X-Serialize": "msgpack"}
)
import json
return Response(
content=json.dumps(data, ensure_ascii=False),
media_type="application/json",
headers={"X-Serialize": "json"}
)
客户端也别写得花里胡哨,能看懂最重要。
import requests
import msgpackresp = requests.get(
"http://127.0.0.1:8000/api/order/snapshot",
params={"user_id": 9527},
headers={"accept": "application/x-msgpack"},
timeout=3,
)
if resp.headers.get("X-Serialize") != "msgpack":
raise RuntimeError("server fallback to json, check gateway config")
data = msgpack.unpackb(resp.content, raw=False)
print(data["user_id"], len(data["items"]))
改完之后,接口耗时从六七百毫秒掉到一百多毫秒。这里别只盯着“5 倍”这个数字,真正省下来的有两块。
一块是序列化 CPU。
JSON 要处理字符串转义、字段名重复输出、数字文本化。MessagePack 是二进制格式,很多值不用绕一圈变成字符串。尤其列表里几千几万条结构差不多的数据,差距会越来越明显。
另一块是响应体大小。
我见过有些接口,JSON 返回 8MB,换成 MessagePack 后只有 4MB 左右。网络传输少了,网关缓冲少了,客户端解析也少吃点 CPU。你看起来只是换了个 Content-Type,实际上整条链路都轻了一截。
但别一听快就全系统替换。
这地方我一般会先看三个指标:
response_body_size > 1MB
json.dumps/json.loads 在 trace 里超过 100ms
调用方不是浏览器直连页面
满足两个,再考虑换。
如果只是普通 CRUD,一个接口返回十几行数据,JSON 就挺好。你硬换 MessagePack,收益没多少,排查问题还麻烦。抓包看不懂,日志不能直接贴,网关默认规则也可能不认。到时候出了问题,别人第一反应不是夸你性能好,是问你为什么搞这么怪。
还有一个坑,二进制协议一定要留降级。
我不喜欢那种一刀切改法。服务端最好同时支持 JSON 和 MessagePack,用 Accept 头控制。调用方没带头,就返回 JSON。这样灰度的时候心里有底,出了问题也能退回来。
生产上我还会加一条日志,不要每次都打,只在慢请求里带上序列化类型和响应大小。
deflog_slow_response(path: str, serializer: str, size: int, cost_ms: float):
if cost_ms < 200:
return print(
f"slow_response path={path} serializer={serializer} "
f"bytes={size} cost_ms={cost_ms:.1f}"
)
后面排查就很舒服。
slow_response path=/api/order/snapshot serializer=json bytes=8388120 cost_ms=642.7
slow_response path=/api/order/snapshot serializer=msgpack bytes=4219035 cost_ms=118.4
这种日志比写一堆“性能优化最佳实践”管用。
JSON 没错,错的是不看场景。
小接口、开放 API、调试频繁、前端直连,用 JSON 省心。大响应、内部调用、批量数据、机器对机器传输,还无脑 JSON,就属于把方便留给开发,把账单甩给服务器。
接口慢的时候,别只盯着 SQL。 有时候真正拖后腿的,就是最后那一下打包。