一直以来有个很奇怪的现象,很多写过几年 Python 的人,一提到 ORM 就摆手说“不如直接写 SQL”,觉得 ORM 是中间商赚差价,性能差还不好调。但你要是真让他们在一个几千行 SQL 堆起来的业务系统里改需求,他们又抱怨维护成本高。这不是 ORM 的错,是很多人在用 SQLAlchemy 的路上,只学了个 add 和 query,然后就急着上手,最后卡在关系映射、会话管理、事务隔离这些真正的核心问题上。
SQLAlchemy 是 Python 生态里最成熟的 ORM 框架,它的定位不是帮你把 SQL 藏起来,而是帮你把数据库表和业务对象之间的那层转换逻辑体系化。这篇笔记我会按自己实际项目的学习路径来写,从最基础的环境配置,到模型定义、会话管理、关系映射、查询优化、事务控制,最后到一个完整的图书借阅实战项目。适合刚接触 ORM 的 Python 开发者,也适合已经会写 SQL 但想系统理解 SQLAlchemy 设计思路的人。
1. 环境准备:从安装到第一个连接串
1.1 创建干净的环境
我见过太多人一上来就在全局环境里pip install sqlalchemy,结果后面装别的库把依赖搞得一团乱。现在 Python 开发基本都用虚拟环境,Anaconda 或者 venv 都行。这里我用最标准的做法:
# 创建虚拟环境 python -m venv sqlalchemy_env # 激活环境(Windows 与 Linux / Mac 命令不同) source sqlalchemy_env/bin/activate # Linux / macOS sqlalchemy_env\Scripts\activate # Windows # 安装 pip install sqlalchemy装完之后先别急着跑,确认一下版本。SQLAlchemy 2.0 和 1.4 的 API 有差异,网上很多老教程都是 1.x 时代的写法,你照着抄可能会踩坑:
import sqlalchemy print(sqlalchemy.__version__)现在稳定版本基本都到 2.x,所以下面所有示例我都按2.0 风格写。
1.2 数据库驱动:SQLite 起步,生产换 PG
SQLAlchemy 自己不直接连数据库,它只是ORM 层,真正和数据库对话的是下面的DBAPI 驱动(也叫方言驱动)。这层关系经常被初学者忽略,结果就是连接串写不对、连不上数据库还不知道哪里错了。
| 数据库 | 连接串格式 | 需要的驱动 |
|---|---|---|
| SQLite | sqlite:///./test.db | 内置sqlite3,无需装 |
| SQLite(内存) | sqlite:///:memory: | 内置 |
| MySQL | mysql+pymysql://user:pwd@host:3306/db | pip install pymysql |
| PostgreSQL | postgresql+psycopg2://user:pwd@host:5432/db | pip install psycopg2-binary |
| Oracle | oracle+oracledb://user:pwd@host:1521/db | pip install oracledb |
我自己的选择是:学习阶段完全用 SQLite,零配置、好调试,所有库就是一个文件。要把模型设计、查询逻辑这些核心概念搞明白,SQLite 完全够用。等上了生产,基本就是 PostgreSQL + psycopg2 的组合,性能和数据安全都能兜住。
注意:SQLite 对日期函数、并发写锁的支持比较弱,你会在学习和生产之间感到明显差异。这不是 SQLAlchemy 的问题,是底层数据库能力不一样。
1.3 创建 Engine:连接池与 echo 调试
Engine 是 SQLAlchemy 最底层的入口,它管理着连接池和方言。一个项目里,全局只需要创建一次 Engine,可以多个模型共用。
from sqlalchemy import create_engine engine = create_engine( "sqlite:///./blog.db", echo=True, # 打印所有SQL,调试神器 pool_size=5, # 连接池大小(SQLite内存模式不适用) future=True, # 使用2.0风格API )很多新手不知道echo=True这个参数有多有用。开了它,你在控制台能直接看到 SQLAlchemy 帮你生成的所有 SQL 语句,这对理解 ORM 底层行为太关键了。比如你执行一个User.query.filter_by(name='xxx'),能看到到底有没有带WHERE name = ?,有没有多查一次别的东西。排查 N+1 问题第一步就是开 echo。
连接池参数pool_size、max_overflow是在生产数据库上重要的指标,比如高峰期连接不够用,会有TimeoutError: QueuePool limit of size 5 overflow 10 reached。但学习阶段用 SQLite 就无所谓,它走的是文件读写。
2. 核心概念:Core 还是 ORM,两条路线怎么选
SQLAlchemy 不是单纯的一个 ORM,它其实是两套 API 的集合,这一点官方文档讲得很清楚,但是很多人根本没注意。
| 概念 | 核心 (Core) | 映射 (ORM) |
|---|---|---|
| 操作对象 | 表(Table)、列(Column)、SQL 表达式 | 持久化类(Model)、对象实例 |
| 心智模型 | 用 Python 写 SQL | 操作对象,自动翻译成 SQL |
| 适合场景 | 复杂报表、动态 SQL、数据仓库 | 业务 CRUD、领域模型 |
| 学习曲线 | 平缓,接近 SQL 本身 | 陡峭,必须先理解映射和会话 |
我自己实际项目中的体会是:80% 的 CRUD 用 ORM,剩下的复杂统计用 Core 甚至直接裸 SQL。很多人把这两者对立起来,其实 SQLAlchemy 最强大的地方在于两者可以混用。
打个比方:Core 像你自己去菜市场按清单买菜(明确列出表、列、条件);ORM 像告诉管家“我要一顿三菜一汤的家宴”,管家帮你规划买什么、怎么处理。管家省事,但你得信任它的流程。
2.1 MetaData 与 Table:Core 的基石
Core 模式下,你用Table和Column描述数据库结构,这些对象挂在MetaData上:
from sqlalchemy import MetaData, Table, Column, Integer, String metadata = MetaData() user_table = Table( "t_user", metadata, Column("id", Integer, primary_key=True), Column("name", String(50), nullable=False), Column("email", String(120), unique=True), )这个metadata是全局的“结构注册中心”,后面可以用metadata.create_all(engine)一次性把所有表建出来。
2.2 声明式基类:ORM 的神奇入口
ORM 模式走的另一条路:定义一个继承declarative_base()返回的基类,然后把每个表映射成一个 Python 类:
from sqlalchemy.orm import declarative_base Base = declarative_base() class User(Base): __tablename__ = "t_user" id = Column(Integer, primary_key=True) name = Column(String(50), nullable=False) email = Column(String(120), unique=True) def __repr__(self): return f"<User(id={self.id}, name={self.name})>"这个Base内部有自己的metadata,所以你不需要再单独建 MetaData,所有继承Base的类都会自动注册进去。
2.3 Column 类型与约束:一张表设计的“风水”
写模型是第一道坎,很多新手不看文档,上来就String()不给长度,或者主键忘加primary_key=True。我把常用的列参数整理成一个对照表,照着设置基本不会错:
| 参数 | 作用 | 踩坑提醒 |
|---|---|---|
primary_key=True | 主键 | 联合主键直接写在PrimaryKeyConstraint |
nullable=False | 非空 | 不是所有数据库都支持改约束,建议建表时就定好 |
unique=True | 唯一约束 | 注意唯一约束和索引不是一回事 |
default=xxx | 默认值 | 是 Python 层的默认,不是数据库层的 server_default |
server_default=text("CURRENT_TIMESTAMP") | 数据库层默认值 | 比如时间字段常用 |
index=True | 创建索引 | 高频查询字段记得开 |
autoincrement=True | 自增 | SQLite 默认支持,PG 用序列 |
时间字段是新手最常踩的坑。你想要“插入时自动填当前时间”,用default=datetime.now只对 ORM 层的 insert 生效,如果纯 SQL 插入就不会有值。生产环境我推荐写成:
from sqlalchemy import func from datetime import datetime created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())这样数据库和 ORM 的语义都对得上。
3. 模型定义与建表:写一个能跑通的完整示例
概念熟悉了,就该上手写一整套模型了。这一节我会从零写一个用户 + 文章的模型,然后建表、验证结果。
3.1 定义基础模型
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, ForeignKey from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.sql import func Base = declarative_base() class User(Base): __tablename__ = "t_user" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(30), nullable=False, unique=True, comment="用户名") email = Column(String(120), nullable=False, unique=True, comment="邮箱") created_at = Column(DateTime, server_default=func.now(), comment="创建时间") # 反向关系:这个用户下的所有文章 articles = relationship("Article", back_populates="author") def __repr__(self): return f"<User(id={self.id}, name={self.name})>" class Article(Base): __tablename__ = "t_article" id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(120), nullable=False, comment="标题") content = Column(Text, comment="正文") user_id = Column(Integer, ForeignKey("t_user.id"), nullable=False, comment="作者ID") # 正向关系:一篇文章属于一个作者 author = relationship("User", back_populates="articles") created_at = Column(DateTime, server_default=func.now(), comment="发布时间") def __repr__(self): return f"<Article(id={self.id}, title={self.title})>"这里有几个关键点,非常容易被忽略:
ForeignKey("t_user.id")引用的必须是表名.列名,不是类名。有人写ForeignKey("User.id")直接报NoReferencedTableError。relationship两边的back_populates必须互相点名,这样 SQLAlchemy 知道这两个字段是同一个关系的一体两面,同步更新不会乱。__repr__不是可选的,调试的时候你会感谢自己写了它。没有它,你打印对象看到的就是<Article object at 0x...>,毫无信息量。
3.2 建表:create_all 与数据库校验
模型定义完,建表只需要一行:
engine = create_engine("sqlite:///./blog.db", echo=True) Base.metadata.create_all(engine)create_all只会创建不存在的表,不会更新已存在的表结构。这意味着你想改字段、加列,靠它是没用的,必须用迁移工具(后面实战部分我会介绍 Alembic)。
执行完打开blog.db文件看一眼,如果echo=True,你会看到类似这样的 SQL:
CREATE TABLE t_user ( id INTEGER NOT NULL, name VARCHAR(30) NOT NULL, email VARCHAR(120) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE (name), UNIQUE (email) )到这里,配置和建模的链路已经通了。但 ORM 真正的工作才刚刚开始,因为接下来的会话管理才是决定你用得好不好、稳不稳的核心关卡。
4. 会话的生命周期:Session 到底是个什么东西
很多人用 SQLAlchemy 最大的误区,就是把Session当成普通的“数据库连接”,用完随手关。实际上 Session 是工作单元(Unit of Work),它管理的是一个事务边界内所有对象的状态变化。
4.1 从 Engine 到 Sessionmaker
正确做法是:先定义一个sessionmaker工厂,然后每次需要操作数据库时再从工厂拿一个 Session:
from sqlalchemy.orm import sessionmaker SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)注意expire_on_commit=False这个参数。默认情况下,事务提交之后 session 里的所有对象会被“失效化”,你再去访问对象的属性,SQLAlchemy 会重新发 SQL 查询一次。这在简单场景下没感觉,但如果你在session.commit()之后还想拿旧对象的属性去拼响应数据,就会多一次无谓查询,偶尔还会因为 session 已经关闭直接报异常。所以我的习惯是设为False,提交之后对象状态保持可用。
4.2 对象的四种状态
理解 Session 必须先理解对象的四个状态,我面试别人的时候十个人有八个说不清:
| 状态 | 含义 | 如何进入 |
|---|---|---|
| Transient | 对象创建了,但还没跟 Session 关联 | User(name="abc") |
| Pending | 调用session.add(obj)之后,还没 flush | 还没实际 INSERT |
| Persistent | 对象在 Session 中且有对应的数据库行 | session.commit()后仍在 |
| Detached | 对象脱离了 Session,属性还能访问但不再被跟踪 | session.close()之后 |
实际体验一下:
user = User(name="alice", email="alice@example.com") # 状态: Transient session.add(user) # 状态: Pending session.commit() # 状态: Persistent(在 session 里,和数据库行对应) session.close() # 状态: Detached理解状态有什么实际价值?举个例子,如果你把一个 Persistent 对象丢到另一个 Session 里去做更新,可能会遇到各种奇怪的问题;如果你把 Detached 对象当作持久化对象直接传给前端,生成的 JSON 可能不是你想要的。
4.3 session 的增删改查实测
来一套完整的 CRUD 演示:
# 新增 user = User(name="bob", email="bob@example.com") session.add(user) session.flush() # 可选,立即生成 INSERT 并拿到 id print(user.id) # flush 之后就能拿到自增 id session.commit() # 查询 user = session.query(User).filter_by(name="bob").first() print(user.email) # 更新 user.email = "bob_new@example.com" session.commit() # 删除 session.delete(user) session.commit()这里有几个实操细节:
flush()和commit()的区别。flush只是把 SQL 发给数据库,但事务还没提交;commit才是真正落库。你需要user.id的时候可以 flush,但最终一次 commit 即可,不要频繁 commit,否则性能损耗大。- 直接改对象的属性,然后
commit,SQLAlchemy 会智能生成UPDATE t_user SET email=? WHERE id=?,不用显式调用 update 方法。 - 删除对象时记得考虑关系。删除 User 之前,如果它下面关联了 Article,会因为有外键约束报
IntegrityError。这种情况要么先删子表,要么配置好cascade。
4.4 线程安全:scoped_session 与 session 的隔离
一个经常踩的坑:把同一个 Session 传给多个线程用。SQLAlchemy 的 Session 不是线程安全的,一个 Session 只能在一个线程里使用。常见的解决方案是用scoped_session:
from sqlalchemy.orm import scoped_session Session = scoped_session(sessionmaker(bind=engine))scoped_session本质是线程本地变量:每个线程第一次调用Session()时创建一个新 session,之后在这个线程里再调用拿到的是同一个。这在 Web 框架(Flask、FastAPI)里特别常见。
我的经验是:Web 应用每个请求一个 Session,请求结束关闭它,不要全局共享。在 FastAPI 中我用依赖注入来做,每个请求依赖里创建一个新的 session,用完即关,干净利落。
5. 关系映射实战:一对多、多对多,以及关联查询
关系映射是 SQLAlchemy 的精华,也是最容易混乱的地方。这一节我用实际业务场景讲透。
5.1 一对多与多对一:用户和文章的经典组合
前面已经写了User.articles和Article.author的关系,现在直接看查询怎么玩:
# 查某个作者的所有文章 user = session.query(User).filter_by(name="bob").first() for article in user.articles: print(article.title) # 查某篇文章的作者 article = session.query(Article).filter_by(id=1).first() print(article.author.name)看起来很好用对吧?但你要注意,user.articles的默认行为是懒加载(lazy='select')——也就是说你访问user.articles的瞬间,SQLAlchemy 才发一条SELECT * FROM t_article WHERE user_id=?。如果你在一个循环里访问了 100 个用户的 articles,就会有 1 + 100 条 SQL,这就是著名的 N+1 问题。
5.2 外键与 relationship 的参数到底怎么配
relationship有很多参数,我只挑实战会用的:
| 参数 | 作用 | 推荐建议 |
|---|---|---|
back_populates | 双向关系的互相指定 | 两侧都写,最稳 |
lazy='select' | 默认,访问时再查 | 适合单条访问 |
lazy='joined' | 用 LEFT JOIN 一次性查出来 | 简单场景适合 |
lazy='selectin' | 用 IN 查询批量加载 | 列表页面最优解 |
cascade='all, delete-orphan' | 父对象删除时级联处理子对象 | 谨慎使用 |
foreign_keys | 指定复合外键 | 多列关联时必填 |
举一个典型场景:首页要展示文章列表,每篇带作者名。如果默认懒加载,100 篇文章就要 101 条 SQL。解决办法之一:
from sqlalchemy.orm import selectinload articles = session.query(Article).options(selectinload(Article.author)).all() # 生成 SQL —— 先查文章,再一次性查所有关联的 author # SELECT * FROM t_article # SELECT * FROM t_user WHERE id IN (..., ..., ...)注意selectinload第二条 SQL 是用IN查询,也就两条 SQL 搞定,效率比 joined 在某些场景下更好(尤其关联表数据大时)。
5.3 多对多:用关联表拆解
多对多关系,比如文章和标签。SQLAlchemy 有两种写法:一种是显式建关联表,一种是隐式。我强烈建议用显式关联表,结构清楚,以后要加关联字段(比如“打标签时间”)也方便。
from sqlalchemy import Table, Column, ForeignKey # 关联表 —— 不是模型类,只是 Table article_tag = Table( "t_article_tag", Base.metadata, Column("article_id", ForeignKey("t_article.id"), primary_key=True), Column("tag_id", ForeignKey("t_tag.id"), primary_key=True), ) class Tag(Base): __tablename__ = "t_tag" id = Column(Integer, primary_key=True) name = Column(String(30), unique=True, nullable=False) class Article(Base): # ... 其他字段 ... tags = relationship("Tag", secondary=article_tag, backref="articles")这里关键是secondary=article_tag,告诉 SQLAlchemy:Article 和 Tag 之间要通过这张关联表建立关系。
实际操作:
tag1 = Tag(name="python") tag2 = Tag(name="orm") article = Article(title="SQLAlchemy 实战", content="...") article.tags.append(tag1) article.tags.append(tag2) session.add(article) session.commit()SQLAlchemy 会自动帮你在t_article_tag里插入两条关联记录,完全不用手写 INSERT。
5.4 踩坑记录:关系没同步的问题
写relationship时我踩过的最深的一个坑是:只在一侧定义了关系,另一侧没定义,导致操作一侧时另一侧的数据“看起来没更新”。
比如只定义User.articles,不定义Article.author。你给一篇文章设置article.user_id = 1后,直接访问user.articles发现它是空的——因为user.articles的缓存里没有加载新文章。要刷新它要么session.expire(user),要么让关系定义成双向。所以我还是推荐两边都用back_populates写清楚。
6. 查询进阶:filter 还是 filter_by,分页与聚合
查询是整个使用中频率最高的操作,值得单独拿出一节来掰扯清楚。
6.1 filter 与 filter_by:别选错了表达力
filter_by(**kwargs)只能做等值比较,按列名传关键字参数,适合简单场景。filter(condition)支持任意 SQL 表达式,==、!=、like、in_、>、<、and_、or_随便组合,适合复杂查询。
# filter_by 风格 session.query(User).filter_by(name="bob", email="bob@example.com").all() # filter 风格 session.query(User).filter(User.name == "bob", User.email.like("%example.com")).all()多个条件在filter里用逗号分隔,默认是 AND 关系。
6.2 常用查询操作符速查
| 需求 | 写法 |
|---|---|
| 不等于 | User.name != "bob" |
| 模糊匹配 | User.name.like("%bo%") |
| 在列表里 | User.id.in_([1, 2, 3]) |
| 范围 | User.id.between(10, 20) |
| 为空 | User.email.is_(None) |
| AND | and_(User.name == "bob", User.email == "bob@example.com") |
| OR | or_(User.name == "bob", User.name == "alice") |
有个细节:随便打开一个老项目,会看到有人在 filter 里写User.name == None,这是错的,应该用is_(None)。虽然大多数数据库会把= NULL当成 false 返回,但语义完全不对,遇到极端数据会出 bug。
6.3 分页:别把所有数据都捞出来
数据量小的时候无所谓,但一旦表里上了几十万行,全量查询翻车是必然的。业务上要做分页:
page = 1 page_size = 20 # 方法1:limit + offset users = session.query(User).order_by(User.id).offset((page - 1) * page_size).limit(page_size).all() # 方法2:利用窗口函数效率更高(但是仅部分数据库支持,比如 pg) # 先统计总条数 total = session.query(func.count(User.id)).scalar() print("总用户数:", total)需要提醒的是:offset 越大,性能越差,因为数据库要跳过前面所有行。数据量到十万级别后,可以改用“键集分页”(keyset pagination),用上一页的最后一条 ID 来做游标:
last_id = 100 # 上一页最后一条记录 ID users = session.query(User).filter(User.id > last_id).order_by(User.id).limit(20).all()这种方式在深分页场景下性能稳定,是生产级别的通用方案。
6.4 聚合与分组:COUNT、SUM、GROUP BY
统计场景用 ORM 也可以写得很自然:
from sqlalchemy import func # 统计每个作者的发布文章数 result = ( session.query(Article.user_id, func.count(Article.id).label("total")) .group_by(Article.user_id) .all() ) for user_id, total in result: print(user_id, total)func是 SQLAlchemy 对数据库函数的包装层,几乎覆盖所有常见函数。用.label()给聚合列起别名,方便取出数据。
6.5 比“能用”更重要的是索引设计
这节不算 SQLAlchemy 本身的内容,但和 ORM 使用息息相关。很多人模型里写了很多filter,数据库一执行就是全表扫描,N 条数据慢到爆炸。ORM 生成的 SQL 再标准,没有索引一样白搭。
我建索引的几把尺子:
- 主键、外键默认有索引,不需要手动建。
- 高频条件字段(比如
status、category_id)建单列索引。 - 多条件组合查询的字段(
user_id + status)建联合索引。 - 索引不是越多越好,写多读少的表每多一个索引都是一份写入开销。
class Article(Base): __tablename__ = "t_article" user_id = Column(Integer, ForeignKey("t_user.id"), nullable=False, index=True) status = Column(Integer, default=1, index=True) # 联合索引 __table_args__ = ( Index("ix_article_user_status", "user_id", "status"), )7. 事务、并发与异常:上线前必须搞懂的控制点
只会在本地跑通 demo,和真正能上生产环境,差的就是事务和并发这一大块。
7.1 事务的本质:一个 Session 里的一组操作
Session 默认开启事务。你调session.add(),SQL 可能还没发出去;你调session.commit(),才真正落库;中途报错,可以session.rollback()撤销全部操作。
try: session.add(User(name="tom", email="tom@example.com")) session.add(User(name="jerry", email="jerry@example.com")) session.commit() except Exception: session.rollback() raise为什么强调事务?想象一个转账场景:扣 A 的钱、加 B 的钱,必须是同一事务。如果 A 扣款成功、B 入账失败,手动 rollback 可以避免数据不一致。实际项目里,不要让 Session 自动帮你 commit,必须显式控制边界。
7.2 用 with 简化事务边界
SQLAlchemy 2.0 提供了一个简洁语法:
with SessionLocal() as session: with session.begin(): session.add(User(name="alice", email="alice@example.com")) session.add(User(name="bob", email="bob@example.com"))with session.begin()内部管理事务:正常结束自动 commit,异常自动 rollback。代码干净,也少了一层 try/except 嵌套。
7.3 悲观锁与乐观锁:处理并发写冲突
两个请求同时改同一条记录,后提交的会覆盖先提交的。解决方式有两种:
悲观锁:SELECT ... FOR UPDATE
from sqlalchemy import select from sqlalchemy.orm import with_for_update stmt = select(Account).where(Account.id == 1).with_for_update() account = session.execute(stmt).scalar_one() # 此时这行记录被锁定,其他事务无法修改,直到当前事务结束 account.balance += 100 session.commit()适合高冲突场景,但要注意锁等待可能造成死锁,事务尽量短。
乐观锁:版本号字段
在模型上定义一个version_id_col,提交时 SQLAlchemy 自动比对版本号:
class Account(Base): __tablename__ = "t_account" id = Column(Integer, primary_key=True) balance = Column(Integer, default=0) version_id = Column(Integer, nullable=False) __mapper_args__ = {"version_id_col": version_id}这种方式提交时如果版本号对不上,会抛StaleDataError,业务层捕获后提示用户“数据已被修改,请刷新重试”。
我个人习惯是:默认用乐观锁,写冲突概率非常高的场景(比如库存扣减)才用悲观锁。
7.4 死锁与重试
并发上来之后,死锁不是“会不会发生”,而是“什么时候发生”。最典型的死锁场景:事务 A 锁了行 1 想要行 2,事务 B 锁了行 2 想要行 1,两个事务互相等待,数据库检测到后直接杀掉其中一个事务。
一个简单粗暴但有效的策略:统一数据库表操作顺序。比如所有事务都先操作 user 表再操作 article 表,死锁概率就会大幅下降。另一个策略是设置随机重试:
import time from sqlalchemy.exc import OperationalError for attempt in range(3): try: with SessionLocal() as session: with session.begin(): # 业务操作 ... break except OperationalError: time.sleep(0.1 * (attempt + 1))8. 实战项目:图书借阅系统从零到跑通
前面七节都是点,现在用一个小项目把它们串成线。我选图书借阅这个场景,是因为它的关系天然适合练 ORM——图书、读者、借阅记录,能充分覆盖我需要展示的关系映射、查询、事务控制这些知识点。
8.1 项目结构与模型设计
目录结构非常简单,单文件也能演示,但为了模拟真实项目,我建议拆成模块:
library/ ├── models.py # 模型定义 ├── database.py # engine / session 配置 ├── main.py # 业务操作示例 └── app.db # SQLite 数据库database.py:
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base engine = create_engine("sqlite:///./app.db", echo=False, future=True) SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) Base = declarative_base()8.2 图书借阅模型
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean from sqlalchemy.orm import relationship from sqlalchemy.sql import func from database import Base class Book(Base): __tablename__ = "t_book" id = Column(Integer, primary_key=True) title = Column(String(100), nullable=False) author = Column(String(50), nullable=False) is_borrowed = Column(Boolean, default=False) # 借阅记录 borrow_records = relationship("BorrowRecord", back_populates="book") class Reader(Base): __tablename__ = "t_reader" id = Column(Integer, primary_key=True) name = Column(String(30), nullable=False) max_borrow = Column(Integer, default=3) # 最多同时借几本 records = relationship("BorrowRecord", back_populates="reader") class BorrowRecord(Base): __tablename__ = "t_borrow_record" id = Column(Integer, primary_key=True) book_id = Column(Integer, ForeignKey("t_book.id"), nullable=False) reader_id = Column(Integer, ForeignKey("t_reader.id"), nullable=False) borrow_time = Column(DateTime, server_default=func.now()) return_time = Column(DateTime, nullable=True) book = relationship("Book", back_populates="borrow_records") reader = relationship("Reader", back_populates="records")注意BorrowRecord.return_time为空表示还没还书。这样设计的借阅记录表自带多对多关系,还能承载借还时间字段,以后要统计“谁在什么时间借了什么书”都非常方便。
8.3 借书与还书的完整流程
借书流程要考虑几个业务约束:读者不能超借、书不能被借走、同一本书不能重复借。事务的作用就在这里体现:
from datetime import datetime from sqlalchemy.orm import sessionmaker, joinedload from sqlalchemy.exc import IntegrityError from database import SessionLocal def borrow_book(reader_id: int, book_id: int) -> str: with SessionLocal() as session: with session.begin(): reader = session.query(Reader).with_for_update().filter(Reader.id == reader_id).first() if not reader: return "读者不存在" # 统计当前未还数量 active_borrow_count = ( session.query(func.count(BorrowRecord.id)) .filter(BorrowRecord.reader_id == reader_id, BorrowRecord.return_time.is_(None)) .scalar() ) if active_borrow_count >= reader.max_borrow: return "借书数量已达上限" book = session.query(Book).with_for_update().filter(Book.id == book_id).first() if not book or book.is_borrowed: return "图书不存在或已被借走" record = BorrowRecord(book_id=book_id, reader_id=reader_id) session.add(record) book.is_borrowed = True return "借书成功"这段代码的亮点在于:
with_for_update()锁住读者和图书记录,防止并发借书时超借或者重复借。- 借阅数量和
is_borrowed的判断都放在同一个事务里,保证数据一致性。 - 整个流程不需要手动 commit,
with session.begin()自动完成。
还书流程:
def return_book(record_id: int) -> str: with SessionLocal() as session: with session.begin(): record = ( session.query(BorrowRecord) .filter(BorrowRecord.id == record_id, BorrowRecord.return_time.is_(None)) .first() ) if not record: return "借阅记录不存在或已归还" record.return_time = datetime.now() book = session.query(Book).filter(Book.id == record.book_id).first() if book: book.is_borrowed = False return "还书成功"8.4 关联查询实战:查一个读者的借阅详情
常见需求:展示读者列表,每个读者下面带当前未还的书名。这时候就要控制加载策略,避免 N+1:
def list_readers_with_books(): with SessionLocal() as session: readers = ( session.query(Reader) .options(joinedload(Reader.records).joinedload(BorrowRecord.book)) .all() ) result = [] for reader in readers: borrowed_titles = [ rec.book.title for rec in reader.records if rec.return_time is None ] result.append({ "reader": reader.name, "borrowed_books": borrowed_titles, }) return resultjoinedload在这里的效果是:读 Reader 时一次性 JOIN 出借阅记录和图书信息,用 1 条 SQL 带出三层数据。如果默认懒加载,读者有 100 个就会变成 1 + 100 + N 条查询,性能天差地别。
8.5 数据迁移:为什么 create_all 不够用
项目上线后,模型加了字段,比如Reader.level。你直接改模型、create_all一点用都没有——它只建表不迁移。生产环境必须用迁移工具,SQLAlchemy 官方方案就是 Alembic。
pip install alembic alembic init alembic改配置alembic/env.py指向你的Base.metadata,模型变更后顺序执行:
alembic revision --autogenerate -m "add reader level" alembic upgrade head这一整套完成之后,你的模型变更就有了版本历史,团队合作、回滚都从容得多。新手做项目可以跳过这一步,但作为职业技能,迁移是躲不掉的。
8.6 我在这个实战里踩过的三个坑
第一个坑是关系字段忘记加lazy策略。列表页加载慢,一查 SQL 全是短查询,后来把所有列表查询都改成了selectinload或joinedload,性能立刻上来。
第二个坑是expire_on_commit默认值没改。我在一次 FastAPI 接口里 commit 之后返回对象数据,结果每次都触发额外查询,接口响应慢了一截,查了半天才意识到是这个参数闹的。
第三个坑是并发借书测试。刚开始没有with_for_update(),开两个线程同时借同一本书,两个请求都能通过is_borrowed == False的判断,结果一本书被借了两次。加了行锁之后,第二个事务必须等第一个提交后才能读到最新状态,这才堵住并发漏洞。
最后的实操心得
这套笔记写下来,我自己最大的感受是:SQLAlchemy 本质是一套约定与配置并重的框架——你按它的约定写模型,大部分工作自动完成;但如果你不理解Session的生命周期、relationship的加载策略、事务的边界,它也会让你在莫名其妙的地方消耗大量时间。
如果只让我给一个具体建议:从头到尾开一个echo=True的调试开关,把你写的每一个 ORM 操作在终端里仔细观察一遍生成的 SQL。你会在几天之内从“会用 API”变成“理解机制”。等你能预测一条 ORM 查询会生成什么 SQL 的时候,SQLAlchemy 对你来说就没什么神秘的了。