技术分享 · 性能实测

起因是生产一个慢到离谱的裸渲染数字:88 RPS。把同一个博客列表页用 Python、Node、Go、.NET 各写一遍,同机、同核、同数据压测后,结论很反直觉:慢的不是 Python,是一段每请求重复执行的 IO。删掉它,生产裸渲染从 88 提到 536 RPS

dreamyouxi.com 个人技术站 2026-06-21 腾讯云 2 vCPU · Xeon 8255C · Ubuntu 24.04 592 篇文章 · 内网 ApacheBench · 单核绑定

结论先行(TL;DR)

88 → 536
生产慢的真凶是 auto_excerpt:每请求把列表页 10 篇文章的正文重读一遍(10 次文件 IO + 10 次 BeautifulSoup),而摘要本就存在 meta.json 里。删掉即 6.1×,跟 Python 无关。
0.87 ms
Jinja2 单次渲染最快做到 0.87ms,全场第一——比 Go gonja、.NET Scriban、Node nunjucks 都快。模板引擎不是 Python 的短板。
1567 → 4121
同一份 Python + Jinja2,逐层剥掉框架 / 事件循环 overhead,cache 命中吞吐探底 2.6×。这是天花板探索,未上生产
伪命题
“Python 慢” 站不住。瓶颈是 重复 IO + 重框架 + 偏重事件循环三层叠加,每一层都跟语言本身无关。

01起因与测试方法

这个站的博客基于 FastAPI + Jinja2,JSON 文件存数据,生产上跑得好好的。直到我做了一次裸压测——把响应缓存旁路掉、只测“纯渲染”这一段,博客列表页只有 88 RPS / 单次 11.3ms。一个十篇文章的列表页,模板渲染怎么会要 11ms?是 Jinja2 慢?Python 慢?还是 FastAPI 这套异步框架的锅?与其猜,不如把同一个页面用别的语言各写一遍,让数字说话。

对照原则

所有实现读同一份 meta.json(592 篇文章元数据),渲染同一套模板结构,只读不写(写仍由生产 FastAPI 承担),保证比的是“渲染 + HTTP 这条路径”本身。

环境与单核绑定

腾讯云单台 2 vCPU(Xeon 8255C @ 2.50GHz)/ Ubuntu 24.04。施压机与被测机走内网,排除公网带宽干扰。所有副本用 taskset -c 0启动时绑到单核——这点关键:Go 的 GOMAXPROCS、.NET 的线程池都在进程初始化时读核数,必须启动即绑核,否则它们按 2 核初始化、再被限到单核运行,会因线程争抢得出失真数据(本文初版就因 .NET 副本漏绑核,把双核的 ~1043 误当单核,后已订正为 422)。

两组对照

含义怎么触发测的是
A · cache 命中默认 UA,命中响应缓存普通请求缓存层 + HTTP 栈的吞吐天花板
B · 裸渲染旁路缓存,每次真渲染特殊 UA(bench-random-access)触发旁路模板引擎 + 业务逻辑的真实开销

A 组反映“生产实际表现”(绝大多数请求命中缓存),B 组反映“应用裸上限”(缓存失效 / 首次访问的成本)。压测工具 ApacheBench(keep-alive),QPS 取多并发档位平均(非峰值)。下文数字均为 2026-06-21 单核公平复测值。

02第一战:裸渲染横向对比

先看 B 组——每个请求都真渲染,最能暴露引擎和业务逻辑的真实成本。单位 RPS,越长越快:

B 组 · 裸渲染吞吐(单核,RPS)
socketifyJinja2 · Py3.14
1154
裸 ASGIJinja2 · Py3.14 · uvloop
1083
FastAPI liteJinja2 · Py3.14 · uvloop
966
net/httpgonja · Go 1.23
833
生产 FastAPI · 优化后删 auto_excerpt
536
fastifynunjucks · Node 22
451
ASP.NETScriban · .NET 9
422
生产 FastAPI · 优化前含 auto_excerpt
88

三个观察:① Jinja2(socketify / 裸 ASGI / FastAPI lite)裸渲染全场最快,把 Go、.NET、Node 全甩在后面——同样的模板渲染,Python 不慢;② 单核下 .NET Scriban(422)、Node nunjucks(451)垫底,模板引擎本身更重;③ 优化前的生产 FastAPI 只有 88,比同样 Jinja2 的实现慢一个数量级——问题一定不在模板引擎,而在它每请求多干了什么。优化后回到 536,落在中游(被生产完整中间件栈拖着,详见 §07)。

03元凶:auto_excerpt 的重复 IO

同样是 Jinja2,干净实现能到 900+ RPS,优化前的生产却只有 88。差距藏在列表页的“摘要生成”里。下面是优化前的真实代码逻辑(app/services/blog_renderer.py):

# 优化前:渲染列表页时,每篇文章都重算摘要
def _enrich_post(post, ..., auto_summary=True):   # ← 列表 context 传了 True
    if auto_summary:
        out["summary"] = _auto_excerpt(post["id"])   # 每篇都重算

def _auto_excerpt(post_id):
    html = open(f"{post_id}.html").read(8192)              # ① 读文件
    text = BeautifulSoup(html, "html.parser").get_text()   # ② 解析 HTML
    return text[:50] + "…"                              # ③ 截断 50 字

# 列表 page_size=10  →  每请求 = 10 次文件读 + 10 次 BeautifulSoup 解析
# 而 meta.json 里,每篇文章其实早就存好了 summary 字段……

也就是说:服务器每渲染一次列表页,就把 10 篇文章的正文从磁盘读出来、用 BeautifulSoup 解析一遍 HTML、再截取摘要——而这个摘要本就已经存在 meta.json。一段纯粹的重复劳动,且 BeautifulSoup 解析是 CPU 密集的。

修复很简单:把摘要生成挪到写入侧。抽出纯函数 generate_summary(html),在上传 / 编辑文章时生成、固化进 meta.json(存量文章启动时一次性迁移回填);渲染侧 _enrich_post() 不再传 auto_summary(默认 False),直接读 meta.summary——零文件 IO、零解析。

删掉这段重复 IO 后,生产裸渲染从 88 跳到 536 RPS6.1×)。在更精简的 FastAPI lite 栈上甚至能到 ~870(见 §05)。这跟 Python 快慢毫无关系,纯粹是一段不该每请求执行的 IO + 解析。这条优化已上生产

04第二战:cache 命中对比

再看 A 组——命中响应缓存。这里要先说清缓存层是怎么工作的(app/core/cache_middleware.py):HTML 响应按 Accept-Encoding 预压缩成 gzip / identity 两个内存桶,命中时直接吐对应桶的字节流 + ETag——命中路径零渲染、零压缩、零拷贝。所以 A 组测的不是渲染,而是“缓存层 + HTTP 栈”每秒能吐多少个预压缩响应:

A 组 · cache 命中吞吐(单核,RPS)
Node fastify
4425
Go net/http
4292
Python socketify
4121
Python 裸 ASGI
3155
.NET ASP.NET
3077
Python FastAPI lite
2210
生产 FastAPI
1567

Node fastify、Go 在极轻的 HTTP 栈上领先;Python 用 socketify 也追到 4121,跟它们同档。但真正值得说的,是 Python 这条线从 生产的 1567 一路爬到 4121 是怎么做到的——而且这一段全程不涉及渲染(命中根本不渲染)。

05cache 优化阶梯:1567 → 4121

命中既然不渲染,瓶颈就只能在“接到请求 → 吐出缓存字节”这条 HTTP 路径的每请求开销上。同样的 Python、同样的缓存逻辑,只改“外围”,逐层剥掉 overhead:

生产 FastAPIPy3.12 · 完整中间件栈
1567
FastAPI litePy3.14 + uvloop + 去审计
2210
+41%
裸 ASGI去 FastAPI/Starlette
3155
+43%
socketify换 uWebSockets
4121
+31%

① Py3.14 + uvloop + 砍掉审计中间件(→ 2210,+41%)

两个免费提升:CPython 3.12 升到 3.14(+10%)、事件循环 asyncio 换成 uvloop(+6%,uvicorn --loop auto 装了就自动启用、不改一行代码)。再砍掉生产那层审计采集开销,到 2210。

② 去掉 FastAPI / Starlette 框架层(→ 3155,+43%)

生产每个请求要穿过两层 BaseHTTPMiddlewarecustom_middleware + cache_middleware,见 server.py)。而 BaseHTTPMiddleware 为每个请求开一个 anyio task group + 构造一个完整的 Request 对象——缓存命中时这些全用不上。手写最薄的 ASGI、命中直接 send 预压缩字节,省掉这些每请求开销,+43%。这印证了:cache 命中的瓶颈从来不是渲染,而是框架对象的创建成本。

③ 把 HTTP 层换成 uWebSockets(→ 4121,+31%)

socketify.py 基于 C++ 的 uWebSockets,绕过 uvicorn / ASGI,HTTP 解析全压进 C++,Python 只管业务,再 +31%。代价是 0.x 库、生态不成熟——这一步是“能到多高”的探底,不适合上生产

关键观察:2.6 倍提升里没有一行是“把 Python 写得更像 C”。全部来自删掉不必要的工作——审计、用不到的框架对象、偏重的 HTTP 解析。

06多核扩展性:谁能吃满第二个核

生产机是 2 vCPU,所以还有一个关键维度:把裸渲染从单核放开到双核,各栈能不能用上第二个核?差异巨大,直接决定选型:

单核渲染双核渲染扩展比
.NET ASP.NET .NET422~1043~2.5×
Go net/http GO833~1002~1.2×
Python 全部 PY不扩展1.0× · GIL
Node fastify NODE不扩展1.0× · 单线程

.NET / Go 的多线程运行时能并行吃满两个核,Python(GIL)和 Node(单线程事件循环)锁死单核。所以 2 vCPU 上单论裸渲染峰值,编译型多线程栈确实能比 Python 高一截——但这只影响缓存失效(MISS)路径。生产绝大多数请求命中缓存(不渲染),所以这个优势在真实流量下几乎用不上。

注:本文初版曾把 .NET 的双核值(~1043,未绑核误测)当成单核数据列在裸渲染榜,导致 .NET 看起来排第 4。绑核重测后单核真值是 422,已全文订正。

07落地:优化前后对比(生产实测)

探底归探底,真正搬上生产的只有“零风险纯收益”这批:删 auto_excerpt 重复 IO + 固化 uvloop / httptools。生产单核裸渲染的实测前后对比:

生产裸渲染 · 单核 RPS(越长越快)
优化后删 auto_excerpt + uvloop
536
优化前含 auto_excerpt
88
指标优化前优化后效果
裸渲染(单核 RPS)885366.1× ↑
单次渲染耗时11.3ms1.87ms快 6×
cache 命中(RPS)15701594持平 *

* cache 命中走预压缩内存桶、不经渲染,删 auto_excerpt 只作用于裸渲染路径,cache 持平属预期——优化打在了刀刃上。

为什么生产是 536,而不是 §05 说的 ~870?因为生产带着完整中间件栈(两层 BaseHTTPMiddleware + 审计 + Request 对象),那 ~870 是 FastAPI lite 精简栈的数。这 536 → ~870 的缺口要靠把中间件改写成 pure ASGI 才能补,跟“换语言”无关——而且只惠及罕见的缓存失效路径,性价比远不如直接给缓存命中那条路加一层极薄的 ASGI fast-path(可把命中从 1567 提到 ~2800)。

08结论

把这台 2 核小机器上的数字摆齐,故事很清楚:

  • Jinja2 裸渲染 0.87ms / 1154 RPS 全场最快,快过 Go gonja、.NET Scriban、Node nunjucks。模板引擎不是 Python 的短板。
  • 生产那个 88 RPS 的“慢”,100% 是 auto_excerpt 的重复 IO——每请求 10 次文件读 + 10 次 BeautifulSoup,一删就回到 536(6.1×)。
  • 缓存命中的吞吐瓶颈在框架 / HTTP 栈的每请求开销(两层 BaseHTTPMiddleware 的 task group + Request 对象),不在渲染——去框架 +43%,换 uWebSockets 再 +31%。
  • 2 vCPU 上只有 .NET / Go 能并行吃满双核;但生产主路径是缓存命中(不渲染),这个优势用不上。

“Python 慢” 在这个场景里是个伪命题。慢的从来不是语言,是架构里那些不必要的工作。把它们找出来删掉,Python 一样能打。真正值得落地的是“删 auto_excerpt + uvloop”这类零风险优化(已上生产,6.1×);性能探底(socketify 4121)是为了看清天花板,不等于都要搬上生产。

09附录:完整数据

2026-06-21 单核公平复测(taskset -c 0 启动绑核)。QPS 为多并发档位平均值(A 组 c=10/50/100/200,B 组 c=10/20/50),ApacheBench keep-alive,内网。

裸渲染(B 组,单核)

实现模板引擎运行时单次RPS
socketify PYJinja2Python 3.14 · uWebSockets0.87ms1154
裸 ASGI PYJinja2Python 3.14 · uvloop0.92ms1083
FastAPI lite PYJinja2Python 3.14 · uvloop1.04ms966
net/http GOgonjaGo 1.231.20ms833
生产 FastAPI · 优化后 PYJinja2(读 meta.summary)Python 3.12 · uvloop · 完整中间件1.87ms536
fastify NODEnunjucksNode 222.22ms451
ASP.NET .NETScriban.NET 9(单核)2.37ms422
生产 FastAPI · 优化前 PYJinja2 + auto_excerptPython 3.1211.3ms88

Python 版本细分(Jinja2 裸渲染):3.12·asyncio 821 → 3.12·uvloop 870 → 3.14·asyncio 902 → 3.14·uvloop 953。3.14 与 uvloop 各贡献约一档。

cache 命中(A 组,单核)

实现HTTP 栈RPS对生产
Node fastify NODEfastify44252.8×
Go net/http GOnet/http42922.7×
Python socketify PYuWebSockets41212.6×
Python 裸 ASGI PYuvicorn · uvloop31552.0×
.NET ASP.NET .NETKestrel30772.0×
Python FastAPI lite PYFastAPI · uvicorn22101.4×
生产 FastAPI PYFastAPI · 完整中间件栈15671.0×

cache 命中不经渲染,删 auto_excerpt 对其无影响,故生产优化前后均为 ~1570(复测 1567 / 部署后 1594,差异在运行间波动内)。Go / 裸 ASGI 在高并发档偶发连接重置,取有效档位均值。

多核扩展性(裸渲染,单核 → 双核)

单核双核扩展比
.NET ASP.NET .NET422~1043~2.5×
Go net/http GO833~1002~1.2×
Python / Node不扩展1.0×(GIL / 单线程)

优化量化(区分裸渲染 vs cache 命中两条路径)

优化作用路径效果
删 auto_excerpt 重复 IO (已上生产)裸渲染生产 88 → 536(6.1×)
CPython 3.12 → 3.14裸渲染+10%
asyncio → uvloop (已上生产)裸渲染+6%
去 2 层 BaseHTTPMiddleware(task group + Request)cache 命中+43%(2210 → 3155)
uvicorn → uWebSockets(探底,未上生产)cache 命中+31%(3155 → 4121)

实测于 dreamyouxi.com 生产同款腾讯云 2 vCPU 机型 · ApacheBench 内网压测 · 单核绑定(taskset -c 0)· 2026-06-21 复测。数据为多并发档位平均,非峰值。删 auto_excerpt + uvloop 优化已部署生产。