← 返回题目列表

FastAPI 一个请求从进来到返回经历了什么?ASGI 是怎么运转的?

困难 第 27 / 27 题 更新于 2026/08/03
FastAPIASGI请求生命周期中间件Starlette

简化版

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/sendbody 还没读
2. 中间件(外→内)层层穿过后 add 的先执行
3. 路由匹配Router 遍历 routes按注册顺序
4. 依赖解析递归构建依赖树同请求内缓存
5. 参数校验Pydantic 校验失败 → 422
6. 调用视图async def / defdef 走线程池
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 里有个细节:endpointroute 是路由匹配之后才有的,所以中间件里读不到(这解释了「中间件拿不到路由信息」)。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 判断后走 awaitrun_in_threadpool)。response_model 的处理链有个重要分支:如果路由返回的是 Response 实例,会直接使用、跳过序列化——这是优化大列表接口的最简单手段。要知道 response_model 会「再校验一次」field.validate),加上 jsonable_encoderjson.dumps1000 条记录的开销很明显;优化手段是直接返回 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 的 scopeCORS 最后 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/latest vs /files/{name}/orders/export vs /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 的 scopeif 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 直接 awaitdef 丢线程池)、把 yield 类型的注册进 AsyncExitStack。这个设计的好处是启动时做一次重活、运行时很轻——代价是启动时间随路由和模型数量增长(大项目冷启动几秒是常见的,对 serverless 不太友好)。想看某个路由的依赖树可以用 from fastapi.dependencies.utils import get_dependant
  • 追问:response_model 的开销具体在哪?怎么优化? 三步开销:① 再校验一次——response_model 不只是「筛选字段」,它会用声明的模型重新校验你返回的数据(这是为了保证契约,但确实是重复劳动);jsonable_encoder——递归遍历整个对象树,把 datetimeDecimalUUIDEnumBaseModel 转成 JSON 兼容的 Python 类型;json.dumps——标准库的序列化。返回 1000 条记录时,这三步都要执行 1000 次,在大列表接口上可能占到响应时间的一大半。四种优化:① 直接返回 Response/JSONResponse 实例——FastAPI 检测到返回值已经是 Response直接使用、完全跳过上面三步(代价是失去了 response_model 的字段过滤和文档生成,要自己保证不泄露敏感字段);② 用 ORJSONResponsedefault_response_class=ORJSONResponse)——orjson 是 Rust 实现的,序列化快 3~5 倍,而且原生支持 datetime/UUIDresponse_model_exclude_unset=True 减少序列化的字段;④ 分页——从根本上减少数据量,这也是最应该先做的。另外注意 jsonable_encoderDecimal 转成 float 会丢精度,金额字段要用 field_serializer 转成字符串。

八、加强记忆

FastAPI 的请求处理 = ASGI 三件套 + 洋葱中间件 + 依赖解析ASGI 应用就是 async def app(scope, receive, send)scope 是元信息字典(此时 body 还没读,而且 endpoint/route 要路由匹配之后才有——所以中间件拿不到路由信息)、receive 异步拉取请求体消费型! 大 body 分多次,还会推送 http.disconnect 消息)、send 发送响应必须先 http.response.startbody,而 start 一发出状态码就定型了——这就是「流式响应中途出错不能改成 500」的根本原因);scopehttp/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_encoderDecimal 转成 float 会丢精度BackgroundTasks 在响应之后执行但仍在当前进程——耗时任务会占 worker、失败没人知道、进程重启就丢(没有持久化),重活要丢给 Celery。最后,请求体只能读一次是因为 receive 是消费型的,纯 ASGI 中间件读了 body 必须自己重放 replay_receive