FastAPI 里的 lifespan:从 on_event 迁过来之后
发布时间:2026/10/21 19:45:00 锦皓数字建站

前几天把一个老 FastAPI 项目升级版本启动日志里突然冒出来一堆DeprecationWarning大意是app.on_event(startup)这种写法以后要没了让改用lifespan。翻了下文档发现这玩意儿其实从 FastAPI 0.93 开始就推荐了Starlette 那边更早。用了一段时间感觉确实比原来两个事件分开写顺手。这篇记一下怎么用以及几个实际踩过的坑。最简长什么样先看最小可运行的版本fromcontextlibimportasynccontextmanagerfromfastapiimportFastAPIasynccontextmanagerasyncdeflifespan(app:FastAPI):# 应用启动时跑这里print(starting up...)yield# 应用关闭时跑这里print(shutting down...)appFastAPI(lifespanlifespan)就这几行。asynccontextmanager是必须的忘了加会直接报错。yield前面是启动逻辑后面是关闭逻辑中间那段yield把控制权交出去FastAPI 就在这个空档里正常处理请求。对比一下原来的写法app.on_event(startup)asyncdefstartup():print(starting up...)app.on_event(shutdown)asyncdefshutdown():print(shutting down...)功能上差不多但 lifespan 把启动和关闭收在一个函数里需要配对的资源比如开个连接池再关掉不用跳来跳去找对应的另一半。实际项目里怎么用lifespan 真正有用的地方是那些应用启动时建一次、关的时候清掉的东西。最常见的就是数据库连接、Redis 客户端、加载到内存的模型。fromcontextlibimportasynccontextmanagerfromfastapiimportFastAPI,Requestfromsqlalchemy.ext.asyncioimportcreate_async_engineimportredis.asyncioasredisasynccontextmanagerasyncdeflifespan(app:FastAPI):# 启动建连接enginecreate_async_engine(postgresqlasyncpg://user:passlocalhost/db)redis_clientredis.from_url(redis://localhost:6379)# 挂到 app.state 上路由里能用app.state.db_engineengine app.state.redisredis_clientyield# 关闭把连接都释放掉awaitengine.dispose()awaitredis_client.close()appFastAPI(lifespanlifespan)# 路由里这样拿app.get(/users/{uid})asyncdefget_user(uid:int,request:Request):enginerequest.app.state.db_engine redis_clientrequest.app.state.redis# ...这里有个点要提一下app.state是 FastAPI其实是 Starlette给你准备的一个随便挂对象的地方。不用自己搞全局变量挂上去之后通过request.app.state.xxx拿。测试的时候换起来也方便。如果你加载的是大模型比如 PyTorch 模型启动时加载一次放内存里别每次请求都加载一遍——这个谁都知道但 lifespan 是最自然放它的位置asynccontextmanagerasyncdeflifespan(app:FastAPI):importtorchfromtransformersimportAutoModelForCausalLM,AutoTokenizer app.state.llmAutoModelForCausalLM.from_pretrained(xxx)app.state.tokenizerAutoTokenizer.from_pretrained(xxx)yield# 不需要特意 del进程退出操作系统会回收几个坑1. 别在 lifespan 里写阻塞代码lifespan 是跑在事件循环里的。你要是在这里写了个time.sleep(10)或者同步地去读一个大文件、发个同步 HTTP 请求整个事件循环就卡住了应用看起来像启动了但其实没在干活。阻塞的活儿扔线程池importasyncioasynccontextmanagerasyncdeflifespan(app:FastAPI):# 这种同步加载就别直接写# data load_big_csv(xxx.csv) # 阻塞dataawaitasyncio.to_thread(load_big_csv,xxx.csv)app.state.datadatayield2. 启动抛异常应用就起不来lifespan 里要是在 yield 之前抛了异常FastAPI 会直接启动失败这个行为是对的——连数据库都连不上起来干嘛。但调试的时候要注意错误日志是从 lifespan 里抛出来的别只盯着路由报错看。asynccontextmanagerasyncdeflifespan(app:FastAPI):try:app.state.redisawaitredis.from_url(...).ping()exceptExceptionase:# 要么自己处理要么让它炸raiseRuntimeError(redis 连不上别起了)fromeyield3. TestClient 会触发 lifespan这个是好事但很多人不知道。用TestClient(app)写测试的时候进入with块就会跑 startup退出with块就跑 shutdown。所以如果你 lifespan 里连了真数据库测试时要么 mock 掉要么准备一个测试库别把开发库搞脏了。deftest_something():withTestClient(app)asclient:# 这里 lifespan 已经跑过了respclient.get(/users/1)assertresp.status_code200不用with直接TestClient(app).get(...)的话lifespan 不会执行这个行为差异要清楚。4. 多个 startup 事件 vs 一个 lifespan以前用app.on_event(startup)可以写好几个按注册顺序跑。换成 lifespan 之后所有启动逻辑就这一个函数。想拆分的话自己在里面调几个 async 函数就行asyncdefinit_db(app):...asyncdefinit_redis(app):...asyncdefload_models(app):...asynccontextmanagerasyncdeflifespan(app:FastAPI):awaitinit_db(app)awaitinit_redis(app)awaitload_models(app)yield# 关闭顺序反过来什么时候必须用严格说不是什么场景都得用。你写个纯接口、不连外部资源、不加载东西那 lifespan 可以省掉FastAPI 不会因为你不写就报错。但只要你有下面任意一种情况就老老实实用要连数据库、Redis、MQ需要在关闭时释放连接启动时要加载模型、读配置、预热缓存要起后台任务比如asyncio.create_task跑个定时任务关的时候要 cancel 掉它最后这种提一下后台任务的正确关法asyncdefbackground_loop():whileTrue:awaitdo_something()awaitasyncio.sleep(60)asynccontextmanagerasyncdeflifespan(app:FastAPI):taskasyncio.create_task(background_loop())yieldtask.cancel()try:awaittaskexceptasyncio.CancelledError:pass不 cancel 直接关任务会跟着进程死但日志里会留一条未处理的异常强迫症看着难受。一句话on_event那两套写法翻文档的时候看到 deprecated 警告就该迁了别等真移除那天再改。lifespan 本质上就是把启动和关闭两件配对的事写在同一个函数里没什么新东西但确实比原来清爽。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。