1. 事务到底在解决什么问题很多刚接触数据库的朋友会觉得事务这东西有点虚——平时写个增删改查加不加事务好像都能跑业务也没出过什么大乱子。但只要你经历过一次线上对账不平、库存扣成负数、或者转账转了一半系统崩了你就会明白事务不是锦上添花而是数据库能用在生产环境的底线。事务要解决的核心问题就一句话一组操作要么全做要么全不做中间不能被打断也不能只做一半。听起来简单但要在高并发、随时可能宕机、磁盘还可能写坏的现实环境里把这句话兑现背后是一整套相当精巧的机制。我拿一个最经典的场景来说。用户 A 给用户 B 转 100 块数据库层面至少是两条语句A 的账户减 100B 的账户加 100。这两条语句之间如果出了任何岔子——程序抛异常了、数据库进程被杀了、服务器断电了——只要有一条没执行钱就凭空消失或者凭空多出来。事务就是把这俩操作捆成一个不可分割的整体数据库向你保证这个整体要么完整生效要么像从来没发生过一样。这里有个容易被忽略的点事务保证的是数据库层面的原子性它管不了你业务代码里的逻辑。比如你在事务里先查余额再扣款查和扣之间如果有别的并发请求插进来照样可能出问题那是隔离级别要管的事。所以理解事务得把 ACID 四个特性拆开看它们各自解决不同维度的问题又互相配合。ACID 这四个字母大家都会背但真正理解它们之间的关系是后面所有讨论的基础。原子性Atomicity管的是全或全无一致性Consistency其实是目的而不是手段——它是说事务执行前后数据库的完整性约束不能被破坏比如唯一索引不能冲突、外键不能悬空。隔离性Isolation管的是并发事务之间互不干扰持久性Durability管的是提交之后数据不能丢。这四个里面原子性和持久性靠日志实现隔离性靠锁和版本控制实现一致性则是前三个共同作用的结果。我个人的经验是面试或者技术讨论里能把 ACID 讲清楚的人不少但能说清楚原子性具体靠什么日志、持久性又靠什么日志、两者为什么不能合并成一种的人就不多了。这篇文章就打算把这些底层的东西一层层剥开从日志讲到锁从隔离级别讲到 MVCC尽量让你看完之后脑子里能有一张完整的图而不是零散的知识点。2. 原子性和持久性两种日志撑起的两根柱子2.1 undo log 与原子性的实现逻辑原子性说白了就是反悔的能力。事务执行到一半发现不行了得能把已经做的改动撤回来。数据库实现这个反悔靠的是undo log也就是回滚日志。undo log 记录的是逻辑上的反向操作。注意它不是记录数据页的物理变化而是记录如果要撤销这条操作应该怎么做。比如你执行了一条UPDATE account SET balance balance - 100 WHERE id 1undo log 里记的不是把第 X 页第 Y 个字节改回原值而是记了一条把 id1 的 balance 加回 100这样的反向语句。这个区别很关键后面讲 MVCC 的时候还会用到。为什么用逻辑日志而不是物理日志因为逻辑日志更省空间而且能支持一些物理日志做不到的事情比如回滚的时候可以基于当前数据状态重新计算。物理日志你得精确记录每个字节的变化数据页稍微挪个位置就全乱了。undo log 还有一个很多人不知道的用途它不只服务于回滚还服务于 MVCC 的快照读。当你用可重复读隔离级别的时候一个事务读到的可能是历史版本的数据这些历史版本就是从 undo log 里翻出来的。所以 undo log 在事务提交之后不能立刻删得等没有事务再需要这些历史版本了才能清理。这也是为什么长事务特别危险——一个跑了半小时没提交的事务会让 undo log 一直堆积磁盘空间蹭蹭往上涨还可能拖慢整个系统的读性能。提示生产环境一定要监控长事务。我见过一个案例某个定时任务忘了提交事务跑了几个小时undo 表空间从几个 G 涨到几十个 G最后把磁盘写满了整个实例挂掉。排查的时候用information_schema.innodb_trx看trx_started就能找到元凶。2.2 redo log 与持久性的实现逻辑持久性解决的是另一个方向的问题事务提交了数据就不能丢。但这里有个矛盾——如果每次提交都把数据页刷到磁盘性能会惨不忍睹因为随机写磁盘太慢了。数据库的解法是WALWrite-Ahead Logging先写日志再写数据。redo log 记录的是物理层面的变化比如在某个表空间的某个数据页的某个偏移量处写入了什么值。它跟 undo log 的逻辑记录正好相反。为什么 redo 要用物理日志因为崩溃恢复的时候数据库需要快速重放物理日志重放起来最直接、最快不需要再做任何计算。这里有个关键概念叫LSNLog Sequence Number可以理解成日志的全局递增编号。每个数据页上都有一个 LSN记录这个页最后一次被修改时对应的日志位置。崩溃恢复的时候数据库从检查点checkpoint开始往后重放 redo log如果某个数据页的 LSN 已经大于等于要重放的日志 LSN说明这个页已经刷过了跳过就行。这个机制保证了恢复过程既完整又不会重复做无用功。redo log 是循环写的固定大小比如默认两个文件各 48M总共 96MMySQL 8.0 之后可以动态调整。写满了就得推进检查点把脏页刷盘腾出空间继续写。这就引出一个经典问题如果 redo log 写满了脏页又刷不动数据库会卡住。所以 redo log 的大小设置很讲究太小了会导致频繁刷脏页太大了崩溃恢复时间会变长。对比项undo logredo log记录内容逻辑反向操作物理页变化主要作用回滚、MVCC 快照读崩溃恢复、持久性写入时机事务执行过程中事务执行过程中生命周期提交后可能还需保留循环写检查点后可覆盖空间管理独立 undo 表空间固定大小循环使用2.3 两阶段提交redo 和 binlog 怎么保持一致如果你只用过 InnoDB可能不知道还有个binlog归档日志。redo log 是 InnoDB 引擎层的binlog 是 MySQL 服务层的。redo log 循环写会被覆盖binlog 是追加写、永久保留的主要用来做主从复制和数据恢复。问题来了一个事务提交的时候既要写 redo log又要写 binlog这俩怎么保证一致如果 redo 写完了、binlog 没写完主从就会不一致如果 binlog 写完了、redo 没写完崩溃恢复后主库数据又和从库对不上。MySQL 的解法是两阶段提交。提交过程分成 prepare 和 commit 两个阶段先写 redo log 并标记为 prepare 状态然后写 binlog最后把 redo log 标记为 commit 状态。崩溃恢复的时候如果发现 redo log 是 prepare 状态就去检查对应的 binlog 是否完整——完整就提交不完整就回滚。这样无论在哪一步崩溃主库和从库的数据都能保持一致。这个机制理解起来不难但实际运维里有个坑sync_binlog和innodb_flush_log_at_trx_commit这两个参数的组合。前者控制 binlog 刷盘频率后者控制 redo log 刷盘频率。两个都设成 1 是最安全的每次提交都刷盘但性能最差。很多互联网公司会设成sync_binlog1、innodb_flush_log_at_trx_commit2牺牲一点持久性换性能——redo log 每秒刷一次宕机可能丢一秒的数据但 binlog 是实时的主从不会丢。这个取舍要看业务能不能接受丢那一秒。3. 隔离性并发事务之间的博弈3.1 四种隔离级别与三种并发问题隔离性要解决的是并发事务互相干扰的问题。SQL 标准定义了四种隔离级别对应三种典型的并发问题。这三个问题我用一个转账场景串起来讲比干巴巴的定义好理解。脏读事务 A 改了数据但还没提交事务 B 就读到了这个未提交的值。如果 A 后来回滚了B 读到的就是脏数据。比如 A 把余额从 100 改成 200 还没提交B 读到 200 就以为钱多了结果 A 回滚B 白高兴一场。不可重复读事务 A 第一次读余额是 100事务 B 把余额改成 200 并提交事务 A 再读一次变成 200。同一个事务里两次读结果不一样这就是不可重复读。它的问题在于事务内的逻辑可能因此错乱。幻读事务 A 查余额大于 100 的账户有几条得到 3 条事务 B 插入了一条余额 150 的记录并提交事务 A 再查一次变成 4 条。多出来的那条就像幻觉一样所以叫幻读。幻读和不可重复读的区别在于不可重复读是同一行数据变了幻读是结果集的行数变了。隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不可能可能可能可重复读不可能不可能InnoDB 下基本不可能串行化不可能不可能不可能MySQL 的 InnoDB 默认隔离级别是可重复读而且通过 MVCC 加间隙锁在绝大多数场景下把幻读也解决了。这一点和 SQL 标准不太一样标准里可重复读是允许幻读的。所以经常有人问MySQL 可重复读到底能不能防幻读答案是快照读靠 MVCC 防住了当前读靠间隙锁防住了但如果你在事务里混用快照读和当前读还是可能踩坑这个后面细说。3.2 MVCC不加锁也能读一致的秘密MVCC多版本并发控制是 InnoDB 实现隔离性的核心武器。它的核心思想是读操作不加锁而是去读一个历史快照。这样读和写就不会互相阻塞并发性能大幅提升。每个 InnoDB 的行记录里除了用户数据还藏着几个隐藏字段DB_TRX_ID记录最后修改这行的事务 IDDB_ROLL_PTR指向 undo log 里的历史版本DB_ROW_ID是行 ID没有主键时用。当你读一行数据时InnoDB 会拿当前事务的Read View去判断这个版本能不能看见。Read View 里有几个关键信息当前活跃的事务 ID 列表、最小活跃事务 ID、下一个要分配的事务 ID。判断规则大致是如果行的DB_TRX_ID小于最小活跃事务 ID说明修改它的事务早就提交了可见如果大于等于下一个事务 ID说明是未来才开始的不可见如果在活跃列表里说明还没提交不可见。不可见的话就顺着DB_ROLL_PTR去 undo log 里找上一个版本直到找到一个可见的版本。这里有个非常重要的区别读已提交和可重复读生成 Read View 的时机不同。读已提交是每次 SELECT 都生成一个新的 Read View所以能看到别人刚提交的数据可重复读是事务开始时生成一次整个事务都用这一个所以两次读结果一样。这就是为什么可重复读能防不可重复读——不是数据没变而是你看到的快照没变。注意MVCC 只对快照读普通 SELECT生效。如果你用了SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、或者 UPDATE/DELETE这些都是当前读读的是最新数据并且要加锁。很多幻读的坑就出在事务里混用了快照读和当前读。3.3 锁机制当前读背后的秩序维护者快照读靠 MVCC当前读就得靠锁了。InnoDB 的锁按粒度分有表锁和行锁按模式分有共享锁S 锁和排他锁X 锁行锁里还有记录锁、间隙锁、临键锁三种形态。记录锁锁的是一条具体的索引记录。间隙锁锁的是两条记录之间的空隙防止别人往里面插数据。临键锁是记录锁加间隙锁的组合锁住一条记录和它前面的间隙。InnoDB 在可重复读级别下默认用的就是临键锁这也是它能防幻读的关键——你没法在我锁住的范围内插入新记录。间隙锁有个反直觉的地方它锁的是间隙不同事务可以对同一个间隙加间隙锁它们不冲突。因为间隙锁的目的是防止插入而不是互斥。两个事务都给 (5, 10) 这个间隙加了锁它们可以共存但谁都别想往里插 id7 的记录。这个特性导致间隙锁的冲突判断比记录锁复杂得多。还有一个经典问题没有索引的更新会锁全表。因为 InnoDB 的行锁是加在索引上的如果你 UPDATE 的 WHERE 条件没有走索引它就得扫描所有记录把每一条都锁上效果等同于锁表。我见过不少线上事故就是这么来的——一张几百万行的表一个没走索引的 UPDATE直接把整个表锁死所有请求排队。所以生产环境里UPDATE 和 DELETE 的 WHERE 条件一定要走索引这是铁律。4. 实操中怎么观察和验证事务行为4.1 用两个会话亲手复现隔离级别差异光看理论容易忘我建议你开两个 MySQL 客户端会话亲手把四种隔离级别跑一遍。下面这套操作我带着新人做过很多次效果比看十遍文档都好。先建一张测试表CREATE TABLE t_account ( id INT PRIMARY KEY, balance INT NOT NULL ) ENGINEInnoDB; INSERT INTO t_account VALUES (1, 100);然后开两个会话分别叫 A 和 B。把隔离级别设成读未提交SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;A 会话开启事务并更新但不提交-- 会话 A BEGIN; UPDATE t_account SET balance 200 WHERE id 1;B 会话去读-- 会话 B SELECT balance FROM t_account WHERE id 1;你会看到 B 读到了 200这就是脏读。然后把隔离级别改成读已提交重复上面的操作B 就读到 100 了脏读被挡住。接着在 A 提交后B 再读一次读已提交下会变成 200可重复读下还是 100。这样一步步对比四种级别的差异就非常直观了。4.2 查看当前事务和锁等待的实用命令线上排查事务问题有几个命令是必须掌握的。第一个是看当前正在跑的事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;trx_started越早说明事务跑得越久trx_query能看到它当前在干什么。如果发现一个事务跑了几分钟还没结束基本可以判定是长事务得赶紧找对应线程处理。第二个是看锁等待SELECT * FROM performance_schema.data_lock_waits;这个表能告诉你谁在等谁的锁。MySQL 8.0 之前用的是information_schema.innodb_lock_waits8.0 之后换成了 performance_schema 里的表。配合performance_schema.data_locks能看到具体的锁信息包括锁的类型、锁在哪个索引上、锁的模式是什么。第三个是看引擎状态SHOW ENGINE INNODB STATUS;这个输出信息量巨大里面有最近一次死锁的详细信息包括两个事务各自持有什么锁、在等什么锁、执行的 SQL 是什么。死锁排查基本就靠它。提示SHOW ENGINE INNODB STATUS的输出里LATEST DETECTED DEADLOCK那一段是排查死锁的黄金信息。但要注意它只保留最近一次死锁如果死锁频繁发生最好开启死锁日志记录到错误日志里方便事后分析。4.3 死锁的成因与规避思路死锁是并发事务互相等对方释放锁造成的。最典型的场景是两个事务以相反的顺序更新同一批记录。比如事务 A 先更新 id1 再更新 id2事务 B 先更新 id2 再更新 id1两边各持有一把锁又都在等对方的锁就死锁了。InnoDB 有死锁检测机制发现死锁后会主动回滚其中一个事务通常是修改行数少的那个让另一个继续。所以死锁本身不可怕可怕的是频繁死锁导致大量事务被回滚业务成功率下降。规避死锁的思路有这么几条。第一固定加锁顺序。所有事务都按同一个顺序访问资源比如都按 id 升序更新就不会出现交叉等待。第二缩短事务长度。事务越长持有锁的时间越久冲突概率越大。第三减少加锁范围。能用唯一索引精确定位的就别用范围条件。第四必要时降级隔离级别。读已提交没有间隙锁死锁概率会低一些但代价是可能幻读。死锁成因典型场景规避手段加锁顺序相反两事务交叉更新多行统一按主键升序操作事务过长事务里夹杂远程调用拆分事务先算后写范围锁冲突范围更新加间隙锁精确条件走唯一索引索引缺失无索引更新锁全表补索引避免全表扫描5. 几个容易踩坑的实战细节5.1 长事务为什么是万恶之源长事务的危害我在前面提过这里展开说说。一个长事务不提交会带来三个连锁反应。undo log 无法清理历史版本一直堆积磁盘空间持续增长同时快照读要遍历的版本链越来越长读性能下降。锁持有时间过长其他事务等锁等到天荒地老连接池被占满整个服务雪崩。主从延迟加剧大事务在从库重放时间长主从数据不一致的窗口拉大。我处理过一个线上问题某个报表查询事务忘了提交跑了十几分钟期间所有对相关表的更新全部阻塞前端页面转圈转到用户投诉。后来加了事务超时配置innodb_lock_wait_timeout和max_execution_time才缓解。但根本解法还是代码层面保证事务尽快提交别在事务里做网络请求、文件读写这些耗时操作。5.2 可重复读下幻读的真实案例很多人以为可重复读就万事大吉了其实有个经典陷阱。假设你要做先查有没有没有就插入的逻辑-- 事务 A BEGIN; SELECT * FROM t_user WHERE username abc; -- 快照读没查到 -- 此时事务 B 插入了 usernameabc 并提交 INSERT INTO t_user (username) VALUES (abc); -- 当前读插入成功 COMMIT;如果 username 没有唯一索引事务 A 的 INSERT 会成功结果就是两条 abc 记录。因为 SELECT 是快照读看不到 B 的插入INSERT 是当前读但插入本身不检查唯一性没唯一索引的话。这就是快照读和当前读混用导致的幻读。解法有两个要么给 username 加唯一索引让 INSERT 直接报错要么 SELECT 的时候用FOR UPDATE走当前读提前把间隙锁住。生产环境里这种查了再插的逻辑一定要配唯一索引兜底别指望隔离级别能帮你兜住所有情况。5.3 事务提交了但数据丢了是怎么回事偶尔会有人问明明事务提交成功了为什么重启后数据没了这种情况大概率是持久性配置的问题。前面说过innodb_flush_log_at_trx_commit这个参数如果设成 0 或 2redo log 不是每次提交都刷盘宕机就可能丢数据。设成 0 是每秒刷一次设成 1 是每次提交都刷设成 2 是每次提交写到操作系统缓存但不刷盘。设成 2 的时候如果只是 MySQL 进程崩了数据还在操作系统缓存里不会丢但如果操作系统也崩了或者断电缓存里的数据就没了。所以对数据安全性要求高的业务这个参数必须是 1。还有一个隐蔽的坑从库的持久性配置。主库设成 1 保证了数据不丢但如果从库设成 0主库宕机切到从库时从库可能还没把最新的 redo log 刷盘切换后就丢了。所以主从的持久性配置要一致别只顾主库。6. 把事务知识串成一张图事务这块知识零散看的时候觉得东西很多但串起来其实就三条线。第一条是日志线undo log 管回滚和 MVCCredo log 管持久性binlog 管复制两阶段提交保证 redo 和 binlog 一致。第二条是隔离线MVCC 管快照读锁管当前读Read View 的生成时机决定隔离级别间隙锁决定能不能防幻读。第三条是实践线长事务要监控死锁要预防持久性参数要配对查了再插要加唯一索引。我个人的体会是事务这东西光看文档学不透必须动手跑。开两个会话把隔离级别跑一遍故意制造一次死锁看看日志用SHOW ENGINE INNODB STATUS观察锁等待这些操作做过一次比看十篇文章都管用。而且事务的问题往往不是孤立出现的它和索引、锁、日志、复制都有关联理解的时候要有全局视角别把它当成一个孤立的知识点。最后分享一个我常用的排查套路遇到事务相关的性能问题先看innodb_trx找长事务再看data_lock_waits找锁等待最后看INNODB STATUS找死锁。这三板斧下去大部分问题都能定位到。至于怎么解决那就回到前面说的——缩短事务、统一加锁顺序、补索引、调参数对症下药就行。
阅读完成 · 觉得有帮助?