Transactions and Connection Management
Transactions and Connection Management
https://docs.sqlalchemy.org/en/21/orm/session_transaction.html
这是一份为你整理的数据库事务、并发与锁机制的教学博客。我结合了之前的对话内容和 SQLAlchemy 的官方文档,力求用通俗易懂的语言和清晰的代码示例,帮助初学者掌握这些核心概念。
🚀 深入浅出:数据库事务、并发与锁机制详解
在开发高并发应用(如电商秒杀、银行转账)时,你是否遇到过“余额变负数”或者“超卖”的问题?这通常是因为我们没有正确处理数据库的事务(Transaction)和锁(Lock)。
今天,我们将结合 Python 的 ORM 框架 SQLAlchemy,彻底搞懂这些概念。
1. 什么是事务?(The Transaction)
简单来说,事务就是一组不可分割的数据库操作。就像原子一样,要么全部成功,要么全部失败。
事务的四大特性 (ACID)
- 原子性 (Atomicity):事务中的操作要么全做,要么全不做。
- 一致性 (Consistency):事务前后,数据必须保持一致(例如转账前后总金额不变)。
- 隔离性 (Isolation):多个事务并发执行时,互不干扰。
- 持久性 (Durability):事务一旦提交,数据改变是永久的。
💻 代码示例:使用 SQLAlchemy 管理事务
在 SQLAlchemy 2.0+ 中,我们推荐使用 session.begin() 上下文管理器来自动处理提交和回滚。
from sqlalchemy.orm import Session
# 假设我们有一个数据库引擎 engine
session = Session(engine)
try:
# 开启事务
with session.begin():
# 1. 扣款操作
user_a = session.get(User, 1)
user_a.balance -= 100
# 2. 加款操作
user_b = session.get(User, 2)
user_b.balance += 100
# 如果这里发生异常(例如余额不足),事务会自动回滚
# 如果代码顺利执行完,事务会自动提交
except Exception as e:
print(f"操作失败: {e}")
# 事务已回滚,数据恢复原状
2. 并发带来的问题:为什么要加锁?
假设账户 A 有 1000 元。
- 事务 1:读取余额 (1000)。
- 事务 2:同时也读取余额 (1000)。
- 事务 1:扣款 100,写入余额 (900)。
- 事务 2:扣款 200,写入余额 (800)。
结果:原本应该剩 700 元,结果变成了 800 元!这就是并发修改导致的数据不一致。
为了解决这个问题,数据库引入了锁。
3. 核心机制:共享锁 vs 互斥锁
数据库主要通过两种锁来协调并发访问:
🔒 共享锁 (Shared Lock / S-Lock)
- 别名:读锁。
- 规则:大家都能读,谁都不能改。
- 场景:当你需要确保读取数据期间,数据不被别人修改(例如生成报表)。
- 兼容性:事务 A 加了共享锁,事务 B 也可以加共享锁,但事务 C 想加互斥锁(修改)就必须等待。
✍️ 互斥锁 (Exclusive Lock / X-Lock)
- 别名:排他锁、写锁。
- 规则:我读/写的时候,别人既不能读也不能写。
- 场景:当你需要修改数据时(例如取款、下单)。
- 兼容性:一旦加了互斥锁,其他任何事务想加任何锁(读或写)都必须等待。
📊 锁兼容性矩阵
| 当前持有的锁 | 申请共享锁 (读) | 申请互斥锁 (写) |
|---|---|---|
| 共享锁 (读) | ✅ 允许 | ❌ 阻塞 |
| 互斥锁 (写) | ❌ 阻塞 | ❌ 阻塞 |
4. 实战:如何在 SQLAlchemy 中加锁?
在 SQLAlchemy 中,我们使用 with_for_update() 方法来控制锁。
场景一:添加互斥锁 (写锁)
这是最常用的场景,用于“读取并修改”数据。
with session.begin():
# SELECT ... FOR UPDATE
# 这行代码会锁定该用户记录,直到事务结束
user = session.execute(
text("SELECT * FROM users WHERE id = :id FOR UPDATE"),
{"id": 1}
).fetchone()
# 或者使用 ORM 方式 (推荐):
# user = session.get(User, 1, with_for_update=True)
if user.balance >= 100:
user.balance -= 100
else:
raise ValueError("余额不足")
注意:
with_for_update()默认就是申请互斥锁。
场景二:添加共享锁 (读锁)
当你只想读取数据,且不希望别人修改,但允许别人也来读。
with session.begin():
# SELECT ... FOR SHARE (PostgreSQL)
# 或 SELECT ... LOCK IN SHARE MODE (MySQL)
user = session.execute(
text("SELECT * FROM users WHERE id = :id"),
{"id": 1}
).with_for_update(read=True).fetchone()
print(f"当前余额: {user.balance}")
# 此时其他事务无法修改该行,但可以读取
5. 进阶:MVCC 与 普通 SELECT
你可能会问:“如果我不加锁,普通的 SELECT 读到的是什么?”
这就涉及到了 MVCC (多版本并发控制)。
-
普通 SELECT:读取的是快照 (Snapshot)。
- 数据库会保留数据的多个版本。
- 事务开始时,会看到一个“一致性视图”。
- 你读到的是事务开始时(或语句开始时,取决于隔离级别)已经提交的数据,而不会读到别人未提交的修改。
- 优点:读写不冲突,性能极高。
-
加锁 SELECT (
FOR UPDATE):读取的是最新值。- 它会忽略快照,直接去读磁盘上最新的数据,并加锁。
📌 总结对比
| 操作类型 | SQL 语句 (以 MySQL 为例) | 读取内容 | 是否加锁 | 适用场景 |
|---|---|---|---|---|
| 普通查询 | SELECT * FROM t |
MVCC 快照 | 无 | 绝大多数查询 |
| 共享锁 | SELECT ... LOCK IN SHARE MODE |
最新已提交值 | 共享锁 | 依赖数据一致性但不修改 |
| 互斥锁 | SELECT ... FOR UPDATE |
最新已提交值 | 互斥锁 | 修改数据前 (读+写) |
6. 避坑指南
-
锁必须在事务中使用: 如果你不在
session.begin()或with session.begin():块中使用with_for_update,锁可能会在语句执行完后立即释放,起不到保护作用。 -
死锁风险: 如果事务 A 锁了行 1 想锁行 2,事务 B 锁了行 2 想锁行 1,就会死锁。
- 建议:多个事务操作多行数据时,尽量保持相同的加锁顺序(例如都按 ID 从小到大排序后加锁)。
-
性能影响: 锁会阻塞其他操作。持有锁的时间越短越好,不要在锁内部做耗时的网络请求或复杂计算。
希望这篇博客能帮你彻底理解数据库的锁机制!如有疑问,欢迎在评论区留言。
对于死锁风险如何处理?
你提到的“死锁”是并发编程中一个非常棘手的问题。简单来说,当两个或多个事务互相持有对方需要的锁,并都在等待对方释放时,就会发生死锁,导致所有相关操作都被永久阻塞。
虽然你提供的 SQLAlchemy 官方文档没有直接讨论死锁的处理策略,但它为我们提供了处理这个问题的基础工具——事务。基于这些工具,我们可以采用一套组合拳来有效应对死锁风险。
🛡️ 处理死锁风险的三大策略
处理死锁通常不是单一方法,而是预防、检测和恢复三个层面的结合。
1. 预防:从源头避免
这是最有效的方法。通过遵循一些最佳实践,可以极大地降低死锁发生的概率。
-
保持固定的加锁顺序 这是预防死锁最核心、最有效的方法。如果所有事务都以相同的顺序访问和锁定资源(例如,总是先按 ID 从小到大排序),那么循环等待的条件就无法形成。
错误示例 (可能导致死锁):
- 事务 A: 锁定行 1 -> 尝试锁定行 2
- 事务 B: 锁定行 2 -> 尝试锁定行 1
- 结果: 事务 A 等待事务 B 释放行 2,事务 B 等待事务 A 释放行 1,形成死锁。
正确示例 (避免死锁):
- 事务 A: 锁定行 1 -> 尝试锁定行 2
- 事务 B: 锁定行 1 -> 尝试锁定行 2
- 结果: 事务 B 会等待事务 A 完成,然后继续执行,不会发生死锁。
-
保持事务简短 事务持有锁的时间越长,与其他事务发生冲突的概率就越高。
- 不要在事务中执行耗时操作:例如网络请求、复杂的文件 I/O 或繁重的计算。
- 最佳实践:在事务外部准备好所有数据,然后在事务内部只执行必要的数据库读写操作,并尽快提交。
-
选择合适的隔离级别 较低的隔离级别(如
READ COMMITTED)通常持有锁的时间更短,从而减少了死锁的机会。但需要在数据一致性和并发性能之间做权衡。
2. 检测:让数据库来处理
实际上,你通常不需要自己编写代码去检测死锁。现代数据库(如 MySQL, PostgreSQL)都有内置的死锁检测器。
当数据库检测到死锁时,它会主动选择一个事务作为“牺牲者”,并终止它,通常是通过抛出一个特定的异常。这样,其他事务就可以继续执行。
在 SQLAlchemy 中,这个异常通常是 sqlalchemy.exc.OperationalError 或其子类,错误码因数据库而异(例如,MySQL 的错误码是 1213)。
3. 恢复:实现重试逻辑
既然数据库会帮我们解决死锁(通过终止一个事务),那么应用层需要做的就是优雅地处理这个异常,并重试失败的操作。
这是一种非常常见的模式,通常被称为“指数退避重试”。
from sqlalchemy.exc import OperationalError
import time
import random
def execute_with_retry(session_factory, operation, max_retries=3):
"""
执行数据库操作,并在遇到死锁时自动重试。
"""
for attempt in range(max_retries):
# 为每个尝试创建一个新的 Session
with session_factory() as session:
try:
with session.begin():
# 执行传入的业务逻辑
operation(session)
# 如果成功,直接返回
return
except OperationalError as e:
# 检查是否为死锁错误 (以 MySQL 为例,错误码 1213)
# 不同数据库的错误码不同,需要查阅对应文档
is_deadlock = "1213" in str(e.orig)
if is_deadlock and attempt < max_retries - 1:
# 如果是死锁且还有重试机会
print(f"检测到死锁,{2 ** attempt}秒后重试... (尝试 {attempt + 1})")
# 等待一段时间后重试,等待时间可以指数增长并加入随机抖动
time.sleep(2 ** attempt + random.uniform(0, 1))
else:
# 如果不是死锁,或已达到最大重试次数,则重新抛出异常
raise
使用示例:
def transfer_money(session, from_id, to_id, amount):
# 假设我们总是按 ID 升序锁定,这是预防死锁的关键
first_id, second_id = sorted([from_id, to_id])
from_account = session.get(Account, first_id, with_for_update=True)
to_account = session.get(Account, second_id, with_for_update=True)
if from_account.balance < amount:
raise ValueError("余额不足")
from_account.balance -= amount
to_account.balance += amount
# 使用带重试的函数来执行转账
Session = sessionmaker(engine)
try:
execute_with_retry(Session, transfer_money, 1, 2, 100)
print("转账成功")
except Exception as e:
print(f"操作最终失败: {e}")
📌 总结
| 策略 | 描述 | 关键行动 |
|---|---|---|
| 预防 | 通过良好设计,从源头降低死锁概率。 | 固定加锁顺序、保持事务简短。 |
| 检测 | 依赖数据库自动发现并打破死锁。 | 无需手动编码,数据库会抛出异常。 |
| 恢复 | 捕获死锁异常,并重试失败的操作。 | 实现带退避的重试逻辑。 |
通过结合这三种策略,你可以构建出健壮、高并发的应用程序,有效应对死锁风险。
对于事务中没有select,只有update, 这种情况有锁吗?
有的,而且锁得非常严格。
即使你的事务中没有显式的 SELECT 语句,只有 UPDATE,数据库也一定会自动加上互斥锁(Exclusive Lock)。
这是数据库保证数据一致性的核心机制,你无法绕过。
🔒 锁是如何自动加上的?
当你执行 UPDATE 语句时,数据库为了保证操作的原子性和隔离性,会按以下流程自动处理:
- 定位数据:数据库首先会根据
WHERE条件找到需要修改的行。 - 自动加锁:在读取这些行的同时,数据库会立即在这些行上加上互斥锁(写锁)。
- 执行修改:在持有锁的状态下,数据库对数据进行修改。
- 持有锁:这个互斥锁会一直被持有,直到整个事务结束(
COMMIT或ROLLBACK)。
这个过程是数据库引擎内部自动完成的,你不需要、也无法通过代码来阻止它。
💻 代码示例
from sqlalchemy.orm import Session
with Session(engine) as session:
with session.begin():
# 这条 UPDATE 语句会自动在 id=1 的行上加上互斥锁
session.execute(
text("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
)
# 在事务提交前,其他事务无法读取或修改 id=1 的这行数据
# 它们会被阻塞,直到当前事务提交或回滚
# 事务提交,锁被释放
🤔 为什么必须加锁?
想象一下如果不加锁会发生什么:
- 事务 A:执行
UPDATE accounts SET balance = 900 WHERE id = 1(准备将余额从1000改为900)。 - 事务 B:同时执行
UPDATE accounts SET balance = 800 WHERE id = 1(准备将余额从1000改为800)。
如果两个事务都能无锁地读取和修改,最终的结果将取决于谁后写入,导致数据不一致(例如,本应扣款两次,结果只扣了一次)。
因此,数据库强制为 UPDATE 操作加上互斥锁,确保“读-改-写”这个过程的原子性,这是保证数据正确的基石。

浙公网安备 33010602011771号