☰
Python操作MySQL事务处理实战:从ACID到隔离级别与并发控制
2026/10/22 22:05:38 网站建设 项目流程

1. 事务到底在解决什么问题

先聊点实际的。我刚开始用 Python 连 MySQL 写业务代码的时候,一直觉得事务是“高大上”的东西,好像只有金融系统、订单系统那种高并发场景才需要。直到有次做一个模拟库存系统,遇到一个特别头疼的线上问题:用户下单后,库存表的数据扣掉了,订单表却因为 SQL 语句报错没写进去。钱收了货发不出去,后台数据还对不上。

那一刻我才真正意识到,事务不是一个可有可无的选项,而是保证数据不出错的底线机制。后来我把事务处理的关键步骤梳理清楚之后,再也没出过这种数据不一致的幺蛾子。这篇文章就把我实际用过的方法、踩过的坑、以及每个操作背后的原理,从头到尾摊开讲清楚。

1.1 事务的本质与 ACID 通俗解读

事务,简单来说就是一组 SQL 操作打包成一个整体,要么全部成功,要么全部失败,不允许出现“做了一半”的中间状态。

用生活化的类比来说:你在超市买了东西,扫了付款码,收银台那边的系统要从你的账户扣钱,同时要把这笔钱记到超市的账上。扣钱成功、记账失败,或者反过来,这都是一场灾难。事务就是那个协调员,保证这两件事同时成功,或者同时不发生,线下刷 POS 机、银行转账都是这个逻辑。

专业一点讲,事务必须满足四个特性,也就是常说的 ACID:

  • 原子性(Atomicity):事务里的所有 SQL 要么都执行成功,要么都执行失败回滚。就像掰一根筷子,整根掰断,不存在掰一半的可能。
  • 一致性(Consistency):事务执行前和执行后,数据库的完整性约束不被破坏。比如说账户余额不能为负数,如果转账会导致余额变成负数,整个事务就必须终止。
  • 隔离性(Isolation):多个事务并发执行时,彼此之间不能互相干扰。两个用户同时扣同一件商品的库存,数据库要保证他们看到的数据和最终扣减的结果是有序、正确的。
  • 持久性(Durability):事务一旦提交成功,数据变更就永久保存下来,即使系统突然断电、崩溃,数据也不会丢。

很多人刚接触事务时容易陷入一个误区:以为只有当业务“非常重要”才需要事务。实际上,只要一次操作涉及两条及以上的 SQL 更新语句,就应该考虑事务,否则数据一定会找机会给你挖坑。

1.2 没有事务会导致什么严重后果

我拿一个非常典型的电商下单场景举例子,这个例子我后面还会反复用到。假设某个用户提交了一个订单,后台代码要做三件事:

  1. 往订单表插入一条订单记录。
  2. 扣减商品库存表中的库存数量。
  3. 锁定用户的优惠券或扣减账户余额。

这三步操作要是不放在事务里,而是顺序执行、执行完直接提交,会发生什么?

第一步插入订单成功,第二步扣库存时发现库存不足报错了。代码抛异常跳出,第三步没执行,订单却已经留在数据库里了。看起来好像问题不大?但用户端会显示“下单成功”,后台一看库存没扣,超卖了。更可怕的是,如果第二步卡在网络超时上,代码重试或者用户重复点击,订单可能被创建多条,数据直接烂掉。

还有一种情况大家很容易忽略:即使代码里写了事务,但执行过程中某个 SQL 报错了,程序没有正确回滚就退出,数据库连接一释放,这个未提交的事务会被数据库自动回滚掉。但如果在长连接里复用了这个连接,上一个事务残留的数据可能串到下一个业务请求里,造成脏数据。所以我后来养成了一个习惯:每次讲 Python + MySQL 事务处理,一定会强调“代码里的异常处理和事务控制必须成对出现”,因为这是新手最容易栽跟头的地方。

2. Python 操作 MySQL 的准备工作

Python 连接 MySQL 的方式有很多,常见的主要是pymysql和mysql-connector-python,另外还有一些基于 ORM 的框架,比如 SQLAlchemy、Django ORM 也提供了事务接口。我这里以pymysql为主线讲,因为它轻量、直白、容易看清楚底层的执行流程,非常适合用来理解事务的每个步骤。

2.1 驱动选型:pymysql 还是 mysql-connector-python

我自己的经验是,追求“开箱即用”可以用mysql-connector-python,它是官方驱动,对 MySQL 8.0+ 的新特性支持最及时;追求“轻量、部署简单、社区资料多”就用pymysql,它是纯 Python 实现,装起来不用编译,出问题网上随便一搜就有大把解决方案。

两者在事务处理方面的 API 设计几乎是一致的:

功能点pymysqlmysql-connector-python
连接数据库pymysql.connect()mysql.connector.connect()
获取游标connection.cursor()connection.cursor()
提交事务connection.commit()connection.commit()
回滚事务connection.rollback()connection.rollback()
自动提交开关connection.autocommitconnection.autocommit

不管用哪个驱动,事务处理的核心调用逻辑没有任何区别,因为你面对的都是同一个 MySQL 服务器,底层都是通过 MySQL 协议告诉数据库“我要开启事务”“我要提交”或者“我要回滚”。

有一点要提醒:很多人会觉得“我用的是 ORM,事务怎么处理?”,但 ORM 底层一样要调用这些 commit 和 rollback,只是帮你封装了一层。比如 SQLAlchemy 的session.commit()本质就是触发底层连接的 commit 操作。所以我建议想真正理解事务的人,先用裸 SQL +pymysql把流程走一遍,再去看 ORM 封装,这样整个链路会非常通透。

2.2 建立数据库连接的关键参数

连接数据库看起来就一行代码,但里面有几个参数和事务行为直接相关,比如下面这段基础连接代码:

import pymysql conn = pymysql.connect( host="127.0.0.1", port=3306, user="root", password="yourpassword", charset="utf8mb4", database="shop_demo", autocommit=False )

这里有个极其重要的参数:autocommit。

MySQL 默认情况下,每次执行一条 SQL 语句,如果语句成功了,它会立刻把改动持久化到磁盘,这个行为就叫“自动提交”。如果autocommit=True(默认就是 True),那你在 Python 里写的多条 SQL 其实是各自独立的,完全不具备事务的原子性。只有把autocommit设为 False,MySQL 才会在你执行第一条 SQL 时自动开启一个事务,之后所有的 SQL 都在这个事务里暂存,直到你调用commit()才算真正落盘,或者调用rollback()把这一批操作全部撤销。

很多人事务不生效,第一反应是怀疑代码有问题,其实 80% 的情况是忘了把autocommit关掉。

除了autocommit,连接时还要注意charset参数。如果表结构是 utf8mb4,Python 连接也最好显式声明charset="utf8mb4",不然插入表情符号、生僻字可能出现编码错误。这类错误虽然不影响事务机制,但会中途打断 SQL 的执行,引发异常,间接影响事务流程。

2.3 游标类型选择与受影响行数

执行 SQL 需要游标(cursor)。pymysql 默认返回的查询结果是一个元祖嵌套的列表,每一行是一个元组,没有字段名。如果你希望结果带上字段名,建议使用字典游标:

cursor = conn.cursor(pymysql.cursors.DictCursor)

这样查询出来的每一行都是一个字典,键是字段名,值是对应的内容。在事务处理中,游标还有一个常见作用:获取受影响的行数。比如执行UPDATE后,可以用cursor.rowcount来判断到底更新了多少行,如果期望更新 1 行结果 rowcount 是 0,那大概率 WHERE 条件写错了或者数据本身就不存在。

需要注意的是,影响行数不一定是更新条数,取决于 MySQL 是否启用了 CLIENT_FOUND_ROWS 标志。默认情况下,如果更新时字段值没有发生变化,MySQL 可能返回 0 行受影响,这和实际“匹配到”的行数可能不一致。在做事务中的乐观锁判断时,这个细节容易导致误判。新手可以先忽略这个 0 行问题,但要知道有这回事。

3. 事务处理完整实操流程

现在进入正题。以下是我在实际项目里反复使用的一套事务处理模板,每一步背后都有明确的逻辑,不是平白无故写出来的。

3.1 标准事务五步法:开启、执行、提交、回滚、收尾

事务最基础的写法,就像下面这样:

import pymysql conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpassword", database="shop_demo", autocommit=False, charset="utf8mb4" ) try: cursor = conn.cursor() # 1. 插入订单 cursor.execute( "INSERT INTO orders (user_id, product_id, amount) VALUES (%s, %s, %s)", (1001, 5002, 199.90) ) # 2. 扣减库存 cursor.execute( "UPDATE products SET stock = stock - 1 WHERE id = %s AND stock > 0", (5002,) ) # 3. 检查有没有真的扣到库存 if cursor.rowcount == 0: raise Exception("库存不足,事务需要回滚") # 4. 全部成功,提交 conn.commit() except Exception as e: print("事务执行出错,正在回滚:", e) # 5. 出错回滚 conn.rollback() finally: cursor.close() conn.close()

这套流程其实就是事务的完整生命周期,拆开看是五步:

  1. 建立连接并关闭自动提交。这决定了后续所有 SQL 会被 MySQL 当作一个事务来处理。
  2. 执行业务 SQL。多个 SQL 按业务逻辑顺序执行,同时需要仔细设计 WHERE 条件和行锁策略,确保并发情况下数据依然正确。
  3. 业务校验。很多开发者忽略这一步。SQL 执行成功了,不代表业务逻辑上正确。比如上面例子中,扣库存的 SQL 需要确认影响行数是 1,如果是 0 就说明库存不够,必须主动抛异常触发回滚。
  4. 提交或回滚。所有 SQL 都成功、业务校验也全部通过,才调用commit()。任何一步出错,直接rollback()把事务撤销。
  5. 释放资源。游标和连接都要关闭,避免连接泄漏。

这套模板我已经用了很多年,几乎所有 Python 操作 MySQL 的场景都是在这个骨架上做延伸,比如加日志、加重试、加分布式锁等。

3.2 为什么要手动 commit,而不是靠 MySQL 自动提交

有些初学者会问:既然 MySQL 默认是自动提交的,为什么不直接依赖默认行为,反而要手动autocommit=False再调用commit?

原因在于,自动提交意味着每条 SQL 都是独立事务,你无法把两条 SQL 捆绑成一个整体。没有捆绑,第一条 SQL 成功、第二条 SQL 失败时,你根本没有机会回滚第一条已经执行成功的操作。手动管理事务,核心价值就是掌握“整体成功或整体失败”的控制权。

这里要补充一个细节:conn.commit()和conn.rollback()不只是提交和回滚,还会清空这个连接上的事务上下文。换句话说,每次调用 commit 或 rollback 之后,当前事务就结束了。如果你接下来还要执行新的 SQL,MySQL 会自动开启一个新的事务,前提是autocommit依然是 False。

这就引出一个常见的操作困惑:如果要在一个连接里连续处理多个业务事务,可以在每次业务操作结束后调用 commit,再执行下一轮 SQL。千万不要把不相关的多组操作混在一个事务里,否则一旦后面出错,回滚会把前面已经成功的无关操作也一并撤销掉。

3.3 用好 try-except-finally,让异常处理无死角

事务代码里的异常处理,要分成三层来看:

第一层,SQL 本身可能出错。比如语法错误、字段不存在、唯一键冲突,这类异常会从cursor.execute()抛出来。

第二层,业务逻辑校验不通过。SQL 能正常执行,但结果不满足预期,需要手动抛异常。我用得最多的手段就是raise Exception("..."),或者自己定义一个业务异常类。

第三层,提交/回滚过程中可能出现异常。比如网络中断,conn.commit()本身也可能抛异常,需要捕获。

一个容易忽略的问题:异常发生之后,事务并不一定需要回滚。万一错误发生在rollback()之后,比如finally里关闭游标的代码报错,这时候事务已经结束了,不需要再 rollback。所以我更推荐下面这种写法:

conn = None cursor = None try: conn = pymysql.connect(...) cursor = conn.cursor() ... conn.commit() except Exception: if conn: conn.rollback() raise finally: if cursor: cursor.close() if conn: conn.close()

这个写法的重点在于,except里先判断conn是否存在,再调用 rollback;同时把异常重新抛出raise,让外层调用者知道这里发生了失败。finally里再安全关闭游标和连接。这套结构我在生产环境里用了很久,从来没有因为资源释放顺序的问题导致连接泄露。

4. 使用上下文管理器简化事务代码

每次手写 try-except-finally 其实有点繁琐,而且很容易在代码里漏掉某条分支。Python 的with语句配合上下文管理器,可以把事务的提交、回滚、资源释放全部封装起来,让业务代码只关注 SQL 本身。

4.1 自封装一个事务上下文管理器

先看一段可以直接拿去用的代码:

import pymysql from contextlib import contextmanager @contextmanager def transaction(conn): """事务上下文管理器:正常则提交,异常则回滚""" try: yield conn conn.commit() except Exception: conn.rollback() raise

使用时是这样:

conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpassword", database="shop_demo", autocommit=False, charset="utf8mb4" ) with transaction(conn) as conn: cursor = conn.cursor() cursor.execute("INSERT INTO orders (user_id, product_id, amount) VALUES (%s, %s, %s)", (1001, 5002, 199.90)) cursor.execute("UPDATE products SET stock = stock - 1 WHERE id = %s AND stock > 0", (5002,)) if cursor.rowcount == 0: raise Exception("库存不足")

这段代码的好处是显而易见的。正常执行完with块,上下文管理器自动调用conn.commit();一旦块内抛异常,不管异常来自 SQL、业务判断还是别的地方,都会自动执行conn.rollback(),并且异常继续往上抛。你再也不用在每个业务方法里重复写 try-except-finally 了。

有人可能会担心:这个上下文管理器好像没有关闭连接?是的,连接关闭这个动作我没有放进来,因为连接通常是整个应用共用的资源,不应该在一个事务结束后就被关闭。更合理的做法是,把连接交给上层的连接池来管理,事务结束后由连接池决定是否回收。

4.2 上下文管理器内部的执行细节分析

这个封装看起来很简单,但里面有几个值得深挖的细节。

第一,@contextmanager装饰器把一个生成器函数包装成了上下文管理器。关键字yield conn之前的部分是进入with块时执行的,yield conn之后的部分是离开with块时执行的。在离开时,如果块内没有异常,就执行conn.commit(),否则执行conn.rollback()。

第二,raise这个动作不能省。如果在上下文管理器里捕获异常后不回抛,调用者就感知不到事务失败,可能继续往下执行无关逻辑,导致整个业务流程数据不一致。

第三,这个上下文管理器里的conn参数是外部传入的,而不是在内部创建。这样做的好处是,一个连接可以在一个请求里被多个with transaction(conn)块复用,每个块都是独立事务,互不干扰。

4.3 嵌套事务与保存点实现部分回滚

有时候业务非常复杂,比如一个大的事务流程里面,前面几步操作已经做了,后面某一步出错时,希望只回滚到中间某个位置,而不是全部推翻重来。这时候就要用保存点(savepoint)。

保存点的使用方式是在事务中给某个位置打一个标记,之后一旦出错,只回滚到这个标记,保留这个标记之前已经执行成功的操作。

MySQL 中使用保存点很简单:

SAVEPOINT sp1; -- 执行一些操作 ROLLBACK TO SAVEPOINT sp1;

但注意,ROLLBACK TO SAVEPOINT之后,事务还没有结束,你仍然可以继续执行新的 SQL,最后统一调用 COMMIT。

在 Python 中这样操作:

try: cursor.execute("UPDATE account SET balance = balance - 100 WHERE user_id = 1") cursor.execute("SAVEPOINT after_deduct") cursor.execute("UPDATE account SET balance = balance + 100 WHERE user_id = 2") # 这里模拟一个业务异常 if some_condition: cursor.execute("ROLLBACK TO SAVEPOINT after_deduct") # 此时第一个更新生效,第二个更新被撤销 conn.commit() except Exception: conn.rollback()

这里有个细节值得强调:ROLLBACK TO SAVEPOINT不会释放行锁。比如第一个 UPDATE 已经锁住了某些行,即使回滚到保存点,这部分锁也要等到事务结束(commit 或 rollback)才会释放。这也是很多并发问题产生的土壤,不要以为回滚到保存点就万事大吉,锁仍然握在手里。

保存点适合用在批处理、长流程、可部分失败的场景中。如果你的业务没有那么复杂,完全不需要用保存点,一个事务直接全部成功或全部失败反而更简单、更好维护。

5. 事务的隔离级别与并发控制

事务处理做到上面那一步,单机单用户环境下已经可以稳定运行了。但一旦上生产、上并发,光会提交和回滚还远远不够。你还得知道 MySQL 的隔离级别是怎么回事,否则数据明明没写错,读出来却是错的。

5.1 MySQL 四种隔离级别详解

SQL 标准把事务隔离级别分成四档,MySQL 默认的隔离级别是REPEATABLE READ(可重复读),这一点和很多其他数据库不同。

四种隔离级别依次如下:

隔离级别脏读不可重复读幻读说明
READ UNCOMMITTED可能可能可能啥都不防,基本没人用
READ COMMITTED避免可能可能多数数据库默认级别
REPEATABLE READ避免避免可能(InnoDB 实际解决了)MySQL 默认级别
SERIALIZABLE避免避免避免完全串行,性能最低

先解释几个关键概念。

脏读:事务 A 修改了一条数据但还没提交,事务 B 这时候去读,读到了 A 的“临时修改值”。后来 A 回滚了,B 读到的就是一个不存在的假数据。

不可重复读:事务 A 先读了一次数据,事务 B 在这期间修改并提交了这条数据,事务 A 再读第二次,发现值变了。重点在于同一行数据内容前后不一致。

幻读:事务 A 按某个条件查出来一批记录,事务 B 插入了一条新记录并提交,事务 A 再按同样条件查,发现多了一行“像幻觉一样”的记录。重点在于记录数量前后不一致。

MySQL 的 InnoDB 存储引擎通过多版本并发控制(MVCC)和间隙锁,在 REPEATABLE READ 级别下实际上把幻读也解决了,但是这个概念比较复杂,新手可以暂时先记住结论:MySQL InnoDB 下的 REPEATABLE READ 已经能够避免脏读、不可重复读和大多数幻读问题。

5.2 Python 中如何设置隔离级别

pymysql 连接成功之后,可以通过执行 SQL 来设置隔离级别:

cursor.execute("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")

或者在建立连接时,直接通过 init_command 参数来指定:

conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpassword", database="shop_demo", autocommit=False, charset="utf8mb4", init_command="SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED" )

我个人的经验是:如果业务对数据一致性要求高,比如账务、订单、库存,优先考虑 READ COMMITTED,因为它能避免脏读,同时并发性能比 REPEATABLE READ 好。MySQL 默认 REPEATABLE READ 主要是为了兼容主从复制和 binlog 的某些场景,但现代业务里很多东西在 READ COMMITTED 下反而更简单。

5.3 行锁与乐观锁:如何正确扣减库存

事务并发时,最经典的竞态问题是“超卖”。两个用户同时下单买同一件商品,库存只有 1 件,两个事务都先查库存,都发现大于 0,然后各自扣减,最终库存变成 -1。

解决方案有两个方向。

第一个方案是用数据库行锁。在事务中执行SELECT ... FOR UPDATE,把这一行锁住,其他事务必须等这个事务提交后才能继续操作:

cursor.execute("SELECT stock FROM products WHERE id = %s FOR UPDATE", (5002,)) row = cursor.fetchone() if row["stock"] <= 0: raise Exception("库存不足") cursor.execute("UPDATE products SET stock = stock - 1 WHERE id = %s", (5002,))

FOR UPDATE是悲观锁思路,它认为冲突一定会发生,所以提前把所有可能冲突的行锁住,简单粗暴,但并发量大的时候容易造成锁等待和死锁。

第二个方案是乐观锁,用版本号或者条件更新的方式,认为冲突不常发生,在更新时通过 WHERE 条件来校验数据是否被修改过:

cursor.execute( "UPDATE products SET stock = stock - 1 WHERE id = %s AND stock > 0", (5002,) ) if cursor.rowcount == 0: raise Exception("库存不足")

这就是我前面代码里反复出现的写法。它是利用 MySQL 更新的原子性,stock > 0这个条件能保证只有库存大于 0 时才会更新成功。受影响行数为 0 则说明库存不足或者已经被其他事务改掉了。

两种方案没有绝对的优劣,取决于业务规模。库存扣减这种高频操作,我倾向于乐观锁 + 条件更新,代码简单,不需要考虑事务外层的锁等待;如果是金融转账这种对一致性极其敏感的场景,悲观锁反而更稳妥。

6. 实际项目中的常见问题与排查记录

这一部分是我最想写的,因为事务的坑几乎都是在线上才暴露出来的。下面这些问题是 Python + MySQL 事务处理中最高发的问题,每一个都是我自己或者身边同事真实踩过的。

6.1 事务没生效的 5 个检查点

很多人写完事务代码,测试下来发现数据不对,回滚没生效。遇到这种情况,我一般按下面的顺序排查:

  1. 检查autocommit是否关闭。如果autocommit=True,每次 SQL 执行完立刻提交,事务根本不存在。这是最高频的问题。
  2. 检查 commit 或 rollback 的位置。如果你把 commit 放在异常捕获之前,异常发生时事务已经被提交了,回滚就没有意义。
  3. 检查连接是否被连接池复用。有些连接池默认会把 autocommit 重置为 True,你在代码里设置的 autocommit=False 可能被覆盖。
  4. 检查是否跨了多个连接。事务是针对单个数据库连接的,如果第一条 SQL 用连接 A,第二条 SQL 用连接 B,它们根本不在同一个事务里。
  5. 检查 SQL 是不是涉及了不支持事务的存储引擎。MySQL 中 MyISAM 引擎不支持事务,表建错了引擎,commit 和 rollback 都不会生效。

检查过一遍之后,90% 的问题都能解决。

6.2 死锁与 Lock wait timeout exceeded

死锁是事务并发绕不开的问题。两个事务各自持有一个锁,又同时去申请对方持有的锁,谁都不让谁,数据库只能检测到死锁后强制回滚其中一个事务。

死锁在 Python 里的典型报错是:

Deadlock found when trying to get lock; try restarting transaction

还有一个很常见的报错是:

Lock wait timeout exceeded; try restarting transaction

这是单个事务等待锁的时间超过了innodb_lock_wait_timeout(默认 50 秒),MySQL 主动放弃等待。

遇到这种问题,不要只盯着一行代码,要检查整个业务链路的加锁顺序。比如事务 A 先更新订单表再更新库存表,事务 B 先更新库存表再更新订单表,两个事务交叉执行时就很可能死锁。解决办法很简单:所有事务里操作多张表时,都按同一个顺序加锁,比如先订单后库存,统一保持一致。

另外要注意:死锁发生后,被回滚的是 MySQL 选中的那个事务,Python 代码这边会收到异常。如果业务允许,可以做一次重试机制,捕获死锁异常后重新执行整个事务。但不要无限重试,一般重试 3 次以内比较合理。

6.3 事务里执行了 SELECT,但查不到自己的修改

这种情况也叫“当前读”和“快照读”的差异。在 REPEATABLE READ 隔离级别下,普通 SELECT 属于一致性快照读,读的是事务开始时的快照,即使自己在这个事务里已经 UPDATE 了数据并还没提交,再次 SELECT 时可能仍然是旧值。

解决方案是用“加锁读”:

cursor.execute("SELECT stock FROM products WHERE id = %s FOR UPDATE", (5002,))

加了FOR UPDATE的查询属于当前读,读取的是最新已提交的数据,并会对读出记录加锁。这样就能保证事务内读到的是最新、真实的数据。同理,SELECT ... LOCK IN SHARE MODE是加共享锁的当前读,可以允许其他事务继续读,但不能修改。

这个问题在 REPEATABLE READ 下特别容易碰到,比如你刚插入一条订单,转手去查询自己插入的订单列表,结果发现查不到,容易怀疑人生。通常这时候用普通查询即可,因为快照读对已提交的数据也很少产生问题;真正需要加锁读的场景是在“读完之后要更新同一批数据”的时候。

6.4 事务时间过长导致 binlog 堆积

还有一个很多人不会注意到的坑:事务如果长时间不提交,会一直持有事务产生的 undo log 和行锁,导致两个连锁反应。

第一个连锁反应是修改的数据行会积累大量历史版本,MySQL 的 undo log 膨胀,占用大量磁盘空间。

第二个连锁反应是主从复制时,binlog 要等事务提交才写入,大事务会直接拖慢复制进度,从库数据延迟严重。

所以在 Python 代码里,要避免在一个事务里做大量循环操作。比如一次性往事务里插入一万条数据,猛一看是一条事务,实际上远超合理范围。更合理的做法是分批提交,每 500 条或 1000 条commit()一次,既能保证单批操作的原子性,又不会拖垮数据库。

6.5 连接泄漏与 MySQL has gone away

事务处理代码里,如果某条路径上提前 return 了,没有执行finally里的连接关闭,连接就一直挂在数据库上。时间长了数据库连接数被占满,新的请求连接时就会报“Too many connections”,执行 SQL 时可能报“MySQL server has gone away”。

建议所有连接都加到finally或者上下文管理器里统一关闭,不要裸写conn = pymysql.connect(...)然后忘记关闭。生产环境建议直接用连接池,比如DBUtils或者SQLAlchemy内置的池,统一管理连接的生命周期,从根上避免连接泄漏。

7. 一套完整的事务处理最佳实践代码

把上面所有知识点汇总起来,我最后给出一个可以直接改造使用的核心工具模块。这个模块封装了连接管理、事务上下文、重试机制和日志输出,基本能覆盖绝大多数 Python + MySQL 事务处理场景。

import time import logging import pymysql from contextlib import contextmanager logger = logging.getLogger("mysql_transaction") class MySQLClient: def __init__(self, host, port, user, password, database, **kwargs): self.config = { "host": host, "port": port, "user": user, "password": password, "database": database, "autocommit": False, "charset": "utf8mb4", "cursorclass": pymysql.cursors.DictCursor, } self.config.update(kwargs) def connect(self): return pymysql.connect(**self.config) @contextmanager def transaction(self, retry_times=3): """事务上下文,支持死锁重试""" conn = self.connect() try: for attempt in range(retry_times): try: yield conn conn.commit() return except pymysql.err.OperationalError as e: if "Deadlock" in str(e) and attempt < retry_times - 1: logger.warning("发生死锁,第 %s 次重试", attempt + 1) conn.rollback() time.sleep(0.2 * (attempt + 1)) continue conn.rollback() raise except Exception: conn.rollback() raise finally: conn.close()

使用的时候:

client = MySQLClient( host="127.0.0.1", port=3306, user="root", password="yourpassword", database="shop_demo" ) with client.transaction() as conn: cursor = conn.cursor() cursor.execute( "UPDATE products SET stock = stock - 1 WHERE id = %s AND stock > 0", (5002,) ) if cursor.rowcount == 0: raise Exception("库存不足") cursor.execute( "INSERT INTO orders (user_id, product_id, amount) VALUES (%s, %s, %s)", (1001, 5002, 199.90) )

这套代码有几个地方是专门为事务设计的:

  • autocommit=False写死在配置里,防止调用方误改。
  • 事务上下文统一管理 commit 和 rollback。
  • 死锁时有 3 次重试机会,重试间隔递增,避免雪崩式重试。
  • 连接固定在用完即关,由上下文管理器负责,杜绝连接泄漏。
  • 所有异常向外抛出,由更上层决定是返回错误提示还是记录日志。

有人会问:连接每次事务都新建一个,开销会不会很大?这确实存在,所以生产环境应当引入连接池。连接池可以把连接复用起来,但事务的提交和回滚逻辑保持一模一样。

8. 站在实际项目中看事务处理的取舍

日常开发中,不是所有数据库操作都需要套事务模板。如果一个操作只涉及一条 SQL 的写入,比如只是更新一个字段、插入一条日志,MySQL 自动提交已经完全够用,强行包事务反而增加无谓的开销。

什么时候必须手动管理事务?我的判断标准很简单:只要一个业务流程包含两步或以上写操作,并且这些写操作之间存在逻辑上的“全有或全无”关系,就必须手动管理事务。反之,如果操作之间互不依赖、互相独立,即使其中一步失败也不影响其他步骤,那么各自独立提交反而更合理。

这个取舍的背后,本质是在一致性和可用性之间做权衡。有些人一听“一定要用事务”就觉得万事大吉,其实事务也是有代价的:锁竞争、性能下降、死锁风险、undo 日志膨胀。无脑用事务和完全不用事务,都是两个极端。

从我个人的实操体会来讲,事务处理最核心的不是学会用commit()和rollback()这两个方法,而是能够判断“什么该放事务、什么不该放事务”。加锁解决并发冲突、新增索引避免锁范围过大、缩短事务时间减少锁持有周期,这些配套手段往往比事务本身更能提升系统的稳定性。

最后再分享一个我实际用过的排查技巧:遇到事务相关的问题,先在 MySQL 命令行执行SHOW ENGINE INNODB STATUS,看其中的 LATEST DETECTED DEADLOCK 部分,能直接看到死锁产生的 SQL 语句、锁等待的关系,定位效率远高于在 Python 代码里瞎猜。把这一步当作压箱底的绝招用,能把排查时间缩短一半以上。

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

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

立即咨询