Python还能这么跑?不用改代码,性能飙升50倍
我跟你说,前两天我被一个破 Python 脚本折磨到快凌晨两点,人都麻了。一个很简单的 ETL,小表转大表那种,逻辑一点不复杂,就是跑得慢得离谱。平时十几分钟搞定,那天业务量一上来,直接飙到两小时,运维在旁边眼神已经开始暗示:“要不要…考虑下 Go 啊?”
我当场不服气啊,我说等等等等,Python 还能再抢救一下,先别动业务代码一行,我就改“跑法”。结果真是,最后没改逻辑,没动 if else,性能直接干到原来的三四十倍,某几个场景接近 50 倍,真的不是吹牛那种夸张法。那个感觉,跟当年压测 Postgres 干翻 MySQL 那次差不多…
先说第一个骚操作啊,就是——换解释器。听着很虚对吧,但是真的好用。
我们那个脚本,本质上就是各种 for 循环在干活,纯 Python 算法,CPU 算得飞起,IO 反而不多。典型 CPython 克星。我当时就随手写了个小玩具,测一下到底卡哪儿:
# prime_bench.py
import math
import timedefis_prime(n: int) -> bool:
if n < 2:
returnFalse
if n % 2 == 0:
return n == 2
r = int(math.sqrt(n))
i = 3
while i <= r:
if n % i == 0:
returnFalse
i += 2
returnTrue
defbench(limit: int = 200_000) -> None:
start = time.time()
count = 0
for i in range(limit):
if is_prime(i):
count += 1
cost = time.time() - start
print(f"found {count} primes in {cost:.3f}s")
if __name__ == "__main__":
bench()
这个脚本你就当它是你业务里那堆 for / while,啥业务逻辑不重要。
然后我在同一台机器上干了两件事:
用系统默认 python跑一次再装个 pypy3,用pypy3 prime_bench.py再跑一次
不夸张,CPython 那边是那种“嗯…还在跑…再等会”的节奏,PyPy 这边已经“刷”一下结束。循环次数往上怼一点,差距直接到几十倍。你想想,我业务脚本里全是这种纯 Python 算法,你解释器换成带 JIT 的 PyPy,本质上就是给那堆 for 循环加了个自动“编译”,但是——业务代码一行没动。
运行方式就从这货:
python etl_job.py
变成这货:
pypy3 etl_job.py
老板看 commit 记录:0 行变更。性能监控再一看:QPS 翻了十几倍。那一刻我心想:这要是我早两年知道,我那堆“重构用 Go 重写”的夜晚是不是都可以不加班了。
当然,现实没这么完美,PyPy 也不是万能钥匙,C 扩展多的项目会翻车,但那天那个任务刚好是很“干净”的 CPU 逻辑,就特别适合。这个就是第一个套路:先想办法换跑法,不要动业务逻辑。
第二个位置,是我后来复盘才发现最恶心的——JSON。
很多人写服务,习惯性:
import jsondefencode(data):
return json.dumps(data, ensure_ascii=False)
然后线上一压测,监控一看:CPU 有一半在那儿拼字符串、转 JSON,我直呼离谱。之前排查 Feign 超时那次,最后定位到一条巨耗时 SQL,一模一样的味道,只不过一个是数据库,一个是 JSON 序列化
问题来了,你要是乖乖去改每个文件的 import json,那肯定会被人打死:“你把代码全改一遍万一出问题谁扛?”
那我就换个思路:Python 启动的时候,会自动尝试 import 一个叫 sitecustomize 的模块,只要它在 PYTHONPATH 里。那我就整一个全局补丁,业务代码一个都不动,启动时自动把 json.dumps 换成更快的实现,比如 orjson。
搞个独立文件,名字就叫 sitecustomize.py:
# sitecustomize.py
"""
自动给全局 json 库打鸡血。
放到 PYTHONPATH 里,Python 启动会自动 import 它。
"""
try:
import json
import orjson
except ImportError:
# 环境没准备好就老老实实用原版
pass
else:
def_fast_dumps(obj, **kwargs) -> str:
# 这里只演示字符串输出,复杂配置你自己加
return orjson.dumps(obj).decode("utf-8") json.dumps = _fast_dumps
然后部署的时候,虚拟环境下这么玩:
pip install orjson
# 把 sitecustomize.py 丢到 venv/lib/pythonX.Y/site-packages/ 里
python your_app.py
业务代码里那堆 import json 全不知道自己被换腰子的事,继续愉快调用 json.dumps,结果底层已经是 orjson 在飞了。
这招的好处就是——你可以先在测试环境玩,压一压,真的看到 CPU 掉下来了,RT 降下来了,再慢慢考虑要不要在线上也统一加上。不像那种“全项目搜一遍 import json”的操作,一看就很危险。
第三个点,是纯粹靠“堆进程”干出来的性能。
有些同学写了个爬虫、批处理工具,单进程跑得很稳,但是就是慢。然后他脑子一热,跑去改代码,加 multiprocessing、加队列、加锁,搞着搞着自己都看不懂了,最后性能也没多快。
我那次任务稍微幸运一点,本身就是一个 Web 服务,Flask 那种,前面挂了个 gunicorn。这个时候真的是“Python 不行,gunicorn 多来几个”的经典场景。
原来上线的时候,gunicorn 启动脚本就这么写:
gunicorn app:app
这个等价于“我就开一个 worker 混日子”。CPU 8 核在那儿看戏。
我当时困得眼睛都睁不开了,随手改成这样:
# run_gunicorn.sh
#!/usr/bin/env bash
WORKERS=${WORKERS:-8}
TIMEOUT=${TIMEOUT:-30}exec gunicorn \
-w "${WORKERS}" \
-k "uvicorn.workers.UvicornWorker" \
-t "${TIMEOUT}" \
app:app
然后上线的时候只改了个启动命令和环境变量:
WORKERS=8 ./run_gunicorn.sh
就这,CPU 利用率瞬间上去了,RT 直接砍半还多一点。因为我们这个接口很明显是 IO 型的,后面要调好几个下游服务,像之前那个 TCP 1024 字节那个诡异 bug 一样,卡的不是算力,是网络那一段链路
关键是:Flask 代码一行没动,路由一个没改,连依赖都没加。改的是 gunicorn 的 worker 数量、worker 类型,用的是 uvicorn 那套更快的 event loop。你可以理解成“我没换车,只是把一个司机变成了八个司机轮着开”。
这三招叠加起来,大概就是那天晚上我从“想跑路”到“又爱上 Python”的全过程:
先看算力是不是浪费在解释器上,能上 PyPy 的先试试 PyPy,CPU-bound 的脚本收益非常夸张; 再看有没有那种巨热的库调用,像 JSON、正则、压缩、加密这几个,把它们通过 sitecustomize或者配置换成 C 实现,业务逻辑不要动;最后再调进程模型,Web 服务就老老实实上多 worker,多核机器不用就是浪费,配合更快的 event loop / worker 类。
整个过程中,版本管理里看起来就像你“只是”改了几个启动脚本、加了一个小小的 sitecustomize.py,业务方看 diff:嗯?你这不叫性能优化,你这是打补丁。
但监控不会说谎嘛。
那天最后我看着那条关键任务的耗时,从 120 分钟刷到 3 分多钟,内心是有一点复杂的:
一方面觉得“卧槽 Python 还能这么跑?”,另一方面也有点后怕——这么简单的活儿,居然拖到线上才想起来动解释器和运行时,这不是活该被同事吐槽么。
算了我先去续杯咖啡,等会儿有空再跟你八一八我怎么靠改一个 MySQL 参数把主从同步救回来的…