4.3 理解请求内缓存与依赖执行方式
理解请求内缓存与依赖执行方式
依赖复用有两个容易混淆的概念:FastAPI 默认会在一次请求内复用同一个依赖调用的结果;依赖函数本身是同步还是异步,又决定了它以什么方式执行。这种缓存不是 Redis,也不会把结果带到下一个请求。同步依赖也不会因为“很短”就自动在事件循环里运行。
本节用计数器验证请求范围的缓存,用 use_cache=False 对照强制重新执行的情况,再观察同步和异步依赖的执行线程。计数器只为测试行为服务,不能作为生产缓存实现。
默认缓存的范围
下面的 /cached 路由两次声明同一个 load_context 依赖。由于它们使用的是同一个依赖调用并采用默认的 use_cache=True,一次请求只会调用一次函数,并把同一个结果注入两个参数。第二次 HTTP 请求会重新执行依赖。
use_cache=False 的含义更窄:在当前请求的依赖解析中,不复用这个依赖已经得到的结果。它不会关闭其他请求的缓存,也不会把结果保存到应用生命周期中。下面的 /uncached 路由用两个 use_cache=False 参数验证同一请求内会执行两次。
<!-- file: ch04_cache/dependency_cache_demo.py -->
from __future__ import annotations
import threading
from typing import Annotated, Any
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
context_calls = 0
sync_calls = 0
async_calls = 0
async def load_context() -> dict[str, int]:
"""模拟轻量的请求上下文解析,只用于观察调用次数。"""
global context_calls
context_calls += 1
return {"call": context_calls}
RequestContext = Annotated[dict[str, int], Depends(load_context)]
UncachedContext = Annotated[
dict[str, int], Depends(load_context, use_cache=False)
]
app = FastAPI(title="依赖缓存与执行方式示例")
@app.get("/cached")
async def cached(
first: RequestContext,
second: RequestContext,
) -> dict[str, Any]:
return {
"first": first,
"second": second,
"same_result": first is second,
"context_calls": context_calls,
}
@app.get("/uncached")
async def uncached(
first: UncachedContext,
second: UncachedContext,
) -> dict[str, Any]:
return {
"first": first,
"second": second,
"same_result": first is second,
"context_calls": context_calls,
}
def sync_dependency() -> str:
"""没有等待操作的同步依赖,只为观察执行位置。"""
global sync_calls
sync_calls += 1
return f"sync:{threading.get_ident()}"
async def async_dependency() -> str:
global async_calls
async_calls += 1
return f"async:{threading.get_ident()}"
SyncValue = Annotated[str, Depends(sync_dependency)]
AsyncValue = Annotated[str, Depends(async_dependency)]
@app.get("/dependency-kinds")
async def dependency_kinds(
sync_value: SyncValue,
async_value: AsyncValue,
) -> dict[str, Any]:
return {
"route_thread": threading.get_ident(),
"sync_value": sync_value,
"async_value": async_value,
"sync_calls": sync_calls,
"async_calls": async_calls,
}
def self_check() -> None:
global context_calls, sync_calls, async_calls
context_calls = 0
sync_calls = 0
async_calls = 0
with TestClient(app) as client:
first_request = client.get("/cached")
second_request = client.get("/cached")
uncached_request = client.get("/uncached")
kinds_request = client.get("/dependency-kinds")
assert first_request.status_code == 200
assert first_request.json()["same_result"] is True
assert first_request.json()["context_calls"] == 1
assert second_request.status_code == 200
assert second_request.json()["same_result"] is True
assert second_request.json()["context_calls"] == 2
assert uncached_request.status_code == 200
assert uncached_request.json()["same_result"] is False
assert uncached_request.json()["context_calls"] == 4
kinds = kinds_request.json()
assert kinds_request.status_code == 200
assert kinds["sync_calls"] == 1
assert kinds["async_calls"] == 1
assert kinds["sync_value"].startswith("sync:")
assert kinds["async_value"].startswith("async:")
sync_thread = int(kinds["sync_value"].split(":", 1)[1])
async_thread = int(kinds["async_value"].split(":", 1)[1])
assert sync_thread != kinds["route_thread"]
assert async_thread == kinds["route_thread"]
print("dependency_cache_demo self-check passed")
print(
"sync dependency and async dependency thread observations:",
kinds["sync_value"],
kinds["async_value"],
"route:",
kinds["route_thread"],
)
if __name__ == "__main__":
self_check()
运行检查:
python ch04_cache/dependency_cache_demo.py
关键断言表示:
- 第一次
/cached请求中,两个参数拿到同一个结果,计数器从 0 变为 1。 - 第二次
/cached请求重新解析依赖,计数器变为 2;上一个请求的结果没有泄漏进来。 /uncached的两个参数都设置了use_cache=False,同一请求内产生两次调用,计数器变为 4。
示例中的缓存判断限定在同一个依赖调用、同一个请求内。FastAPI 的依赖图还会根据依赖的调用关系和参数参与解析;不要把本节现象扩大成“所有参数相同的函数都会全局缓存”。如果业务需要跨请求缓存,应明确设计数据失效、并发一致性和错误回退,再选择数据库、Redis 或其他缓存工具。
同步依赖和异步依赖怎么选
async_dependency() 在当前异步请求链中直接执行;sync_dependency() 是普通 def,FastAPI 会把它放到 Starlette 使用的线程池中执行。示例返回线程 ID 只是帮助观察这个事实,线程 ID 和具体数量不应被写成业务契约。
对于只读取请求头、比较一个数字或做其他轻量且不阻塞的检查,异步依赖可以避免一次不必要的线程调度。这里的“异步”不等于必须包含 await;它表示该依赖符合当前异步调用链的执行方式。
如果依赖内部调用同步数据库驱动、同步 SDK 或文件系统操作,把它改名为 async def 不会使阻塞消失。应按照第二章的规则使用明确的线程池边界,或者选择该库真正的异步客户端。相反,已经是异步 I/O 的依赖要在异步函数中 await 它,不能用同步包装把它重新送进线程池。
同样不要为了追求“全都 async”把 CPU 密集型计算塞进事件循环。依赖的形式只解决依赖解析方式,不能改变底层工作类型;计算任务仍应根据耗时和吞吐要求选择进程、任务系统或其他隔离方案。
把缓存和副作用分开
依赖可能被多个路由参数复用,但不要依赖“只执行一次”来承载必须发生的副作用。例如写审计日志、扣减库存或发送通知都不应藏在一个默认缓存的依赖中,否则调用方修改依赖图后,副作用次数可能改变。读取上下文、加载资源和检查权限适合成为可复用依赖;写操作应由明确的 service 或任务边界负责,并留下独立的测试。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“拆分并复用依赖项。依赖调用会被缓存”和“优先使用
async依赖项”部分。本节增加了请求计数和线程观察,限定结论在当前 FastAPI 执行模型内。 - 官方参考:FastAPI 依赖项 与 FastAPI 异步。