1. 一条SQL从客户端到InnoDB的完整路径
1.1 连接器:会话不是一锤子买卖
你打开终端,敲下mysql -u root -p,输入密码回车,看到欢迎横幅的同时,MySQL服务端实际上只做了一件看起来简单的事:创建一条会话。这一步不是InnoDB干的,而是MySQL Server层的连接器负责的。连接器会做TCP握手、校验用户名和密码、读取当前账号的权限元数据,然后把这个会话挂到线程池或临时线程上。从这一刻开始,你在这个会话里能操作哪些表、哪些字段,不再每次请求时都查一遍MySQL库,而是直接使用连接器在握手时缓存下来的权限集。这也是为什么线上给账号改了权限后,要求业务重新建立连接才生效——不是系统反应慢,是权限集在会话建立时就已经固定了。
连接器还负责处理那些“看起来像SQL问题”的坑。比如报错MySQL server has gone away,经常是wait_timeout或interactive_timeout把空闲连接断掉了,业务侧连接池里的旧连接还没感知。又比如max_allowed_packet设置太小,一个批量插入的报文发不过来,连接器会直接拒绝。再比如认证插件不匹配,客户端是旧版libmysqlclient,服务端是MySQL 8.0默认的caching_sha2_password,也会卡在握手阶段。这里我建议你排查Session超时问题时,先看performance_schema里的events_statements_current,而不是一上来就追SQL执行计划。会话层的问题特征很明确:SQL根本没进去,慢日志里没有记录。
1.2 解析与优化:语法树和成本模型
连接器验明正身之后,SQL进入解析器。解析器做的是词法分析和语法分析,把select * from t where id = 1拆成关键字、表名、字段、常量,然后构建一棵语法树。这一步只校验“你能不能写出这句话”,不校验“这张表存不存在、这个字段对不对”。所以一旦报Unknown column,说明已经过了语法检查,是在语义阶段才失败的,你直接怀疑字段名拼写就行。
语法树构建完,真正的决策者是优化器。MySQL优化器不是“硬猜”的,它基于存储引擎提供的统计信息,计算各种执行路径的成本。比如全表扫描要读多少页、走二级索引要回表多少次、多表连接先连哪张更划算,最终选一个它认为成本最低的执行计划。很多人以为WHERE条件的书写顺序会影响执行计划,其实5.7和8.0的优化器不会这么傻,它会把条件重排列,但如果你用了OR连接多个非索引条件,优化器往往只能选全表扫描,这不是优化器笨,而是没有更好的路径可选。
这里有一个容易被忽略的点:优化器选错索引,往往不是优化器的问题,而是统计信息不够新。如果一张表频繁增删改,但ANALYZE TABLE从没跑过,优化器拿到的“大概有多少行”可能严重失真。我在线上遇到过一张表实际只有2万行,但统计信息告诉优化器有200万行,它死活不走索引,FORCE INDEX之后执行时间从2秒降到20毫秒。所以看到不合理执行计划,先别骂优化器,先检查information_schema.statistics和表的行数估算。
1.3 执行器与存储引擎接口:谁在真正干活
执行计划确定后,执行器登场。执行器首先会判断当前用户是否对目标表有权限,这也是为什么你在连接器阶段权限校验没错,但执行具体SQL时仍可能报权限不足。然后执行器调用存储引擎的Handler接口,把计划一步步落下去。对InnoDB来说,执行器说“我要从第1行开始扫描”,InnoDB就从缓冲池或磁盘里取回第一个满足条件的行;执行器说“这行不满足WHERE”,InnoDB就继续取下一行;直到InnoDB返回“没有更多行”。
这个分工很关键:Server层只负责“编排”,真正读取数据页、判断索引范围、返回行数据的是InnoDB。执行器每次从InnoDB拿一行,都会累加rows_examined,慢日志里的Rows_examined字段就是执行器“向存储引擎要了多少行”,而不是“最终返回了多少行”。理解了这条路径,你再看最典型的慢查询:一张表建了索引但没走,执行器大概要扫描几十万行;走了索引但回表次数过多,执行器向InnoDB发起的单页读取也会高达几万次。这两类问题的本质都不一样,前者是优化器选路问题,后者是数据组织方式问题,得用不同手段去解。
2. InnoDB的内存与磁盘布局:数据到底放在哪
2.1 页、区、段和表空间
MySQL 8.0里,一张InnoDB表的数据和索引最终落在表空间文件中。逻辑上,InnoDB把空间划分成段(Segment)、区(Extent)、页(Page)三层。一页默认16KB,一区默认1MB,包含连续64个页;段是B+树索引的管理单位,一个索引至少对应两个段:叶子节点段和非叶子节点段。很多人天天说B+树,但没意识到B+树不是纯逻辑概念,它的每一个节点都对应一个16KB的物理页。页是InnoDB和磁盘交互的最小单位,哪怕你只查询一行记录,InnoDB也要把整页16KB读进内存。
为什么要扯这个结构?因为在理解IO放大时,这是底层依据。你在二级索引上等值查到一条记录,回表读取聚簇索引时,InnoDB读取的不是“那条记录”,而是包含那条记录的整个页。一次回表往往要一次随机读,一次随机读在传统机械硬盘上约消耗几毫秒,如果SQL需要回表几万次,慢是必然的。后来SSD普及了,随机读延迟降到几十微秒,但也不能无脑回表,覆盖索引的思路依然很重要。
B+树能存多少行,也可以从页大小直接估算。假设主键是BIGINT(8字节),页指针6字节,非叶子节点一条索引项占14字节,一个16KB页大约能放1170个索引项。假设一行数据1KB,一个叶子页放16行。三层的B+树,第一层是根,第二层1170个分支,第三层叶子页数量就是1170×1170,约137万个页,乘以16行,约2190万行。这就是为什么InnoDB索引深度通常3到4层就能支撑千万级表。如果主键从8字节变成128字节的UUID,非叶子节点一条索引项占134字节,一个页只能放约122个索引项,同样三层树最多只能支撑约24万行,性能断崖式下降。顺便说一句,这就是我一直反对无脑用UUID当主键的原因,它不只是随机写问题,也在数学上减少了一棵树的容量。
2.2 Buffer Pool与Change Buffer:内存命中率决定快慢
理解了页之后,再看InnoDB的缓存机制就顺了。所有数据页的读写都先经过Buffer Pool。理想状态下,一个热点表的所有页都常驻内存,SQL基本不产生磁盘IO;如果内存放不下,InnoDB只能按照LRU策略淘汰冷页。但InnoDB对LRU做过分代:默认前5/8是young区,后3/8是old区,新读入的页先进old区,只有再次被访问才晋升到young区。这个设计是为了防止全表扫描一次性把热数据全部冲掉。你可以通过Innodb_buffer_pool_read_requests和Innodb_buffer_pool_reads两个状态值算命中率,命中率长期低于99%的库,优先加大Buffer Pool,而不是去调一堆看似高深的参数。
Buffer Pool里还藏着一个容易被低估的结构:Change Buffer。以前它叫Insert Buffer,后来扩展成对二级索引的UPDATE、DELETE也生效。二级索引的写入往往是随机的,如果每次插入都直接去磁盘改二级索引页,代价很高。ChangeBuffer的做法是:先把对二级索引的修改缓存在内存里,等这个索引页因为其他查询被读入Buffer Pool时,再把这批修改合并进去。这个设计让“到处乱插”的二级索引写入变成顺序缓冲,但代价是崩溃恢复和刷脏逻辑变得更复杂。MySQL 8.0里Change Buffer默认占用Buffer Pool的25%,如果业务写多读少,这个比例可以适当调高;如果是读写均衡型,保持默认就够了。
2.3 聚簇索引与二级索引:为什么主键不能随便选
InnoDB的表是索引组织表,数据行存储在聚簇索引的叶子节点里。聚簇索引通常就是主键索引,如果你没有定义主键,InnoDB会找一个没有NULL值的唯一键作为聚簇索引;再找不到,就生成一个隐藏的ROWID。所以“我没有建主键”不代表表没有主键,只是你在被动接受一个不可见的、完全随机的ROWID,这种表做范围查询和备份恢复都不舒服。
二级索引的叶子节点存储的是主键值,而不是行的物理地址。这个设计的巧妙之处在于:主键改变时,二级索引不需要同步更新;但糟糕之处在于:每次通过二级索引查数据,除了扫描二级索引页,还要根据主键值去聚簇索引里重新定位一次,这就是“回表”。如果二级索引已经包含了所有需要返回的字段,执行计划会直接用“覆盖索引”跳过回表,Extra列显示Using index。这是索引优化里最实用的手段:把高频查询中WHERE和SELECT涉及的关键列都放进同一个联合索引,一次索引扫描就把数据拿完。
回到主键选择:自增主键之所以好,不只是因为它简单。自增ID顺序插入,新记录大概率落在当前最右侧的叶子页,页分裂少,空间利用率高。而UUID主键每次插入的位置都是随机的,B+树中间的页不断发生分裂和重组,不仅写入变慢,还会留下大量碎片页。如果你一定要用业务主键,至少应该选趋势递增的雪花ID,而不是毫无顺序可言的随机字符串。
3. 事务隔离与锁:InnoDB如何不让自己乱套
3.1 隔离级别不是配置出来的,是ReadView和锁一起撑起来的
MySQL的事务隔离级别是面试高频,但如果只背“RC会产生幻读、RR不会”,遇到实际问题照样抓瞎。在InnoDB里,隔离级别是ReadView(读视图)机制和锁机制组合出来的结果。普通SELECT走的是MVCC快照读,不加锁;SELECT ... FOR UPDATE、UPDATE、DELETE走的是当前读,必须加锁。
ReadView的作用是给事务定义一个“可见版本边界”。事务执行过程中,每行数据可能被多个事务改过,InnoDB通过undo log把这些版本串成一条版本链。ReadView里记录了当前活跃事务的最小ID和最大ID,判断某个版本是否可见,就用版本的事务ID和ReadView的边界去比。RC和RR的核心区别在于:RC下每个普通SELECT语句都会生成一个新的ReadView,所以一个事务里两次查询能看到其他事务新提交的数据,于是可能出现不可重复读;RR下只在事务第一次执行SELECT时生成ReadView,之后整个事务都沿用这个快照,因此“快照读”天然不会看到其他事务的更新。
真正让RR和RC拉开差距的是“当前读”。RR下,InnoDB除了锁住目标记录,还会锁住目标记录周围的间隙,防止其他事务往这个范围里插入新行。这就是为什么RR能一定程度防幻读,而RC不太防。注意这里说的是“一定程度”,因为RR的MVCC只保证快照读不出现幻读,如果你在同一个RR事务里先跑了一次SELECT,再执行SELECT ... FOR UPDATE做当前读,仍然可能看到新插入的行。所以“RR完全避免幻读”这个说法不严谨。
3.2 锁的类型与加锁规则
InnoDB的锁可以从粒度分成表锁和行锁。表锁最常见的是MDL(元数据锁),它保护表结构在DDL期间不被修改。行锁则细分为三类:Record Lock记录锁,只锁索引记录本身;Gap Lock间隙锁,锁住记录之间的开区间,阻止其他事务在这个区间插入;Next-Key Lock临键锁,是记录锁和间隙锁的组合,锁住的是“当前记录以及它前面的间隙”。在RR隔离级别下,InnoDB的默认加锁单位就是Next-Key Lock,这也是它与RC最显著的区别。
很多人搞不清楚“唯一索引等值查询为什么有时没有间隙锁”。规则其实清晰:当等值查询命中的是唯一索引时,优化器知道目标记录唯一,只需要加一个Record Lock,不需要锁间隙。但如果这个等值查询没有命中任何记录,比如查id=9,而表里id分别是1和10,InnoDB会认为“防止在9这个位置插入新行”是必要的,于是加一个间隙锁。范围查询则更复杂:WHERE id BETWEEN 8 AND 10 FOR UPDATE不仅会锁住8到10之间的已有记录,还会锁住它们之间的间隙以及边界,让其他事务无法插入新记录。理解了这套规则,你基本就能解释为什么RR隔离级别高并发下死锁概率更高:大家互相锁住的间隙更多,冲突面自然变大。
还有一个很常见的坑:如果WHERE条件没有命中任何索引,InnoDB只能全表扫描所有聚簇索引记录,每个记录都会加锁,相当于把整张表锁住了。这不是表锁,但效果比表锁更隐蔽,因为SHOW PROCESSLIST里看不到LOCK提示,只有阻塞链和锁等待超时能暴露问题。排查时优先确认SQL的WHERE列是否有可用索引,尤其是高频UPDATE和DELETE语句。
3.3 死锁是怎么来的,又该怎么解
死锁的本质是加锁顺序不一致,形成资源的循环等待。比如事务A先锁id=1再想锁id=2,事务B先锁id=2再想锁id=1,两个事务同时推进,就必然有一个事务阻塞,最终InnoDB的死锁检测会回滚其中一个。你会在客户端看到Deadlock found when trying to get lock; try restarting transaction错误,错误码通常是1213。InnoDB选择回滚“代价较小”的事务,这个代价是估算修改行数和锁数量得出来的。
解死锁的思路,第一步永远是先看清锁等待关系。你可以执行SHOW ENGINE INNODB STATUS,看LATEST DETECTED DEADLOCK段,里面会打印最近一次死锁涉及的SQL语句、持有锁的KEY值、事务开始时间和回滚选择。通过日志你能准确还原加锁顺序。第二步是修改业务代码,尽量让所有事务按同一顺序访问资源。比如多个事务都要操作账户1和账户2,就统一先锁ID小的账户。第三步是缩小事务范围:把无关查询挪到事务外,减少事务持锁时间;能用一条UPDATE解决就别拆成两条;能在RC隔离级别下满足业务就尽量别用RR,减少间隙锁带来的死锁概率。
4. 三种日志的分工与协作:redo log、undo log、binlog
4.1 redo log:物理日志为什么必须小
InnoDB最核心的持久性保障来自redo log。InnoDB修改数据页时,并不会立刻把脏页刷到磁盘,而是先写redo log。这就是WAL(Write-Ahead Logging):写日志先行。为什么能这么做?因为刷数据页是随机IO,而写redo log是连续追加IO,顺序写比随机写快几个数量级。把随机刷盘变成顺序写日志,再把真正刷脏页交给后台异步线程,整体性能就能拉高一大截。
redo log本质上记录的是“对某个物理页的某个偏移量做了什么修改”,属于物理日志。它是循环写的,文件组里的多个日志文件写满一圈后,会被覆盖。数据库在后台推进Checkpoint,把此前已经刷到磁盘的脏页对应的日志空间标记为可重用。如果Checkpoint推进太慢,redo log写满,InnoDB会强制执行脏页刷新,这时候系统会出现“写盘抖动”。线上如果观察到周期性IO飙升,很多情况下是innodb_log_file_size设置太小。5.7时代默认只有48MB,对写密集型业务来说太小,我一般建议单实例在512MB到2GB之间,具体还要看峰值写入量和刷脏速度。注意,太大也有代价:崩溃恢复时需要重放的日志范围更大,启动恢复时间会变长。
innodb_flush_log_at_trx_commit参数直接决定redo log在事务提交时的刷盘策略。这个参数有三个值,我建议用下面的表格来对照理解:
| 参数值 | 提交时行为 | 可能丢失范围 | 适用场景 |
|---|---|---|---|
| 0 | 不主动刷盘,由后台线程每秒刷一次 | MySQL崩溃时最多丢失最近1秒的已提交事务 | 日志型、流水型数据,能容忍少量丢失 |
| 1 | 每次提交都强制fsync刷盘 | 不丢失 | 账户、订单、支付等强一致场景 |
| 2 | 写入操作系统缓存,由OS每秒刷盘 | 操作系统崩溃或断电时最多丢1秒,MySQL进程崩溃不丢 | 大多数在线业务,允许极端情况丢1秒 |
一致性要求越高的系统,越应该用1。如果用了2,最好同时让binlog也保证落盘,否则主从复制可能追不上。
4.2 undo log:回滚和MVCC都靠它
undo log是逻辑日志,和redo log不同,它记录的是“如何撤销这次操作”。事务执行一条UPDATE,undo log里会记一条“原来这行的旧值是xxx”;事务执行一条DELETE,undo log里会记一条“被删掉的行长什么样”。一旦事务要回滚,InnoDB就去undo log里找对应的反向操作,把数据恢复成原样。这是原子性的底层保障。
但undo log不只是给回滚用的,它还支撑MVCC版本链。前面提到的ReadView判断某个行版本是否可见,靠的就是沿着undo log从最新版本往前遍历,找到第一个满足可见性条件的版本。这带来一个非常容易被忽略的问题:一个长事务虽然没有写入,但只要它一直持有ReadView,那些“旧版本”就不能被清理,undo表空间会持续膨胀。我遇到过测试环境一个连接把事务开着半天不提交,binlog没涨,undo却涨到几十GB。清理这些undo log麻烦得很,根本不像删除binlog那么直接。所以线上严格监控长事务,不仅是业务上的好习惯,更是InnoDB日志机制的客观要求。
4.3 binlog:Server层的逻辑日志
redo log和undo log都是InnoDB存储引擎层的东西,binlog则是MySQL Server层的日志。它记录的是逻辑变化,要么是原始SQL(STATEMENT格式),要么是每一行数据的前后镜像(ROW格式)。默认场景下,binlog有两个核心用途:主从复制和时间点恢复。主库把binlog发给从库,从库重放这些事件,数据就同步过去了;数据库误删了数据,也可以用全量备份+binlog把数据滚到故障前的一秒。
binlog的格式选择直接影响主从一致性。STATEMENT格式日志体积小,但同样的SQL在从库执行结果可能因为随机函数、存储过程等原因不一致。ROW格式记录逐行变化,日志体积大,但最安全,主从差异基本不会出现。MySQL 8.0默认就是ROW格式,这在实际运维中省了非常多事。另外sync_binlog参数控制binlog的刷盘策略,配合单个事务里的binlog事件,尽量在binlog写入时也做到落盘。对强一致场景,一般建议sync_binlog=1,让binlog和redo log在“提交不丢”这件事上对齐。
4.4 两阶段提交与崩溃恢复
如果你只记住一个关于MySQL日志机制的知识点,我觉得应该是两阶段提交。为什么需要它?因为redo log在InnoDB手里,binlog在Server层手里,两个组件各自维护各自的落盘状态。如果事务先提交到InnoDB,但binlog还没写就崩溃了,重启后主库数据是新的,从库却没有这条变更;如果先写binlog但redo log没提交,主库数据回滚,从库却已经应用了这条日志。两条路都会主从不一致。
InnoDB的内部XA事务解决了这个问题,过程大致是这样:事务执行完修改后,InnoDB进入prepare状态,把redo log刷盘(取决于innodb_flush_log_at_trx_commit);Server层随后写入binlog并落盘;binlog写入成功后,InnoDB再把事务标记为commit。这套流程的关键点是:崩溃恢复时,如果redo log事务处于prepare状态,InnoDB会让Server层去查binlog里是否有对应的XID事件,有就提交,没有就回滚。也就是说,binlog是否写入成功,成了“要不要承认这个事务”的判定依据。
这个机制也让崩溃恢复过程非常清晰。数据库启动时,InnoDB利用最后Checkpoint的位置,从redo log中找出所有没有刷盘或没有提交的事务,逐一重放或回滚。所以你会看到MySQL重启后自己打印恢复日志,等待一段时间才能接受连接。数据量越大、redo log越大、Checkpoint越久、恢复耗时越长。运维上,周期性推进Checkpoint、及时清理长事务,比在崩溃后祈祷恢复更快靠谱得多。
5. 底层机制如何影响日常SQL性能:从参数到执行计划
5.1 参数不是越大越好
很多刚接触MySQL调优的人,喜欢照着网上的“万年配置模板”一顿改,最常见的是把innodb_buffer_pool_size调得特别大、把innodb_log_file_size改成2GB、把innodb_flush_log_at_trx_commit设为0。这些参数没有绝对的最佳值,只有和业务匹配的值。
先说Buffer Pool。它用来缓存数据页,但数据库总内存里还有排序缓冲、连接线程、各种内部结构,不能全塞给它。我的经验是初始设为物理内存的50%,观察命中率后再微调到60%-70%。如果内存只有8GB却把Buffer Pool设置成6GB,内存不足会导致操作系统换页,整个数据库响应曲线直接变成锯齿状。你还得记住,Buffer Pool再大,也挡不住没有索引的全表扫描,因为扫描过程中新读入的页会不断把热页顶出缓存,这个场景下LRU分代只是缓解,不是根治。
然后是innodb_flush_log_at_trx_commit和sync_binlog这对组合。在“双1”配置下,每次事务提交要等两次落盘,性能最差但最安全。现实中我见过一个支付核心库被迫从双1改成2加sync_binlog=0,结果一次物理机重启丢了最近一秒钟的交易流水,业务方差点炸毛。后来他们老老实实用回双1,用批量提交和合并写来提升吞吐。记住一个原则:性能瓶颈永远优先用索引、减少扫描行数来解决,而不是用牺牲持久性去换TPS。
5.2 为什么DELETE后表文件没变小
这是一个经常被问到的“空间之谜”。你执行DELETE FROM t WHERE create_time < ...删掉了上百万行,再看ls -lh表文件大小,几乎没变化。原因还是要回到InnoDB的页组织上:DELETE只是把记录在B+树里的位置标记为已删除,把它挂到了一个可复用的链表上,并不会自动把页里的空间归还给文件系统。后续如果插入一条尺寸相近的记录,InnoDB可以优先复用这些“空洞”,所以大表删除后继续写入,文件大小不会继续快速增长。
但如果你删完之后再也不写入,这张表的空间就白白占着。真正的释放需要重建表。常用的手段是OPTIMIZE TABLE或ALTER TABLE t ENGINE=InnoDB,它们的本质都是新建一张表,把数据按B+树重新组织,最后切换回去。注意这个操作会消耗临时磁盘空间,如果在空间不足的实例上执行,反而会把磁盘填满。在8.0里OPTIMIZE TABLE多数情况下是Online操作,但建议仍在低峰执行。更复杂的表结构变更可以用pt-online-schema-change或gh-ost这类工具,它们把重建过程拆成小批量,对线上影响更小。和DELETE形成鲜明对比的是TRUNCATE,它直接DROP段再重建,所以表空间会立即缩小。
5.3 排序和索引失效:最常见的一类慢SQL
MySQL慢查询里,Using filesort和Using temporary出现频率极高。它们的根源都一样:优化器找不到一条能让数据“本来就是目标顺序”的索引路径,只能把数据搬到临时空间排序。InnoDB的B+树叶子节点本来按索引键顺序排列,所以如果ORDER BY字段是某个索引的一部分,且前面的等值条件让这个字段在索引里保持有序,优化器就能直接按索引扫描输出,连排序都省了。反之,如果排序字段在联合索引的第N列,而前N-1列的条件是范围查询或缺失,排序字段的顺序就被打断,只能额外排序。
举个具体例子。订单表有联合索引(user_id, status, create_time),SQL是:
SELECT order_id, amount FROM orders WHERE user_id = 123 AND status IN (0, 1) ORDER BY create_time DESC LIMIT 20;执行计划里很可能出现Using filesort。原因在于status IN (0, 1)是范围条件,当进入第二个值范围时,create_time的有序性已经被打破,B+树无法保证整体按create_time排列。遇到这种查询,更合适的索引是(user_id, create_time),让user_id确定等值后,create_time天然有序,排序直接由索引解决。少一次临时排序,大数据量下可能差几十倍。
索引失效的另一大类是隐式转换和函数操作。WHERE phone = 13800000000,如果phone是VARCHAR,MySQL会把字符串列转成数字再比较,等于在列上做了函数操作,索引自然用不上;WHERE DATE(create_time) = '2025-01-01'也是同理。排查这类问题时,别只看执行计划里有没有走索引,还要看type是不是ref或range,如果出现index甚至ALL,基本就是索引条件被破坏了。我会在SQL前端加一层规范检查,禁止在WHERE条件里对索引列做任何运算,这是成本最低的预防方案。
6. 把原理串起来:一次真实死锁和一次崩溃恢复的复盘
6.1 死锁复现:两个账户,两条相反更新路径
前面讲了死锁理论,这里我放一个实际复现过的场景。表结构很简单:
CREATE TABLE account ( id INT PRIMARY KEY, balance DECIMAL(10,2) NOT NULL ) ENGINE=InnoDB;事务A执行:
START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 停顿片刻,保证A持有id=1锁 UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;事务B执行:
START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 2; -- 停顿片刻,保证B持有id=2锁 UPDATE account SET balance = balance + 100 WHERE id = 1; COMMIT;如果两个事务并发执行到“停顿”之后,A持有id=1、想拿id=2,B持有id=2、想拿id=1,两个事务立刻形成循环等待。InnoDB的锁等待超时参数innodb_lock_wait_timeout默认50秒,但死锁检测机制会在更短时间内发现环,直接回滚一个事务。客户端会收到类似“Deadlock found when trying to get lock; try restarting transaction”的异常。打开SHOW ENGINE INNODB STATUS,LATEST DETECTED DEADLOCK段能看到两个事务的SQL和加锁信息。
这类死锁在账户转账场景里太典型了。我的修复思路是:所有涉及多账户更新的操作,先对账户ID排序,统一按从小到大加锁。A和B都先锁id=1再锁id=2,就不会出现互相争抢的环。更极致的方式是用一条SQL完成双向余额变更,比如:
UPDATE account SET balance = balance - IF(id = 1, 100, -100) WHERE id IN (1, 2);这样InnoDB会按主键顺序加锁,天然避免交叉。就算业务无法改代码,至少也应用SELECT id FROM account WHERE id IN (1,2) ORDER BY id FOR UPDATE手动统一加锁顺序。
6.2 崩溃恢复复盘:kill -9后数据还能回来吗
日志机制到底靠不靠谱,我最喜欢用测试环境直接模拟一次崩溃。操作流程如下:先把innodb_flush_log_at_trx_commit=1、sync_binlog=1设置好,重启MySQL生效;连接后开启事务,插入一行数据并COMMIT;在提交成功后的几秒内,直接kill -9MySQL进程;然后重新启动MySQL服务,观察启动日志。
启动时大概率能看到InnoDB在做崩溃恢复相关的检查,MySQL会先扫描最后一次Checkpoint之后的redo log,把可能处于prepare状态的事务和binlog对照。因为我刚才COMMIT的数据已经完整写入了redo log,binlog也落盘了,所以重启后查这张表,新插入的行应该在。这就是WAL和两阶段提交共同保证的结果:数据不会因为进程被杀而消失。
相反,如果把innodb_flush_log_at_trx_commit=0,在事务提交后立刻杀进程,重启后就有概率丢数据,因为这个模式下事务提交时不强制刷redo log,数据还停留在日志缓冲区里。这个实验我建议每个DBA和业务开发都亲手做一次,它对理解“持久性不是数据库宕机保护,而是由刷盘时机决定的”特别有帮助。真到了生产事故排查时,你一眼就能根据参数判断出是配置问题还是硬件故障。
6.3 我的一些体会
做了这么多年MySQL排查和优化,我最大的感受是:很多问题如果只停留在SQL语法和索引层面,永远治标不治本。连接器、优化器、InnoDB内存结构、日志落盘策略,它们是一条完整的链路。你理解了Buffer Pool和脏页刷盘,就能明白为什么大量无索引查询不只是慢,还会拖垮整个实例的IO;你理解了redo log和binlog的两阶段提交,就能判断主从延迟的根源到底在传输层还是刷盘层;你理解了undo log的版本链,就再也不会写一个长事务把undo表空间撑爆。
如果让我给刚接触MySQL的同学一个落地建议,我会说:先把这条链路画出来,再往每个节点填参数和命令。连接器对应SHOW PROCESSLIST,优化器对应EXPLAIN,Buffer Pool对应状态变量,日志机制对应双1参数。等你把这几个节点串成一条线,很多“玄学”问题其实都是透明的。