FastAPI 中的 events ,更准确地说是应用生命周期事件。它们解决的是整个服务启动前要准备什么、停止前要清理什么。数据库连接池、HTTP 客户端、机器学习模型、缓存连接、消息队列消费者,都适合放在这个生命周期边界里统一管理。
当前 FastAPI 推荐使用 lifespan,旧式的 startup 和 shutdown 事件已经进入弃用路线。理解这套机制,也就理解了一个 FastAPI 服务是怎样从代码变成可接收流量的运行实例。下面从原理、代码结构和生产实践逐层展开。
一、Events 到底是什么
应用生命周期可以看作一个带有明确边界的运行区间。
在 FastAPI 中,生命周期代码通常分成两段。
yield之前的代码用于初始化资源。yield之后的代码用于释放资源。yield期间,应用处于正常服务状态,可以处理 HTTP 请求和 WebSocket 连接。
官方给出的典型场景是加载机器学习模型。模型从磁盘或 GPU 加载可能耗时数秒,若每次请求都重新加载,接口很快就会被拖垮;若在 Python 模块导入时加载,又会让测试、命令行脚本和迁移工具平白承担成本。lifespan 恰好把初始化放在服务真正开始接收流量之前。
Events 不是什么
这里的 events 很容易和其他概念混在一起,实际边界相当清楚。
| 机制 | 触发时机 | 典型用途 | 执行次数 |
|---|---|---|---|
lifespan |
应用启动和停止 | 连接池、共享客户端、模型加载 | 每个应用实例一次 |
| 依赖注入 | 请求处理期间 | 鉴权、事务、请求级资源 | 每次请求一次 |
BackgroundTasks |
响应返回前后 | 邮件、轻量日志、短任务 | 按请求触发 |
| 中间件 | 每次请求经过应用时 | 日志、追踪、跨域、耗时统计 | 每次请求一次 |
| SSE | 服务端持续向客户端推送数据 | 实时通知、流式输出 | 按连接运行 |
因此,生命周期事件不是业务事件总线,也不是前端常说的 SSE,更不是用于执行所有后台任务的万能抽屉。它只负责应用级资源的建立与销毁。
二、推荐写法是 Lifespan
FastAPI 当前推荐把一个异步上下文管理器传给 FastAPI 的 lifespan 参数。这样,启动和关闭逻辑写在同一个函数里,资源在哪里创建,就在哪里释放,不容易出现启动代码改了、关闭代码却被忘在另一个文件里的情况。
一个可以直接运行的基础示例
python
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import httpx
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动阶段
app.state.http_client = httpx.AsyncClient(
timeout=httpx.Timeout(10.0),
limits=httpx.Limits(
max_connections=100,
max_keepalive_connections=20,
),
)
print("应用资源初始化完成")
try:
# yield 期间,应用正常处理请求
yield
finally:
# 关闭阶段
await app.state.http_client.aclose()
print("应用资源已经释放")
app = FastAPI(lifespan=lifespan)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/example")
async def fetch_example(request: Request):
client: httpx.AsyncClient = request.app.state.http_client
response = await client.get("https://example.com")
return {
"status_code": response.status_code,
"content_length": len(response.content),
}
运行方式如下。
bash
uvicorn main:app --reload
执行顺序可以近似理解为下面这样。
python
await initialize_resources()
try:
await serve_requests()
finally:
await close_resources()
这里使用 try...finally 很重要。只要生命周期进入了 yield,即使后续关闭过程中出现取消信号或其他异常,finally 中的清理代码仍更有机会得到执行。它不是绝对保险箱,但比把清理代码随手堆在函数末尾稳健得多。
Starlette 还支持从生命周期函数中 yield 一个状态字典,再通过 request.state 访问其中的资源。请求获得的是该状态命名空间的浅拷贝 ,其中保存的连接池、客户端等对象本身仍是共享对象。对于 FastAPI 工程,app.state 和生命周期状态都可以使用;团队应统一一种风格,避免一部分资源藏在全局变量里,另一部分又散落在不同状态容器中。
三、工程中应该初始化哪些资源
判断一个资源是否应放入 lifespan,可以看三个特征。
- 创建成本较高。
- 多个请求需要共享。
- 服务停止时需要显式释放。
常见资源包括:
- 数据库连接池或异步数据库引擎。
- Redis、Elasticsearch、Kafka 等客户端。
httpx.AsyncClient之类的共享 HTTP 客户端。- 机器学习模型、分词器、GPU 上下文。
- 指标采集器、追踪导出器。
- 配置中心客户端或服务发现客户端。
- 需要伴随应用运行的受控后台协程。
更适合生产工程的资源容器
资源较多时,不要不断给 app.state 增加零散属性。可以建立一个有类型标注的资源容器。
python
from contextlib import asynccontextmanager
from dataclasses import dataclass
import httpx
from fastapi import Depends, FastAPI, Request
class Database:
async def connect(self) -> None:
print("数据库已连接")
async def disconnect(self) -> None:
print("数据库已断开")
async def ping(self) -> bool:
return True
@dataclass
class AppResources:
database: Database
http_client: httpx.AsyncClient
@asynccontextmanager
async def lifespan(app: FastAPI):
database = Database()
await database.connect()
http_client = httpx.AsyncClient(timeout=10.0)
app.state.resources = AppResources(
database=database,
http_client=http_client,
)
try:
yield
finally:
await http_client.aclose()
await database.disconnect()
app = FastAPI(lifespan=lifespan)
def get_resources(request: Request) -> AppResources:
return request.app.state.resources
@app.get("/ready")
async def readiness(
resources: AppResources = Depends(get_resources),
):
database_ok = await resources.database.ping()
return {
"ready": database_ok,
}
这种写法带来几项实际收益。
- 资源类型更清晰,编辑器可以自动补全。
- 路由不必知道资源是如何创建的。
- 测试时可以覆盖依赖或替换资源对象。
- 生命周期函数负责所有权,依赖函数负责向业务层提供资源。
- 后续增加 Redis 或消息队列时,修改范围比较集中。
工程上可以把 lifespan 理解为资源的所有者。谁创建资源,谁负责关闭资源。这条朴素规则能避免大量连接泄漏问题。
四、多个资源如何安全地创建和关闭
当资源之间存在依赖关系时,关闭顺序通常应与创建顺序相反。例如,业务消费者依赖数据库和 HTTP 客户端,那么应先停止消费者,再关闭客户端和数据库。
Python 的 AsyncExitStack 很适合管理这种场景。
python
from contextlib import AsyncExitStack, asynccontextmanager
import httpx
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncExitStack() as stack:
http_client = await stack.enter_async_context(
httpx.AsyncClient(timeout=10.0)
)
app.state.http_client = http_client
# 所有资源初始化成功后,应用才开始接收请求
yield
# 离开上下文后,资源按照注册的逆序关闭
app = FastAPI(lifespan=lifespan)
它的一个关键优势是部分初始化失败时也能回滚。假设数据库已经连接成功,随后 Redis 连接失败,退出栈仍会关闭数据库,不会留下半初始化资源。
资源初始化失败时,通常不应强行让服务进入可接流量状态。对于数据库不可达、模型文件损坏、必要密钥缺失等情况,直接让异常向外抛出更合理。ASGI 服务器会把启动失败视为服务无法就绪,而不是让一个残缺实例继续提供接口。ASGI Lifespan 协议定义了 lifespan.startup.complete、lifespan.startup.failed、lifespan.shutdown.complete 等消息,服务器会等待应用确认启动完成后再处理连接。
五、后台协程怎样放入生命周期
有些服务需要伴随应用运行的长期协程,例如定期刷新缓存、消费消息、上报心跳。这类任务可以在启动阶段创建,在关闭阶段主动取消并等待结束。
python
import asyncio
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
async def refresh_cache_forever() -> None:
try:
while True:
print("刷新缓存")
await asyncio.sleep(30)
except asyncio.CancelledError:
print("缓存刷新任务正在退出")
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(
refresh_cache_forever(),
name="cache-refresher",
)
app.state.cache_task = task
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
app = FastAPI(lifespan=lifespan)
生产环境里还应处理几个问题。
- 任务异常不能悄悄消失。长期任务若意外退出,应记录日志、上报监控,必要时让进程退出并由编排系统重启。
- 关闭必须有超时。外部服务卡住时,不能让进程无限等待。
- 任务要响应取消 。捕获
CancelledError后通常需要重新抛出。 - 强一致任务不要只放在 Web 进程里。订单结算、可靠消息消费等关键工作,更适合专门的 worker 和消息队列。
- 对结构化并发要求较高时,可使用 AnyIO 的任务组。Starlette 文档也建议用任务组管理生命周期内的异步任务。
六、旧式 startup 和 shutdown 事件
早期 FastAPI 项目常见下面这种写法。
python
from fastapi import FastAPI
app = FastAPI()
@app.on_event("startup")
async def startup_event():
print("服务启动")
@app.on_event("shutdown")
async def shutdown_event():
print("服务关闭")
这套接口现在属于弃用方案 。旧项目短期内仍可能运行,但新代码应优先使用 lifespan。
两种方式的差异如下。
| 对比项 | lifespan | on_event |
|---|---|---|
| 官方建议 | 推荐 | 已弃用 |
| 启动与清理位置 | 写在同一上下文中 | 分散在不同函数中 |
| 资源所有权 | 清晰 | 容易分散 |
| 部分初始化失败回滚 | 较容易组织 | 较难协调 |
| 新项目选择 | 应使用 | 不建议使用 |
还有一个容易踩坑的地方。一旦给 FastAPI 提供了 lifespan,就不要再期待旧式 startup 和 shutdown 处理器同时执行。迁移时应把旧处理器整体收拢进生命周期函数,而不是新旧机制混搭。
七、多进程部署中的真实行为
部署时使用多个 Uvicorn worker,意味着存在多个独立进程。每个进程都有自己的事件循环、内存空间和生命周期,因此会分别执行一次 lifespan。
bash
uvicorn main:app --workers 4
在这种配置下,通常会发生下面这些事情。
- 数据库连接池创建四份。
- HTTP 客户端创建四份。
- 内存缓存创建四份,彼此不共享。
- 机器学习模型可能加载四份。
- 生命周期内创建的定时任务也会运行四份。
ASGI 规范明确要求生命周期按处理请求的事件循环执行,以保证数据库连接池等对象不会被错误地跨事件循环共享。
这会直接影响容量规划。假设每个 worker 的数据库连接池上限是二十,四个 worker 理论上就可能建立八十条连接。配置时不能只看单进程参数。
更危险的是定时任务。如果你在 lifespan 中启动每日结算任务,四个 worker 可能同时执行四遍。可选的工程方案包括:
- 把任务迁移到 Celery、Dramatiq、Arq 等独立任务系统。
- 使用数据库锁或 Redis 分布式锁选举唯一执行者。
- 将调度器拆成单独服务。
- 让任务具备幂等性,即便重复执行也不会产生错误结果。
一句话概括,进程内资源可以放在 lifespan,要求全局唯一的工作不能只靠 lifespan 保证唯一。
八、同步阻塞操作要谨慎处理
lifespan 是异步函数,但函数里写的代码并不会自动变成非阻塞操作。加载大型模型、读取巨量文件、调用同步数据库驱动时,仍可能阻塞事件循环。
可以把确实需要同步执行的初始化工作放入线程。
python
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
def load_large_model():
print("正在同步加载模型")
return object()
@asynccontextmanager
async def lifespan(app: FastAPI):
model = await asyncio.to_thread(load_large_model)
app.state.model = model
try:
yield
finally:
app.state.model = None
app = FastAPI(lifespan=lifespan)
不过,启动阶段阻塞事件循环不一定会影响用户请求,因为此时服务尚未开始接收流量。真正的问题通常是启动超时、健康检查失败和优雅关闭失效。若初始化需要几分钟,应同步调整容器探针、编排系统启动超时和部署策略。
九、健康检查要区分存活与就绪
生命周期资源初始化成功,只说明应用在启动时可用,并不意味着数据库永远不会断开。生产工程通常需要两类检查。
- 存活检查表明进程和事件循环仍在工作。失败后可重启容器。
- 就绪检查表明当前实例具备处理业务流量的条件。失败后应暂时摘除流量,但不一定立刻重启。
python
from fastapi import Depends, FastAPI
@app.get("/health/live")
async def live():
return {"alive": True}
@app.get("/health/ready")
async def ready(
resources: AppResources = Depends(get_resources),
):
database_ok = await resources.database.ping()
return {
"ready": database_ok,
"database": database_ok,
}
不要让存活检查执行昂贵的数据库查询,否则一次数据库抖动可能触发整批 Web 实例重启,场面会从小雨变成台风。
十、如何测试生命周期逻辑
测试时必须让测试客户端以上下文管理器 方式运行。进入 with 代码块时执行启动逻辑,退出代码块时执行清理逻辑。若只实例化 TestClient 而不进入上下文,生命周期未必会按预期运行。
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
events: list[str] = []
@asynccontextmanager
async def lifespan(app: FastAPI):
events.append("startup")
app.state.ready = True
try:
yield
finally:
events.append("shutdown")
app.state.ready = False
app = FastAPI(lifespan=lifespan)
@app.get("/status")
async def status():
return {"ready": app.state.ready}
def test_lifespan():
assert events == []
with TestClient(app) as client:
assert events == ["startup"]
response = client.get("/status")
assert response.status_code == 200
assert response.json() == {"ready": True}
assert events == ["startup", "shutdown"]
assert app.state.ready is False
测试资源较重时,可以构造一个测试专用应用,注入轻量生命周期。
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def test_lifespan(app: FastAPI):
app.state.model = FakeModel()
yield
def create_app(lifespan_handler) -> FastAPI:
application = FastAPI(lifespan=lifespan_handler)
return application
test_app = create_app(test_lifespan)
这样不会真的连接生产数据库或下载模型,测试速度也更稳定。FastAPI 官方测试文档和 Starlette 文档都明确推荐使用 with TestClient(app) 来保证生命周期启动与清理得到执行。
十一、子应用与挂载场景
大型工程可能会使用 mount 挂载子应用。
python
from fastapi import FastAPI
main_app = FastAPI()
admin_app = FastAPI()
main_app.mount("/admin", admin_app)
在这类结构中,不应未经验证就假定每个挂载子应用的生命周期都会像主应用那样自动执行。FastAPI 官方文档特别说明,生命周期事件主要针对主应用,挂载子应用的行为需要谨慎处理。共享资源更适合由主应用统一管理,再通过明确的依赖或状态传递给子模块。
如果某个子系统必须拥有独立生命周期,更稳妥的做法通常是:
- 将其拆成独立服务。
- 在主应用生命周期中显式初始化。
- 使用组合式异步上下文管理器,把多个模块的生命周期合并起来。
十二、常见错误与改进方式
在模块顶层创建异步客户端
python
import httpx
client = httpx.AsyncClient()
这种写法会把资源创建和 Python 导入绑定在一起,测试收集、脚本导入都可能触发资源创建,事件循环归属也更难判断。
改进方式 是放入 lifespan,由应用运行时创建并关闭。
每次请求都重新创建连接池
这会失去连接复用能力,增加延迟,也给数据库带来额外压力。
改进方式是生命周期管理连接池,请求级依赖只获取连接或会话。
清理时直接吞掉所有异常
python
try:
await resource.close()
except Exception:
pass
这种代码看似稳,实际上会让连接泄漏和关闭失败彻底隐身。
改进方式是记录结构化日志,在确保其他资源仍能继续清理的前提下保留异常信息。
把所有后台工作塞进 Web 进程
Web worker 重启、扩缩容或多副本部署时,任务可能中断或重复。
改进方式是区分进程级辅助任务与可靠业务任务。后者应交给独立消费者和持久化队列。
假定 shutdown 一定执行
正常的 SIGTERM 通常会触发优雅关闭,但 SIGKILL、进程崩溃、机器断电不会给应用清理机会。
改进方式是让资源具备租约、超时和自动恢复能力。数据库连接断开后由服务器回收,分布式锁应有过期时间,业务数据不能依赖 shutdown 才能保持一致。
十三、推荐的工程目录
一个中型项目可以按照下面的方式组织。
text
app/
├── main.py
├── lifespan.py
├── resources.py
├── dependencies.py
├── api/
│ ├── health.py
│ └── users.py
├── services/
│ └── user_service.py
└── tests/
└── test_lifespan.py
各文件的职责保持简单。
resources.py定义数据库、缓存、HTTP 客户端等资源类型。lifespan.py创建和释放应用级资源。dependencies.py把资源提供给路由和业务服务。main.py组装 FastAPI 应用、中间件与路由。tests使用上下文形式的TestClient验证启动和清理行为。
依赖关系大致如下。
这种结构让生命周期代码保持在基础设施层,不会渗入每一个接口函数。
结语
FastAPI 的 events 本质上是一套应用级资源管理机制 。当前工程应以 lifespan 为主,把 yield 前视为启动阶段,把 yield 后视为清理阶段,再通过 app.state、生命周期状态或依赖注入把共享资源提供给业务代码。
落到生产环境,有几条原则最管用:
- 昂贵、共享、需要关闭的资源交给
lifespan。 - 初始化失败就阻止实例接收流量,不要带病启动。
- 多个资源采用逆序清理,复杂场景使用
AsyncExitStack。 - 多 worker 环境下,每个进程都会单独初始化资源。
- 要求全局唯一的任务不能只依赖生命周期机制。
- 测试客户端必须放进
with上下文。 - 不假设 shutdown 永远有机会执行,系统仍需具备超时和故障恢复能力。
把这些边界理顺之后,FastAPI 服务的启动、运行和退出都会变得可预测。连接从哪里来、模型何时加载、任务怎样停止,也不再是一团散落在全局变量里的线头。
参考资料
FastAPI Lifespan Events
fastapi.tiangolo.com/advanced/ev...
FastAPI Testing Events Lifespan and Startup Shutdown
fastapi.tiangolo.com/advanced/te...
Starlette Lifespan
ASGI Lifespan Protocol Specification
asgi.readthedocs.io/en/latest/s...
FastAPI 生命周期事件中文文档