第 16 章 Session 的使用与事务控制
从一个生活场景说起
这一章是全教程的"内功心法"。先用一个比喻把 Session 讲透。
Session 很像你在银行柜台办业务时遇到的那位柜员:
- 你取号坐下,柜员开始为你服务(创建 Session);
- 你说"先查一下余额",柜员帮你查(加载对象);
- 你说"取 500 块,再转 300 给张三"——柜员先记在工单上,不急着动账(add/修改对象,变更被跟踪);
- 你说"先打张回单我看看"——柜员把操作预演一遍给你看,账还没最终动(flush);
- 你确认无误说"就这样办"——柜员盖章,账目一次性生效(commit);
- 如果中途任何一步出问题(比如余额不足),柜员把整张工单撕掉,账目恢复原样(rollback);
- 你离开窗口,柜员接待下一位(close)。
关键认识:一个柜员一次只能服务一位客户。 如果两个客户同时指挥同一个柜员,工单必然记混——这就是"Session 不是线程安全的"。
核心概念
Session 是 ORM 的核心,可以理解为"一次数据库会话":它跟踪你加载过的所有对象、记录所有变更,最后在 commit() 时统一写入数据库。
事务:同生共死的操作打包
术语解释 —— 事务(Transaction):一组数据库操作的集合,具有"原子性"——要么全部成功,要么全部失败回滚。经典例子是转账:A 扣款和 B 入账必须绑定为一个整体,绝不能只完成一半。
Session 从创建起就自动开启一个事务,直到 commit() 或 rollback() 才结束。
事务控制示例(逐行讲解)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine(
"mysql+pymysql://root:123456@localhost:3306/demo_db?charset=utf8mb4",
echo=True,
)
# sessionmaker 是"柜员生产线":统一配置,每次调用生产一个新 Session
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
with SessionLocal() as session:
try:
user = User(name="张三")
session.add(user)
session.flush() # 预演:把 INSERT 发给数据库(但不提交),此时可拿到 user.id
order = Order(user_id=user.id, amount=99.9) # 用刚拿到的 id 建订单
session.add(order)
session.commit() # 全部成功才提交:盖章生效
except Exception:
session.rollback() # 任何一步失败,整体回滚:撕掉工单
raise这个例子的精妙之处在 flush():创建订单需要 user.id,但 id 要等 INSERT 执行后才由 MySQL 分配。flush() 就是"先把已有的变更发给数据库执行(事务还没结束),把 id 拿到手",然后继续后续操作。如果后面订单创建失败,rollback() 会把用户的插入也一并撤销——两者同生共死。
对象生命周期
一个对象在 Session 中会经历四种状态:
临时态(Transient) → add() → 持久态(Pending) → flush → 托管态(Persistent)
↓ commit 后
游离态(Detached,session 关闭后)用快递包裹来理解:
| 状态 | 含义 | 快递类比 |
|---|---|---|
| Transient 临时态 | 刚 new 出来,与数据库无关 | 包裹还在你家里 |
| Pending 持久态 | 已 add,等待写入 | 包裹已交给快递员,还没装车 |
| Persistent 托管态 | 已入库,被 Session 跟踪 | 包裹在物流系统中,全程可查 |
| Detached 游离态 | Session 已关闭 | 包裹已签收,物流单号作废 |
第 11 章遇到的
DetachedInstanceError就是"拿着作废的单号查物流":对象处于游离态时再触发懒加载,SQLAlchemy 找不到活的 Session 去查库,只能报错。
关键知识点
| 方法 | 作用 |
|---|---|
session.add() | 标记待插入 |
session.flush() | 发送 SQL 到数据库但不提交(事务内可见) |
session.commit() | 提交事务 |
session.rollback() | 回滚事务,撤销未提交的变更 |
session.close() | 关闭会话,归还连接 |
sessionmaker | Session 工厂,统一管理配置 |
深入理解:flush 和 commit 的区别
这是新手最常混淆的一对:
flush() | commit() | |
|---|---|---|
| 发 SQL 到数据库 | ✅ | ✅(内部先 flush) |
| 结束事务 | ❌ 事务还在 | ✅ 事务结束 |
| 数据对其他连接可见 | ❌ 仅本事务内可见 | ✅ 全局可见 |
| 可以 rollback 撤销 | ✅ | ❌ 已生效 |
类比:flush 是"把草稿发给打印机预览",commit 是"正式打印并盖章下发"。
commit() 内部其实自动调用了 flush(),所以你平时只用 commit 就够了——只有当你需要提前拿到数据库生成的值(如自增 id)时,才显式调 flush。
深入理解:为什么不能共享 Session?
Session 内部维护着"哪些对象改了、改成什么样"的记账本。如果两个线程共用一个 Session:
- 线程 A 刚
add(user),线程 B 就调了commit()——A 的半成品数据被提前入库; - 两个线程的变更混在一本账上,谁 rollback 都会误伤对方。
铁律:每个线程/每个请求各自创建、使用、关闭自己的 Session。 sessionmaker 工厂的存在就是为了方便地"要一个,造一个"。
进阶内容
1. MySQL 的事务隔离级别。 MySQL InnoDB 默认是 REPEATABLE READ(可重复读):一个事务内多次读同一行,结果都是事务开始时的快照。好处是读数据稳定一致;副作用是长事务看不到别人的新提交,而且快照会阻止 undo log 被清理,拖慢整个数据库。所以:事务要尽量短,别在事务里做网络请求、文件 IO 等慢操作。
2. 死锁与重试。 两个事务互相等待对方持有的锁,MySQL 会检测到死锁并强制回滚其中一个(报错 Deadlock found when trying to get lock)。健壮的应用层应该捕获这个错误并重试:
from sqlalchemy.exc import OperationalError
for attempt in range(3):
try:
with SessionLocal() as session:
# ... 业务操作 ...
session.commit()
break
except OperationalError as e:
if "Deadlock" in str(e) and attempt < 2:
continue # 死锁了,重试
raise3. with SessionLocal() as session: 的隐藏福利。 退出 with 块时会自动 close();如果块内抛异常且你忘了 rollback,close() 会自动回滚未提交的变更。加上 try/except 双重保险,事务就很难"泄漏"。
注意事项
- 事务原则:一个业务流程要么全部成功要么全部失败,用 try/except + rollback 包裹。
- Session 不是线程安全的,每个线程/请求应持有自己的 Session,绝不共享。
- 推荐用
with Session() as s:语法,退出时自动 close。 flush()和commit()的区别:flush 只发 SQL 不结束事务,commit 发 SQL 并结束事务。- 长事务会占用连接和锁,事务要尽量短。MySQL InnoDB 中长事务会阻碍 undo log 清理、引发行锁等待甚至死锁。
- MySQL 遇到死锁(
Deadlock found when trying to get lock)时,事务会被自动回滚,应用层应捕获后重试。 - 不要把耗时操作(调第三方 API、读文件)放在事务中间——先准备好数据,再开事务快速写入。
本章小结
- Session = 银行柜员:记账本、管事务、一人一位不共享;
- 事务 = 同生共死的操作打包,commit 生效 / rollback 作废;
- flush 是"预演",commit 是"盖章";需要提前拿 id 时用 flush;
- 对象四状态:临时态 → 持久态 → 托管态 → 游离态;
- MySQL 下谨记:事务要短、死锁要重试。