☰
SQLAlchemy核心机制与实战:从ORM概念到事务控制
2026/10/21 23:34:07 网站建设 项目流程

一直以来有个很奇怪的现象,很多写过几年 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 驱动(也叫方言驱动)。这层关系经常被初学者忽略,结果就是连接串写不对、连不上数据库还不知道哪里错了。

数据库连接串格式需要的驱动
SQLitesqlite:///./test.db内置sqlite3,无需装
SQLite(内存)sqlite:///:memory:内置
MySQLmysql+pymysql://user:pwd@host:3306/dbpip install pymysql
PostgreSQLpostgresql+psycopg2://user:pwd@host:5432/dbpip install psycopg2-binary
Oracleoracle+oracledb://user:pwd@host:1521/dbpip 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)
ANDand_(User.name == "bob", User.email == "bob@example.com")
ORor_(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 result

joinedload在这里的效果是:读 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 对你来说就没什么神秘的了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询