线上有个接口突然报警。
不是 QPS 飙了,也不是数据库慢,而是 CPU 直接打满。
我看监控第一眼有点懵:这个接口平时挺轻,主要就是查数据然后返回 JSON。没有复杂计算,没有大循环,也没有大 SQL。
结果火焰图一展开,最热的位置居然是:
json.dumps()
我当时第一反应:“JSON 序列化还能把 CPU 吃满?”
后来才发现,问题不是 JSON 慢,而是我们太相信 JSON 了。
很多 Python 服务里,JSON 就像空气一样存在。接口返回它,日志打印它,消息传它,缓存存它。小数据时没感觉,数据一上来,序列化成本就藏不住了。
先看一个很常见的写法。业务接口查出来一批数据,然后直接返回:
import json
def build_response(rows):
result = []
for row in rows:
result.append({
"id": row["id"],
"name": row["name"],
"detail": row["detail"]
})
return json.dumps(result)
代码没毛病,很多项目里就是这么写的。但线上数据量一上来,这里会发生几件事:
第一,Python 对象创建。
第二,字符串编码转换。
第三,JSON 格式拼接。
第四,大量内存复制。
假设返回的数据有 20 万条,你以为只是“转一下格式”,实际上 CPU 在疯狂做字符串处理。
我遇到过一个类似场景:一个数据导出接口,用户点击下载报表。后端逻辑大致是:
- MySQL 查询 20 万条数据
- Python 组装 dict
json.dumps 序列化
- 把结果返回前端
接口耗时从几秒慢慢涨到几十秒。监控看到:
CPU: 95%
Memory: 正常
DB: 正常
Network: 正常
这种情况我一般不会先看数据库。因为数据库正常、网络正常,CPU 高,优先怀疑的就是应用层计算。直接抓了一下 profile,结果:
json/encoder.py
耗时占比 48%
问题就出来了:不是查数据慢,而是返回数据之前把 CPU 烧掉了。
后来把代码拆开看,原来还有一个隐藏操作。为了方便排查问题,开发加了日志:
logger.info(
"export data=%s",
json.dumps(data)
)
这一行更坑。很多人以为日志级别 INFO 关闭了,这个序列化不会执行。实际上不是这样。
Python logging 的参数处理和字符串格式化方式有关。如果提前拼接:
logger.info(
"export data=" + json.dumps(data)
)
不管日志开不开,json.dumps 都会执行。数据量大的时候,日志没输出,CPU 先干活。
我线上看到过类似代码:
def save_order(order):
content = json.dumps(order)
logger.debug(content)
save_to_db(order)
debug 日志关闭了,但序列化已经发生了。
这种地方我一般会改成延迟处理。比如:
import logging
logger = logging.getLogger(__name__)
def save_order(order):
logger.debug(
"order snapshot=%s",
order
)
save_to_db(order)
让 logging 自己决定是否处理,不要提前把大对象转字符串。
还有一个坑在接口返回里更明显。很多人喜欢这么写:
return json.dumps(
{
"code": 0,
"data": big_list
}
)
小数据没问题,大数据直接炸。
我的处理习惯是先看数据规模。比如:
def check_payload(items):
size = len(items)
if size > 50000:
raise ValueError(
f"payload too large:{size}"
)
不是说 50000 一定不行,而是接口边界要明确。一个接口返回几十万条数据,本身设计就值得怀疑。
那 JSON 有没有更快的方式?有。Python 默认的 json 库追求兼容性,性能场景可以考虑更快的序列化库,比如 orjson。简单测试代码:
import json
import orjson
import time
records = [
{
"uid": i,
"name": "user_" + str(i),
"active": True
}
for i in range(100000)
]
start = time.time()
json.dumps(records)
print(
"json cost:",
time.time() - start
)
start = time.time()
orjson.dumps(records)
print(
"orjson cost:",
time.time() - start
)
实际效果和数据结构有关,但大对象序列化这种场景,orjson 通常会明显降低 CPU 消耗。
不过别看到性能库就上。我排查性能问题的时候有个习惯:先确认是不是方向错了。很多优化都是换 JSON 库、换缓存、加机器,最后发现真正的问题是一开始就不该返回那么大的数据。
还有一个容易忽略的问题:JSON 很浪费。比如:
{
"user_id": 10001,
"user_name": "dongge",
"create_time": "2026-01-01 10:20:30"
}
字段名本身也占空间。如果内部服务之间通信,没必要一定用 JSON。比如大量数据同步,可以考虑二进制协议。简单一点:
import pickle
data = {
"id": 1001,
"name": "test"
}
binary = pickle.dumps(data)
print(len(binary))
当然,pickle 不适合跨服务开放接口,更不能接收不可信数据。这里只是想说明一个问题:JSON 最大的优势是可读,不是性能。
还有一个场景特别容易踩坑——缓存。很多项目喜欢:
redis.set(
"user:list",
json.dumps(users)
)
然后每次读取:
users = json.loads(
redis.get("user:list")
)
数据量小没问题。数据大了以后,Redis 很快,慢的是 Python。因为每次取缓存,都重新反序列化。
监控里可能看到:
Redis GET: 3ms
接口耗时: 800ms
然后大家开始怀疑 Redis。其实 Redis 已经把数据给你了,CPU 花在 Python 自己身上。
后来我处理这类问题,会先看三个地方:
第一,看返回大小。
curl -w "%{size_download}"
确认接口到底返回多少数据。
第二,看 CPU 火焰图,确认是不是 serializer 占时间。
第三,看代码里有没有隐藏序列化。尤其是这几个地方:
这些位置最容易偷偷消耗 CPU。
JSON 没错,它解决的是数据交换问题。只是很多系统发展到一定阶段后,会发现“能传”和“高效传”是两回事。刚开始几十 KB 的接口,用 JSON 很舒服。等数据到了几十 MB、甚至几百 MB,还抱着 JSON 不放,CPU 迟早会告诉你:这个选择当初挺方便,现在开始收费了。
类似的性能排查经验,在云栈社区上也经常有人分享,排查思路值得参考。