ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL事务与锁机制深度解析:从InnoDB原理到高并发实战

MySQL事务与锁机制深度解析:从InnoDB原理到高并发实战 聊到高并发数据系统MySQL的事务和锁永远是绕不开的两个词。我见过太多项目前期只盯着索引和SQL优化结果流量一上来死锁、锁等待、数据不一致轮番轰炸生产环境直接“红温”。这篇我把掏心窝的经验整理一遍从InnoDB的事务模型到各种锁的实现原理再结合我们线上趟过的真实坑一次性讲清楚。不管你是刚入门想搞懂事务隔离级别还是已经在处理线上锁问题都建议耐心看完内容偏底层但很落地。1. 事务与锁高并发系统的两条生命线1.1 事务数据一致性的“保险丝”先问个问题为什么要有事务我习惯把它类比成银行转账。A账户扣100B账户加100这两步必须同时成功或同时失败不能出现扣了钱但没入账的情况。事务就是来保证这种“要么全做要么全不做”的机制也就是原子性Atomicity。除了原子性事务还有三个特性一致性Consistency、隔离性Isolation和持久性Durability合称ACID。这里很多新手容易混淆一致性和隔离性。一致性是业务层面的比如余额不能为负、库存不能为负数这需要应用代码和数据库约束共同保证。隔离性是并发层面的多个事务同时操作同一批数据时要像排队一样互不干扰。持久性则靠redo log保证即使数据库宕机已提交的数据也不丢。在MySQL里默认的InnoDB引擎是支持事务的而MyISAM不支持。这也是为什么生产环境几乎没人用MyISAM的原因之一。如果你还在用MyISAM遇到并发写基本就是灾难。事务机制不光是ACID四个字母它背后牵扯到undo log、redo log、锁、隔离级别等一整条链路下面我会拆开讲。1.2 锁并发冲突的“交通警察”有了事务还没解决并发问题。两个事务同时修改同一行数据如果没有控制最终结果是谁后写谁赢可能覆盖掉前一个人的合法修改。锁Lock就是来解决这个冲突的。它有点像十字路口的红绿灯控制哪些事务能通行哪些要等待。数据库的锁一般分悲观锁和乐观锁两大类。悲观锁假设冲突一定会发生所以操作前先加锁锁定资源直到事务结束。MySQL里通过SELECT ... FOR UPDATE就是悲观锁。乐观锁假设冲突很少发生不加锁但在更新时检查数据是否被改过典型做法是加一个version字段更新时比较版本号。两种方案没有绝对优劣后面我会专门用一节讲怎么选型。在MySQL InnoDB里锁的粒度又可以分表级锁、行级锁和间隙锁。行级锁并发度高但管理和开销也大表级锁开销小但并发度低。InnoDB支持行级锁这得益于它的索引结构。很多面试官爱问“为什么InnoDB行锁是建立在索引上的”其实答案很简单InnoDB的聚簇索引本身就存了整行数据锁定索引项就等于锁定数据行。而如果SQL没有走索引行锁会升级为表锁这是一个极其隐蔽的坑后面细说。2. 深入InnoDB事务机制原理与实战2.1 一条UPDATE语句背后的redo与undo很多人在学习事务时只知道ACID不知道它内部是怎么实现的。我建议从一条UPDATE语句入手。假设执行UPDATE user SET balance balance - 100 WHERE id 1。InnoDB并不是直接修改磁盘上的数据文件而是先把数据页读入内存缓冲池Buffer Pool在内存中修改然后记录redo log最后在合适的时候把脏页刷回磁盘。整个过程叫做WALWrite-Ahead Logging也就是先写日志再写数据。这样即使突然断电重启后也能通过redo log重放保证持久性。而undo log则是用来回滚的。它在更新之前把原来的值记录到undo log中。如果事务回滚InnoDB会根据undo log把数据恢复原样。此外undo log还承担着MVCC多版本并发控制的重任事务隔离级别中的快照读就是基于undo log构建的历史版本链。这里有一个重点redo log是物理日志记录的是“页上哪个偏移量改成了什么值”undo log是逻辑日志记录的是“怎么把数据改回去”。两者不是一个东西。很多人混淆面试时一深问就露馅。实操中我们还可以用SHOW ENGINE INNODB STATUS里的事务列表观察到长事务占用的undo log膨胀问题这个下面会提。2.2 隔离级别从读未提交到串行化SQL标准定义了四个隔离级别分别是读未提交READ UNCOMMITTED、读已提交READ COMMITTED、可重复读REPEATABLE READ和串行化SERIALIZABLE。每个级别解决了不同的并发问题也残留了不同的问题。读未提交允许读未提交的数据也就是脏读。比如事务A改了数据没提交事务B读到了然后A回滚B就把脏数据当成真实数据用了。这个级别几乎不用。读已提交只能读已提交的数据解决脏读。但会出现不可重复读即同一事务内两次读取同一行结果不一致因为其他事务在中间提交了修改。Oracle默认是这个级别。可重复读保证同一事务内多次读取同一行结果一致。但有可能出现幻读也就是同一条件下两次查询返回的行数不同因为其他事务插入了新行。MySQL InnoDB默认是这一级并且通过间隙锁和MVCC解决了幻读问题后面会细讲。串行化完全串行执行最强隔离但性能最差把并发变排队基本只有极少数强一致性场景才用。需要特别注意MySQL的默认隔离级别是REPEATABLE READ而标准SQL里该级别未完全解决幻读。InnoDB在可重复读级别下通过MVCC解决普通读的幻读通过间隙锁解决当前读的幻读所以实际使用时MySQL的可重复读比标准定义更有保障。我会在线上环境把隔离级别设为READ COMMITTED吗我的答案是要看业务。如果业务需要稳定的一致性快照读比如报表查询要看到同一个点的数据那就保留REPEATABLE READ。如果是高并发OLTP数据准确性对时间并不敏感可以考虑READ COMMITTED以减少间隙锁导致的锁冲突。但一定要先在测试环境压测不要盲目改。2.3 事务使用中的五个典型坑第一坑自动提交没关导致SQL被意外包裹成单条事务。MySQL默认开启autocommit1每一条语句都是一个独立事务。如果业务需要多条语句一起成功一起失败必须先BEGIN或SET autocommit0否则中途一条失败前面的不会回滚。第二坑事务里做了远程调用或耗时操作。之前有个业务在扣库存的事务里调外部接口结果外部接口响应慢事务长时间持有行锁后面所有库存单都排队最终压垮数据库。记住事务里绝不加远程调用尽量只做数据库操作并且保持短平快。第三坑长事务导致undo log膨胀。有些后台跑批程序一开就是几十分钟不提交InnoDB为了支持MVCC需要保留undo log里所有历史版本。执行SELECT * FROM information_schema.innodb_trx可以看到运行中的事务如果发现超过几秒的长事务就要预警。我曾经遇到undo表空间涨到几百G无法收缩最后只能重启实例重建很痛苦。第四坑事务里同时更新多个表顺序不一致导致死锁。例如同时更新订单和库存一个事务先更新订单再更新库存另一个事务先更新库存再更新订单两个事务就会互相等待。这个到死锁章节再展开。第五坑事务里查完数据再更新中间不做锁控制。比如先SELECT * FROM goods WHERE id1判断库存够不够然后UPDATE goods SET stockstock-1 WHERE id1在高并发下大概率超卖。正确做法是直接UPDATE ... WHERE id1 AND stock1或SELECT ... FOR UPDATE先锁行。这几个坑每一个都是从生产事故里踩出来的。3. MySQL锁机制全解从行锁到间隙锁3.1 锁类型与兼容矩阵InnoDB的锁从模式上分有共享锁S锁和排他锁X锁。S锁是读锁允许多个事务同时读同一行X锁是写锁一个事务拿了X锁其他事务要读也要等。两者的兼容关系很简单S和S兼容S和X不兼容X和X不兼容。这里说的不兼容是指不能同时持有必须等对方释放。除了普通的S/X锁InnoDB还有意向锁Intention Locks分为意向共享锁IS和意向排他锁IX。意向锁是表级锁用来表示事务准备在表中的某些行加S锁或X锁。它的作用是让表级锁判断更快——比如事务要LOCK TABLES ... WRITE如果表里有事务持有行锁那么意向锁会告诉它不能加表锁否则会冲突。意向锁之间是兼容的因为它们只是“打算加”还没真正锁到行。日常我们执行SELECT ... FOR UPDATE加X锁执行SELECT ... LOCK IN SHARE MODE加S锁。普通SELECT不加锁走MVCC快照读。这是一个非常重要的概念普通读不等待行锁。所以你会看到一个事务在改某行另一个事务普通SELECT依然能查到旧版本数据这就是MVCC的功劳。3.2 行锁的三兄弟记录锁、间隙锁、临键锁InnoDB的行锁不是笼统地“锁住一行”根据索引类型和查询条件它可能锁住记录、锁住间隙或者锁住记录加间隙。记录锁Record Lock锁定索引记录本身。比如SELECT * FROM user WHERE id1 FOR UPDATE锁住id为1的那条索引项。如果id是唯一索引或主键记录锁就够了。间隙锁Gap Lock锁住两个索引记录之间的间隙防止其他事务在该间隙插入新记录。比如WHERE id BETWEEN 1 AND 10范围内没有id5的记录间隙锁锁住(1,10)这个区间另一个事务插入id5就会阻塞。它是解决幻读的有效手段。临键锁Next-Key Lock等于记录锁加间隙锁锁住当前记录和前面一个间隙。InnoDB在REPEATABLE READ隔离级别下默认使用临键锁。它锁的是一个左开右闭区间比如(1,10]即间隙(1,10)加上记录10。理解它们一定要结合实际SQL和索引结构。假设表t有索引列a值为1,3,5,7。执行SELECT * FROM t WHERE a5 FOR UPDATE如果a是非唯一索引那么InnoDB会锁住a5这个记录以及前面的间隙(3,5)和后面的间隙(5,7)即(3,7)区间防止其他事务在5前后插入数据。如果a是唯一索引那只需要锁住a5这一条记录不需要间隙锁。这里面涉及一个优化唯一索引查询记录存在则降级为记录锁记录不存在则加间隙锁。很多高并发场景下的锁等待、插入阻塞都是间隙锁搞的鬼。比如你的订单表有个非唯一索引status你更新所有status1的订单结果间隙锁把新的status1的插入也堵住了。排查的方法就是看SHOW ENGINE INNODB STATUS里的锁信息识别到Gap关键字。3.3 表锁、意向锁与元数据锁除了行锁还有表锁。InnoDB在两种情况下会加表锁一是LOCK TABLES显式指定二是DDL操作ALTER TABLE等会自动获取表级锁。注意LOCK TABLES会锁住整张表InnoDB很少用因为它会阻塞所有并发。更常见的表级锁其实是元数据锁MDL。元数据锁Metadata Lock是MySQL 5.5之后引入的目的是保护表结构。任何对表的CRUD操作都会先加MDL读锁任何ALTER TABLE等DDL操作会加MDL写锁两者互斥。这就是为什么你执行一个大查询时ALTER TABLE会一直卡住等锁而大查询又因为长期持有MDL读锁阻塞了后续的DDL。我遇到过一个经典事故业务高峰期一个小查询跑了很久没结束后台DBA想给表加索引结果ALTER TABLE一直等待MDL写锁更糟的是后续所有对这个表的查询都在排队等MDL读锁因为一旦有写锁等待后面到来的读锁也会被阻塞最终整个表不可读写。解决这种问题的方法一是优化慢查询让读锁尽快释放二是可以用ALTER TABLE ... ALGORITHMINPLACE, LOCKNONE在允许的情况下避免锁表但MDL冲突问题本质上还是要靠缩短占用时间。4. 高并发场景下的锁优化与事务设计4.1 死锁成因、检测与破解实战死锁是并发系统里最头疼的问题。四个必要条件互斥、持有并等待、不可剥夺、循环等待。InnoDB内部会检测死锁并自动回滚其中一个事务然后返回错误Deadlock found when trying to get lock。最常见的死锁场景是两个事务以不同顺序更新相同行。假设两个事务同时执行事务AUPDATE orders SET status1 WHERE order_id1; UPDATE orders SET status1 WHERE order_id2;事务BUPDATE orders SET status1 WHERE order_id2; UPDATE orders SET status1 WHERE order_id1;事务A锁住order_id1事务B锁住order_id2然后互相等对方的锁死锁形成。破解死锁的方法很多保持事务内多条语句的加锁顺序一致让所有事务都按order_id从小到大更新尽量缩短事务时间合理拆分较大事务。MySQL还有innodb_lock_wait_timeout参数控制锁等待超时时间默认50秒如果单个事务等待锁时间过长会报超时错误。排查死锁核心用SHOW ENGINE INNODB STATUS里面LATEST DETECTED DEADLOCK段落会打印造成死锁的两条SQL、涉及的锁对象以及事务持有的锁信息。我建议把它输出到日志定期收集再用脚本分析常见死锁组合优化对应的SQL逻辑。另外开启innodb_print_all_deadlocksON可以把所有死锁都打印到错误日志方便事后追溯。4.2 索引决定锁粒度为什么没索引就锁全表这是我最想强调的一个坑行锁是建在索引上的如果SQL无法有效利用索引InnoDB会退化为对全表所有记录加锁相当于表锁。不是它故意锁全表而是因为要锁定所有扫描到的行没有索引就只能全表扫描那就每行都得加锁。举个实际例子。用户表user有name字段没有索引。执行UPDATE user SET age30 WHERE name张三。这条SQL会全表扫描InnoDB会对所有访问到的行加X锁导致整个表的写操作全部阻塞。即使只想更新一行也会锁全表而且通过SHOW ENGINE INNODB STATUS看到的锁信息是大量record lock。解决方式很简单给查询条件建立合适的索引让Update能命中索引。没有索引时除了锁粒度大还会造成严重的死锁概率因为并发更新不同行但都锁了全表互相干扰。曾经我们一个系统线上偶发死锁排查半天发现就是个update语句没走索引加了索引后死锁瞬间消失。所以在做并发设计时一定要先看执行计划确认type不是ALL。4.3 乐观锁与悲观锁选型要分场景很多同学在面试里被问到乐观锁和悲观锁回答总是“乐观锁用版本号悲观锁用for update”但实际项目中选型没这么简单。先说悲观锁。SELECT ... FOR UPDATE适合写多读少、冲突严重的业务比如库存扣减、账户扣减。它的优势是处理冲突代价很低直接阻塞不需要重试。劣势是锁会被持有到事务结束如果事务里有网络请求或慢SQL会把锁表时间拉得很长。乐观锁适合读多写少的场景。以库存为例UPDATE stock SET countcount-1, versionversion1 WHERE id1 AND version5如果影响行数为0说明版本不对需要重试或其他补偿逻辑。优势是没有锁等待高并发下吞吐量较高劣势是冲突多时会导致大量无效更新而且需要应用程序处理重试逻辑。我的建议是如果并发严重且更新必须立即生效用悲观锁如果冲突概率不高但读流量很大用乐观锁。也可以两者结合比如先乐观锁更新失败再走悲观锁兜底。我之前做过一个秒杀系统把库存放在Redis里做原子扣减DB靠乐观锁兜底既能扛峰值又能保证最终一致。选型没有银弹关键是要定量分析业务冲突概率和数据准确性的容忍度。5. 锁等待与事务异常排查实录5.1 用SHOW ENGINE INNODB STATUS抓锁等待现场线上发现锁等待第一反应不是重启而是抓现场。MySQL提供了一套诊断工具最基础的就是SHOW ENGINE INNODB STATUS。它的LATEST DETECTED DEADLOCK段和TRANSACTIONS段会显示当前等待锁的事务与持有的锁。我常用的排查流程是先定位导致等待的源头SQL。一般通过performance_schema里的表比如sys.innodb_lock_waits这个视图会显示阻塞者、等待者、等待时间、SQL文本。执行SELECT * FROM sys.innodb_lock_waits\G输出里能看到waiting_pid、blocking_pid、waiting_query和blocking_query。拿到阻塞者PID后可以进一步查events_statements_currentSELECT THREAD_ID, SQL_TEXT FROM performance_schema.events_statements_current WHERE THREAD_ID 阻塞者线程ID;然后决定是等待还是强杀。紧急情况下可以KILL阻塞线程但前提是确认那个线程的事务不是重要业务。我的经验是最好先开一个窗口持续监控锁等待视图观察是偶发还是持续。偶发可能是一条慢SQL导致的持续则要考虑锁范围过大的问题。5.2 参数调优与监控脚本除了诊断预防也很重要。有几个参数是高并发系统必须关注的。innodb_lock_wait_timeout默认50秒可以调小到5秒或10秒。太大会导致请求长时间阻塞拖垮连接池太小又会误伤正常等待。线上我一般设10秒左右然后配合死锁日志。innodb_print_all_deadlocks默认OFF建议开启这样任何死锁都会输出到日志。innodb_buffer_pool_size虽然主要影响缓存但缓存越大数据页落盘越少事务持有锁的时间就越短间接减少锁冲突。transaction-isolation视业务调整如果不需要可重复读可以切到READ COMMITTED减少间隙锁。监控方面我写过一个简单脚本每30秒拉一次锁等待信息输出到日志并接入告警。mysql -uXXX -pYYY -e SELECT * FROM sys.innodb_lock_waits /var/log/mysql_lock_waits.log然后用awk统计每个SQL等待次数。实践下来锁等待频率能很直观地反映索引和事务是否健康。如果某个表频繁出现锁等待就该检查索引、事务长度和隔离级别了。5.3 面试高频考点速览把上面讲的压缩成面经就是这几个知识点ACID分别靠什么实现原子性靠undo log持久性靠redo log隔离性靠锁和MVCC一致性靠约束和业务逻辑。InnoDB默认隔离级别是什么REPEATABLE READ怎么解决幻读MVCC和间隙锁。什么是当前读和快照读普通SELECT是快照读加FOR UPDATE、LOCK IN SHARE MODE、UPDATE、DELETE、INSERT都是当前读。为什么行锁会变表锁SQL没走索引导致全表扫描锁所有记录。死锁怎么排查SHOW ENGINE INNODB STATUS和performance_schema。乐观锁和悲观锁的主要区别与适用场景版本号重试机制 vs 行锁阻塞等待。如果面试官问得更深比如“间隙锁什么时候变成记录锁”“RR和RC在加锁上的区别是什么”那就需要结合唯一索引、普通索引和隔离级别的具体场景来分析前面章节里其实都覆盖了。把原理吃透面试基本没问题。最后分享一个我自己的习惯每次上线涉及事务和锁的代码前都会先跑一遍小流量压测用SHOW ENGINE INNODB STATUS录制锁信息看看有没有额外的间隙锁或MDL等待。线上数据分布往往和测试环境差异很大索引选择一旦变化锁范围就会跟着变。宁可多花半小时压测也不要在高峰期被锁等待搞到火急火燎。这些内容希望大家在实际项目里能少走弯路。
返回列表