1. 为什么我在FastAPI项目里选了SQLModel,而不是继续手动“双写”模型
最早做FastAPI接口的时候,我其实就是老老实实走SQLAlchemy那套:先写一个SQLAlchemy的ORM模型管数据库表,再写一套Pydantic的Schema管请求参数和响应结构。项目小的时候还能忍,等表一多起来,你会发现同一个实体在代码里出现了三四处——数据库列要改个长度,ORM模型改一遍,Pydantic入参模型改一遍,Pydantic出参模型可能还得改一遍,有时候漏改了一个字段,接口文档还是旧的,但请求已经报校验错误了。
SQLModel这个库就是在那个痛点下出现的。它是FastAPI作者tiangolo自己做的一个工具库,底层直接复用SQLAlchemy 2.0和Pydantic v2的能力。简单讲:一个类,同时承担了“数据库表模型”和“数据校验模型”两个身份。你在类里写一句字段声明,既决定了表里那一列是什么类型、有没有索引、是不是主键,也决定了FastAPI的请求体验收什么结构、响应体长什么样。这样至少把“同一份字段定义写两遍”的问题消掉了大半。
这篇内容围绕“用SQLModel把FastAPI和关系型数据库连起来”这件事,整理了从项目结构、连接配置、模型设计、CRUD接口,到多表关联和迁移落地的完整链路。适合准备上FastAPI做项目、但还在纠结ORM选型的人;也适合已经用SQLAlchemy但每天被两套模型维护成本搞烦的人。我尽量把实操中的细节和坑也一起放出来,省得你再走弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目初装:依赖、连接串与Session依赖注入的细节
2.1 安装包与数据库驱动
在实际项目里,安装并不只是pip install sqlmodel这么简单。SQLModel本身会帮你装好SQLAlchemy和Pydantic,但FastAPI是配套使用的,所以基础依赖通常是这样:
bash复制pip install sqlmodel fastapi uvicorn[standard]
关系型数据库有很多种,SQLModel本身不关心你连的是哪种,底层的SQLAlchemy会去适配。但“数据库驱动”是绕不开的,你总得装一个让Python能和具体数据库对话的库。
以最常见的三类为例:
| 数据库 | 推荐驱动 | 对应的连接串示例 |
|---|---|---|
| SQLite | 无需额外驱动(Python内置sqlite3) | sqlite:///./app.db |
| PostgreSQL | psycopg(或 psycopg2-binary) | postgresql+psycopg://user:password@localhost:5432/mydb |
| MySQL / MariaDB | PyMySQL | mysql+pymysql://user:password@localhost:3306/mydb?charset=utf8mb4 |
驱动安装命令分别是pip install psycopg和pip install pymysql。后端MySQL连接串里的charset=utf8mb4建议保留,不然中文写入时容易遇到编码问题。SQLite适合开发环境快速跑通,生产上建议直接用PostgreSQL或MySQL,字段类型、并发写入和约束行为都更接近真实业务场景。
2.2 创建Engine:比建连接本身更重要的参数
SQLModel的create_engine其实和SQLAlchemy的接口是一致的。最基础的做法:
python复制from sqlmodel import create_engine
DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(DATABASE_URL, echo=True)
echo=True会让SQLAlchemy把每一条实际执行的SQL打印到控制台,开发阶段强烈建议打开。你看一眼SQL输出,就能确认ORM到底替你执行了什么,排查问题会快很多。
但只写这两行,在真实项目里还不够。生产环境我不会让Engine裸奔,至少要加上连接池和探活参数:
python复制engine = create_engine(
DATABASE_URL,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
)
pool_size=10:连接池最多保持10个空闲连接。max_overflow=20:高峰期最多再额外创建20个连接。pool_pre_ping=True:每次从连接池取连接前,先ping一下数据库,如果发现连接已经被数据库端断开,会自动重连而不是直接抛异常。
这个pool_pre_ping参数是我在生产环境被坑过之后才记住的。数据库长时间空闲后,有些中间件或云数据库会主动断开空闲连接,如果连接池还傻傻地复用那些“死连接”,接口就会莫名报错。加了它之后,这类问题基本消失。
另外还要提醒一点:create_engine不是连接数据库,它只是建立了一个“连接工厂”。SQLAlchemy是惰性连接,真正执行第一条SQL时才和数据库建立连接,所以不用害怕启动阶段就暴露数据库地址。
2.3 写一个Session依赖注入
FastAPI推荐用依赖注入来管理数据库会话。你不应该在每个路由里手动Session(engine)再close,那样事务边界很难控制,出一次异常session可能就泄漏了。最稳妥的做法是用一个生成器依赖,把session的创建和关闭收口在一个地方:
python复制from typing import Generator
from sqlmodel import Session
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
FastAPI遇到Depends(get_session)时,会先执行到这个yield处拿到session,等请求处理完,无论成功还是抛异常,都会回到生成器把with Session(engine)后面的清理逻辑走完。
我把这段逻辑单独放在一个database.py模块里,避免路由文件里混入数据库连接细节:
python复制# database.py
from typing import Generator
from sqlmodel import Session, create_engine
DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(DATABASE_URL, echo=True)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
有人可能会问:Session(engine)和sessionmaker有什么区别?如果你的路由里到处都要手动拿session,用sessionmaker会更方便,因为它是“会话工厂”,可以预先绑定engine、绑定autocommit等参数。但FastAPI项目里我们几乎只在依赖注入里用Session,所以直接实例化就够清晰了。偏复杂的项目想统一管理session,也可以改成:
python复制from sqlmodel import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine(DATABASE_URL, echo=True)
SessionLocal = sessionmaker(bind=engine)
def get_session() -> Generator[Session, None, None]:
with SessionLocal() as session:
yield session
注意这里是sessionmaker,默认返回的session类型会兼容SQLModel的Model。这样改动不影响后面的路由代码。
2.4 启动时建表:lifespan还是on_event?
FastAPI刚火那会儿,官方文档里用的是@app.on_event("startup")来建表,后来这个写法被标记为deprecated,更推荐用lifespan上下文管理器。我自己也早就切到lifespan了:
python复制from contextlib import asynccontextmanager
from fastapi import FastAPI
from database import engine
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时导入所有模块,确保表模型注册到 metadata
import models # noqa
SQLModel.metadata.create_all(engine)
yield
app = FastAPI(lifespan=lifespan)
SQLModel.metadata.create_all(engine)的作用是根据所有声明了table=True的SQLModel类,在数据库里创建那些还不存在的表。它“create all”但不会修改已存在的表结构——这一点后面我会专门展开,很多人正是在这里产生了误解。
3. 模型声明逻辑:从一个Hero表看懂“ORM+校验一体”到底是什么回事
3.1 最简单的表模型
SQLModel的模型声明,第一眼看上去和Pydantic的BaseModel几乎一样:
python复制from typing import Optional
from sqlmodel import Field, SQLModel
class Hero(SQLModel, table=True):
__tablename__ = "hero"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, max_length=50)
secret_name: str = Field(max_length=120)
age: Optional[int] = Field(default=None, index=True)
拆开看几个容易忽略的重点:
第一,table=True是开启数据库表映射的开关。没有这个参数,类就只是普通Pydantic模型,FastAPI可以用它做请求体或响应体,但不会建表,不代表任何数据库实体。一个模型是可以既做表模型,也做API模型,但多数情况下,我们还会为请求和响应单独定义几个不开启table的模型。
第二,__tablename__是表名。虽然SQLModel会默认根据类名生成一个表名,比如Hero默认就是hero,但类名一旦变成HeroGroup这种复合词,默认表名未必符合你的预期,显式声明才是稳妥做法。
第三,id字段必须写成Optional[int]而不是int。这不是风格问题。写成int且没有默认值,在Pydantic里意味着实例化Hero时必须传id;但id是由数据库自增的,创建对象时根本不该传。所以官方推荐Optional[int] = Field(default=None, primary_key=True),让它在插入前是None,提交后由数据库分配。
第四,Field是SQLModel自己封装过的字段函数。它能同时把参数转给Pydantic和SQLAlchemy。比如max_length=50,一方面成为Pydantic的字符串长度校验规则,另一方面在数据库建表时也会影响列的定义。如果你只写了name: str,Pydantic当然不会拦长度,但SQLAlchemy生成列时可能因为没有明确长度而在某些数据库方言上报错或产生预期外的TEXT类型。所以字符串字段尽量给max_length。
3.2 同一套字段定义,怎么兼顾请求体和响应体?
如果直接把上面这个Hero表格模型当作接口的响应模型,也可以工作,FastAPI能把它序列化成JSON。但这意味着secret_name这种内部字段也会被暴露出去,而且创建Hero时,如果你拿同一个模型作为请求体,客户端就必须传secret_name,但id又不该传,语义会很拧巴。
所以我在项目里习惯把“基础字段”抽出来,再派生出请求模型和响应模型:
python复制from typing import Optional
from sqlmodel import Field, SQLModel
class HeroBase(SQLModel):
name: str = Field(max_length=50)
secret_name: str = Field(max_length=120)
age: Optional[int] = None
class HeroCreate(HeroBase):
pass
class HeroUpdate(SQLModel):
name: Optional[str] = Field(default=None, max_length=50)
secret_name: Optional[str] = Field(default=None, max_length=120)
age: Optional[int] = Field(default=None)
class HeroRead(HeroBase):
id: int
class Hero(HeroBase, table=True):
__tablename__ = "hero"
id: Optional[int] = Field(default=None, primary_key=True)
这里有个设计取舍值得说:HeroBase不带table=True,所以它是纯Pydantic模型,用来给多个API模型复用公共字段。HeroCreate用来接收POST请求体,没有id字段,客户端就不可能往id里塞值。HeroRead用来输出,确保一定有id。HeroUpdate不同于HeroCreate,每个字段都是可选的,因为PATCH请求只更新部分字段,全字段必填的话就没法做局部更新了。
最后那个Hero才是真正落库的表模型,它继承了Base字段,并额外带上主键。字段在HeroBase里声明一次,在表模型和API模型里就都生效了,这就是SQLModel“一体化”的体现。
3.3 路由里怎么用这些模型?
看一个创建Hero的接口,代码会非常短:
python复制from fastapi import Depends, FastAPI
from sqlmodel import Session
from database import get_session
from models import Hero, HeroCreate, HeroRead
@app.post("/heroes", response_model=HeroRead)
def create_hero(payload: HeroCreate, session: Session = Depends(get_session)):
hero = Hero.model_validate(payload)
session.add(hero)
session.commit()
session.refresh(hero)
return hero
Hero.model_validate(payload)是把HeroCreate的字段数据“拷贝”到一个新的Hero表格模型实例里。因为这个操作是读HeroCreate对象的属性,而不是修改它,所以用model_validate而不是model_copy。以前SQLModel旧版教程里常见Hero.from_orm(payload),那是Pydantic v1时代的API,放到现在的新版本会报兼容问题,新版统一用model_validate。
提交之后必须session.refresh(hero),因为commit后,数据库自增的id、默认值、触发器生成的字段,都要重新从数据库拿一遍才能出现在内存对象上。不refresh直接返回,响应里的id很可能是None。
3.4 为什么很多教程不区分Table模型和Pydantic模型?
如果你看SQLModel官方文档的快速入门,会发现他们经常直接用一个带table=True的Hero类同时充当请求体和响应模型,代码确实很简洁。比如创建Hero接口的请求体就是Hero本身,响应模型也是Hero,一个类全搞定。
这种写法在Demo和小工具里没问题,但在稍微正式一点的项目里,问题会逐渐暴露:
- 接口文档会把表结构细节全部暴露出去,包括不该给前端看的字段;
- 表模型包含了和数据库直接相关的配置,比如索引、外键、sa_column参数,这些概念混进API契约里,对前端不友好;
- 创建和更新接口的校验规则往往不同,比如创建时
name必填,更新时name可选,一个表模型没法同时表达两种语义。
所以我的建议是:快速原型用单模型没问题,但项目只要准备长期迭代,就尽早拆出HeroCreate、HeroUpdate、HeroRead这一套。SQLModel的优势恰恰在于这种读写模型分离的成本很低,因为字段可以继承,你不用重写三遍。
4. CRUD接口实战:从增删改查里体会Session的设计逻辑
4.1 创建与读取:session.exec返回的是什么?
SQLModel推荐的查询入口是session.exec(),它和原生的session.execute()不同,后者返回的是SQLAlchemy的Row结果集,前者返回的是ScalarResult,能直接拿到模型实例,省去手动.scalars()的步骤。
读取列表的接口:
python复制from sqlmodel import select
@app.get("/heroes", response_model=list[HeroRead])
def list_heroes(
offset: int = 0,
limit: int = 20,
session: Session = Depends(get_session),
):
heroes = session.exec(select(Hero).offset(offset).limit(limit)).all()
return heroes
注意select(Hero)这里传入的是类本身,等价于SQL的SELECT * FROM hero。.offset().limit()就是分页。
实际业务里很少会无条件查全表,所以通常要加过滤条件。SQLModel的查询条件写法和SQLAlchemy一致:
python复制heroes = session.exec(
select(Hero)
.where(Hero.age >= 18)
.order_by(Hero.age.desc(), Hero.name)
.offset(offset)
.limit(limit)
).all()
.where()可以用多个条件,多个where之间默认是AND关系。.order_by(Hero.age.desc())是年龄倒序,.order_by(Hero.name)是name升序。分页参数一般绑定到接口的query参数上,比如/heroes?offset=0&limit=10,FastAPI会自动解析并做类型校验。
读取单个:
python复制@app.get("/heroes/{hero_id}", response_model=HeroRead)
def get_hero(hero_id: int, session: Session = Depends(get_session)):
hero = session.get(Hero, hero_id)
if not hero:
raise HTTPException(status_code=404, detail="Hero not found")
return hero
session.get(Hero, hero_id)是SQLAlchemy 2.0推荐的按主键查询方式,比select().where(Hero.id == hero_id)更简洁,也更容易读。如果查不到,get返回None,所以要自己抛404,否则FastAPI会拿None去序列化,很容易给你一个莫名其妙的500。
4.2 更新:不要忽略exclude_unset
更新接口最常见的坑是“覆盖了客户端没传的字段”。假设前端只想改年龄,按HeroUpdate模型解析后,name和secret_name是None,如果直接setattr回Hero对象,name和secret_name就被清空了。
解决办法是使用exclude_unset=True,只取出请求里真正显式携带的字段:
python复制@app.patch("/heroes/{hero_id}", response_model=HeroRead)
def update_hero(
hero_id: int,
payload: HeroUpdate,
session: Session = Depends(get_session),
):
hero = session.get(Hero, hero_id)
if not hero:
raise HTTPException(status_code=404, detail="Hero not found")
update_data = payload.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(hero, key, value)
session.add(hero)
session.commit()
session.refresh(hero)
return hero
这里有一个细节:model_dump(exclude_unset=True)只排除“没传”的字段。如果客户端显式传了"age": null,age仍然会出现在update_data里,值为None,语义上就是把数据库里的age改成NULL。这在部分更新场景里其实是合理的——想清空某个字段你就传null。所以exclude_unset比exclude_none更符合PATCH语义。
为什么已经查询出来的hero还要再session.add(hero)?因为SQLAlchemy的session自带“脏检查”,你修改了已经关联到session的对象后,即使不调用add,commit时也会自动检测到变更并生成UPDATE语句。但显式add一下并没有坏处,代码意图更清晰,尤其当hero对象是在另一个session里查出来再传进来时,add能把它“纳入”当前session的管理范围。所以这个add我是建议保留的。
4.3 删除:先查再删,注意返回码
删除接口看起来最机械:
python复制@app.delete("/heroes/{hero_id}", status_code=204)
def delete_hero(hero_id: int, session: Session = Depends(get_session)):
hero = session.get(Hero, hero_id)
if not hero:
raise HTTPException(status_code=404, detail="Hero not found")
session.delete(hero)
session.commit()
返回204表示成功了,且没有响应体。这里有个容易踩的小坑:FastAPI规定204响应不能带body,如果你的删除接口不小心写了response_model=HeroRead,框架在返回时可能会因为要序列化而报错或产生不规范的响应。我没必要返回被删对象,HTTP状态码已经说明一切。
4.4 一个容易被忽略的事务边界问题
以上几个接口,每个都在自己的依赖里创建了独立的session,接口结束时session自动关闭,事务也随之结束。这就是“每个请求一个session”的事务边界设计。
有些人会把engine或者session定义为全局变量,路由里到处复用同一个session。这在请求量小的时候问题不明显,并发一上来就会出现“一个请求修改的数据被另一个请求意外提交”之类的诡异问题。因为session不是线程安全的,它应该代表“一个工作单元”。FastAPI的Depends生成器模式,天然保证了每个请求拿到的session是独立的,用完即关。我建议不要打破这个模式。
5. 多表关系:Team和Hero的关联,这次把relationship讲透
5.1 外键声明:不是加一个int字段那么简单
最常见的业务场景是一个Team下面有多个Hero。先看表模型:
python复制from typing import Optional
from sqlmodel import Field, Relationship, SQLModel
class Team(SQLModel, table=True):
__tablename__ = "team"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, max_length=50)
headquarters: str = Field(max_length=100)
heroes: list["Hero"] = Relationship(back_populates="team")
class Hero(SQLModel, table=True):
__tablename__ = "hero"
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, max_length=50)
secret_name: str = Field(max_length=120)
age: Optional[int] = Field(default=None, index=True)
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
team: Optional["Team"] = Relationship(back_populates="heroes")
关键点有四处:
第一,外键字段本身是team_id,它是一个普通的整数列,靠Field(foreign_key="team.id")告诉SQLAlchemy,这个列引用team表里的id列。字符串里的team.id是“表名.列名”,所以__tablename__如果你设置成别的名字,这里要跟着改。
第二,team_id通常是可选的,因为一个Hero可能暂时没分配队伍。除非业务上要求每个Hero必须有Team,那才应该用必填类型int而不是Optional[int]。
第三,Team.heroes和Hero.team这两个Relationship字段,并不真的存在于数据库表里,它们只是ORM层面给出的“关系导航属性”。为了能双向访问,必须用back_populates="team"和back_populates="heroes"把两边的Relationship绑定起来。
第四,仅声明team_id这个外键列,你就能查Hero时拿到hero.team_id的数值;但如果没有Relationship字段,你没法直接通过hero.team拿到完整的Team对象。Relationship的意义就是把外键关联“翻译”成Python对象属性的懒加载访问。
5.2 新增关联数据:先建主表还是先拿主键?
插入关联数据的顺序其实很有讲究。我见过新手直接这样写:
python复制hero = Hero(name="Iron Man", secret_name="Tony Stark", team=team)
理论上SQLAlchemy的relationship支持通过对象赋值来建立关联,SQLModel在部分版本里也能这么用,但我不推荐依赖这个行为。因为SQLModel对table=True模型的构造逻辑会特殊处理关系字段,版本差异可能带来意外。更稳妥、逻辑也更清晰的做法是先建Team,拿到数据库生成的主键,再创建Hero并关联外键:
python复制team = Team(name="Avengers", headquarters="New York")
session.add(team)
session.commit()
session.refresh(team)
hero = Hero(name="Iron Man", secret_name="Tony Stark", team_id=team.id)
session.add(hero)
session.commit()
session.refresh(hero)
这样做的最大好处是你能明确感知建表和取主键的先后顺序,不会把一个半透明的“关系自动赋值”机制当成黑盒。如果团队创建失败,根本走不到Hero创建那一步,事务边界也更清楚。
5.3 查询关联对象:小心懒加载变成N+1查询
查询Hero时如果直接访问hero.team,ORM会在首次访问时自动执行一次额外SQL查询。这个设计叫懒加载,在单条数据展示时没问题。但如果你在一个列表接口里循环heroes去访问hero.team,每个hero都会触发一次数据库查询,这就是经典的N+1问题。
python复制# 不推荐的写法:列表里每次访问team,都会多查一次数据库
heroes = session.exec(select(Hero)).all()
for hero in heroes:
print(hero.team.name)
如果Hero有100条,你会执行1次查hero表 + 100次查team表,共101次SQL。接口响应时间会随着数据量线性恶化。
解决方式是预加载,也就是在查询时一次性把关联数据查出来:
python复制from sqlalchemy.orm import selectinload
heroes = session.exec(
select(Hero).options(selectinload(Hero.team))
).all()
selectinload的原理是先查询Hero列表,然后根据所有hero的team_id生成一条WHERE id IN (...)的查询,把关联的Team一次性加载进session。这样总SQL通常只有2条。
5.4 响应模型里怎么返回关联对象?
前面那个Hero表模型里虽然有team: Optional[Team] = Relationship(...),但这个字段不会乖乖出现在FastAPI的响应JSON里。SQLModel的Relationship字段是给ORM用的,不是Pydantic校验字段,直接拿response_model=HeroRead(里面没有team字段)时,接口不会返回球队信息。
想返回关联数据,需要在Read模型里主动声明:
python复制class TeamRead(SQLModel):
id: int
name: str
headquarters: str
class HeroReadWithTeam(HeroRead):
team: Optional[TeamRead] = None
然后写接口时,用预加载把team带出来,返回给HeroReadWithTeam响应模型:
python复制@app.get("/heroes/{hero_id}", response_model=HeroReadWithTeam)
def get_hero_with_team(hero_id: int, session: Session = Depends(get_session)):
hero = session.exec(
select(Hero)
.where(Hero.id == hero_id)
.options(selectinload(Hero.team))
).one_or_none()
if not hero:
raise HTTPException(status_code=404, detail="Hero not found")
return hero
反过来,如果一个Team要带出下面所有Hero,也同样定义TeamReadWithHeroes:
python复制class HeroReadWithoutTeam(SQLModel):
id: int
name: str
age: Optional[int] = None
class TeamReadWithHeroes(SQLModel):
id: int
name: str
headquarters: str
heroes: list[HeroReadWithoutTeam] = []
到这里你应该已经感受到SQLModel对懒加载和多表关系的处理,和SQLAlchemy几乎是一样的。因为SQLModel压根就是基于SQLAlchemy实现的,学习成本一下子就摊平了。
6. 坑位复盘:迁移、异步与create_all的边界问题
6.1 create_all只建表,绝不改表
这是我在无数项目里见过的高频事故现场。很多新手以为数据库字段改了,重启服务调用SQLModel.metadata.create_all(engine)就会自动同步表结构,结果发现数据库里的表纹丝不动。
原因很简单:create_all只会检查“表是否存在”,不存在就创建,已存在就跳过。它不会比较表结构和模型定义的差异,更不会自动加字段、改类型、删列。
所以开发阶段你可以用create_all建表,但要清楚它的边界。一旦进入需要迭代表结构的阶段,就必须引入数据库迁移工具。SQLModel生态里最有默契的选择是Alembic,它也是SQLAlchemy官方推荐的迁移工具。
Alembic和SQLModel集成时,有一个东西一定要改:默认的alembic/env.py里target_metadata通常是None,你得把它指向SQLModel.metadata,否则autogenerate不知道你的模型长什么样。
python复制# alembic/env.py
from sqlmodel import SQLModel
import models # noqa: F401
target_metadata = SQLModel.metadata
改成这样后,生成迁移脚本时Alembic才能对比当前表结构和模型定义。要注意的是,SQLModel模型里的Pydantic校验元数据和SQLAlchemy列配置混在一起,autogenerate生成的结果偶尔会不够准确,尤其涉及默认值、索引变动时。用Alembic生成之后,务必人工审一遍生成的upgrade/downgrade脚本再执行,别闭着眼alembic upgrade head。
6.2 SQLite的连接小毛病:外键默认不生效
SQLite属于轻量级数据库,默认情况下外键约束是不开启的。也就是说,即使你给Hero设置了team_id外键,直接删除一个Team,只要没有手动级联删除Hero,数据库可能不会阻止,也不会自动清理,造成“孤儿Hero”。
如果开发环境坚持用SQLite,建议在建立连接后主动开启外键:
python复制from sqlalchemy import event
from sqlmodel import create_engine
engine = create_engine("sqlite:///./app.db")
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
生产切换到PostgreSQL或MySQL后,外键约束默认就生效了,不会再出现这种“本地好好的,上生产就报错”的反差。
6.3 不要在async接口里用同步Session
FastAPI对同步路由的支持方式是将其扔进线程池,这能避免阻塞事件循环。但如果你写的是async def路由,又在里面直接调用了同步的Session(engine)去查数据库,那这个阻塞调用会卡住整个事件循环,高并发时所有请求一起变慢,这是比较隐蔽的性能问题。
官方现在支持两种方案:
第一种:路由和依赖都写成普通def,让FastAPI自动用线程池执行。这对中小项目完全够用,代码也最简单,前面例子都是这种风格。
第二种:用异步引擎和异步Session。需要额外安装驱动,SQLite用aiosqlite,PostgreSQL用asyncpg,然后这样配置:
python复制from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/mydb"
engine = create_async_engine(DATABASE_URL, echo=True)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_session():
async with AsyncSessionLocal() as session:
yield session
异步路由里可以这样用:
python复制@app.get("/heroes", response_model=list[HeroRead])
async def list_heroes(session: AsyncSession = Depends(get_session)):
heroes = await session.exec(select(Hero))
return heroes.all()
注意session.exec在异步Session上也是一个协程,要await。如果项目里的数据库调用频率很高,而且I/O等待时间占比大,异步方案通常比纯def+线程池方案的吞吐量更好。不过它带来的复杂度也不小,类型标注、session生命周期都需要更小心。小项目不必为了“用async而async”。
6.4 别过度沉迷单模型,读写分开才是长期主义
SQLModel看似允许你一个模型打天下,但实际项目里我会严格规定:开启table=True的表模型,绝不直接用来接收创建请求或作为全部响应模型。理由我前面讲过,这里再补一个真实教训。
我有一次图省事,把表模型直接挂在POST请求体上,因为表模型里的id是Optional[int],前端传了一个id进来被我忽略掉了吗?没有。Pydantic在校验时会把请求里的id解析到表模型的id字段上,然后我session.add时,SQLAlchemy会带着这个id去插入。如果id和现有数据冲突,直接抛主键唯一性异常;如果不冲突,也可能造成一个“指定主键”的插入,打乱自增序列。这类问题非常难排查,因为它不是每次都触发。后来我把创建模型统一改为不包含id的HeroCreate,这个世界就清净了。
类似的还有更新接口。用HeroUpdate这种全可选模型,配合exclude_unset=True,就能安全做到局部更新。如果拿表模型当更新体,普通用户甚至能把自己换到另一个team里,因为你没有细粒度控制哪些字段可写。等你的接口开始涉及权限控制时,读写模型分离几乎是必须的。
6.5 关于连接池和事务的日常维护
用SQLModel/FastAPI这套组合做久了,你会发现真正出问题的不是SQL写不出来,而是连接和事务的生命周期没管好。
具体来说,我踩过这几个坑:
第一,每个请求都新建engine。create_engine本身是重量级操作,底层涉及连接池的初始化。engine应该全局唯一,模块加载时创建一次,之后所有session共用。我看到有些代码把create_engine写进路由函数里,这会导致连接无法复用,而且并发高一点就会把数据库连接数打爆。
第二,session用完了才想起来commit。SQLAlchemy里,session.commit()会结束当前事务并释放连接。如果你在一个请求里查完数据没做修改,不commit也没关系,session关闭时会回滚;但如果你做了修改却不commit,数据不会真正写入数据库,而且session关闭时还会因为未提交事务产生潜在锁等待。
第三,长事务。
如果一个session打开后长时间不关闭,它可能一直占着事务和连接,数据库端的行锁、表锁也会迟迟不释放。所以我不建议在任何业务代码里手动缓存session,每个请求一个干净session,用完即关,是最好维护的方案。
7. 最后补一条个人经验:怎么调试SQLModel的“魔法”
如果你第一次在FastAPI项目里跑通SQLModel,可能会觉得这东西有点像魔法:一个类既能建表又能校验,路由里几行代码就完成了CRUD。魔法越多,越需要趁手的调试手段。
我调试SQLModel相关问题的习惯是三步走:
第一步,先看echo=True时打印的SQL。很多问题在SQL这一层就能看出来:查询条件不对、不该出现的N+1、外键关联查出来的对象不对,都在SQL里写得明明白白。确认SQL正确之后,还在疑惑“为什么结果不对”,再往下查。
第二步,用交互式环境手动复现。遇到复杂的关联查询,我不急着改FastAPI路由,而是直接开一段Python脚本连上数据库,用session.exec(select(...))逐条执行,把model、查询条件和预期结果对齐。因为脱离了请求上下文,调试干扰会少很多。
第三步,给响应模型单独做序列化测试。SQLModel的响应模型本质是Pydantic模型,你可以手动构造一个对象然后调用model_dump()看看输出结构是否符合预期,再决定是不是路由里的字段名写错了。
这套流程看着朴素,但确实帮我解决了大量“接口返回和预期不一致”的问题。SQLModel本身不是什么黑科技,它的底层就是SQLAlchemy加Pydantic,只要你还保有SQL思维,出了问题一层层往下拆,总能找到根因。
如果你正准备从零搭一个FastAPI后端,我建议别一上来就追求完美架构,先用SQLModel把一个最简单的Hero表跑通,再在它上面加字段、加关联、迁移到PostgreSQL、引入Alembic。这个库最大的价值就是让你把精力放在业务逻辑上,而不是整天在两个模型之间搬运字段。等跑过一两个真实项目后,你自然会对“哪些场景用单模型、哪些场景必须读写分离”有属于自己的判断。
