尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MySQL锁机制深度解析:从共享锁、排他锁到死锁排查与性能优化

MySQL锁机制深度解析:从共享锁、排他锁到死锁排查与性能优化 1. 从一次线上事故说起为什么我们需要深入了解MySQL锁那天晚上系统监控突然告警核心交易接口的响应时间从平时的几十毫秒飙升到了十几秒TPS断崖式下跌。登录服务器一看CPU和IO都挺正常但数据库连接池几乎全满大量线程状态显示为Waiting for table metadata lock。紧急排查后发现是一个开发同学在业务低峰期执行了一个ALTER TABLE操作试图给一个千万级的大表加个索引。就是这个看似平常的DDL语句与业务高峰期仍在进行的查询和更新操作产生了锁冲突直接“锁”住了整张表导致服务近乎瘫痪。最后我们不得不强制Kill掉那个DDL进程并调整了后续所有表结构变更的流程——必须在严格的维护窗口并且先评估锁的影响。这次事故让我深刻意识到对于使用MySQL的开发者、DBA甚至架构师而言仅仅会写SQL是远远不够的。“锁”这个隐藏在数据库引擎深处的并发控制机制平时悄无声息一旦发威就足以让整个系统“卡死”。很多人对锁的理解停留在“表锁”和“行锁”这两个名词上或者背过“MyISAM用表锁InnoDB用行锁”这样的面试题。但在真实的、高并发的生产环境中锁的机制远比这复杂和微妙。共享锁、排他锁、意向锁、间隙锁、临键锁、元数据锁……这些锁是如何协同工作的什么情况下行锁会升级为什么我明明只更新一行数据却感觉锁住了半张表死锁是怎么产生的又该如何避免和排查理解MySQL的锁机制不是为了应付面试而是为了能设计出更健壮的数据模型编写出更高效的SQL语句制定出更安全的运维策略。它就像数据库世界的交通规则不懂规则迟早会出“车祸”。接下来我将结合多年的实战经验和案例分析为你层层剥开MySQL并发控制的神秘面纱不仅告诉你锁是什么更告诉你它们为什么这样工作以及如何在实践中驾驭它们。2. 锁的基石共享锁与排他锁的本质与交互要理解MySQL中纷繁复杂的锁类型我们必须从最基础、最核心的两个概念入手共享锁Shared Lock, S Lock和排他锁Exclusive Lock, X Lock。这是所有锁语义的基石。你可以把数据库中的一条数据想象成一个会议室。共享锁S锁就像是对这个会议室的“读取权限”。多个参与者可以同时持有这个会议室的S锁一起进去查阅资料读取数据互不干扰。在MySQL中普通的SELECT语句在默认的REPEATABLE READ隔离级别下并不会加锁它是通过MVCC多版本并发控制来保证一致性的这点后面会详述。但是如果你使用了SELECT ... LOCK IN SHARE MODE或者SELECT ... FOR SHAREMySQL 8.0就会对被读取的行加上S锁。排他锁X锁则像是这个会议室的“独占使用权”。一旦一个参与者拿到了X锁他就独占了这间会议室其他任何人无论是想查阅还是想使用都不能再进入。在MySQL中UPDATE、DELETE、INSERT语句以及使用了SELECT ... FOR UPDATE的查询都会对被操作的行加上X锁。它们之间的兼容性规则是理解并发冲突的关键S锁与S锁是兼容的多个事务可以同时读取同一行数据。S锁与X锁是不兼容的一个事务正在读取某行持有S锁另一个事务就不能修改它申请X锁会被阻塞反之亦然一个事务正在修改某行持有X锁其他事务也不能读取它申请S锁会被阻塞。X锁与X锁是不兼容的这很好理解两个事务不能同时修改同一行数据。注意这里说的“读取”指的是加锁读Locking Read。在InnoDB默认的RR隔离级别下普通的快照读Snapshot Read是不加锁的它通过ReadView访问undo log中的历史版本因此不会与写操作冲突。这是MVCC带来的巨大优势也是高并发读的保障。但务必分清“快照读”和“当前读加锁读”的区别。一个核心的实战场景SELECT ... FOR UPDATEvsUPDATE很多人会混淆这两个语句的加锁行为。假设我们有一个账户余额表accounts现在要实现一个“查询并扣款”的操作。错误示范逻辑漏洞-- 事务1 START TRANSACTION; SELECT balance FROM accounts WHERE user_id 1001; -- 假设查到100元 -- 在应用层判断if (balance 50) { ... } UPDATE accounts SET balance balance - 50 WHERE user_id 1001; -- 扣款 COMMIT;在并发环境下事务1的SELECT快照读和事务2的SELECT可能看到相同的余额100元都认为可以扣款然后相继执行UPDATE。最终余额可能变成-50元这就是典型的“丢失更新”问题。正确做法使用悲观锁-- 事务1 START TRANSACTION; SELECT balance FROM accounts WHERE user_id 1001 FOR UPDATE; -- 对user_id1001的行加上X锁 -- 此时其他事务对该行的SELECT ... FOR UPDATE或UPDATE都会被阻塞 UPDATE accounts SET balance balance - 50 WHERE user_id 1001; COMMIT;SELECT ... FOR UPDATE会对查找到的行施加X锁从而在查询的瞬间就“锁定”了数据避免了并发修改。UPDATE语句本身也会在更新时对行加X锁但SELECT ... FOR UPDATE的锁是在UPDATE执行前就获取的可以保证查询和更新之间数据的一致性。这个例子揭示了锁的第一个核心价值在并发环境中保护数据从“读取”到“修改”这个关键窗口期的一致性。3. InnoDB的锁升级意向锁、行锁与表锁的协同当我们说“InnoDB支持行级锁”时并不意味着它只有行锁。为了实现高效的锁管理InnoDB设计了一套多粒度的锁机制包括意向锁Intention Locks。为什么需要意向锁想象一下如果没有意向锁事务A锁定了表中的一行行级X锁。此时事务B想给整个表加一个表级X锁比如执行ALTER TABLE。事务B如何判断自己能否加这个表锁呢它必须逐行检查表中是否有任何一行已经被加锁。对于一个有上亿行记录的表这种检查是灾难性的、不现实的。意向锁就是为了解决这个问题而生的。它是一种表级锁但它表达的是一种“意向”Intention而不是真正的锁定。意向共享锁Intention Shared Lock, IS锁事务打算给表中的某些行加S锁。在给一行加S锁之前必须先获得该表的IS锁。意向排他锁Intention Exclusive Lock, IX锁事务打算给表中的某些行加X锁。在给一行加X锁之前必须先获得该表的IX锁。规则是事务在获取行锁S或X之前必须先获取对应表级的意向锁IS或IX。意向锁之间是兼容的因为它们只是表达意向不冲突。但意向锁与真正的表级S/X锁之间有特定的兼容性规则正是这套规则让表级锁的快速判断成为可能。请求锁类型 vs 已存在锁类型X表IX表S表IS表X表冲突冲突冲突冲突IX表冲突兼容冲突兼容S表冲突冲突兼容兼容IS表冲突兼容兼容兼容从这个兼容性矩阵我们可以解读出几个关键点表级X锁与任何意向锁都冲突。这意味着如果一个事务持有了表级X锁如LOCK TABLES ... WRITE其他事务连获取意向锁即打算操作任何行都不被允许。表级S锁与IX锁冲突。这意味着如果一个事务持有了表级S锁如LOCK TABLES ... READ其他事务就不能获取IX锁即不能“打算”修改任何行。IX锁与IX锁是兼容的。这是实现高并发写的关键两个事务可以同时持有同一个表的IX锁意味着它们可以同时修改表中不同的行因为行级X锁是互斥的但表级意向锁不互斥。现在回到开头的场景事务B想给表加X锁。它只需要检查两件事1. 当前是否有其他事务持有该表的X锁2. 当前是否有其他事务持有该表的IS或IX锁只要发现任何一个事务B的加锁请求就会被阻塞。这个检查是瞬间完成的无需遍历每一行。行锁升级为表锁的常见误区网上常说“InnoDB的行锁会在某些情况下升级为表锁”这其实是一个容易误导的说法。更准确的情况是索引失效导致锁升级如果UPDATE或DELETE语句的WHERE条件无法使用索引InnoDB将无法通过索引精确定位到需要锁定的行。为了保证数据一致性它可能会退而求其次锁定所有被扫描过的行甚至锁定整个索引或整个表。这本质上不是“升级”而是因为无法使用行锁而被迫使用更粗粒度的锁。这也是为什么我们必须为高频查询和更新的字段建立合适索引的重要原因之一。-- 假设phone字段没有索引 UPDATE users SET status 1 WHERE phone 13800138000; -- 这条语句会进行全表扫描对扫描到的每一行可能是全表都尝试加X锁效果类似于锁表。显式的表锁语句使用了LOCK TABLES ... READ/WRITE这是应用层主动要求的表锁与引擎无关。所以在InnoDB中表锁意向锁、自增锁等和行锁是协同工作的共同构建起一套高效的并发控制体系。理解意向锁是理解InnoDB锁机制如何平衡精度与效率的关键。4. 超越单行间隙锁、临键锁与幻读的防治在“可重复读Repeatable Read, RR”隔离级别下InnoDB引入了两种更复杂的锁用于解决“幻读Phantom Read”问题间隙锁Gap Lock和临键锁Next-Key Lock。什么是幻读在一个事务内两次执行相同的查询第二次查询看到了第一次查询时未出现的“幻影行”。注意幻读强调的是“看到了新插入的行”。在“读已提交Read Committed, RC”级别下幻读是可能发生的。间隙锁Gap Lock间隙锁锁定的不是一条具体的记录而是索引记录之间的“间隙”。例如表中存在id为5和10的记录那么间隙锁可以锁定区间(5, 10)。它的存在是为了防止其他事务在这个间隙中插入新的记录。间隙锁是共享的。多个事务可以在同一个间隙上持有间隙锁。它的唯一目的就是阻止插入。间隙锁只存在于RR隔离级别或以上在RC级别下不存在。临键锁Next-Key Lock临键锁是行锁Record Lock 间隙锁Gap Lock的组合。它既锁定了记录本身也锁定了该记录之前的间隙。假设索引中有值10, 11, 13, 20。那么临键锁可能锁定的区间是(-∞, 10], (10, 11], (11, 13], (13, 20], (20, ∞)。它是一个“左开右闭”的区间。当InnoDB扫描索引并准备加锁时默认使用的就是临键锁。这是RR级别下防止幻读的主要手段。实战案例分析范围查询如何加锁假设表t有一个主键索引id现有数据1, 5, 10, 15。-- 事务A (RR隔离级别) START TRANSACTION; SELECT * FROM t WHERE id 10 AND id 15 FOR UPDATE;这条语句会查询到id10这一条记录。那么它到底锁定了什么找到id10的记录加上行锁X锁。为了阻止幻读InnoDB还会加上间隙锁。对于id 10它会锁定[10, ∞)这个区间吗不完全是。因为条件是id 15所以它找到的下一个索引键值是15。因此InnoDB实际加的是临键锁锁定的范围是[10, 15)。具体来说对id10的记录加行锁。对间隙(10, 15)加间隙锁。 所以锁定的区间是(5, 15]临键锁区间是左开右闭但这里id10是右闭端点整体效果是锁住了10以及10到15之间的空隙。此时如果事务B尝试执行以下操作-- 事务B INSERT INTO t (id) VALUES (12); -- 被阻塞因为12在(10, 15)间隙内 UPDATE t SET ... WHERE id 15; -- 成功因为15不在锁定的临键锁范围内右边界是开区间。但如果是SELECT ... FOR UPDATE id15也可能被阻塞取决于具体实现和是否存在其他锁。 INSERT INTO t (id) VALUES (8); -- 成功8在(5,10)间隙未被锁定。如何观察和验证锁信息可以通过performance_schema库中的data_locks和data_lock_waits表MySQL 8.0来查看当前的锁等待情况。对于更早的版本可以使用SHOW ENGINE INNODB STATUS命令在输出的TRANSACTIONS部分查看锁信息。这对于排查死锁和锁超时问题至关重要。理解间隙锁和临键锁是掌握RR隔离级别下InnoDB行为的关键。它们虽然增强了数据一致性但也带来了更复杂的锁冲突可能性是许多死锁场景的“元凶”。在设计索引和编写SQL时必须考虑这些锁的影响范围。5. 锁的冲突与化解死锁的成因、排查与规避策略死锁是并发系统中经典且棘手的问题。在MySQL中当两个或更多事务相互等待对方释放锁并且都无法继续推进时就形成了死锁。InnoDB引擎内置了死锁检测机制一旦发现死锁会立即回滚其中一个代价最小的事务通常以修改的行数为参考让其他事务得以继续。一个典型的死锁场景假设有账户表accounts有两个事务同时操作-- 事务1 START TRANSACTION; UPDATE accounts SET balance balance - 100 WHERE id 1; -- 持有id1的X锁 UPDATE accounts SET balance balance 100 WHERE id 2; -- 尝试获取id2的X锁 -- 事务2 START TRANSACTION; UPDATE accounts SET balance balance - 50 WHERE id 2; -- 持有id2的X锁 UPDATE accounts SET balance balance 50 WHERE id 1; -- 尝试获取id1的X锁执行时序如下事务1锁定id1。事务2锁定id2。事务1尝试锁定id2发现被事务2持有于是等待。事务2尝试锁定id1发现被事务1持有于是等待。 至此事务1等待事务2事务2等待事务1形成循环等待死锁产生。InnoDB检测到后会回滚其中一个事务比如事务2并向客户端返回1213 - Deadlock found when trying to get lock; try restarting transaction错误。事务1随后可以成功执行。死锁的排查工具SHOW ENGINE INNODB STATUS这是最常用的工具。在输出中查找LATEST DETECTED DEADLOCK部分它会详细记录最后一次死锁发生的时间、涉及的事务、正在执行的SQL语句、以及每个事务持有和等待的锁。这是分析死锁根因的第一手资料。performance_schema在MySQL 5.7 / 8.0中可以启用performance_schema的相关消费者consumers通过查询events_transactions_current,data_locks,data_lock_waits等表来实时监控锁和事务状态进行更深入的分析。规避死锁的实战策略死锁无法完全避免但可以通过良好的设计和编码习惯大幅降低其发生概率和影响保持事务小巧且快速事务越大持有锁的时间越长与其他事务冲突的概率就越高。尽快提交或回滚事务。约定一致的访问顺序这是最重要、最有效的原则。在业务逻辑中如果存在多个需要更新的对象如表、行所有事务都按照相同的顺序去访问它们。例如总是先更新id小的账户再更新id大的账户。在上面的例子中如果两个事务都按id1 - id2的顺序更新就不会发生死锁。为查询创建合适的索引避免因为索引失效导致锁范围扩大从行锁升级为类似表锁的行为增加冲突面。使用EXPLAIN检查SQL的执行计划。降低隔离级别如果业务允许将隔离级别从RR降为RC。RC级别下没有间隙锁可以消除大量因间隙锁导致的死锁。但需评估幻读风险。使用乐观锁对于冲突不那么激烈的场景可以考虑使用乐观锁。在表中增加一个版本号version字段。更新时将version作为条件。-- 乐观锁更新示例 UPDATE products SET stock stock - 1, version version 1 WHERE id 100 AND version 5;如果更新影响行数为0说明版本号已被其他事务修改本次更新失败需要在应用层重试或提示用户。设置合理的锁等待超时通过innodb_lock_wait_timeout参数默认50秒设置锁等待超时时间。对于不重要的后台任务可以设置一个较短的超时时间避免长时间阻塞。但这只是缓解不是解决。重试机制在应用层捕获死锁异常如1213错误并进行有限次数的重试。这对于由短时锁竞争引起的偶发死锁非常有效。死锁是系统并发度达到一定水平后的自然产物不必谈之色变。关键在于建立有效的监控、分析和应对机制将其对业务的影响控制在可接受的范围内。6. 隐形的守护者与性能杀手元数据锁与自增锁除了我们熟知的用于保护数据的锁MySQL还有两种至关重要的“后台锁”它们不直接参与业务数据的并发控制却深刻影响着数据库的可用性和性能元数据锁Metadata Lock, MDL和自增锁AUTO-INC Lock。元数据锁MDL表结构的守护神文章开头的事故罪魁祸首就是MDL锁。MDL锁是Server层实现的锁用于保护表结构元数据的一致性防止在查询或修改表数据的同时表结构被更改如ALTER TABLE,DROP TABLE。MDL锁也分为不同级别MDL读锁在执行DML操作SELECT,INSERT,UPDATE,DELETE时自动获取。多个事务可以同时持有同一表的MDL读锁。MDL写锁在执行DDL操作ALTER TABLE,DROP TABLE时自动获取。MDL写锁是排他的。MDL锁的阻塞与死锁风险MDL锁的引入带来了一个经典的阻塞链问题一个长查询或长事务SELECT * FROM big_table开始执行获取了该表的MDL读锁。此时另一个线程执行ALTER TABLE big_table ADD COLUMN ...它需要获取MDL写锁。由于MDL读锁与写锁互斥这个DDL操作被阻塞进入等待队列。后续所有新的、针对big_table的查询需要MDL读锁都会被这个等待的MDL写锁阻塞因为MDL锁的调度是“写锁优先”在写锁等待期间新的读锁申请也会被堵在后面。 这就导致了“一荣俱荣一损俱损”的雪崩效应一个慢查询或未提交的事务可以阻塞整个表的所有后续访问包括查询。如何应对MDL锁问题监控与发现使用SHOW PROCESSLIST命令查看线程状态关注Waiting for table metadata lock。在performance_schema中可以通过metadata_locks表查看MDL锁的详细信息。规范DDL操作在业务低峰期执行这是铁律。使用pt-online-schema-change或gh-ost等在线改表工具这些工具通过创建影子表、同步数据、原子切换的方式避免了长时间持有MDL写锁对业务影响极小。对于核心表的结构变更应强制使用此类工具。设置超时在MySQL 5.7可以为ALTER TABLE设置LOCK和ALGORITHM选项有时能减少锁持有时间。但最根本的还是用在线工具。避免长事务及时提交事务特别是使用了BEGIN或START TRANSACTION显式开启的事务。快速定位并Kill阻塞源通过information_schema.innodb_trx结合SHOW PROCESSLIST找到持有MDL读锁的长时间运行的事务或查询并评估是否可以将其Kill掉。自增锁AUTO-INC Lock序列号的秩序维持者当表中有AUTO_INCREMENT列时InnoDB使用一种特殊的表级锁——自增锁来保证为每一行新数据生成的自增主键值是唯一且连续的。自增锁的行为模式由innodb_autoinc_lock_mode参数控制innodb_autoinc_lock_mode 0(“传统”模式)每次执行INSERT语句时都会获取一个特殊的表级AUTO-INC锁并在语句结束后释放。这保证了所有INSERT语句的自增ID是连续且可预测的但并发插入性能最差。innodb_autoinc_lock_mode 1(“连续”模式默认值)这是大多数情况下的最佳选择。对于“简单插入”能预先确定插入行数的语句如INSERT INTO t VALUES (1), (2), (3)它使用一个轻量级的互斥量来生成ID不需要持有AUTO-INC锁到语句结束大大提升了并发性。对于“批量插入”如INSERT ... SELECT,LOAD DATA它仍然会使用AUTO-INC锁以保证生成的ID对于当前语句是连续的。innodb_autoinc_lock_mode 2(“交错”模式)所有INSERT语句都不使用AUTO-INC锁完全依靠互斥量生成ID。这能提供最高的并发插入性能但会带来两个后果1) 同一语句内生成的自增ID可能不连续2) 在基于语句的复制SBR模式下可能导致主从不一致。因此只有在使用行复制RBR或混合复制MBR时才考虑使用此模式。实战建议除非有非常特殊的、要求绝对连续ID且并发插入不高的场景否则保持默认的innodb_autoinc_lock_mode 1即可。它很好地平衡了性能、一致性和并发性。理解MDL锁和自增锁能帮助我们在进行表结构变更和高并发插入时提前预判风险制定更稳妥的方案避免它们从“守护者”变成“性能杀手”。7. 锁的实战观测与性能优化思路理论最终要服务于实践。我们如何直观地看到数据库中的锁又该如何基于对锁的理解来优化系统性能这是本章要解决的核心问题。锁信息观测实战SHOW ENGINE INNODB STATUS(经典方法) 执行该命令后在输出结果中重点关注TRANSACTIONS和LATEST DETECTED DEADLOCK如果有部分。这里会显示当前活跃事务、持有的锁以及锁等待信息。虽然信息是文本格式不如新系统表直观但在所有版本中均可用。---TRANSACTION 1234567890, ACTIVE 10 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 100, OS thread handle 0x7f123456, query id 200 localhost root updating UPDATE accounts SET balance balance - 100 WHERE id 2 ------- TRX HAS BEEN WAITING 5 SEC FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 300 page no 3 n bits 72 index PRIMARY of table test.accounts trx id 1234567890 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: ...这段输出告诉我们事务1234567890正在等待一个行锁lock_mode X锁的模式是locks rec but not gap记录锁非间隙锁锁在accounts表的主键索引上。information_schema与performance_schema(推荐MySQL 5.7/8.0)information_schema.innodb_locks/innodb_lock_waits(在8.0中已被移除迁移至performance_schema)可以查看当前的锁和锁等待关系。performance_schema.data_locks(MySQL 8.0)记录了所有当前持有的锁包括行锁、表锁、意向锁等的详细信息如锁类型、模式、所属对象等。performance_schema.data_lock_waits(MySQL 8.0)记录了当前的锁等待关系明确指出哪个事务在等待哪个事务持有的锁。一个常用的排查锁阻塞的查询-- MySQL 8.0 SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM performance_schema.data_lock_waits w INNER JOIN information_schema.innodb_trx b ON b.trx_id w.blocking_engine_transaction_id INNER JOIN information_schema.innodb_trx r ON r.trx_id w.requesting_engine_transaction_id;这个查询能清晰地展示出“谁被谁阻塞了”是诊断锁等待问题的利器。基于锁机制的SQL编写与索引设计优化对锁的理解直接影响我们编写SQL和设计索引的方式尽量使用主键或唯一索引进行更新/删除这能确保InnoDB使用行锁将锁的粒度控制在最小范围。避免全表扫描导致的锁升级。避免大范围更新特别是无索引的更新UPDATE table SET status 0 WHERE status 1如果status字段没有索引会锁住大量甚至全部记录极易引发长时间锁等待和死锁。务必为WHERE条件中的字段添加索引。谨慎使用SELECT ... FOR UPDATE明确你真的需要“当前读”并锁定数据。如果只是要保证读取一致性RR级别下的普通SELECT快照读通常就够了它不会加锁并发性能更高。只在需要基于查询结果立即进行更新且要防止其他事务修改时才使用FOR UPDATE。将大事务拆分为小事务这是黄金法则。一个更新10万行的事务持有锁的时间可能长达几分钟。拆分成每次更新1000行并在每个批次后提交能显著减少锁的持有时间和冲突概率。注意间隙锁的影响范围在RR级别下范围查询BETWEEN,,和FOR UPDATE会加间隙锁。在设计业务逻辑和索引时要考虑这个特性。有时使用SELECT ... FOR UPDATE SKIP LOCKEDMySQL 8.0可以跳过已被锁定的行实现简单的无锁队列提高并发处理能力。监控innodb_row_lock_*状态变量通过SHOW STATUS LIKE innodb_row_lock%;可以查看行锁的竞争情况。innodb_row_lock_current_waits当前正在等待行锁的数量。innodb_row_lock_time系统启动以来行锁定的总时间毫秒。innodb_row_lock_time_avg每次行锁等待的平均时间。innodb_row_lock_time_max行锁等待的最长时间。innodb_row_lock_waits系统启动以来行锁等待发生的总次数。 如果innodb_row_lock_waits和innodb_row_lock_time_avg持续很高说明行锁竞争激烈需要优化。锁是数据库并发控制的精髓也是性能调优的深水区。从被动地解决锁超时、死锁告警到主动地通过设计规避锁冲突是每个后端开发者成长的必经之路。掌握观测工具理解锁的行为模式才能让我们在构建高并发、高可用的数据服务时真正做到心中有数游刃有余。
返回列表