Stay Hungry,Stay Foolish!

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. 避坑指南

  1. 锁必须在事务中使用: 如果你不在 session.begin()with session.begin(): 块中使用 with_for_update,锁可能会在语句执行完后立即释放,起不到保护作用。

  2. 死锁风险: 如果事务 A 锁了行 1 想锁行 2,事务 B 锁了行 2 想锁行 1,就会死锁。

    • 建议:多个事务操作多行数据时,尽量保持相同的加锁顺序(例如都按 ID 从小到大排序后加锁)。
  3. 性能影响: 锁会阻塞其他操作。持有锁的时间越短越好,不要在锁内部做耗时的网络请求或复杂计算。

希望这篇博客能帮你彻底理解数据库的锁机制!如有疑问,欢迎在评论区留言。

 

对于死锁风险如何处理?

你提到的“死锁”是并发编程中一个非常棘手的问题。简单来说,当两个或多个事务互相持有对方需要的锁,并都在等待对方释放时,就会发生死锁,导致所有相关操作都被永久阻塞。

虽然你提供的 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 语句时,数据库为了保证操作的原子性和隔离性,会按以下流程自动处理:

  1. 定位数据:数据库首先会根据 WHERE 条件找到需要修改的行。
  2. 自动加锁:在读取这些行的同时,数据库会立即在这些行上加上互斥锁(写锁)
  3. 执行修改:在持有锁的状态下,数据库对数据进行修改。
  4. 持有锁:这个互斥锁会一直被持有,直到整个事务结束(COMMITROLLBACK)。

这个过程是数据库引擎内部自动完成的,你不需要、也无法通过代码来阻止它。

💻 代码示例

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 操作加上互斥锁,确保“读-改-写”这个过程的原子性,这是保证数据正确的基石。

 

posted @ 2026-09-21 11:01  lightsong  阅读(5)  评论(0)    收藏  举报
千山鸟飞绝,万径人踪灭