FastAPI 一个请求从进来到返回经历了什么?ASGI 是怎么运转的?
简化版
FastAPI 的请求处理是「ASGI 三件套 + 洋葱式中间件 + 依赖解析」三层叠加。最底层是 ASGI 协议:服务器(uvicorn)把每个请求变成三个东西交给应用——scope(一个 dict,含请求方法、路径、头、客户端地址等元信息)、receive(一个可 await 的协程,用来读取请求体,可能分多次)、send(一个协程,用来发送响应消息);应用就是一个 async def app(scope, receive, send) 的可调用对象。中间件是层层包裹的洋葱——app.add_middleware() 注册的中间件后加的在最外层,请求从外往内穿过、响应从内往外穿回;最外层是 Starlette 自动加的 ServerErrorMiddleware(兜底 500)、最内层是 ExceptionMiddleware(处理 HTTPException 和你注册的处理器)——这个位置关系解释了为什么中间件里抛的异常不会被 @app.exception_handler 捕获。中间往里是路由匹配(Starlette 的 Router 按注册顺序逐条匹配,这和 Flask 按复杂度排序完全不同),匹配到后进入 FastAPI 的核心:解析依赖树 → 校验参数(Pydantic)→ 调用视图(async def 直接 await、def 丢线程池)→ 用 response_model 序列化 → 执行 yield 依赖的清理 → 跑 BackgroundTasks。理解这条链路能解释一大批「奇怪」的现象:中间件为什么拿不到路由信息、yield 依赖的清理为什么在响应之后、BackgroundTasks 为什么会阻塞下一个请求。核心记忆:ASGI = scope + receive + send;中间件洋葱、后加的在外层;路由按注册顺序匹配;依赖解析 → 校验 → 视图 → 序列化 → 清理 → 后台任务。
详细版
一次请求的完整阶段:
| 阶段 | 做什么 | 关键点 |
|---|---|---|
| 1. ASGI | 服务器构造 scope/receive/send | body 还没读 |
| 2. 中间件(外→内) | 层层穿过 | 后 add 的先执行 |
| 3. 路由匹配 | Router 遍历 routes | 按注册顺序 |
| 4. 依赖解析 | 递归构建依赖树 | 同请求内缓存 |
| 5. 参数校验 | Pydantic 校验 | 失败 → 422 |
| 6. 调用视图 | async def / def | def 走线程池 |
| 7. 序列化 | response_model | 有开销 |
| 8. 发送响应 | send | 状态码定型 |
| 9. 清理 | yield 依赖的 finally | 在响应之后 |
| 10. 后台任务 | BackgroundTasks | 仍占进程 |
# ① ★最小的 ASGI 应用(理解本质)★
async def app(scope, receive, send):
assert scope["type"] == "http"
# ★scope 里有什么★
method = scope["method"] # "GET"
path = scope["path"] # "/items/1"
headers = dict(scope["headers"]) # ★[(b"host", b"..."), ...]★
query = scope["query_string"] # b"page=2"
client = scope["client"] # ("127.0.0.1", 54321)
# ★读请求体(可能分多次)★
body = b""
while True:
message = await receive()
if message["type"] == "http.request":
body += message.get("body", b"")
if not message.get("more_body", False):
break
elif message["type"] == "http.disconnect": # ★★客户端断开★★
return
# ★发响应(两步)★
await send({"type": "http.response.start", "status": 200,
"headers": [(b"content-type", b"application/json")]})
await send({"type": "http.response.body", "body": b'{"ok":true}'})
# ② ★中间件的洋葱结构★
app.add_middleware(A) # ★内层★
app.add_middleware(B)
app.add_middleware(C) # ★★外层(最后 add)★★
# 请求:C → B → A → 路由
# 响应:路由 → A → B → C
# ★实际的完整栈(Starlette 自动加的):★
# ★ServerErrorMiddleware★(最外,兜底 500)
# → 你的中间件(C → B → A)
# → ★ExceptionMiddleware★(处理 HTTPException 和自定义 handler)
# → Router → 路由函数
# ③ ★纯 ASGI 中间件(推荐)★
class TimingMiddleware:
def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
start = time.perf_counter()
async def send_wrapper(message):
if message["type"] == "http.response.start":
dur = (time.perf_counter() - start) * 1000
message["headers"].append(
(b"x-process-time", f"{dur:.1f}".encode()))
await send(message) # ★★逐条转发,不缓冲★★
await self.app(scope, receive, send_wrapper)
# ④ ★依赖树的解析顺序★
async def get_db(): ...
async def get_current_user(db=Depends(get_db), token=Depends(oauth2)): ...
async def get_admin(user=Depends(get_current_user)): ...
@app.get("/admin")
async def admin_page(u=Depends(get_admin), db=Depends(get_db)):
...
# ★解析顺序:oauth2 → get_db → get_current_user → get_admin★
# ★★get_db 被两条路径依赖,但同一请求内只执行一次(use_cache=True)★★
# ⑤ ★yield 依赖的执行时机★
async def get_db():
async with AsyncSessionLocal() as s:
print("① 请求前")
yield s
print("③ ★响应发送之后★") # ★★注意时机★★
@app.get("/x")
async def x(db=Depends(get_db)):
print("② 视图执行")
return {"ok": True}
# ★输出:① → ② → (响应发出)→ ③★
⚠️ 三个必须记住的点:① ASGI 应用就是
async def app(scope, receive, send)这一个函数签名——scope是请求的元信息字典(注意此时请求体还没读),receive用来异步拉取请求体(大 body 会分多次,还会推送http.disconnect消息告知客户端断开),send用来发送响应(必须先发http.response.start(含状态码和头)再发http.response.body,所以响应头一旦发出,状态码就无法再改——这解释了「流式响应中途出错为什么不能返回 500」)。FastAPI、Starlette、以及所有中间件本质上都是这个签名的层层包装。② 中间件是洋葱,add_middleware后加的在最外层——请求从外往内、响应从内往外。关键在于 Starlette 自动在最外层加了ServerErrorMiddleware、最内层加了ExceptionMiddleware:你注册的@app.exception_handler(SomeError)是由内层的ExceptionMiddleware执行的,所以中间件里抛出的异常根本走不到它,只会被最外层的ServerErrorMiddleware兜成 500。同理中间件里也拿不到request.state里由依赖设置的东西(依赖比中间件更内层)。③yield依赖的清理代码在响应发送之后执行——这意味着在那里做耗时操作会延长连接占用(虽然用户已经拿到响应了,但 worker 还没释放),也意味着清理阶段抛出的异常无法再影响响应(响应已经发出去了,只能记日志)。同样在响应之后执行的还有BackgroundTasks——它仍然运行在当前进程里,会占用 worker。
完整版教学
一、ASGI 协议
★ ★WSGI vs ASGI★:
┌────────────────────────────────────────────────────────┐
│ ★WSGI★:def app(environ, start_response) -> iterable │
│ ★同步★、★一次请求占一个 worker 直到结束★ │
│ ★不支持 WebSocket / 长连接 / 流式接收★ │
├────────────────────────────────────────────────────────┤
│ ★ASGI★:async def app(scope, receive, send) │
│ ★异步★、★事件驱动★ │
│ ★支持 HTTP / WebSocket / lifespan 三种 scope type★ │
│ ★请求体可流式接收、响应可流式发送★ │
└────────────────────────────────────────────────────────┘
★ ★scope 的三种 type★:
scope["type"] == "http" # ★普通 HTTP 请求★
scope["type"] == "websocket" # ★WebSocket 连接★
scope["type"] == "lifespan" # ★★应用启动/关闭★★
★ ★中间件必须处理非 http 的情况★:
if scope["type"] != "http":
return await self.app(scope, receive, send) # ★★直接透传★★
★ ✗ 忘了这句 → ★WebSocket 和启动流程会崩★
★ ★http scope 的完整内容★:
{
"type": "http", "asgi": {"version": "3.0"},
"http_version": "1.1", "method": "POST",
"scheme": "https", "path": "/items/1", "raw_path": b"/items/1",
"query_string": b"page=2",
★"headers": [(b"host", b"api.x.com"), ...]★, # ★★小写 bytes 元组★★
"client": ("1.2.3.4", 54321), "server": ("10.0.0.1", 8000),
"root_path": "", # ★子路径部署时的前缀★
★"state": {}★, # ★★可以存跨中间件的数据★★
"app": <FastAPI>, "router": ..., "endpoint": ..., "route": ...
# ★★注意:endpoint/route 是路由匹配之后才有的★★
}
★ ★receive 的消息类型★:
{"type": "http.request", "body": b"...", "more_body": True/False}
{"type": "http.disconnect"} # ★★客户端断开★★
★ ★请求体可能分多个 http.request 消息★(大 body、chunked)
★ ★Starlette 的 request.body() 内部就是循环 receive★
★ ★send 的消息类型★:
{"type": "http.response.start", "status": 200, "headers": [...]}
{"type": "http.response.body", "body": b"...", "more_body": True}
{"type": "http.response.body", "body": b"", "more_body": False}
★ ★必须先 start 再 body★
★ ★start 一旦发出,状态码和响应头就定型了★
→ ★这就是"流到一半出错不能改成 500"的原因★
★ ★lifespan 的流程★:
服务器启动 → send {"type": "lifespan.startup"}
→ 应用执行 startup 逻辑
→ send {"type": "lifespan.startup.complete"}
服务器关闭 → send {"type": "lifespan.shutdown"}
→ 应用清理
→ send {"type": "lifespan.shutdown.complete"}
★ FastAPI 的 @asynccontextmanager lifespan 就是包装了这个
★ ★为什么理解 ASGI 有用★:
① ★写高性能中间件★(不用 BaseHTTPMiddleware)
② ★理解流式响应的 more_body★
③ ★排查"中间件顺序"问题★
④ ★理解为什么请求体只能读一次★(receive 是消费型的)
ASGI 相比 WSGI 的核心区别是「异步 + 事件驱动 + 支持三种 scope type」(http/websocket/lifespan)。中间件必须处理非 http 的情况——忘了 if scope["type"] != "http": return await self.app(...) 这句透传,WebSocket 和应用启动流程都会崩。scope 里有个细节:endpoint 和 route 是路由匹配之后才有的,所以中间件里读不到(这解释了「中间件拿不到路由信息」)。send 必须先发 http.response.start 再发 body,而 start 一旦发出状态码就定型了——这就是「流式响应中途出错不能改成 500」的根本原因。理解 ASGI 的实际价值有四个:写高性能中间件、理解流式的 more_body、排查中间件顺序问题、以及理解请求体只能读一次(receive 是消费型的)。
二、中间件洋葱
★ ★完整的中间件栈(★包含 Starlette 自动加的★)★:
┌────────────────────────────────────────────────────────┐
│ ★ServerErrorMiddleware★(最外层,Starlette 自动加) │
│ ↓ ★兜底所有未处理异常 → 500★ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 你的中间件 C(最后 add_middleware 的) │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ 你的中间件 B │ │ │
│ │ │ ┌──────────────────────────────────────┐ │ │ │
│ │ │ │ 你的中间件 A(最先 add 的) │ │ │ │
│ │ │ │ ┌────────────────────────────────┐ │ │ │ │
│ │ │ │ │ ★ExceptionMiddleware★ │ │ │ │ │
│ │ │ │ │ ★处理 HTTPException 和你注册的★ │ │ │ │ │
│ │ │ │ │ ★@app.exception_handler★ │ │ │ │ │
│ │ │ │ │ ┌──────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ Router → 路由函数 │ │ │ │ │ │
│ │ │ │ │ └──────────────────────────┘ │ │ │ │ │
│ │ │ │ └────────────────────────────────┘ │ │ │ │
│ │ │ └──────────────────────────────────────┘ │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
★ ★★这个结构解释的三个"奇怪"现象★★:
① ★中间件里抛的异常不会被 @app.exception_handler 捕获★
→ 因为 ★ExceptionMiddleware 在你的中间件内层★
→ ★只会被最外层的 ServerErrorMiddleware 兜成 500★
✓ 中间件里要自己 try/except 并返回 Response
② ★错误响应可能没有 CORS 头★
→ 如果 CORSMiddleware 不在最外层
→ ★500 响应从 ServerErrorMiddleware 直接返回,绕过了 CORS★
✓ ★CORS 最后 add(最外层)★
③ ★中间件拿不到 request.state 里依赖设置的值★
→ ★依赖在路由函数内部执行,比中间件更内层★
✓ 中间件之间传数据用 ★scope["state"]★ 或 request.state
(中间件设置 → 依赖和路由能读到;反过来不行)
★ ★两种中间件写法的区别★:
# ① BaseHTTPMiddleware(@app.middleware("http"))
@app.middleware("http")
async def mw(request: Request, call_next):
resp = await call_next(request)
return resp
★ ✓ 简单、能直接用 Request 对象
★ ✗ ★★破坏 StreamingResponse 的流式★★
★ ✗ ★性能开销大★(内部用 anyio 内存流桥接)
★ ✗ ★影响 BackgroundTasks 的时序★
★ ✗ 异常传播行为与纯 ASGI 不同
# ② ★纯 ASGI 中间件(推荐)★
class MW:
def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send): ...
★ ✓ ★零缓冲、性能最好、行为可预测★
★ ✗ 要自己处理 scope type、自己构造 Request(如果需要)
★ ★中间件里读请求体的坑★:
@app.middleware("http")
async def log_body(request, call_next):
body = await request.body() # ★★消费了 receive★★
return await call_next(request) # ★★路由函数读不到 body 了!★★
★ 原因:★receive 是消费型的★
✓ Starlette 的 Request 会缓存 body(★同一个 Request 对象内★)
→ BaseHTTPMiddleware 下 ★request 对象会传下去,所以其实能用★
✓ 纯 ASGI 中间件要自己重放:
body = b""
while True:
msg = await receive()
body += msg.get("body", b"")
if not msg.get("more_body"): break
async def replay_receive(): # ★★重放★★
return {"type": "http.request", "body": body, "more_body": False}
await self.app(scope, replay_receive, send)
完整的中间件栈里有两个 Starlette 自动加的:最外层 ServerErrorMiddleware(兜底 500)、最内层 ExceptionMiddleware(处理 HTTPException 和你注册的 handler)。这个结构解释了三个「奇怪」现象:① 中间件里抛的异常不会被 @app.exception_handler 捕获(因为 ExceptionMiddleware 在更内层),要自己 try/except;② 错误响应可能没有 CORS 头(500 从最外层直接返回,绕过了内层的 CORS 中间件)——所以 CORS 要最后 add;③ 中间件拿不到依赖设置的 request.state(依赖更内层)。中间件里读请求体有个坑:receive 是消费型的,纯 ASGI 中间件读完 body 后必须自己重放(构造一个返回缓存 body 的 replay_receive),否则路由函数读到的是空。
三、路由匹配与依赖解析
★ ★路由匹配:按注册顺序(★和 Flask 相反★)★:
Starlette 的 Router.__call__:
for route in self.routes:
match, child_scope = route.matches(scope)
if match == Match.FULL:
★return await route.handle(scope, receive, send)★ # ★第一个匹配的胜出★
elif match == Match.PARTIAL:
partial = route # ★路径匹配但方法不对 → 405★
★ ★所以 /users/me 必须写在 /users/{user_id} 前面★
★ ★Match.PARTIAL 的作用:路径对但方法不允许 → 返回 405 而不是 404★
★ ★路径参数的转换★:
/items/{item_id} → str
/items/{item_id:int} → ★Starlette 层的转换器★
# ★但 FastAPI 主要靠类型注解★:
async def get(item_id: int): ... # ★Pydantic 校验并转换★
→ ★/items/abc 会返回 422(不是 404)★
★ ★★依赖解析的完整过程★★:
① ★启动时★:FastAPI 分析路由函数签名,★构建 Dependant 树★
- 哪些参数是 Path / Query / Header / Cookie / Body
- 哪些是 Depends(递归展开成子树)
- ★这一步在 import 时完成,不是每请求做★
② ★请求时★:solve_dependencies()
- ★深度优先遍历依赖树★
- ★对每个依赖:检查缓存 → 没有则执行 → 存入缓存★
- async def 依赖 → 直接 await
- ★def 依赖 → run_in_threadpool★
- ★yield 依赖 → 用 AsyncExitStack 管理★
③ 收集所有参数 → Pydantic 校验 → 调用路由函数
★ ★依赖缓存(use_cache)★:
async def get_db(): ...
async def get_user(db=Depends(get_db)): ...
async def get_perms(db=Depends(get_db)): ...
@app.get("/x")
async def x(u=Depends(get_user), p=Depends(get_perms), db=Depends(get_db)):
...
★ ★get_db 在一个请求内只执行一次★(三处共享同一个结果)
★ 关闭缓存:Depends(get_db, ★use_cache=False★)
★ ★缓存的 key 是"依赖函数 + 参数"★
★ ★yield 依赖的 AsyncExitStack★:
FastAPI 用一个 ★AsyncExitStack★ 管理所有 yield 依赖
→ ★进入时按依赖顺序 enter★
→ ★退出时按★相反顺序★ exit(后进先出)★
★ ★退出时机:响应发送之后★(0.106+ 明确了这个行为)
★ ✗ 所以:
- ★不能在 yield 之后返回响应★
- ★yield 之后的代码不能太耗时★
- ★yield 之后抛异常只能记日志★(响应已发出)
★ ★参数从哪来(FastAPI 的推断规则)★:
┌────────────────────────────────┬──────────────────┐
│ 参数名出现在路径里 │ ★Path★ │
│ 类型是 Pydantic 模型 │ ★Body★ │
│ 类型是简单类型(int/str/...) │ ★Query★ │
│ 显式声明 Header()/Cookie()/Body()│ 按声明 │
│ 类型是 UploadFile │ ★File★ │
│ 有 Depends() │ ★依赖★ │
│ 类型是 Request/Response/BgTasks │ ★特殊对象★ │
└────────────────────────────────┴──────────────────┘
★ ★校验失败的处理★:
Pydantic 抛 ValidationError
→ FastAPI 包成 ★RequestValidationError★
→ ★默认处理器返回 422 + 字段级错误列表★
✓ 自定义:@app.exception_handler(RequestValidationError)
路由匹配按注册顺序、第一个匹配的胜出(和 Flask 按复杂度排序完全相反),所以 /users/me 必须写在 /users/{user_id} 前面;Match.PARTIAL 的作用是「路径对但方法不允许」时返回 405 而不是 404。依赖解析分两个阶段:启动时(import 时)分析函数签名构建 Dependant 树(不是每请求都做)、请求时深度优先遍历并执行(def 依赖走线程池、yield 依赖用 AsyncExitStack 管理)。依赖缓存的 key 是「依赖函数 + 参数」,所以 get_db 被多处依赖时一个请求内只执行一次。yield 依赖用 AsyncExitStack,退出顺序和进入相反(后进先出),且退出时机在响应发送之后——所以不能在 yield 之后返回响应、不能做耗时操作、抛异常只能记日志。
四、视图执行与响应生成
★ ★视图调用的分派★:
# fastapi/routing.py 的 get_request_handler
if is_coroutine:
raw_response = await run_endpoint_function(...) # ★直接 await★
else:
raw_response = await run_in_threadpool(...) # ★★线程池★★
★ ★这就是 def / async def 区别的实现★
★ ★response_model 的处理链★:
路由函数返回 obj
↓
① ★如果返回的是 Response 实例 → 直接使用★(★跳过序列化★)
↓
② ★用 response_model 做序列化★:
- ★field.validate(obj)★ ← ★★再校验一次!★★
- ★jsonable_encoder()★ ← 转成 JSON 兼容的 Python 对象
- ★response_class(content)★ ← 默认 JSONResponse
↓
③ 应用 response_model_exclude_unset / exclude_none 等
↓
④ 设置 status_code、headers、cookies
↓
⑤ ★挂上 BackgroundTasks★
★ ★response_model 的性能代价(★大列表时明显★)★:
返回 1000 条记录
→ ★每条都要:Pydantic 校验 + jsonable_encoder + json.dumps★
★ 优化:
① ★直接返回 Response/JSONResponse★(★跳过 response_model 处理★)
return JSONResponse(content=data)
② ★用 ORJSONResponse★(★序列化快 3~5 倍★)
app = FastAPI(default_response_class=ORJSONResponse)
③ ★response_model_exclude_unset=True★(少序列化字段)
④ ★分页★(根本上减少数据量)
★ ★jsonable_encoder 做了什么★:
datetime → ISO 字符串
Decimal → float(★注意精度!★)
UUID → str
Enum → value
BaseModel → dict
set → list
★ ✗ ★Decimal → float 会丢精度★ → 金额要用 field_serializer 转 str
★ ★状态码的确定★:
@app.post("/items", ★status_code=201★) # ★声明式★
async def create(): ...
# 或运行时改
async def create(response: Response):
response.status_code = 201
# 或直接返回
return JSONResponse(content=..., status_code=201)
★ ★204 不能有 body★ → return Response(status_code=204)
★ ★BackgroundTasks 的执行时机★:
响应已经发送给客户端
↓
★yield 依赖的清理★
↓
★BackgroundTasks 执行★(★仍在当前进程/事件循环★)
★ ✗ 所以:
- ★耗时任务会占用 worker★(虽然用户已经拿到响应)
- ★任务失败没人知道★(响应已发出,只能记日志)
- ★进程重启任务就丢了★(★没有持久化★)
✓ ★重活交给 Celery/RQ★,BackgroundTasks 只做轻量的事
(发个通知、写条日志、清个缓存)
★ ★异常的传播路径★:
路由函数抛异常
↓
★yield 依赖的 finally 执行★(能捕获到异常)
↓
★ExceptionMiddleware★:
- HTTPException → 转成对应的响应
- ★注册过 handler 的异常 → 调用 handler★
- 其他 → 继续往外抛
↓
你的中间件(★通常不处理,直接往外★)
↓
★ServerErrorMiddleware★ → ★500★(debug 下返回堆栈)
视图调用的分派就是 def/async def 区别的实现(is_coroutine 判断后走 await 或 run_in_threadpool)。response_model 的处理链有个重要分支:如果路由返回的是 Response 实例,会直接使用、跳过序列化——这是优化大列表接口的最简单手段。要知道 response_model 会「再校验一次」(field.validate),加上 jsonable_encoder 和 json.dumps,1000 条记录的开销很明显;优化手段是直接返回 JSONResponse、用 ORJSONResponse(快 3~5 倍)、或分页。jsonable_encoder 有个坑:Decimal → float 会丢精度,金额要用 field_serializer 转字符串。BackgroundTasks 在响应发送后执行但仍在当前进程——耗时任务会占 worker、失败没人知道、进程重启就丢,所以重活要交给 Celery。
五、用生命周期解释实际问题
★ ★问题一:为什么中间件里 request.state.user 是空的?★
@app.middleware("http")
async def mw(request, call_next):
print(request.state.user) # ★★AttributeError★★
return await call_next(request)
# 依赖里设置的:
async def get_current_user(request: Request):
request.state.user = user # ★★在路由内部执行★★
★ 原因:★依赖比中间件更内层,中间件执行时还没解析依赖★
✓ 要在中间件用:★在中间件里自己解析 token★
✓ 或者用 ★contextvars★ 让后续能读到
★ ★问题二:为什么自定义异常处理器没生效?★
@app.exception_handler(MyError)
async def handler(request, exc): ...
@app.middleware("http")
async def mw(request, call_next):
raise MyError() # ★★不会被 handler 处理★★
★ 原因:★ExceptionMiddleware 在你的中间件内层★
✓ 中间件里自己 try/except 返回 JSONResponse
★ ★问题三:为什么 SSE 变成一次性返回?★
★ 原因:★BaseHTTPMiddleware 会缓冲整个响应体★
✓ 用纯 ASGI 中间件
★ ★问题四:为什么 yield 依赖里的 commit 没生效/报错?★
async def get_db():
async with AsyncSessionLocal() as s:
yield s
await s.commit() # ★★响应已发出,这时才提交★★
★ 问题:
- ★如果 commit 失败,用户已经收到 200 了★
- ★事务边界不受 service 控制★
✓ ★commit 放在 service 层★
★ ★问题五:为什么 BackgroundTasks 会让下一个请求变慢?★
★ 原因:★它在当前进程的事件循环里执行★
→ ★如果是 def 任务 → 占线程池 token★
→ ★如果是 async 任务里有阻塞 → 卡事件循环★
✓ 重活丢 Celery
★ ★问题六:为什么 500 响应没有 CORS 头?★
★ 原因:★ServerErrorMiddleware 在最外层,直接返回★
→ 如果 CORSMiddleware 不是最后 add,就被绕过了
✓ ★CORS 最后 add★
★ ★问题七:为什么请求体只能读一次?★
★ 原因:★receive 是消费型的★
★ Starlette 的 Request 对象会 ★缓存 body★
→ ★同一个 Request 对象内可以多次 await request.body()★
→ ★但纯 ASGI 中间件里手动 receive 后不重放,下游就读不到★
★ ★用中间件观测完整链路★:
class TraceMiddleware:
def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
rid = uuid4().hex[:16]
★scope["state"]["request_id"] = rid★ # ★★传给下游★★
t0 = time.perf_counter()
status = 500
async def send_wrapper(message):
nonlocal status
if message["type"] == "http.response.start":
status = message["status"]
message["headers"].append((b"x-request-id", rid.encode()))
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
logger.info("req", extra={
"rid": rid, "method": scope["method"],
"path": scope["path"], "status": status,
"dur_ms": (time.perf_counter() - t0) * 1000})
# ★路由/依赖里读:request.state.request_id(Starlette 会把 scope["state"] 映射过去)★
七个实际问题都能用生命周期解释:中间件读不到 request.state.user 是因为依赖更内层;自定义异常处理器对中间件里的异常无效是因为 ExceptionMiddleware 更内层;SSE 变一次性返回是 BaseHTTPMiddleware 缓冲;yield 依赖里 commit 在响应之后导致事务失败时用户已收到 200;BackgroundTasks 拖慢下一个请求是因为它在当前进程执行;500 没有 CORS 头是最外层直接返回绕过了 CORS;请求体只能读一次是 receive 消费型的本质。最后那个 TraceMiddleware 是个实用模板——用 scope["state"] 传递 request_id 给下游(Starlette 会把它映射到 request.state)。
六、实践清单
★ ★完整链路速记★:
┌──────────────────────────────────────────────────────┐
│ ① uvicorn 收到 TCP → 解析 HTTP → 构造 scope │
│ ② ★ServerErrorMiddleware★ │
│ ③ ★你的中间件(后 add 的先执行)★ │
│ ④ ★ExceptionMiddleware★ │
│ ⑤ ★Router 按注册顺序匹配★ │
│ ⑥ ★解析依赖树(缓存、线程池、AsyncExitStack)★ │
│ ⑦ ★Pydantic 校验参数(失败 422)★ │
│ ⑧ ★调用视图(async 直接 await / def 丢线程池)★ │
│ ⑨ ★response_model 序列化★ │
│ ⑩ ★send 响应(start + body)★ │
│ ⑪ ★yield 依赖清理(★响应之后★)★ │
│ ⑫ ★BackgroundTasks(★仍占进程★)★ │
└──────────────────────────────────────────────────────┘
★ 检查清单:
□ ★纯 ASGI 中间件处理了 scope["type"] != "http"★
□ ★CORS 中间件最后 add(最外层)★
□ ★中间件里的异常自己 try/except★
□ ★有流式响应时不用 BaseHTTPMiddleware★
□ ★具体路径写在参数路径前面★
□ ★yield 依赖里不做耗时操作、不 commit★
□ ★BackgroundTasks 只放轻量任务★
□ ★大列表接口考虑跳过 response_model★
□ ★用 ORJSONResponse★
□ ★中间件里读 body 后重放 receive★
★ ★性能观测点★:
- ★中间件耗时★(每层都加计时)
- ★依赖解析耗时★(复杂依赖树可能不便宜)
- ★视图执行耗时★
- ★序列化耗时★(大响应时占比可能很高)
✓ 用 OpenTelemetry 自动埋点,或自己写 TraceMiddleware
★ ★调试技巧★:
print([{"path": r.path, "name": r.name, "methods": r.methods}
for r in app.routes]) # ★★看路由顺序★★
print(app.user_middleware) # ★看中间件栈★
print(app.dependency_overrides) # 测试时的覆盖
# 看某个路由的依赖树
from fastapi.dependencies.utils import get_dependant
d = get_dependant(path="/x", call=my_endpoint)
★ 一句话总结:
★"ASGI = async def app(scope, receive, send);
中间件是洋葱、后 add 的在最外层,
Starlette 在最外加了 ServerErrorMiddleware、最内加了
ExceptionMiddleware(所以中间件抛的异常走不到你的 handler);
路由按注册顺序匹配;然后是依赖解析 → 校验 → 视图 → 序列化 →
发响应 → yield 清理 → BackgroundTasks。"★
完整链路的十二步值得背下来——它能解释绝大多数「奇怪」现象。检查清单里最关键的四条:纯 ASGI 中间件要处理非 http 的 scope、CORS 最后 add、中间件里的异常自己处理、有流式响应时别用 BaseHTTPMiddleware。调试技巧里 print([r.path for r in app.routes]) 看路由顺序和 app.user_middleware 看中间件栈最实用。
记忆钩子:「FastAPI 的请求处理 = ★ASGI 三件套 + 洋葱中间件 + 依赖解析★。★ASGI 应用就是 async def app(scope, receive, send)★:★scope 是元信息字典(此时 body 还没读,而且 endpoint/route 要路由匹配后才有——所以中间件拿不到路由信息)★、★receive 异步拉请求体(消费型!大 body 分多次,还会推 http.disconnect 消息)★、★send 发响应(必须先 http.response.start 再 body,★start 一发出状态码就定型★——这就是『流式响应中途出错不能改成 500』的根本原因)★;scope 有 ★http/websocket/lifespan 三种 type,纯 ASGI 中间件必须透传非 http 的★否则 WebSocket 和启动流程会崩。★中间件是洋葱、后 add 的在最外层★,而且 ★Starlette 自动在最外层加了 ServerErrorMiddleware(兜底 500)、最内层加了 ExceptionMiddleware(执行你注册的 @app.exception_handler)★——★这个位置关系解释了三个『奇怪』现象★:★① 中间件里抛的异常走不到你的 exception_handler★(要自己 try/except)②★500 响应可能没有 CORS 头★(从最外层直接返回绕过了内层的 CORS)→ ★CORS 必须最后 add★ ③★中间件读不到依赖设置的 request.state★(依赖更内层)。★路由按注册顺序逐条匹配、第一个胜出(和 Flask 按复杂度排序相反)★,所以 ★/users/me 要写在 /users/{id} 前面★;Match.PARTIAL 让『路径对但方法不允许』返回 405 而不是 404。★依赖解析分两阶段:启动时(import 时)分析签名构建 Dependant 树、请求时深度优先执行★,★同一请求内相同依赖只执行一次(use_cache,key 是函数+参数)★,★def 依赖走线程池、yield 依赖用 AsyncExitStack 且退出顺序相反★。★yield 之后的清理代码在响应发送之后执行★——所以★不能在那里返回响应、不能做耗时操作、不能 commit(失败时用户已收到 200)、抛异常只能记日志★。★response_model 会再校验一次 + jsonable_encoder + json.dumps,大列表时开销明显★(优化:★直接返回 JSONResponse 可跳过整个序列化链★、用 ORJSONResponse 快 3~5 倍、分页);注意 ★jsonable_encoder 把 Decimal 转 float 会丢精度★。★BackgroundTasks 在响应之后执行但仍在当前进程★——耗时任务会占 worker、失败没人知道、★进程重启就丢(无持久化)★,重活丢 Celery。★请求体只能读一次因为 receive 是消费型的★,纯 ASGI 中间件读了 body 必须自己重放 replay_receive。」
七、常见误区与追问
- 误区:中间件里可以用
@app.exception_handler注册的处理器来处理异常。 处理不到。Starlette 构建的中间件栈里,你注册的异常处理器是由ExceptionMiddleware执行的,而它位于所有用户中间件的内层(紧挨着 Router)。请求流向是:ServerErrorMiddleware→ 你的中间件 →ExceptionMiddleware→ 路由。所以路由函数或依赖里抛的异常会被ExceptionMiddleware捕获并交给你的 handler;但中间件自己抛的异常已经在ExceptionMiddleware外面了,只能一路向外冒泡到最外层的ServerErrorMiddleware,结果就是一个默认的 500(debug 模式下带堆栈,生产模式下是Internal Server Error)。所以中间件里必须自己 try/except 并返回一个JSONResponse。这也解释了另一个现象:中间件里的异常不会带上 CORS 头(如果 CORS 中间件在它内层的话),前端看到的是「CORS error」而不是真正的错误。 - 误区:
yield依赖里yield之后的代码是在返回响应之前执行的。 是在响应发送给客户端之后(FastAPI 0.106 起明确了这个行为)。FastAPI 用一个AsyncExitStack管理所有yield依赖:请求开始时按依赖顺序进入,响应发送完毕后按相反顺序退出。这带来三个实际约束:① 不能在yield之后返回响应或修改响应——响应已经在网络上了;② 不能在那里做耗时操作——虽然用户已经拿到了响应,但这个连接和 worker 还没被释放,会拖累并发能力;③ 在那里commit是危险的——如果提交失败,用户已经收到了 200 响应却什么都没保存,事务边界应该由 service 层控制。同样在响应之后执行的还有BackgroundTasks,两者的顺序是:发送响应 →yield依赖清理 → 后台任务。 - 误区:FastAPI 的路由匹配会自动选最精确的那条。 它按注册顺序逐条尝试,第一个完全匹配的胜出——这和 Flask/Werkzeug「按规则复杂度排序后匹配」的行为完全相反。所以如果先注册了
@app.get("/users/{user_id}")、后注册@app.get("/users/me"),访问/users/me时会命中前者:"me"被当作user_id,如果类型注解是int就返回一个令人困惑的 422「Input should be a valid integer」;如果是str就拿着"me"去查数据库然后 404。规则是:静态路径必须写在参数路径前面。同类的坑还有/files/latestvs/files/{name}、/orders/exportvs/orders/{id}。排查时用print([r.path for r in app.routes])打印实际顺序最直接。顺带一提,Starlette 的Match.PARTIAL机制让「路径匹配但 HTTP 方法不允许」返回 405 而不是 404——看到 405 就说明路径是对的、只是方法没声明。 - 误区:
@app.middleware("http")是官方推荐的中间件写法,性能也没问题。 它是最方便的写法,但基于BaseHTTPMiddleware,有四个已知代价。① 破坏流式响应——它内部用 anyio 的内存流桥接 ASGI 接口,会等下游把响应体完整产出后再转发,导致StreamingResponse失效:SSE 变成一次性返回、大文件下载先在内存攒齐、LLM 流式输出全部堆到最后。② 性能开销——每个请求都要多创建任务和内存流。③ 影响BackgroundTasks的时序。④ 异常传播行为与纯 ASGI 中间件不一致。推荐写纯 ASGI 中间件:实现async def __call__(self, scope, receive, send),用一个send_wrapper包装send,在http.response.start消息里追加响应头——逐条转发消息、零缓冲。唯一要注意的是必须处理非 http 的 scope(if scope["type"] != "http": return await self.app(scope, receive, send)),否则 WebSocket 连接和 lifespan 启动流程都会崩。 - 误区:
BackgroundTasks是异步执行的,不会影响接口性能。 它确实在响应发送之后执行,但仍然运行在当前的 Web 进程和事件循环里。三个后果:① 占用 worker 资源——虽然用户已经拿到了响应,但这个 worker/task 还没空闲下来,高并发时会明显拉低吞吐;如果任务是def函数,还会占用那 40 个线程池 token;如果是async def但里面有阻塞调用,会直接卡住整个事件循环。② 失败无人知晓——响应早已发出,任务抛异常只能记日志,没有重试、没有告警、调用方完全不知道。③ 没有持久化——进程重启(部署、OOM、崩溃)时,队列里还没执行的任务全部丢失。所以BackgroundTasks的正确定位是「轻量、可丢失、不需要重试的收尾工作」:清个缓存、写条日志、发个非关键通知。真正重要或耗时的任务(发邮件、生成报表、调用外部 API、处理图片)必须交给 Celery/RQ 这类有持久化和重试机制的队列。 - 追问:为什么请求体只能读一次?中间件里读了 body 之后路由函数为什么读不到? 因为 ASGI 的
receive是消费型的——它从底层的网络缓冲区拉数据,拉走的部分就没了,再调用只会返回后续的消息(或者http.disconnect)。Starlette 在Request对象上做了一层缓存:第一次await request.body()时会把内容读完并存在self._body里,之后再调用直接返回缓存——所以在同一个Request对象内可以多次读取。这也解释了为什么BaseHTTPMiddleware里读 body 通常不会破坏下游:它把同一个Request对象传给了call_next。但纯 ASGI 中间件里如果你直接循环await receive()读完了 body,就必须自己”重放”——构造一个新的receive协程返回缓存的内容({"type": "http.request", "body": cached, "more_body": False}),再传给下游应用;否则路由函数拿到的是空 body,表现为「明明发了 JSON,后端却说字段缺失」。 - 追问:依赖树是每个请求都重新分析一次吗? 不是,分析在应用启动(import)时就完成了。FastAPI 在注册路由时会调用
get_dependant(),递归分析路由函数的签名:哪些参数来自 Path、Query、Header、Cookie、Body,哪些是Depends(继续递归展开成子树),最终构建出一棵静态的Dependant树并缓存在路由对象上。请求时只做「执行」:solve_dependencies()深度优先遍历这棵树,对每个依赖检查缓存(同一请求内相同的依赖只执行一次,缓存 key 是「依赖函数 + 参数值」)、执行(async def直接await、def丢线程池)、把yield类型的注册进AsyncExitStack。这个设计的好处是启动时做一次重活、运行时很轻——代价是启动时间随路由和模型数量增长(大项目冷启动几秒是常见的,对 serverless 不太友好)。想看某个路由的依赖树可以用from fastapi.dependencies.utils import get_dependant。 - 追问:
response_model的开销具体在哪?怎么优化? 三步开销:① 再校验一次——response_model不只是「筛选字段」,它会用声明的模型重新校验你返回的数据(这是为了保证契约,但确实是重复劳动);②jsonable_encoder——递归遍历整个对象树,把datetime、Decimal、UUID、Enum、BaseModel转成 JSON 兼容的 Python 类型;③json.dumps——标准库的序列化。返回 1000 条记录时,这三步都要执行 1000 次,在大列表接口上可能占到响应时间的一大半。四种优化:① 直接返回Response/JSONResponse实例——FastAPI 检测到返回值已经是Response就直接使用、完全跳过上面三步(代价是失去了response_model的字段过滤和文档生成,要自己保证不泄露敏感字段);② 用ORJSONResponse(default_response_class=ORJSONResponse)——orjson 是 Rust 实现的,序列化快 3~5 倍,而且原生支持datetime/UUID;③response_model_exclude_unset=True减少序列化的字段;④ 分页——从根本上减少数据量,这也是最应该先做的。另外注意jsonable_encoder把Decimal转成float会丢精度,金额字段要用field_serializer转成字符串。
八、加强记忆
FastAPI 的请求处理 = ASGI 三件套 + 洋葱中间件 + 依赖解析。ASGI 应用就是 async def app(scope, receive, send):scope 是元信息字典(此时 body 还没读,而且 endpoint/route 要路由匹配之后才有——所以中间件拿不到路由信息)、receive 异步拉取请求体(消费型! 大 body 分多次,还会推送 http.disconnect 消息)、send 发送响应(必须先 http.response.start 再 body,而 start 一发出状态码就定型了——这就是「流式响应中途出错不能改成 500」的根本原因);scope 有 http/websocket/lifespan 三种 type,纯 ASGI 中间件必须透传非 http 的,否则 WebSocket 和启动流程会崩。中间件是洋葱、后 add 的在最外层,而且 Starlette 自动在最外层加了 ServerErrorMiddleware(兜底 500)、最内层加了 ExceptionMiddleware(执行你注册的 @app.exception_handler)——这个位置关系解释了三个「奇怪」现象:① 中间件里抛的异常走不到你的 exception_handler(要自己 try/except)、② 500 响应可能没有 CORS 头(从最外层直接返回,绕过了内层的 CORS)→ 所以 CORS 必须最后 add、③ 中间件读不到依赖设置的 request.state(依赖更内层)。路由按注册顺序逐条匹配、第一个胜出(和 Flask 按复杂度排序相反),所以 /users/me 要写在 /users/{id} 前面;Match.PARTIAL 让「路径对但方法不允许」返回 405 而不是 404。依赖解析分两个阶段:启动时(import 时)分析签名构建 Dependant 树、请求时深度优先执行,同一请求内相同依赖只执行一次(use_cache,key 是函数 + 参数),def 依赖走线程池、yield 依赖用 AsyncExitStack 且退出顺序与进入相反。yield 之后的清理代码在响应发送之后执行——所以不能在那里返回响应、不能做耗时操作、不能 commit(失败时用户已收到 200)、抛异常只能记日志。response_model 会再校验一次 + jsonable_encoder + json.dumps,大列表时开销明显(优化:直接返回 JSONResponse 可跳过整个序列化链、用 ORJSONResponse 快 3~5 倍、分页);注意 jsonable_encoder 把 Decimal 转成 float 会丢精度。BackgroundTasks 在响应之后执行但仍在当前进程——耗时任务会占 worker、失败没人知道、进程重启就丢(没有持久化),重活要丢给 Celery。最后,请求体只能读一次是因为 receive 是消费型的,纯 ASGI 中间件读了 body 必须自己重放 replay_receive。