昨天的接口已经能够接收参数、返回数据,但这些数据还没有真正"住"进数据库。刷新页面后内容依然存在,新增、修改和删除也能落到数据表中,才算迈进了真实后端开发的大门。
今天讲述一下如何让 FastAPI 连接 MySQL,并用图书接口练习查询、分页、新增、修改和删除。代码虽然比第一天多,但只要记住一条主线:拿到数据库会话,执行语句,处理结果。 📚
一、启动项目并查看接口文档
项目使用 FastAPI、SQLAlchemy、aiomysql 和 Uvicorn。启动前先运行 MySQL,并创建连接地址中的数据库。
bash
# uv:当前项目推荐
uv sync
uv run uvicorn ORM:app --reload
# pip
pip install fastapi uvicorn "sqlalchemy[asyncio]" aiomysql
uvicorn ORM:app --reload
打开 http://127.0.0.1:8000/docs,可以看到查询、分页、新增、修改和删除接口:

📌 如果提示"无法连接 MySQL",先检查数据库服务、端口和账号密码,再排查接口代码。

二、创建异步数据库引擎
数据库引擎负责管理应用与数据库的连接:
python
from sqlalchemy.ext.asyncio import create_async_engine
DATABASE_URL = "mysql+aiomysql://用户名:密码@localhost:3306/fast_api?charset=utf8"
async_engine = create_async_engine(
DATABASE_URL,
echo=True,
pool_size=10,
max_overflow=20,
)
连接地址中,mysql+aiomysql 表示使用异步 MySQL 驱动,localhost:3306 是数据库地址和端口,fast_api 是数据库名。
三个常见配置如下:
| 参数 | 通俗解释 |
|---|---|
echo=True |
在控制台打印 SQL,学习和排错很方便,生产环境通常关闭 |
pool_size=10 |
连接池平时最多保留 10 个连接 |
max_overflow=20 |
连接不够时,最多再临时创建 20 个连接 |
💡 实际项目不要把密码提交到代码仓库,应放到环境变量或配置文件中。
三、用 ORM 模型描述数据表
ORM 让我们用 Python 类操作数据表:类对应表,对象对应一行,属性对应字段。
python
from datetime import datetime
from sqlalchemy import DateTime, Float, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
create_time: Mapped[datetime] = mapped_column(
DateTime,
# 使用数据库当前时间作为新记录的创建时间
default=func.now(),
)
update_time: Mapped[datetime] = mapped_column(
DateTime,
default=func.now(),
# 数据发生更新时,由数据库自动刷新修改时间
onupdate=func.now(),
)
class Book(Base):
__tablename__ = "book"
# MySQL 会为整数主键自动生成递增的 id
id: Mapped[int] = mapped_column(primary_key=True)
bookname: Mapped[str] = mapped_column(String(50), nullable=False)
author: Mapped[str] = mapped_column(String(50), nullable=False)
price: Mapped[float] = mapped_column(Float, nullable=False)
publisher: Mapped[str] = mapped_column(String(50), nullable=False)
Mapped[int]、Mapped[str] 告诉 Python 字段是什么类型,mapped_column() 则告诉数据库这个字段应该怎样保存。
| 写法 | 含义 |
|---|---|
primary_key=True |
把整数字段设为主键;在当前 MySQL 表中,id 会自动递增 |
String(50) |
字符串最多保存 50 个字符 |
nullable=False |
字段不能为空 |
default=func.now() |
新增数据时写入当前时间 |
onupdate=func.now() |
更新数据时刷新修改时间 |
🔎 func.now() 后面的括号不能漏。func.now 只是拿到函数,func.now() 才会真正使用数据库的当前时间。漏写括号可能导致更新时间失败。
数据库用 DateTime 保存时间,接口显示格式交给响应模型。不要把 strftime() 得到的字符串直接存进时间字段。
项目在应用启动时创建尚不存在的数据表:
python
async def create_tables():
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
create_all() 只会创建还不存在的表,不会自动修改旧表。以后增加或删除字段时,需要使用数据库迁移工具更新表结构。
四、管理应用生命周期与数据库会话
生命周期函数负责启动时建表、关闭时释放连接:
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(_app: FastAPI):
# yield 之前:FastAPI 启动时执行,创建尚不存在的数据表
await create_tables()
# yield 期间:应用正常运行并接收请求
yield
# yield 之后:FastAPI 关闭时执行,释放数据库连接池
await async_engine.dispose()
app = FastAPI(lifespan=lifespan)
每次请求数据库都需要一个独立会话,项目把它封装成了依赖:
python
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
# 需求:查询功能的接口,查询图书→依赖注入:创建依赖项获取数据库会话+Depends 注入路由处理函数
AsyncSessionLocal = async_sessionmaker(
bind=async_engine, # 绑定数据库引擎
class_=AsyncSession, # 指定会话类
expire_on_commit=False # 提交后会话不过期,不会重新查询数据库
)
# 依赖项
async def get_database():
async with AsyncSessionLocal() as session:
try:
yield session # 返回数据库会话给路由处理函数
await session.commit() # 路由成功结束后统一提交事务
except Exception:
await session.rollback() # 有异常,回滚
raise
finally:
await session.close() # 关闭会话
路由通过 Depends 取得这个会话:
python
from fastapi import Depends
async def get_books(
# 先执行 get_database,再把 yield 返回的数据库会话注入 db 参数
# scope="function" 表示响应发送前执行 yield 后面的提交或回滚
db: AsyncSession = Depends(get_database, scope="function"),
):
...
一次请求的顺序是:
text
请求到达
↓
创建 AsyncSession
↓
yield 把会话交给路由
↓
路由执行 SQL
↓
成功则 commit,异常则 rollback
↓
关闭会话
↓
返回响应
get_database() 统一管理事务:接口用 flush() 执行 SQL,成功后 commit(),出错时 rollback()。scope="function" 保证提交或回滚发生在响应返回前。
五、掌握查询、筛选与分页
按主键查询
已知主键时,get() 最直接:
python
book = await db.get(Book, book_id)
找不到数据时会得到 None,因此更新和删除前都要先判断。
使用 select 组合条件
条件查询分三步:创建语句、执行语句、取出结果。
python
from sqlalchemy import func, select
statement = select(Book).where(Book.id == book_id)
result = await db.execute(statement)
book = result.scalar_one_or_none()
| 取值方法 | 适合什么结果 |
|---|---|
scalar_one_or_none() |
最多一条数据,没有数据时返回 None |
scalars().all() |
多条 ORM 对象,最终得到列表 |
scalar() |
单个值,例如平均价格或总数量 |
项目中还出现了几种常见筛选条件:
python
# 价格不低于 200
select(Book).where(Book.price >= 200)
# 书名同时包含"百"和"Py"
select(Book).where(
(Book.bookname.like("%百%")) &
(Book.bookname.like("%Py%"))
)
# 主键为 1 或 3
select(Book).where(Book.id.in_([1, 3]))
% 代表任意数量的字符,因此 "%Py%" 表示书名中只要包含 Py 即可。& 表示条件同时成立,| 表示满足其中一个条件。
⚠️ 当前书名搜索接口连续查询了三次,每次都把新结果放进 result,所以前两次结果会被覆盖,最后只能拿到第三次结果。多次赋值不会自动合并查询结果。需要组合条件时,应先写好一个完整的 where(),然后只查询一次。
聚合查询
平均价格查询返回一个数字:
python
result = await db.execute(select(func.avg(Book.price)))
average_price = result.scalar()
把 avg 换成 count、max、min 或 sum,就可以完成计数、最大值、最小值和求和。
分页查询
分页的核心是 offset() 跳过多少条,limit() 最多取多少条:
python
skip = (page - 1) * page_size
statement = (
select(Book)
.offset(skip)
.limit(page_size)
)
result = await db.execute(statement)
return result.scalars().all()
例如 page=2、page_size=10,会跳过前 10 条,再读取 10 条。还可以限制页码和每页数量:
python
from fastapi import Query
page: int = Query(default=1, ge=1)
page_size: int = Query(default=10, ge=1, le=100)
六、完成图书的增删改查
使用请求与响应模型
请求模型检查客户端提交的数据,响应模型整理接口返回的数据:
python
from datetime import datetime
from fastapi import Depends, HTTPException
from pydantic import BaseModel, ConfigDict, field_serializer
from sqlalchemy.ext.asyncio import AsyncSession
class BookBase(BaseModel):
bookname: str
author: str
price: float
publisher: str
class BookUpdate(BaseModel):
id: int
bookname: str
author: str
price: float
publisher: str
# 接口响应模型:控制返回字段,并统一日期时间的显示格式
class BookResponse(BaseModel):
id: int
bookname: str
author: str
price: float
publisher: str
create_time: datetime
update_time: datetime
# 允许 Pydantic 直接读取 SQLAlchemy ORM 对象的属性
model_config = ConfigDict(from_attributes=True)
# field_serializer 用于控制字段返回给客户端时的格式
# 这里让 create_time 和 update_time 共用同一个序列化方法
@field_serializer("create_time", "update_time")
def serialize_datetime(self, value: datetime) -> str:
# 将 datetime 转为 2026-02-12 13:12:23 格式的字符串
# 只改变接口输出,不改变数据库中的 DateTime 类型
return value.strftime("%Y-%m-%d %H:%M:%S")
ConfigDict(from_attributes=True) 让响应模型可以直接读取 ORM 对象。field_serializer 只负责整理返回给客户端的时间格式,不会修改数据库字段。最终返回的时间如下:
json
{
"create_time": "2026-02-12 13:12:23",
"update_time": "2026-02-12 13:12:23"
}
这些模型会自动出现在 /docs 的 Schemas 中:
BookBase 不包含 id。新增图书时,客户端只需提交书名、作者、价格和出版社,数据库会自动生成递增的主键。BookUpdate 中的 id 用来查找要修改的图书,BookResponse 中的 id 用来把主键返回给客户端。
新增图书
新增时把请求模型转成 ORM 对象,再加入会话:
python
@app.post("/book/add_book", response_model=BookResponse)
async def add_book(
book: BookBase,
db: AsyncSession = Depends(get_database, scope="function"),
):
# 将 Pydantic 请求模型转成字典,再创建 ORM 对象
book_db = Book(**book.model_dump())
db.add(book_db)
# 执行 INSERT 但不提交,最终由 get_database 统一提交事务
await db.flush()
# 重新读取数据库生成的主键和时间字段
await db.refresh(book_db)
return book_db
model_dump() 取出请求数据,flush() 执行 SQL,最后由依赖提交事务。
📌 为什么还要 refresh()? id、create_time 和 update_time 是数据库生成的。flush() 执行 SQL 后,这些值不一定已经回到 ORM 对象中;refresh() 会按主键重新查询一次,把最新值读回来。否则 BookResponse 读取必填的时间字段时,可能出现 ResponseValidationError 或 MissingGreenlet,接口最终返回 500。
新增和修改需要返回最新的主键或时间,所以同时使用 flush() 和 refresh();删除接口只返回成功消息,因此执行 flush() 即可。
修改图书
修改时先查询,查不到就返回 404,找到后再赋值:
python
@app.put("/book/update_book", response_model=BookResponse)
async def update_book(
data: BookUpdate,
db: AsyncSession = Depends(get_database, scope="function"),
):
db_book = await db.get(Book, data.id)
if db_book is None:
raise HTTPException(status_code=404, detail="查无此书")
db_book.bookname = data.bookname
db_book.author = data.author
db_book.price = data.price
db_book.publisher = data.publisher
# 执行 UPDATE 但不提交,最终由 get_database 统一提交事务
await db.flush()
# 重新读取数据库自动更新的 update_time
await db.refresh(db_book)
return db_book
查询结果已经由会话管理,修改属性后直接 flush() 即可,不需要再次 add()。
删除图书
删除同样要先确认数据存在:
python
@app.delete("/book/delete_book/{book_id}")
async def delete_book(
book_id: int,
db: AsyncSession = Depends(get_database, scope="function"),
):
db_book = await db.get(Book, book_id)
if db_book is None:
raise HTTPException(status_code=404, detail="查无此书")
await db.delete(db_book)
# 执行 DELETE 但不提交,最终由 get_database 统一提交事务
await db.flush()
return {"message": "删除成功"}
🧩 新增使用 add(),查询使用 select() 或 get(),修改直接赋值,删除使用 delete()。它们都通过同一个异步会话操作数据库。
补充:复杂查询应该怎么写
条件变多时也应先用 select(),它能完成动态筛选、排序、分组和统计。
动态组合查询条件
下面的接口会根据客户端实际传入的书名和价格添加条件:
python
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
# 可以通过 /book/advanced_search?keyword=Python&min_price=50 访问
@app.get("/book/advanced_search", response_model=list[BookResponse])
async def search_books(
keyword: str | None = None,
min_price: float | None = None,
max_price: float | None = None,
db: AsyncSession = Depends(get_database, scope="function"),
):
# 先创建一个没有筛选条件的查询
statement = select(Book)
if keyword:
# contains 表示书名中包含关键字
statement = statement.where(Book.bookname.contains(keyword))
if min_price is not None:
statement = statement.where(Book.price >= min_price)
if max_price is not None:
statement = statement.where(Book.price <= max_price)
# 按价格从高到低排列
statement = statement.order_by(Book.price.desc())
result = await db.execute(statement)
return result.scalars().all()
使用 is not None 判断价格,可以让 0 也成为有效条件。条件添加完成后,只执行一次查询。
分组和聚合查询
先看一个简单需求:统计每家出版社分别有多少本图书。
python
from fastapi import Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@app.get("/book/publisher_count")
async def get_publisher_count(
db: AsyncSession = Depends(get_database, scope="function"),
):
statement = (
select(
Book.publisher,
func.count(Book.id).label("book_count"),
)
# 把相同出版社的图书放到一组
.group_by(Book.publisher)
)
result = await db.execute(statement)
rows = result.mappings().all()
return [dict(row) for row in rows]
group_by() 按出版社分组,count() 统计每组数量。统计结果不是完整图书对象,因此用 mappings().all() 读取。
了解即可:什么时候使用 text 和原生 SQL
SQLAlchemy 难以表达需求,或必须使用 MySQL 特有语法时,再考虑 text():
📖 新手了解用途和安全写法即可,日常查询优先练习
select()。
python
from sqlalchemy import text
# 查询目的:统计达到最低价格要求的图书
# 先按出版社分组,再计算每组的图书数量和平均价格
# 只保留图书数量达到要求的出版社,最后按平均价格降序排列
# :min_price 和 :min_count 是参数占位符,实际值在执行 SQL 时传入
statement = text("""
SELECT
publisher,
COUNT(id) AS book_count,
AVG(price) AS average_price
FROM book
WHERE price >= :min_price
GROUP BY publisher
HAVING COUNT(id) >= :min_count
ORDER BY average_price DESC
""")
# 参数单独传入,不要使用 f-string 拼接到 SQL 中
result = await db.execute(
statement,
{"min_price": 100, "min_count": 2},
)
rows = result.mappings().all()
return [dict(row) for row in rows]
:min_price 和 :min_count 是参数占位符。SQL 与参数分开传递,可以降低 SQL 注入风险。
📌 普通查询优先使用 SQLAlchemy;特殊语法再用 text(),并始终绑定参数。
七、结语
第二天的重点,是理解 FastAPI 如何借助 SQLAlchemy 异步会话完成数据库操作。先把"引擎、模型、会话、SQL 结果"这条主线理顺,再逐步补充响应模型、数据库迁移和更完善的异常处理,会比死记每个方法更有效。
文章会随着项目继续完善,也欢迎讨论代码中的问题和更合适的写法。💬