无慢查询 RT 却飙升:cProfile 定位 FastAPI 事件循环阻塞

本文摘要:慢查询为空而 P99 上涨,排查时应用层拿不到函数级耗时证据。用 cProfile 按 tottime 排序并过滤业务文件,可把耗时钉到具体的阻塞函数。

一、问题与结论

慢查询日志里一条记录都没有,数据库侧 CPU、连接数曲线都正常,压测下接口 P99 却在抬升。排查链路在 DB 侧断裂,故障被推给应用层,而应用侧没有现成指标能指出是哪个函数吃掉了时间。

RT 增量不在 SQL 执行:某个 async def 端点里的同步调用(同步 DB 驱动、requests、同步写文件、超大对象 json.dumps)占住事件循环,后续请求只能排队等它返回;这段等待计入 RT,却不落在任何数据库指标上。

结论先行:在 workers=1 的 uvicorn 进程里挂 cProfile,用 pstats 按 tottime 排序、以业务文件名正则过滤,可以把阻塞函数钉到具体端点。但证据在排序口径、线程范围、进程边界、profiler 开销四个变量失控时会自洽地指向错误对象,重点是证据怎么拿,以及这四种失败情形怎么防。

二、排查与选择依据

先拆分 RT 增量的三类来源,慢查询日志只覆盖第一类:

耗时来源 DB 侧可见 应用侧表现
SQL 执行本身 是 慢查询日志有记录
Python 侧计算/序列化/同步 IO 否 端点耗时上升
事件循环排队等待 否 端点耗时上升,单请求内部测不出这段等待

第三类是本文目标。选 cProfile 的理由:标准库、无第三方依赖、确定性记录每次调用与返回、可导出 .prof 离线复核、粒度到函数而非端点。代价同样明确:它对每次调用都计时,异步服务里协程切换次数极多,开启后 RT 会整体抬高。

替代方案与取舍

手段 选择条件 代价 边界
cProfile + pstats 需要函数粒度证据、可重启、可短期加压 开销随调用次数上升 只记录启用它的线程,多进程需对齐
asyncio 开发者模式(慢回调警告) 只想证明存在阻塞回调 几乎零侵入,只到协程粒度 不给出协程内是哪个函数慢
采样类 profiler(第三方) 只能附着已运行进程、担心确定性开销 引入依赖,需核对其时间语义 采样可能漏掉短促尖峰
time.perf_counter() 自埋点 已有怀疑对象 只覆盖埋了点的调用 对未知热点无能为力

不该用 cProfile 的场合:多进程或多 worker 未隔离、阻塞在工作线程或 C 扩展内部、生产需要常开时,改用采样类工具或 APM;只想快速确认"存在阻塞",先开 asyncio 开发者模式更省事。

三、关键原理

async def 端点跑在事件循环线程上,其内部的同步阻塞会推迟该循环上所有就绪任务;def 端点与 def 依赖由 Starlette 的线程池执行,阻塞被隔离到工作线程。

cProfile.Profile 的钩子只覆盖启用它的那个线程,由此有两个推论:事件循环线程上的阻塞能被记录,线程池里的执行体不在记录范围------"profile 里没热点"不等于"没有阻塞";反之,把端点改成 def 后 cProfile 看起来干净,也不代表耗时消失,只是移出了观测面。线程覆盖范围以官方文档的说明为准。

不加过滤地按累计耗时排序,selector 的 select/poll 等 I/O 等待帧与 Task 驱动帧常占榜首。事件循环大部分墙钟时间在等 I/O,那是噪声不是根因,框架帧只作"循环在等"的佐证。

还有 GIL:若阻塞实际是 CPU 密集(大对象序列化、加密、批量计算),丢进线程池并不能提高吞吐,同一时刻仍只有一个线程执行 Python 字节码,此时要么把计算移出请求路径,要么改多进程或多进程任务队列。

四、可运行示例

环境为 Python 3 + fastapi + uvicorn,无其他依赖,耗时数字须本地实测后填入。time.sleep 只是替身,换成任何同步阻塞调用结论一致。

app.py:

python 复制代码
import os
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI

BLOCK_MS = int(os.environ.get("BLOCK_MS", "40"))

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield

app = FastAPI(lifespan=lifespan)

@app.get("/fast")
async def fast():
    return {"ok": True}

@app.get("/cpu")
async def cpu_route():
    # time.sleep 只是替身:同步 DB 驱动 / requests / 大对象 json.dumps 同理
    time.sleep(BLOCK_MS / 1000.0)
    return {"blocked_ms": BLOCK_MS}

run_prof.py:

python 复制代码
import cProfile
import pstats

import uvicorn

def main():
    prof = cProfile.Profile()
    prof.enable()
    try:
        # 复现固定 workers=1、不加 --reload,否则 profiler 落不到事件循环所在进程
        uvicorn.run("app:app", host="127.0.0.1", port=8000,
                    workers=1, log_level="warning")
    except KeyboardInterrupt:
        pass
    finally:
        prof.disable()
        prof.dump_stats("loop.prof")

    st = pstats.Stats("loop.prof")
    st.strip_dirs()
    st.sort_stats("tottime").print_stats(30, r"app\.py")    # 起点:只看业务文件
    st.sort_stats("tottime").print_stats(30)                # 对照:噪声有多大
    st.sort_stats("tottime").print_callers(20, r"app\.py")  # 谁调用了可疑函数

if __name__ == "__main__":
    main()

load.py:

python 复制代码
import threading
import time
import urllib.request

BASE = "http://127.0.0.1:8000"

def run(path, n_threads=8, n_each=50):
    out = []

    def hit():
        for _ in range(n_each):
            t0 = time.perf_counter()
            with urllib.request.urlopen(BASE + path) as r:
                r.read()
            out.append(time.perf_counter() - t0)

    ts = [threading.Thread(target=hit) for _ in range(n_threads)]
    t0 = time.perf_counter()
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    s = sorted(out)
    q = lambda p: s[min(len(s) - 1, int(len(s) * p))] * 1000
    print(f"{path}: n={len(s)} wall={time.perf_counter() - t0:.2f}s "
          f"p50={q(.5):.1f}ms p99={q(.99):.1f}ms")

for p in ("/fast", "/cpu"):
    run(p)

操作步骤:先用普通方式起服务,跑一轮 python load.py 记基线;再 python run_prof.py 起服务,跑完全相同 的负载;Ctrl+C 停止后查看三张表。

预期输出 :/cpu 的 p50/p99 显著高于 /fast(数值待实测填入,本文未验证,不给编造数字);loop.prof 第一张表里 cpu_route 及其调用的阻塞帧 tottime 靠前;第二张表榜首是事件循环的 I/O 等待帧;第三张表显示可疑函数确由 cpu_route 触发而非启动阶段代码。

实际输出 :逐项核对上文三张表,并记录「不 profile」与「profile」两轮的 p50/p99 差值------这个差就是 profiler 开销,必须当作数据看待。再开 asyncio 开发者模式跑同负载(阈值参数名与默认值以官方文档为准),慢回调警告应指向 cpu_route,两份证据一致才下结论。

常见失败:loop.prof 里只有启动帧、看不到业务函数,多半是进程边界没对齐------workers=N 或 --reload 让 profiler 落在不跑事件循环的子进程上。修复:复现固定 workers=1、不用 --reload;多进程部署时在每个 worker 进程内单独 dump_stats,文件名带上 pid。

五、验证结果与边界

证据确认后再修,两种改法各有边界:

改法 效果 代价 不适用
换 httpx.AsyncClient 等异步驱动 事件循环不再被占 引入异步客户端,连接池生命周期要与 lifespan 对齐 目标库没有维护中的异步接口
交给线程池(端点改 def 或 run_in_threadpool) 阻塞隔离到工作线程 线程池槽位有限,高并发下排队 CPU 密集受 GIL 限制,需改多进程

四种会让 cProfile 给出错误结论的情形:排序口径错,把 I/O 等待帧当阻塞源;阻塞在工作线程,cProfile 根本没记录;profiler 自身开销摊薄占比或放大现象;进程边界没对齐。规避办法是固定 tottime 加业务文件过滤作为起点、框架帧只作佐证、同负载跑两轮量化开销,并用 asyncio 开发者模式交叉验证。

适用边界:单进程、单事件循环、可重启、可短期加压的定位阶段。多进程未隔离、阻塞在工作线程或 C 扩展内部、生产常开、需要捕捉短促尖峰时,都不该依赖本文方案。cProfile 不是唯一手段,它在函数粒度与离线复核上有优势,在线程覆盖、开销、多进程与短尖峰捕捉上有短板,短板部分需用上表的替代方案补上。

参考资料

相关推荐
龙腾-虎跃1 小时前
AI-Vue3-python-flask-Blog 全栈博客项目深度解析:从零搭建你的 AI 博客
人工智能·python·flask
AINative软件工程1 小时前
LLM 应用的 Adaptive Batching 工程实践:动态合批把吞吐提升 3 倍,但延迟的坑你踩过吗
后端·llm·ai编程
Xiu Yan1 小时前
Python 数据分析:数据分析步骤
开发语言·python·数据分析
vilya1 小时前
把 Python 塞进 APK:Chaquopy 打包实践
android·python
parser1 小时前
Python 装饰器:从语法糖、闭包到 @wraps(上篇)
后端
高频因子挖掘机1 小时前
股票池一大就请求缓慢?量化系统批量获取行情的设计与优化
后端·github·api
黑妹天下第一乖1 小时前
小智改造实战解读-用kokoro-onnx替换云端 TTS 的完整记录
大数据·开发语言·人工智能·python·交互
郑州光合科技余经理1 小时前
本地生活平台搭建:跨业态用户标识怎么贯通
java·开发语言·前端·后端·uni-app·php·ai编程
Thneonl1 小时前
故意弄坏生产:混沌工程不是乱砸,是实验设计
后端·程序员