Python技术迷

别再无脑用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 datetime

defbuild_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, Request

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

resp = 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。 有时候真正拖后腿的,就是最后那一下打包。