Files

15 KiB
Raw Permalink Blame History

第 4-2 课Python 数据库事务与数据访问层

一、本课定位

第一课已经完成 PostgreSQL 连接、游标、参数化查询和资源释放。本课先系统复习事务的核心概念,再学习 Python/Psycopg 中的事务边界,以及如何把 SQL 从业务逻辑中分离。数据库基础不会展开成完整 SQL 课程,但事务是后续 ORM 和 Web 业务正确性的基础,必须讲清楚。

本课会写入远程练习数据库:示例创建专用表 course_bank_account,只重置并操作 COURSE- 前缀数据;练习使用另一张专用表 course_wallet_account,只操作 PRACTICE- 前缀数据。不要连接生产数据库。

二、本课目标

完成本课后,你将能够:

  1. 说明事务是什么以及为什么需要事务;
  2. 结合转账解释原子性、一致性、隔离性和持久性;
  3. 区分提交、回滚、自动提交和事务失败状态;
  4. 解释常见并发异常和事务隔离级别;
  5. 解释 Psycopg 默认事务行为;
  6. 使用连接上下文自动提交和回滚;
  7. 理解为什么异常必须传播出事务上下文;
  8. 使用 executemany() 批量执行同一条参数化 SQL
  9. 使用 FOR UPDATE 锁定待修改数据;
  10. 使用 Repository 隔离 SQL
  11. 把业务规则放在 Service 中;
  12. 让一次业务操作共享同一连接和事务;
  13. 对照 JDBC、MyBatis 和 Spring 事务理解 Python实现。

三、事务是什么

事务Transaction是数据库中的一个工作单元它包含一条或多条操作这些操作应该作为一个不可分割的整体完成。

转账至少包含两条更新:

账户A扣款200元
账户B入账200元

如果第一条成功、第二条失败,却保留了第一条结果,钱就凭空减少了。事务要求最终只能出现两种结果:

全部成功 → 提交COMMIT
任一步失败 → 回滚ROLLBACK恢复到事务开始前

对应的SQL概念是

BEGIN;

UPDATE account SET balance = balance - 200 WHERE account_no = 'A';
UPDATE account SET balance = balance + 200 WHERE account_no = 'B';

COMMIT;

中间发生错误时执行:

ROLLBACK;

使用Psycopg时通常不需要手写BEGIN。驱动会按连接状态自动开始事务,代码负责正确划定提交和回滚边界。

四、事务的ACID特性

ACID是事务需要满足的四类核心性质。

4.1 原子性Atomicity

事务内的操作要么全部成功,要么全部失败。转账中的扣款和入账不能只保留其中一步。

本课的失败示例会先扣除50元再主动抛出异常。最终余额不变就是在验证原子性。

4.2 一致性Consistency

事务执行前后,数据都必须满足数据库约束和业务规则。例如:

  • 余额不能小于0
  • 转账前后两个账户的余额总额不应无故变化;
  • 目标账户必须存在;
  • 主键、非空和检查约束仍然成立。

一致性不是只靠数据库自动保证。数据库约束、事务、锁和Service中的业务校验要共同工作。

4.3 隔离性Isolation

多个事务并发执行时,一个事务不应随意看到另一个尚未完成事务的中间状态。

假设账户A余额为1000元两个请求同时转出800元。如果二者都先读取到1000再分别扣款就可能发生超额转账。事务隔离级别和行锁用于控制这种并发影响。

隔离不等于所有事务完全串行。隔离越强,并发冲突通常越少,但等待和资源成本可能越高。

4.4 持久性Durability

事务成功提交后,结果应该持久保存。程序退出或连接关闭后,再使用新连接查询,仍然能够看到已提交的余额。

本课使用独立连接回查成功转账结果,就是在直观验证持久性。

五、提交、回滚与事务状态

5.1 提交

COMMIT确认事务中的修改。提交成功后,其他事务才能按照隔离规则观察到这些结果,当前事务也不能再整体撤销。

5.2 回滚

ROLLBACK取消当前事务中尚未提交的修改。它不是反向执行一条新的补偿SQL而是让数据库放弃本次事务的未提交结果。

5.3 PostgreSQL事务失败状态

PostgreSQL事务中的某条SQL失败后当前事务通常进入失败状态。即使后面的SQL本身正确也会收到类似错误

current transaction is aborted, commands ignored until end of transaction block

中文含义是当前事务已经失败在事务结束前忽略后续命令。此时必须回滚或让Psycopg的连接上下文因异常退出并自动回滚。

5.4 自动提交

自动提交Autocommit表示每条独立SQL完成后立即提交。它适合部分不需要多语句原子性的操作但无法把扣款和入账自动组合成一个事务。

Psycopg默认autocommit=False。本课保持默认行为,不开启自动提交。

六、隔离级别与并发异常

常见并发异常如下:

并发现象 含义
脏读Dirty Read 读取到其他事务尚未提交的数据;对方回滚后,读到的数据从未真正成立
不可重复读Non-repeatable Read 同一事务两次读取同一行,期间被其他事务提交修改,结果不同
幻读Phantom Read 同一事务两次执行相同范围查询,结果行数量因其他事务提交而变化
丢失更新Lost Update 两个事务基于同一旧值更新,后提交的结果覆盖前一个结果

SQL标准定义四个主要隔离级别

隔离级别 基本含义 PostgreSQL说明
READ UNCOMMITTED 理论上允许读取未提交数据 PostgreSQL内部按READ COMMITTED处理
READ COMMITTED 每条语句读取执行开始前已提交的数据 PostgreSQL默认级别
REPEATABLE READ 事务内多次查询基于稳定快照 提交时仍可能出现并发冲突
SERIALIZABLE 尽量表现得像事务串行执行 冲突时可能要求应用重试事务

隔离级别不能代替所有业务并发控制。本课在默认READ COMMITTED下使用SELECT ... FOR UPDATE锁住即将修改的账户行。

七、JDBC与Psycopg事务对照

Java常见写法 Psycopg写法 含义
connection.setAutoCommit(false) 默认连接首次操作自动进入事务 开始事务工作
connection.commit() 正常离开连接with 提交
connection.rollback() 异常离开连接with 回滚
try-with-resources with psycopg.connect(...) 管理连接生命周期
Mapper/DAO Repository 封装SQL和结果转换
Service Service 组织业务规则
@Transactional 外层连接/事务上下文 划定事务边界

Python没有Spring默认提供的声明式@Transactional。直接使用Psycopg时需要显式组织事务上下文后续SQLAlchemy和FastAPI课程会进一步统一Session生命周期。

八、Psycopg默认事务行为

Psycopg遵循DB-API习惯连接默认不是自动提交模式。包括SELECT在内的数据库操作通常都会启动事务。

with psycopg.connect(**database_config) as connection:
    connection.execute("UPDATE ...")

正常离开with时提交;块内异常传播出去时回滚;最后关闭连接。

8.1 异常不能在事务内部被吞掉

错误写法:

with psycopg.connect(**database_config) as connection:
    try:
        connection.execute("UPDATE ...")
        raise TransferError("后续失败")
    except TransferError:
        print("失败")

异常已经在with内部被捕获,连接上下文看到的是“正常结束”,可能提交前面的更新。

正确边界:

try:
    with psycopg.connect(**database_config) as connection:
        connection.execute("UPDATE ...")
        raise TransferError("后续失败")
except TransferError as error:
    print(error)

异常先离开with,触发回滚,然后才由外层处理。

8.2 手动提交和回滚

连接上下文适合“整个代码块就是一个事务”的情况,也可以显式控制:

connection = psycopg.connect(**database_config)

try:
    connection.execute("UPDATE ...")
    connection.execute("UPDATE ...")
    connection.commit()
except Exception:
    connection.rollback()
    raise
finally:
    connection.close()

这与JDBC手动事务非常接近。但只要业务边界能自然表达为代码块优先使用with,可以减少遗漏回滚或关闭连接的风险。

8.3 连接with与事务with的区别

本课使用:

with psycopg.connect(**database_config) as connection:
    ...

它同时管理事务和连接生命周期。Psycopg还提供

with connection.transaction():
    ...

后者只划定一个事务或保存点范围,不负责创建连接。它适合长连接或连接池场景。本阶段后续课程结合连接池时再深入使用。

九、Repository与Service职责

本课采用:

main
  ↓ 创建连接并划定事务
Service
  ↓ 组织转账规则
Repository
  ↓ 执行参数化SQL
PostgreSQL

Repository不应在每个方法中调用commit(),否则扣款刚提交、入账却失败时,外层已经无法回滚整个业务操作。

class AccountRepository:
    def __init__(self, connection):
        self.connection = connection

    def change_balance(self, account_no, amount):
        self.connection.execute(...)
        # 这里不提交。

Service也不负责创建连接它复用同一个Repository从而保证多个SQL处于同一事务。

十、FOR UPDATE、锁与死锁

转账前先查询余额:

SELECT balance
FROM course_bank_account
WHERE account_no = %s
FOR UPDATE

FOR UPDATE会锁定选中的行,直到事务提交或回滚。它能防止两个并发事务同时读取相同旧余额后分别扣款。

这种“先锁定,再判断和修改”的做法属于悲观锁:代码假设并发冲突可能发生,因此提前取得排他性的行锁。

锁会持续到事务提交或回滚。事务范围过大,会让其他请求等待更久,因此事务中不应夹杂耗时的网络请求、人工操作或无关计算。

10.1 死锁

如果事务一先锁A再锁B事务二同时先锁B再锁A双方可能互相等待。数据库会检测死锁并中止其中一个事务。

降低死锁风险的常用办法:

  • 多个事务按照统一顺序锁定资源,例如始终按账号升序;
  • 缩短事务时间;
  • 只锁真正需要修改的行;
  • 应用捕获死锁或序列化失败,并按策略重试整个事务。

十一、批量操作

多条数据执行相同SQL时可以使用

with connection.cursor() as cursor:
    cursor.executemany(
        "INSERT INTO course_bank_account VALUES (%s, %s, %s)",
        accounts,
    )

它比手工拼接多条SQL安全也明确表达“同一语句、不同参数”。它不等于无限制地一次提交海量数据生产中仍要根据数据量分批。

十二、完整示例

示例文件为transaction_repository_example.py,包含:

  • AccountRepository:建表、初始化、查询和更新;
  • TransferService:金额检查、余额检查、扣款和入账;
  • 成功事务转账200元并自动提交
  • 失败事务先扣50元再抛出异常验证自动回滚
  • 独立连接回查:证明提交和回滚的最终状态。

十三、准备配置

在第二课目录执行:

Copy-Item .\config.example.toml .\config.toml

填写第一课使用的同一套专用练习数据库配置。config.toml已被项目.gitignore排除。

十四、运行方法与预期结果

激活已安装Psycopg的Conda环境

conda activate python-test
python .\transaction_repository_example.py

关键结果应为:

初始余额:
COURSE-A001小明余额1000.00
COURSE-A002小红余额500.00
成功转账 200 元后:
COURSE-A001小明余额800.00
COURSE-A002小红余额700.00
失败事务已回滚:模拟第二步失败,验证前一步更新会被回滚。
失败事务回滚后:
COURSE-A001小明余额800.00
COURSE-A002小红余额700.00

重复运行时,示例会先删除COURSE-前缀数据并重新初始化,因此结果保持一致。

十五、常见错误

15.1 Repository内部提交

这会破坏跨多个SQL的原子性。提交和回滚应由业务事务边界统一控制。

15.2 在with内部捕获业务异常

异常没有传播给连接上下文,可能导致错误提交。先让异常离开事务块,再在外层捕获。

15.3 失败后继续使用同一事务

PostgreSQL语句失败后当前事务通常进入失败状态。在回滚前继续执行SQL会收到current transaction is aborted一类错误。

15.4 使用float表示金额

二进制浮点数可能产生精度误差。课程金额使用Decimal("200.00"),数据库使用NUMERIC(12, 2)

15.5 先查询再更新却没有锁

单用户测试可能正常,但并发时可能发生余额覆盖或超额扣款。本课使用FOR UPDATE锁定账户行。

15.6 清理范围过大

不要使用无条件DELETETRUNCATE。示例和练习只删除指定前缀的课程数据。

十六、课堂练习

打开practice.py,实现独立的钱包转账练习。题目已经明确:

  1. 配置读取;
  2. Repository方法
  3. Service业务规则
  4. 批量初始化;
  5. 成功事务;
  6. 失败回滚;
  7. 回查、输出和异常处理。

十七、本课小结

  • 事务把多条数据库操作组织成一个工作单元;
  • ACID分别是原子性、一致性、隔离性和持久性
  • 提交确认修改,回滚取消尚未提交的修改;
  • PostgreSQL事务中的SQL失败后通常必须先回滚才能继续
  • 隔离级别控制并发事务互相可见的范围;
  • 事务边界应该覆盖完整业务操作;
  • Repository封装SQL但不擅自提交
  • Service组织业务规则但复用外部连接
  • 异常必须先离开事务上下文才能触发自动回滚;
  • executemany()适合相同SQL的多组参数
  • FOR UPDATE用于锁定即将修改的数据;
  • 金额应使用Decimal与数据库NUMERIC

十八、验收标准

  • 能使用转账说明为什么需要事务;
  • 能结合示例解释ACID四个特性
  • 能区分提交、回滚和自动提交;
  • 能简要说明脏读、不可重复读、幻读和丢失更新;
  • 能解释连接上下文何时提交、何时回滚;
  • 能说明为什么Repository不能随意提交
  • 标准示例重复运行且结果一致;
  • 练习的成功转账同时更新两个钱包;
  • 模拟失败后第一条更新被完整回滚;
  • SQL全部参数化
  • 只操作课程专用表和指定前缀数据;
  • 未提交真实config.toml