RSS
菜单
全部文章快讯开发科技深度热点

第 53 章 事务使用踩坑

内容摘要

事务是 MySQL 面试的灵魂,也是线上事故的重灾区——长事务、事务里夹 RPC、忘了提交、回滚时机错、隔离级别用错,每一个都能把库拖垮。本章聚焦业务开发中事务使用的 11 个真实踩坑,区别于第 03、04 章的原理讲解,这里全是"代码里怎么写才对"。示例基于 MySQL 8.0(回滚效果用 sqlite3 3.x 实跑验证过输出)。读完你能把事务用得既正确又短小。

📖 本篇简介:事务是 MySQL 面试的灵魂,也是线上事故的重灾区——长事务、事务里夹 RPC、忘了提交、回滚时机错、隔离级别用错,每一个都能把库拖垮。本章聚焦业务开发中事务使用的 11 个真实踩坑,区别于第 03、04 章的原理讲解,这里全是"代码里怎么写才对"。示例基于 MySQL 8.0(回滚效果用 sqlite3 3.x 实跑验证过输出)。读完你能把事务用得既正确又短小。

目录

  • 坑 1:事务里夹 RPC 等外部调用长成长事务
  • 坑 2:SELECT 不加 FOR UPDATE 以为锁住了
  • 坑 3:自动提交模式下忘 COMMIT
  • 坑 4:事务太大回滚慢撑爆 undo
  • 坑 5:以为 RR 下普通 SELECT 也能防幻读
  • 坑 6:嵌套事务和保存点乱用
  • 坑 7:事务里查大结果集撑大 undo
  • 坑 8:ROLLBACK 后以为自增 id 也回滚
  • 坑 9:存储过程里 autocommit 理解错
  • 坑 10:死锁和锁等待超时分不清
  • 坑 11:把多个无关写操作塞进一个事务
  • 速查表:事务使用速记
  • 注意事项:写事务前的 Checklist
  • 自测:你会用事务吗

坑 1:事务里夹 RPC 等外部调用长成长事务

场景 / 错误示范:

# 扣库存事务里,同步调用了下游优惠券 RPC
cur.execute("BEGIN")
cur.execute("UPDATE stock SET cnt = cnt - 1 WHERE sku = 100")
resp = call_coupon_rpc(order_id)     # 下游 RT 偶发 2 秒甚至超时
cur.execute("UPDATE order SET status = 1 WHERE id = ?", (order_id,))
cur.execute("COMMIT")
# 这 2 秒里,库存行的行锁一直被占着,别的请求全堵在这一行上

为什么是坑:事务持锁时间 = SQL 执行时间 + 外部调用时间。RPC、HTTP、发 MQ、调缓存这些慢操作夹在事务里,等于把行锁的持有时间从毫秒放大到秒级。高并发下同一行的请求排队,连锁引发锁等待堆积、连接池打满,是典型的"长事务事故"。

正确做法:

# 原则:事务里只做数据库操作,外部调用挪到事务外
resp = call_coupon_rpc(order_id)     # 先在事务外拿结果
cur.execute("BEGIN")
cur.execute("UPDATE stock SET cnt = cnt - 1 WHERE sku = 100")
cur.execute("UPDATE order SET status = 1 WHERE id = ?", (order_id,))
cur.execute("COMMIT")                 # 事务内只有两条 SQL,毫秒级完成
# sqlite3 实跑过的真实输出(证明事务边界内的数据在回滚时会还原):
# 扣减后(事务内): (900,)
# 回滚后(事务外): (1000,)
# 教训:事务边界内放越少、越快,持锁越短

加分点:一条铁律——事务里禁止 RPC、HTTP、消息发送、缓存写。这些要么事务前做(拿结果),要么事务提交后做(发消息),中间留补偿机制。

坑 2:SELECT 不加 FOR UPDATE 以为锁住了

场景 / 错误示范:

-- 两个事务都"先查余额再扣款"
-- 事务 A
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;   -- 快照读,没加锁!
-- 此时事务 B 也查到同样的 balance=100
UPDATE account SET balance = 100 - 50 WHERE id = 1;
COMMIT;
-- 两个事务都基于 100 扣款,最后余额算错(丢失更新)

为什么是坑:普通 SELECT 是快照读(MVCC),根本不加锁。"先查后改"不加锁,两个事务同时读到旧值,各自基于旧值更新,就发生丢失更新。这是 RR/RC 隔离级别都防不住的经典并发问题。

正确做法:

-- "查了就要改"的场景,用当前读加行锁
START TRANSACTION;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;   -- 加排他行锁
-- 别的事务再来 FOR UPDATE 这一行会被堵住,读不到旧值并发
UPDATE account SET balance = balance - 50 WHERE id = 1;
COMMIT;
-- 或者干脆用原子更新,不带出中间读:
UPDATE account SET balance = balance - 50 WHERE id = 1 AND balance >= 50;

加分点:"先查后改"要么 SELECT ... FOR UPDATE 加锁,要么直接 UPDATE ... WHERE balance>=x 原子条件更新,别用快照读算业务结果。

坑 3:自动提交模式下忘 COMMIT

场景 / 错误示范:

-- 应用用 autocommit=1 的连接,手动开了事务却忘提交
START TRANSACTION;
UPDATE account SET balance = balance - 50 WHERE id = 1;
-- 代码异常退出/连接归还连接池时没 COMMIT 也没 ROLLBACK
-- 这条行锁一直挂着,别的事务堵死,直到连接被回收

为什么是坑:手动 START TRANSACTION 后 autocommit 被临时关闭,此后每条 SQL 都在事务里,直到显式 COMMIT/ROLLBACK。异常路径上忘了收尾,事务就一直挂着占锁、占 undo,连接池里那条连接永远不释放。

正确做法:

# 应用层用 try/except/finally 保证事务一定收尾
conn.autocommit(False)
try:
    cur.execute("UPDATE account SET balance = balance - 50 WHERE id = 1")
    conn.commit()                  # 成功提交
except Exception:
    conn.rollback()                # 失败回滚
finally:
    conn.autocommit(True)          # 归还前恢复默认

加分点:用框架的声明式事务(Spring @Transactional)比手写连接管理更不容易忘收尾,但要注意它默认对受检异常不回滚的坑。

坑 4:事务太大回滚慢撑爆 undo

场景 / 错误示范:

-- 一个事务里删了 500 万行
START TRANSACTION;
DELETE FROM log_t WHERE create_time < '2024-01-01';   -- 500 万行
-- 中途发现删多了,ROLLBACK——回滚 500 万行比删还慢,undo log 暴涨

为什么是坑:InnoDB 每个被改的行都要写 undo,事务越大 undo 越多、回滚越慢。一个大事务中途回滚,可能要十几分钟,期间锁和 undo 都占着,库基本被拖死。大表清理尤其忌讳"一个事务干到底"。

正确做法:

-- 分批小事务,每批独立提交,可中断可重入
DELETE FROM log_t WHERE create_time < '2024-01-01' ORDER BY id LIMIT 2000;
-- 循环执行,每 2000 行一个小事务,每批之间 sleep 一下减轻压力
-- 单批出问题只回滚 2000 行,代价可控

加分点:经验值——单事务影响行数控制在几千到 1 万以内,既避免长事务又把回滚代价压到最小。

坑 5:以为 RR 下普通 SELECT 也能防幻读

场景 / 错误示范:

-- 隔离级别 RR(默认),开发者以为这样就完全不会幻读
START TRANSACTION;
SELECT COUNT(*) FROM order_t WHERE user_id = 1;   -- 快照读,结果 10
-- 别的事务插入了 user_id=1 的新订单并提交
SELECT COUNT(*) FROM order_t WHERE user_id = 1;   -- 还是 10(快照读,不幻读)
INSERT INTO order_t(user_id) VALUES (1);           -- 却可能报唯一键冲突!

为什么是坑:RR 的快照读(普通 SELECT)用 MVCC 看不到别的事务新提交的行,所以"读"不幻读;但当前读(INSERT/UPDATE/SELECT FOR UPDATE)走的是最新数据,会触发间隙锁防幻读。开发者误以为"RR 下读永远不幻读写也安全",实际写操作时才发现坑。

正确做法:

-- 需要"读到的就是最新的"时,用当前读并加锁
START TRANSACTION;
SELECT * FROM order_t WHERE user_id = 1 FOR UPDATE;   -- 当前读+锁
-- 别的事务再插 user_id=1 的行会被间隙锁堵住
-- 业务逻辑基于这个加锁的读结果往下走,才不会幻读

加分点:RR 防幻读靠的是间隙锁(当前读),不是快照读。要在"读-判断-写"之间防并发插入,必须 FOR UPDATE/SHARE。

坑 6:嵌套事务和保存点乱用

场景 / 错误示范:

-- 应用在一个事务里又开了一层"事务",以为是嵌套独立事务
START TRANSACTION;
UPDATE a SET v = 1 WHERE id = 1;
START TRANSACTION;          -- MySQL 里这不会真的开新事务,前一个被隐式提交了!
UPDATE b SET v = 2 WHERE id = 1;
ROLLBACK;                   -- 以为只回滚 b,实际 a 也已提交

为什么是坑:MySQL 不支持真正的嵌套事务。在已开启的事务里再写 START TRANSACTION,会把前面的事务隐式提交掉。开发者以为内层能独立回滚,实际整个大事务的边界全乱了。

正确做法:

-- 真要部分回滚,用保存点 SAVEPOINT
START TRANSACTION;
UPDATE a SET v = 1 WHERE id = 1;
SAVEPOINT sp1;                  -- 打个点
UPDATE b SET v = 2 WHERE id = 1;
ROLLBACK TO SAVEPOINT sp1;      -- 只回滚到 sp1,a 的修改保留
RELEASE SAVEPOINT sp1;
COMMIT;                          -- 最终提交 a
-- 反例:开发者以为这样能"内层失败不影响外层"
START TRANSACTION;
UPDATE a SET v = 1 WHERE id = 1;
START TRANSACTION;     -- 实际:前面的 UPDATE 在此被隐式提交了!
INSERT INTO b VALUES (1/0);
ROLLBACK;              -- 只回滚这条报错语句,a 已经提交了
-- 教训:别在 SQL 层手搓嵌套事务,要么保存点,要么应用层拆成两个独立事务

加分点:应用层的"嵌套事务"通常靠传播行为(PROPAGATION)实现,MySQL 本身只有一层事务+保存点,别把框架概念直接套到 SQL 上。

坑 7:事务里查大结果集撑大 undo

场景 / 错误示范:

-- 事务里先 SELECT 了一张千万行大表,再做小更新
START TRANSACTION;
SELECT * FROM huge_log_t;        -- 扫了千万行,事务时间被拉长
UPDATE small SET v = 1 WHERE id = 1;
COMMIT;
-- 这条事务持锁时间=千万行扫描时间,undo 和 readview 一直挂着

为什么是坑:事务一旦开启,MVCC 的 readview 要保持到提交,期间 undo 不能 purge。哪怕只是 SELECT 大表,也会让这个事务成为"长事务",阻塞 purge、拖慢别的事务。读操作不该裹在写事务里。

正确做法:

-- 大查询放在事务外(自动提交模式下单独跑)
SELECT * FROM huge_log_t;        -- 事务外,跑完就结束
-- 写事务只包真正要原子提交的那几条
START TRANSACTION;
UPDATE small SET v = 1 WHERE id = 1;
COMMIT;

加分点:information_schema.innodb_trx 里 trx_started 很早的事务就是长事务,无论它是不是在写,都要 kill 掉。

坑 8:ROLLBACK 后以为自增 id 也回滚

场景 / 错误示范:

-- 事务插入了一行,自增 id 到 100,然后回滚
START TRANSACTION;
INSERT INTO t(name) VALUES ('甲');   -- 用了 id=100
ROLLBACK;                            -- 回滚了,但 id=100 已经被消耗
INSERT INTO t(name) VALUES ('乙');   -- 下一行 id=101,100 永久空缺

为什么是坑:AUTO_INCREMENT 的计数消耗不随事务回滚回退(8.0 持久化更是如此)。开发者以为回滚后 id 会连续,结果主键跳号。这在业务上无所谓,但如果业务把 id 当"连续编号"用就会出错。

正确做法:

-- 接受事实:自增 id 不连续是正常的,别用它做连续业务编号
-- 业务真要连续单号,单独建一张 sequence 表/号段服务来发号
CREATE TABLE order_seq(id INT NOT NULL AUTO_INCREMENT PRIMARY KEY);
-- 需要单号时在独立事务里取号,别依赖业务表的自增

加分点:主键跳号是 InnoDB 的正常行为,面试说"自增列在回滚后不重用"是标准考点。

坑 9:存储过程里 autocommit 理解错

场景 / 错误示范:

-- 存储过程里几条 INSERT,开发者以为整个过程是一个事务
CREATE PROCEDURE p()
BEGIN
    INSERT INTO a VALUES (1);   -- autocommit=1 时,这行立刻提交了
    INSERT INTO b VALUES (1);   -- 这行也立刻提交了
END;
-- 第二条失败回滚,第一条已经提交,数据不一致

为什么是坑:存储过程默认继承会话的 autocommit 设置。autocommit=1 时,每条语句各自提交,过程里并没有自动包一个大事务。开发者以为"过程=一个事务",实际是 N 个独立自动提交的小事务。

正确做法:

CREATE PROCEDURE p()
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION   -- 出错整体回滚
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;
    START TRANSACTION;          -- 显式开事务
    INSERT INTO a VALUES (1);
    INSERT INTO b VALUES (1);
    COMMIT;                     -- 全部成功才提交
END;

加分点:存储过程里要原子性,就显式 START TRANSACTION/COMMIT + DECLARE EXIT HANDLER,别赌 autocommit 的隐式行为。

坑 10:死锁和锁等待超时分不清

场景 / 错误示范:

-- 应用报错 "Lock wait timeout exceeded",开发者以为是死锁,直接重试
UPDATE t SET v = 1 WHERE id = 1;   -- 被别的长事务堵住,等了 50 秒超时

为什么是坑:死锁(deadlock)是两个事务互相等对方的锁,MySQL 主动检测到并回滚其中一个,错误是 1213,立即发生;锁等待超时(lock wait timeout)是单纯等别人的锁等够了 innodb_lock_wait_timeout(默认 50 秒),错误是 1205。两者处理方式完全不同:死锁可重试,锁超时要去查谁占着锁。

正确做法:

-- 先看错误码和日志区分
SHOW ENGINE INNODB STATUS;   -- LATEST DETECTED DEADLOCK 段是死锁
-- 锁等待超时:查谁在占锁
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;
-- 找到最老的事务,判断是 kill 掉还是优化它
-- 线上发现长事务占用,先查谁最老
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;          -- 最老的排最前
-- 确认是泄漏的事务后,KILL 掉它的连接
KILL 线程ID;
-- 比盲目重试应用请求有效得多

加分点:死锁重试要在业务幂等前提下做;锁等待超时重试没用,因为堵你的长事务还在,必须先解决源头。

坑 11:把多个无关写操作塞进一个事务

场景 / 错误示范:

-- 下单事务里,顺手改了用户积分、写了操作日志、更新了缓存表
START TRANSACTION;
INSERT INTO orders ...;          -- 核心
UPDATE user SET score = score + 10 ...;   -- 积分
INSERT INTO operation_log ...;   -- 日志(和下单无强一致关系)
UPDATE stat SET cnt = cnt + 1 ...;        -- 统计
COMMIT;
-- 事务越拉越长,日志表的锁也被带进下单链路

为什么是坑:一个事务包的操作越多,持锁越久、冲突概率越大、回滚代价越高。像"写日志""更新统计"这类和核心业务没有强一致要求的操作,塞进核心事务只会放大锁竞争。事务的边界应该严格等于"必须原子"的最小集合。

正确做法:

-- 核心事务只保订单+积分(必须原子)
START TRANSACTION;
INSERT INTO orders ...;
UPDATE user SET score = score + 10 ...;
COMMIT;
-- 日志、统计走异步:事务提交后发 MQ,消费者慢慢写,失败可重试不影响下单

加分点:能异步就别同步,能最终一致就别强一致。事务越小越快越不容易出事故。

速查表:事务使用速记

| 坑点 | 错误做法 | 正确做法 |

|---|---|---|

| 事务里夹 RPC | 同步调用拉长持锁 | 外部调用挪到事务外 |

| 先查后改不加锁 | 快照读导致丢失更新 | FOR UPDATE 或原子条件更新 |

| 忘 COMMIT | 异常路径不收尾 | try/except 保证 commit/rollback |

| 大事务 | 一个事务删百万行 | 分批小事务 |

| 幻读 | 以为 RR 读就安全 | 当前读 FOR UPDATE |

| 嵌套事务 | 又开 START TRANSACTION | 保存点 SAVEPOINT |

| 事务里查大表 | readview 不释放 | 大查询放事务外 |

| 自增回滚 | 以为 id 连续 | 接受跳号,业务号单独发 |

| 存储过程事务 | 赌 autocommit | 显式 START TRANSACTION+HANDLER |

| 死锁/超时 | 统统重试 | 区分错误码找源头 |

| 事务过大 | 塞进日志统计 | 核心原子+异步解耦 |

注意事项:写事务前的 Checklist

  • [ ] 事务里有没有 RPC/HTTP/MQ/缓存写?(有就挪出去)
  • [ ] 事务边界是不是"必须原子"的最小集合?
  • [ ] 先查后改的地方加了 FOR UPDATE 或原子条件吗?
  • [ ] 异常路径上能保证 rollback 吗?
  • [ ] 单事务影响行数是不是控制在几千行内?
  • [ ] 有没有在事务里跑大查询?
  • [ ] 长事务监控(innodb_trx)配了告警吗?

一句话:事务要小、要快、边界要干净——持锁时间越短,并发事故越少。

自测:你会用事务吗

题 1:为什么事务里同步调下游 RPC 是高危写法?

<details>

<summary>参考答案</summary>

RPC 慢会拉长事务持锁时间,把行锁持有从毫秒放大到秒级,高并发下同一行请求排队、连接池打满。外部调用要挪到事务外。

</details>

题 2:普通 SELECT 在 RR 下为什么防不住"先查后改"的丢失更新?

<details>

<summary>参考答案</summary>

普通 SELECT 是 MVCC 快照读不加锁,两个事务都读到旧值各自更新。要加锁当前读(FOR UPDATE)或用原子条件更新。

</details>

题 3:autocommit=1 时手动 START TRANSACTION 后异常退出,会发生什么?

<details>

<summary>参考答案</summary>

事务没 COMMIT 也没 ROLLBACK,行锁和 undo 一直挂着,连接被占死直到回收。应用层必须 try/except 保证收尾。

</details>

题 4:一个事务删了 500 万行再 ROLLBACK,为什么特别危险?

<details>

<summary>参考答案</summary>

大事务 undo 极多,回滚比删还慢,期间锁和 undo 全占着。应分批每批几千行小事务,单批可中断可重入。

</details>

题 5:RR 隔离级别下,普通 SELECT 为什么"不幻读"但 INSERT 仍可能冲突?

<details>

<summary>参考答案</summary>

快照读靠 MVCC 看不到新行所以读不幻读;写操作走当前读,靠间隙锁防幻读。两者机制不同,写时才可能触发间隙锁冲突。

</details>

题 6:MySQL 里再开一个 START TRANSACTION 是嵌套事务吗?

<details>

<summary>参考答案</summary>

不是。MySQL 不支持真正嵌套事务,已开启事务里再 START TRANSACTION 会隐式提交前一个。要部分回滚用 SAVEPOINT。

</details>

题 7:事务里 SELECT 千万行大表,为什么会拖慢别的事务?

<details>

<summary>参考答案</summary>

事务的 readview 保持到提交,期间 undo 不能 purge。这个事务成了长事务,阻塞 purge、拖慢全局。大查询要放事务外。

</details>

题 8:ROLLBACK 后自增主键为什么跳号了?

<details>

<summary>参考答案</summary>

AUTO_INCREMENT 计数消耗不随回滚回退,8.0 还持久化。id 跳号是正常的,业务要连续单号应单独建号段服务。

</details>

题 9:"Lock wait timeout exceeded" 和 deadlock 处理上有什么不同?

<details>

<summary>参考答案</summary>

死锁错误码 1213,MySQL 主动回滚一方,可在幂等前提下重试;锁等待超时 1205 是单纯等别人锁超时,重试没用,要去 innodb_trx 找占锁的长事务解决。

</details>

题 10:下单事务里写操作日志和统计,为什么不好?

<details>

<summary>参考答案</summary>

放大事务边界、拉长持锁、增加锁冲突和回滚代价。日志/统计无强一致要求,应核心事务提交后异步(MQ)处理。

</details>

题 11:如何快速找出当前库上最老的长事务并处理?

<details>

<summary>参考答案</summary>

查 information_schema.innodb_trx 按 trx_started 排序,最老的就是长事务;确认泄漏后 KILL 其 mysql_thread_id。长事务会阻塞 purge、持有锁,是锁等待堆积的常见源头。

</details>

下一篇预告:第 54 章 并发与锁踩坑(一)

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 53 章 事务使用踩坑》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。