1. 项目整体设计与思路拆解
1.1 为什么选择 SQLModel 而不是纯 SQLAlchemy 或 Pydantic
在 FastAPI 生态里做数据库操作,常见的方案有三种:直接用 SQLAlchemy、用 Pydantic 定义数据模型然后手动写 SQL 或使用 Tortoise-ORM,以及 Sebastian Ramirez(FastAPI 的作者)亲手打造的 SQLModel。我最初做 FastAPI 项目时,用的是 SQLAlchemy + Pydantic 分离的方案,但很快发现一个问题:模型定义要写两遍 —— 一遍是 SQLAlchemy 的 ORM 模型,另一遍是 Pydantic 的 schema 用于请求/响应校验。虽然可以借助一些技巧减少重复,但维护成本依然很高。后来迁移到 SQLModel,才真正体会到“干净利落”四个字。
SQLModel 的核心思路是:将 SQLAlchemy 的 ORM 能力与 Pydantic 的数据校验能力合二为一。你只需要定义一个类,它既是数据库表映射,也是 API 的请求/响应模型。这意味着:
- 减少重复代码。一个模型类同时描述了表结构和数据格式。
- 类型提示完整。SQLModel 全面继承了 Pydantic 的类型系统,IDE 补全和类型检查非常友好。
- 异步支持原生。SQLModel 底层基于 SQLAlchemy 2.0 的异步引擎,和 FastAPI 的异步路线天然契合。
我做了一个小对比,供你参考:
| 对比维度 | SQLAlchemy + Pydantic 分离方案 | SQLModel 方案 |
|---|---|---|
| 模型定义 | 需要定义 ORM 模型和 Pydantic schema 至少两个类 | 一个类完成 |
| 类型提示 | Pydantic 部分有类型,ORM 部分类型较弱 | 全程强类型 |
| 与 FastAPI 整合 | 需要手动编写依赖注入和转换逻辑 | 提供原生 Session 依赖,无缝集成 |
| 迁移工具 | 常用 Alembic + SQLAlchemy | 同样支持 Alembic,但模型更简洁 |
| 学习曲线 | 需要同时掌握 SQLAlchemy 和 Pydantic | 只需掌握 SQLModel,底层还是 SQLAlchemy |
当然,SQLModel 也不是银弹。如果你的项目已经成熟使用了 SQLAlchemy 的复杂特性(比如多态继承、自定义方言等),迁移成本可能不小。但多数新项目或中小型项目,SQLModel 绝对是更高效的选择。
1.2 封装的目标:通用 CRUD 与业务逻辑分离
“封装”这个词在标题里很关键。我们做 ORM 封装,不是简单地把 SQLModel 套在 FastAPI 里,而是要提炼出一套可复用的底层操作,让业务代码不再重复写增删改查。我总结了一套“三层封装”的思路:
- 第一层:基础会话管理。封装数据库连接、会话生命周期,让 FastAPI 的依赖注入系统自动管理。
- 第二层:通用 CRUD 基类。提供
create,get,update,delete等泛型方法,支持过滤、分页、排序。 - 第三层:业务逻辑层。基于第二层实现具体业务方法,比如“创建用户时检查邮箱唯一性”。
这样,业务代码只需要关注逻辑,不需要关心 SQL 语句、会话提交、回滚等细节。这套封装理念在多个项目里验证过,团队协作效率提升明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 SQLModel 模型定义的关键细节
定义模型时,最核心的一点是理解 table=True 参数。SQLModel 的类有两种模式:数据库表模型(table=True)和普通数据模型(不设置 table=True)。前者会映射到数据库表,后者只作为数据校验容器,不会建表。
python复制from sqlmodel import SQLModel, Field
from typing import Optional
# 数据库表模型
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True)
email: str
password_hash: str
is_active: bool = True
# 普通数据模型(用于创建请求)
class UserCreate(SQLModel):
username: str
email: str
password: str
注意:id 字段设为 Optional[int] 并给定 default=None,是因为插入时数据库自增主键,ORM 会忽略这个字段。如果设为 int 且没有默认值,Pydantic 在校验时会要求必须提供,这就不对。这是很多人一开始会踩的坑。
另外,Field 函数支持 SQLAlchemy 的所有字段参数,比如 index=True、unique=True、foreign_key、sa_column 等。如果你需要自定义列类型或约束,可以通过 sa_column 参数直接传入 SQLAlchemy 的 Column,比如:
python复制from sqlalchemy import Column, DateTime, func
class Article(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str
content: str
created_at: datetime = Field(sa_column=Column(DateTime, server_default=func.now()))
2.2 关系定义与查询优化
SQLModel 支持 SQLAlchemy 的关系定义,但语法略有不同。推荐使用 Relationship 属性,而不是直接写 SQLAlchemy 的 relationship。例如:
python复制from typing import List, Optional
from sqlmodel import SQLModel, Field, Relationship
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str
posts: List["Post"] = Relationship(back_populates="author")
class Post(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str
user_id: Optional[int] = Field(default=None, foreign_key="user.id")
author: Optional[User] = Relationship(back_populates="posts")
这里有个关键点:Relationship 不会自动加载关联数据,默认是懒加载(lazy='select')。在 FastAPI 的异步上下文中,懒加载可能引发 MissingGreenlet 错误,因为异步会话需要显式开启关联加载。解决办法有两种:
- 在查询时使用
selectinload或joinedload预加载。 - 在模型定义时设置
lazy='selectin'或lazy='joined'。
我倾向于在查询时手动控制,避免全局设置影响性能。例如:
python复制from sqlalchemy.orm import selectinload
from sqlmodel import select, Session
async def get_user_with_posts(user_id: int):
async with Session(engine) as session:
stmt = select(User).where(User.id == user_id).options(selectinload(User.posts))
result = await session.exec(stmt)
return result.one()
2.3 异步会话的生命周期管理
FastAPI 的依赖注入是管理异步会话的最佳场所。我的做法是定义一个 get_session 依赖,每次请求创建一个新的会话,请求结束后自动关闭。
python复制from fastapi import Depends
from sqlmodel import Session, create_engine
from typing import Generator
database_url = "postgresql+asyncpg://user:password@localhost/db"
engine = create_engine(database_url, echo=False)
async def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
注意:SQLModel 的 Session 默认是同步的,但它内部支持异步引擎。如果你使用 asyncpg 驱动程序,实际上 Session 内部的连接是异步的,但 Session 对象本身是同步上下文管理器。FastAPI 的依赖注入可以正确处理 yield 的上下文。如果你希望完全异步,可以使用 AsyncSession,但 SQLModel 的官方文档推荐使用同步 Session 配合异步引擎,因为它在大多数情况下已经足够高效。
3. 实操过程与核心环节实现
3.1 项目初始化与环境配置
开始一个 FastAPI + SQLModel 项目,建议使用 Poetry 或 pipenv 管理依赖。核心依赖如下:
toml复制[tool.poetry.dependencies]
python = "^3.10"
fastapi = "^0.104.0"
sqlmodel = "^0.0.14"
uvicorn = {extras = ["standard"], version = "^0.24.0"}
alembic = "^1.12.0"
psycopg2-binary = "^2.9.9" # 同步驱动,用于Alembic迁移
asyncpg = "^0.29.0" # 异步驱动
注意:sqlmodel 目前最新是 0.0.14,虽然版本号看着小,但已经很成熟了。Alembic 迁移时需要使用同步驱动(psycopg2),因为 Alembic 目前不支持异步连接。实战中,我会在配置文件中区分两个连接:一个同步连接给 Alembic,一个异步连接给运行时。
项目结构示例:
code复制project/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── config.py
│ ├── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── post.py
│ ├── schemas/ # 如果有额外的 Pydantic 模型
│ ├── crud/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── user.py
│ │ └── post.py
│ ├── dependencies/
│ │ ├── __init__.py
│ │ └── session.py
│ └── routers/
│ ├── __init__.py
│ ├── user.py
│ └── post.py
├── alembic/
├── alembic.ini
├── pyproject.toml
└── .env
3.2 数据库连接与 Session 依赖实现
app/database.py 中定义引擎和会话工厂:
python复制from sqlmodel import create_engine, SQLModel
from app.config import settings
# 异步引擎
engine = create_engine(settings.database_url, echo=False)
# 同步引擎(用于Alembic)
sync_engine = create_engine(settings.sync_database_url, echo=False)
def init_db():
SQLModel.metadata.create_all(engine)
app/dependencies/session.py 中定义依赖:
python复制from sqlmodel import Session
from app.database import engine
from typing import Generator
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
3.3 通用 CRUD 基类封装
这是封装的核心。我定义一个 BaseCRUD 类,范型参数为模型类型和创建/更新表单类型。这样所有业务模块都可以继承它。
python复制from typing import TypeVar, Type, Optional, List, Generic
from sqlmodel import Session, SQLModel, select, func
from pydantic import BaseModel
ModelType = TypeVar("ModelType", bound=SQLModel)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
self.model = model
def create(self, session: Session, obj_in: CreateSchemaType) -> ModelType:
obj_data = obj_in.model_dump()
db_obj = self.model(**obj_data)
session.add(db_obj)
session.commit()
session.refresh(db_obj)
return db_obj
def get(self, session: Session, id: int) -> Optional[ModelType]:
return session.get(self.model, id)
def get_multi(
self,
session: Session,
*,
skip: int = 0,
limit: int = 100,
filters: Optional[dict] = None,
order_by: Optional[str] = None,
) -> List[ModelType]:
stmt = select(self.model).offset(skip).limit(limit)
if filters:
for key, value in filters.items():
column = getattr(self.model, key, None)
if column is not None:
stmt = stmt.where(column == value)
if order_by:
column = getattr(self.model, order_by.lstrip("-"), None)
if column is not None:
if order_by.startswith("-"):
stmt = stmt.order_by(column.desc())
else:
stmt = stmt.order_by(column.asc())
result = session.exec(stmt)
return result.all()
def update(
self,
session: Session,
*,
db_obj: ModelType,
obj_in: UpdateSchemaType | dict,
) -> ModelType:
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_obj, field, value)
session.add(db_obj)
session.commit()
session.refresh(db_obj)
return db_obj
def remove(self, session: Session, *, id: int) -> ModelType:
obj = session.get(self.model, id)
if obj:
session.delete(obj)
session.commit()
return obj
说明几个设计点:
model_dump()是 Pydantic v2 的方法,替换了原来的dict()。如果你用 Pydantic v1,需要改成dict()。exclude_unset=True很重要:只更新前端传过来的字段,避免把未传的字段覆盖为 None。get_multi支持简单的过滤和排序,复杂的查询建议在业务层实现。
3.4 业务模型与路由实战
以用户模块为例,定义模型和 CRUD:
python复制# app/models/user.py
from sqlmodel import SQLModel, Field
from typing import Optional
from pydantic import BaseModel
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True)
email: str
password_hash: str
is_active: bool = True
class UserCreate(BaseModel):
username: str
email: str
password: str
class UserUpdate(BaseModel):
email: Optional[str] = None
password: Optional[str] = None
is_active: Optional[bool] = None
python复制# app/crud/user.py
from app.crud.base import BaseCRUD
from app.models.user import User, UserCreate, UserUpdate
from sqlmodel import Session
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class UserCRUD(BaseCRUD[User, UserCreate, UserUpdate]):
def create(self, session: Session, obj_in: UserCreate) -> User:
# 重写 create 方法,加密密码
hashed_password = pwd_context.hash(obj_in.password)
obj_data = obj_in.model_dump()
obj_data.pop("password")
obj_data["password_hash"] = hashed_password
db_user = User(**obj_data)
session.add(db_user)
session.commit()
session.refresh(db_user)
return db_user
def authenticate(self, session: Session, username: str, password: str) -> Optional[User]:
user = session.exec(select(User).where(User.username == username)).first()
if not user:
return None
if not pwd_context.verify(password, user.password_hash):
return None
return user
user_crud = UserCRUD(User)
路由层:
python复制# app/routers/user.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from app.dependencies.session import get_session
from app.crud.user import user_crud
from app.models.user import UserCreate, UserUpdate, User
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=User)
def create_user(*, session: Session = Depends(get_session), user_in: UserCreate):
# 检查用户名是否已存在
db_user = session.exec(select(User).where(User.username == user_in.username)).first()
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
return user_crud.create(session=session, obj_in=user_in)
@router.get("/{user_id}", response_model=User)
def read_user(*, session: Session = Depends(get_session), user_id: int):
user = user_crud.get(session=session, id=user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.get("/", response_model=list[User])
def read_users(
*,
session: Session = Depends(get_session),
skip: int = 0,
limit: int = 100,
):
users = user_crud.get_multi(session=session, skip=skip, limit=limit)
return users
@router.put("/{user_id}", response_model=User)
def update_user(
*,
session: Session = Depends(get_session),
user_id: int,
user_in: UserUpdate,
):
db_user = user_crud.get(session=session, id=user_id)
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
# 如果更新密码,需要加密
if user_in.password is not None:
user_in.password = pwd_context.hash(user_in.password)
return user_crud.update(session=session, db_obj=db_user, obj_in=user_in)
@router.delete("/{user_id}")
def delete_user(*, session: Session = Depends(get_session), user_id: int):
user = user_crud.remove(session=session, id=user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"ok": True}
注意:response_model=User 会使用 Pydantic 的序列化,自动隐藏 password_hash 字段?默认不会,你需要显式在 User 模型上配置 Config 来排除敏感字段。可以在 SQLModel 中直接使用 model_config:
python复制class User(SQLModel, table=True):
...
model_config = {"exclude": {"password_hash"}}
但这样会连数据库写入时也排除,不合适。更好的做法是定义一个专门的响应模型 UserResponse,只包含需要返回的字段。或者使用 FastAPI 的 response_model_exclude 参数:
python复制@router.post("/", response_model=User, response_model_exclude={"password_hash"})
3.5 数据库迁移实战
使用 Alembic 管理迁移。初始化后,修改 alembic/env.py 使用同步引擎:
python复制from app.database import sync_engine
from app.models import SQLModel # 确保所有模型被导入
target_metadata = SQLModel.metadata
def run_migrations_offline():
...
context.configure(url=settings.sync_database_url, target_metadata=target_metadata)
def run_migrations_online():
connectable = sync_engine
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
生成迁移脚本:
bash复制alembic revision --autogenerate -m "init"
alembic upgrade head
注意:--autogenerate 需要 SQLModel 的 metadata 被正确导入。确保在 env.py 中 from app.models import * 或直接导入 User 等模型,让 Alembic 检测到表定义。
4. 常见问题与排查技巧实录
4.1 MissingGreenlet 错误
在异步环境下使用懒加载关系时,会遇到 greenlet 相关的错误。这是因为 SQLAlchemy 2.0 的异步引擎在访问未加载的关系时会尝试使用 greenlet,但 FastAPI 的异步上下文可能没有配置 greenlet 环境。解决方案:
- 在查询时使用
selectinload或joinedload预加载。 - 或者在模型定义时设置
lazy='selectin',但这样会默认对所有查询都预加载,可能影响性能。
我习惯在 get 方法中增加一个可选参数 load_relations,让调用方决定是否加载:
python复制def get(self, session: Session, id: int, load_relations: list = None) -> Optional[ModelType]:
stmt = select(self.model).where(self.model.id == id)
if load_relations:
for relation in load_relations:
stmt = stmt.options(selectinload(relation))
return session.exec(stmt).first()
4.2 单元测试中无法使用异步 Session
SQLModel 的 Session 在同步上下文下工作,但如果你使用 pytest-asyncio 写异步测试,直接使用 Session(engine) 可能会报错。推荐做法:在测试中使用同步引擎,测试函数也用同步风格。或者使用 AsyncSession 配合 run_sync 方法。但最简单的方案是:测试时使用 SQLite 内存数据库,引擎用同步模式。
python复制# conftest.py
@pytest.fixture
def session():
engine = create_engine("sqlite:///:memory:", echo=False)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
这样测试函数可以直接使用同步的 session,无需处理异步。
4.3 数据库连接池耗尽
在高并发场景下,如果每个请求都创建一个新的引擎或会话,连接池会很快耗尽。正确做法是:全局只创建一个引擎(engine 是单例,连接池由引擎管理),每个请求通过依赖注入获取一个独立的 Session 实例。Session 会从引擎的连接池中获取连接,请求结束后自动归还。我在 database.py 中只创建一次引擎,确保 create_engine 只调用一次。
如果使用 FastAPI 的 lifespan 事件,可以更好地控制引擎的生命周期:
python复制from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时创建引擎
app.state.engine = create_engine(settings.database_url)
yield
# 关闭时释放连接池
app.state.engine.dispose()
app = FastAPI(lifespan=lifespan)
4.4 模型变更后 Alembic 无法检测到变化
这是一个常见问题。如果修改了模型字段,但 alembic revision --autogenerate 输出“No changes detected”,检查以下几点:
- 确保
env.py中导入了所有模型模块,或者至少导入了SQLModel.metadata并确保它被填充。 - 确保模型继承自
SQLModel且设置了table=True。 - 如果使用了
config.py动态导入,确保在env.py中import app.models时数据库连接没有被过早初始化(比如在app.models.__init__中调用了create_engine)。最好将模型定义和数据库连接分开。
4.5 事务回滚与异常处理
在 CRUD 基类中,我使用了 session.commit() 和 session.refresh()。但有时业务逻辑需要多个操作在同一个事务中。我的做法是:在路由层获取 session 后,调用多个 CRUD 方法,最后统一 commit。但基类里已经 commit 了,怎么办?可以提供一个 create_without_commit 方法,然后在业务层手动控制事务。但这样做会破坏封装的完整性。我倾向于保持基类方法自动 commit,对于需要事务的场景,使用 session.begin_nested() 或单独封装一个事务管理器。
python复制from contextlib import contextmanager
@contextmanager
def transaction(session: Session):
try:
yield
session.commit()
except Exception:
session.rollback()
raise
在路由中:
python复制@router.post("/register")
def register_user(*, session: Session = Depends(get_session), user_in: UserCreate):
with transaction(session):
user = user_crud.create(session, user_in)
# 其他操作,比如发送邮件
send_welcome_email(user.email)
# 注意:create 内部已经 commit 了,这里会重复 commit?不会,因为 create 内部 commit 后,后续操作在同一个 session 中,但事务已提交。所以需要调整设计。
这里确实存在矛盾。更好的做法是:基类 CRUD 方法不自动 commit,而是由上层调用者负责 commit。这样需要修改基类设计:
python复制def create(self, session: Session, obj_in: CreateSchemaType) -> ModelType:
obj_data = obj_in.model_dump()
db_obj = self.model(**obj_data)
session.add(db_obj)
session.flush() # 只是刷新,确保获得 id
session.refresh(db_obj)
return db_obj
然后在路由层:
python复制@router.post("/")
def create_user(*, session: Session = Depends(get_session), user_in: UserCreate):
user = user_crud.create(session, user_in)
# 其他操作
session.commit()
return user
这样,业务层可以控制事务粒度。我后来一直采用这种方案,虽然路由层多写一行 session.commit(),但灵活性大大提升。
4.6 分页查询的性能优化
get_multi 方法中使用了简单的 offset 和 limit,对于大数据量,offset 会随着页码增大而变慢(因为数据库需要扫描跳过前面的行)。改进方案:使用 keyset 分页(基于游标)或结合 where 条件。我通常在列表接口中提供 cursor 参数,前端传入上次返回的最后一条记录的 ID,后端使用 WHERE id > cursor 查询。
python复制def get_multi_cursor(
self,
session: Session,
*,
cursor: Optional[int] = None,
limit: int = 100,
) -> List[ModelType]:
stmt = select(self.model)
if cursor is not None:
stmt = stmt.where(self.model.id > cursor)
stmt = stmt.order_by(self.model.id).limit(limit)
return session.exec(stmt).all()
4.7 用 SQLModel 处理复杂查询(JOIN、子查询)
SQLModel 的查询语法完全兼容 SQLAlchemy 2.0 的 select 风格。对于 JOIN 查询,直接使用 select 加 join 方法:
python复制from sqlmodel import select, Session
from app.models import User, Post
stmt = select(User, Post).join(Post, User.id == Post.user_id).where(User.is_active == True)
results = session.exec(stmt).all()
for user, post in results:
print(user.username, post.title)
对于子查询,可以使用 subquery() 方法。但要注意,SQLModel 的 Relationship 虽然方便,但复杂查询还是建议直接用 select 和 join,避免性能陷阱。
5. 个人实践中的一些体会
最后再分享一点我在实际项目中的感悟。使用 FastAPI + SQLModel 做封装,最关键的是把握“度”。不要过度封装,把简单问题复杂化;也不要完全不做封装,导致业务代码中到处是 session.exec 和 commit。我的经验是:通用 CRUD 做成基类,业务特殊逻辑放在具体 CRUD 子类中重写,如果有跨模块的事务,在路由层手动控制 session.commit()。这样既保持了代码整洁,又保留了灵活性。
另外,关于异步与同步的抉择:如果你项目中使用的是 Sync 模式(没有 async def 路由),完全可以用同步 Session,配合 create_engine 的异步引擎,性能已经足够。如果你确实需要异步路由(比如同时调用多个外部 API),那么建议使用 AsyncSession 并配合 async with 上下文,但要注意 SQLModel 的 AsyncSession 支持目前还在完善中,有些特性可能不如同步 Session 稳定。我建议绝大多数项目坚持使用同步 Session,因为它更成熟,文档更全,而且 FastAPI 的同步路由也可以很好地处理并发(通过线程池)。
如果你刚开始接触这个组合,不要被“封装”这个词吓到。先按照最简单的模式写,感受一下 SQLModel 的便利,再逐步提炼出通用的基类。记住,好的封装是随着业务演化出来的,不是一开始设计出来的。
