ARTICLE DETAIL

资讯详情

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

数据库事务与InnoDB锁机制:从原理到线上排查

数据库事务与InnoDB锁机制:从原理到线上排查 数据库事务与InnoDB锁是怎么纠缠到一起的我刚开始学MySQL的时候想得很简单以为事务就是begin、commit、rollback三个关键字锁就是“某行数据加锁了别人不能改”。真到了线上排查问题时才发现这两样东西压根是同一枚硬币的两面不把InnoDB的锁机制吃透所谓“理解事务”就是空中楼阁。这篇文章就把数据库事务和InnoDB锁之间的关联彻底摊开来聊看看一段普通的事务语句背后到底发生了什么遇到锁等待、死锁、脏读幻读又该从哪里下手排查。1. 事务和锁先理清关系没有锁事务就是纸上的承诺1.1 为什么ACID里最依赖锁的是隔离性事务的四大特性ACID原子性、一致性、隔离性、持久性很多人背得滚瓜烂熟。但要注意的是原子性靠undo log回滚日志实现持久性靠redo log重做日志实现一致性是前三者的最终结果。真正在多个事务同时运行时还能让“世界清静如初”的靠的就是隔离性——而隔离性的核心实现手段就是锁。这里有个关键认知锁并不是数据库外挂的什么附加能力而是事务在并发执行时的秩序维护者。拿经典的转账场景来说A账户扣钱和B账户加钱必须是一个整体但假如这时候另一个事务同时也在读A账户的余额、甚至也在修改A账户的数据就必须有一个规则让二者排队这个规则就是锁。没有锁的情况下两个事务同时写同一行结果就是后写的覆盖先写的或者读到一半被修改的中间态数据。数据库对外承诺“事务是隔离的”背后如果没有锁在卡位这句话就是一句空话。1.2 一条UPDATE语句背后发生了什么我特别建议每个DBA和后端开发都在脑子里过一遍这个流程因为它能直观解释“事务和锁的关系”。执行一条普通UPDATE时InnoDB会按照主键索引找到目标行对命中行加排他锁X锁然后才修改这行数据。在默认的REPEATABLE READ隔离级别下这个锁要一直持有到事务提交或回滚才算结束而不是语句执行完就释放。这个细节极其致命。它意味着只要你的事务还没提交其他事务对同一行的写操作都得阻塞等待甚至在某些条件下连读都会被阻塞后面讲当前读时会展开。很多人写业务代码时习惯手动begin执行几条SQL后又忘了commit表面上事务“还开着”实际上那些被修改的行已经被你锁了很久直接拖垮线上并发。我见过不止一次“数据库CPU不高但业务卡死”的案例最后定位到的原因就是一个凌晨跑批任务开了事务没及时提交把一张核心业务表锁了一整个白天。注意事务不结束锁就不释放。这是理解事务与锁关联的第一条铁律。2. InnoDB的锁家族不是一把锁而是一整套机制2.1 行锁、间隙锁、临键锁傻傻分不清楚InnoDB最常被提到的锁类型按粒度来分有表锁和行锁按锁模式分有共享锁S锁和排他锁X锁。但真正让新手头皮发麻的是行锁之下的三个细分Record Lock、Gap Lock、Next-Key Lock。Record Lock就是记录锁老老实实锁住索引上的某一条记录。它需要说明的是InnoDB的行锁是加在索引上的不是加在“数据行”这个抽象概念上的。如果一张表没有主键InnoDB会隐式生成一个聚簇索引来锁定如果有二级索引不仅二级索引条目要被加锁还得回表把对应的聚簇索引记录也一起锁住。这也是为什么我一直强调建表必须要有主键——没有主键的表行锁的代价和误伤的几率都会大大增加。Gap Lock是间隙锁锁的是“两条记录之间的空隙”防止其他事务在这个区间内插入新记录。它存在的意义主要是为了在REPEATABLE READ隔离级别下消除幻读现象。严格来说间隙锁锁住的是一个范围范围里可能没有任何实际数据。比如表里只有id为10和20的两条记录间隙锁可以把(10,20)这个区间锁上让别人插不进id15的记录。Next-Key Lock则是记录锁和前面那段间隙锁的结合体锁的是“某条记录和它前面那个间隙”的组合是InnoDB在REPEATABLE READ下的默认加锁方式。它的加锁范围通常是一个左开右闭的区间比如(10,20]既能锁住已有的记录20也能防止别人在10到20之间插入新数据。理解这个区间才能准确推导一条SQL到底锁了哪些范围排查死锁时也才能画得出精确的锁冲突图。2.2 意向锁和表锁在行级和表级之间做协调很多人不理解为什么MySQL里会出现“表级锁”总觉得InnoDB不是支持行锁吗没错InnoDB支持行锁但某些操作依然需要锁表比如ALTER TABLE、DROP TABLE这类DDL操作。就算不走DDL行锁和表锁之间也需要一种沟通机制这就是意向锁Intention Lock存在的理由。意向锁是一个表级锁标记分为意向共享锁IS锁和意向排他锁IX锁。当一个事务要对某行加S锁或X锁时它会先在表级别打上一个“我打算对行加锁”的标记。这个标记的意义在于另一个事务如果准备对整张表加锁就不用一行一行扫描判断“表里有没有行被锁住”直接看表上的意向锁就够了。这里有个常见的理解误区意向锁之间是互相兼容的IS和IS、IS和IX、IX和IX都可以同时存在它们只跟真正的表级S锁、X锁互斥。换句话说两个事务可以同时给同一张表打IX锁因为它们各自要锁的是不同的行。只有当一个事务想直接锁整张表时才会检查表上有没有意向锁或表级S/X锁发现不兼容就等待。这套机制让表锁和行锁并存时不会发生“锁了行却把整张表锁死”的冲突代价极低。2.3 自增锁与插入意向锁容易被忽略但会惹大祸的小角色除了上面三类InnoDB还有两个特殊锁平时不说话一出问题就让人头疼。第一个是AUTO-INC Lock自增锁它属于表级锁专门给自增列用。在传统模式下执行带有AUTO_INCREMENT列的INSERT时InnoDB会持有这个表级锁直到语句结束。虽然它是轻量级的特殊锁但如果一张表的自增主键频繁插入短时间大量并发INSERT也可能因为抢自增锁出现轻微的排队现象。MySQL 8.0之后默认改为自增锁的“轻量模式”只在分配自增值的瞬间短暂持锁性能改善明显。第二个是Insert Intention Lock插入意向锁它是一种特殊的间隙锁。注意插入意向锁听名字像“意向锁”但它不是表级意向锁而是行级锁的一种锁的同样是间隙。它的特点是多个事务可以同时持有同一个间隙上的插入意向锁只要它们插入的位置不冲突但插入意向锁与间隙锁是互斥的一个事务持有某间隙的普通Gap Lock时另一个事务想往该间隙插入数据就得老老实实排队。很多人死锁就是因为没混清间隙锁和插入意向锁间隙锁是“禁止插入”插入意向锁是“等待插入许可”。3. 隔离级别与锁行为同样的SQL不同的世界3.1 四种隔离级别对应哪些锁的开关SQL标准定义了四个隔离级别READ UNCOMMITTED读未提交、READ COMMITTED读已提交、REPEATABLE READ可重复读、SERIALIZABLE串行化。MySQL InnoDB默认是REPEATABLE READ这一点和其他数据库很不一样。很多人困惑“为什么MySQL默认级别不是更常见的READ COMMITTED”答案恰恰是因为InnoDB用Next-Key Lock等机制在REPEATABLE READ下把幻读问题也解决了默认级别的安全性足够高。这里把每种隔离级别下的锁行为说透READ UNCOMMITTED几乎不用锁读所以会读到别的未提交事务的数据造成脏读。虽然它并发最高但业务上几乎没人敢用它因为“读到别人还没提交的数据”意味着你依赖的可能是一个即将被回滚的结果。READ COMMITTED读用MVCC快照不加重型锁但写会加记录锁且只锁住当前行不锁间隙。这意味着同一事务里两次查询可能读到不同的记录集产生不可重复读和幻读在RC级别下幻读理论上存在实践区间插入也容易发现问题。REPEATABLE READInnoDB的默认级别。读用快照写不仅锁记录还默认加Next-Key Lock锁间隙彻底限制幻读。代价是锁范围更大锁冲突的概率也随之上升。SERIALIZABLE最严苛的级别。连普通SELECT都会自动转成加锁读相当于把并发执行强行变成串行执行。这种级别几乎只在极特殊的强一致性场景中才会用一般业务把它当作兜底方案而不是常态。3.2 当前读与快照读MVCC和锁该如何分工很多刚接触InnoDB的人会问“既然REPEATABLE READ下SELECT不加锁那别人并发写的时候我读到的数据怎么保持一致”答案是MVCC多版本并发控制在帮忙。InnoDB的SELECT默认走快照读也就是基于undo log读取某个历史版本不需要加任何锁。快照读让读写之间基本互不阻塞这也是MySQL高并发读性能的来源之一。但反过来UPDATE、DELETE以及SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE走的是当前读必须读最新版本所以要加锁。当前读加锁的目的是为了让“写者”在修改之前锁定目标避免和别的写者交叉修改。问题和坑也在这种“快照读不加锁、当前读加锁”的双轨设计里。比如你SELECT出来一条记录然后程序里根据它做判断再UPDATE这个流程中你读到的旧快照可能已经被别的事务改成别的值了。常见业务错误就是“先查询余额再判断是否足够再扣款”这种方式叫做非阻塞读后续写因为读时无锁到写时才发现数据已变继而覆盖了别人的修改。解决办法要么用SELECT ... FOR UPDATE把读升级为当前读并加锁要么在写逻辑里用原子更新的SQL直接完成判断不给中间窗口留可乘之机。3.3 可重复读下间隙锁是怎么被“扩大”的REPEATABLE READ级别最让人爱恨交织的就是间隙锁。幻读指的是事务内两次查询同一条件的记录第二次突然后出来一条新记录。理论上靠MVCC快照读就能避免这种“上新”但如果另一个事务INSERT了一条满足条件的新数据旧事务的快照里不会出现它。为什么还要加间隙锁因为防止幻读需要同时管住“当前读”的情况比如SELECT ... FOR UPDATE在事务里跑两次第一次没有锁到某行记录第二次别人插进来一条就可能导致加锁结果不一致。InnoDB为了彻底堵住这个洞在REPEATABLE READ下把普通SELECT的加锁范围扩大为了Next-Key Lock把范围锁包含了记录和间隙。一个简单的范围条件查询最后锁定的可能就是一大片“空区间”。这是好事防住了幻读这个顽疾也是坏事因为稍不注意一个范围查询就能锁住整张表的大部分插入点导致并发插入全部排队表面现象就是“数据库写不进去了”。遇到这种情况理论上你可以把隔离级别降到READ COMMITTED让写锁只锁命中的行牺牲掉部分一致性换取并发度。但更推荐的做法是先从业务和索引设计上入手让SQL的WHERE条件走到更精确的唯一索引或更窄的范围从源头减少间隙锁的覆盖面积。4. 锁冲突现场超时、阻塞、死锁的排查与处理4.1 怎么快速定位锁等待和死锁线上遇到SQL执行很慢先别急着加索引如果是锁等待加索引一点用都没有。我用在实战里的排查路径一般是先看SHOW ENGINE INNODB STATUS。这个命令会直接把最近一次死锁事务的完整信息打出来里面包含被锁的索引、两个事务分别持有和等待的锁以及执行的SQL。对普通死锁定位来说这一行输出往往已经足够。再查information_schema.INNODB_TRX和INNODB_LOCK_WAITSMySQL 8.0里对应performance_schema.data_locks、data_lock_waits可以实时看到当前有哪些事务在等锁、谁持有锁、等了多久。查询sys.innodb_lock_waits是个很省事的高层视图直接把正在发生和已经结束的锁等待关系列清楚。锁等待和死锁的区别要分清锁等待是一个事务在排队等别人释放锁之后还能继续死锁则是两个事务各自持有一把锁同时又在等对方释放最终谁也无法继续只能由InnoDB挑一个事务回滚让另一个继续。InnoDB的锁等待是有上限的默认50秒超时超过了就把等待的这个事务给回滚掉然后报错ERROR 1205。如果业务里频繁出现类似“Lock wait timeout exceeded”说明你的高并发事务里存在严重的锁竞争需要优化事务粒度或查询逻辑。4.2 四种经典死锁场景与规避思路死锁虽然让开发很头疼但它的场景其实高度套路化常见的就这么几类。场景一同一个事务同时更新多张表但两个事务更新的顺序相反。比如事务A先更新表1再更新表2事务B先更新表2再更新表1两者交叉等待必死。解决办法很简单让所有事务都按照同一个固定顺序去更新表或行。场景二间隙锁与插入意向锁互踩。事务A对某个范围加了间隙锁事务B想往这个范围插入数据排队等待间隙锁事务A接下来又想插入一条新记录于是也想拿插入意向锁结果自己也排到了事务B后面两个人互相等最终触发死锁。这类问题往往跟范围条件查询和插入点交叉有关代价是缩小锁范围、减少批量更新或简化事务逻辑。场景三唯一键冲突导致的锁转换。两个事务同时插入相同唯一键先插入的拿到锁后插入的等待随后先插入的事务因为别的原因又需要另外一把锁和后插入事务的其他锁形成环。发生唯一键冲突后的事务重试很容易踩这个坑所以业务层面设计好唯一键冲突后的幂等处理逻辑很重要。场景四同一行数据被不同的二级索引加锁路径交叉锁定。因为InnoDB行锁是加在索引上两个事务分别通过不同的索引去修改同一行虽然目标行相同但获得的锁顺序不同就可能形成环形等待。规避方法是在业务SQL里统一走主键或统一访问路径。死锁的共性规避思路可以概括成三句话缩小事务和锁的范围、固定加锁顺序、把可能出现冲突的串行操作通过唯一键或分区设计错开。4.3 锁等待超时怎么调从参数和索引两头下功夫遇到频繁的锁冲突很多人的第一反应是调大innodb_lock_wait_timeout把50秒改成120秒。这个操作短期有效实际是饮鸩止渴它只是把“系统告诉你有问题了”这件事推迟。真要提升并发必须重新审视SQL和索引。索引对锁的影响很容易被忽略。一条UPDATE如果走的是二级索引InnoDB不仅要锁二级索引记录还要回表锁定聚簇索引记录锁的范围可能成倍放大。如果当初建了冗余的唯一索引让WHERE条件定位非常精确锁就只会落在极少数的行上。反过来如果条件上没法用索引InnoDB就可能全表扫描在扫描过程中对每一行都加锁等于整张表被变相锁住。这种场景很典型一个大范围UPDATE在凌晨跑把所有行都锁了早上业务正常流量一起来所有INSERT、UPDATE全部排队数据库立刻被“锁死”在假死状态。所以说锁定范围不取决于你“想锁几行”而取决于执行计划访问了多少行。加索引也好改写SQL也好核心是让执行计划的访问路径尽量窄。另一个常见操作是把大批量更新拆成小事务分批执行比如一次更新5000行拆成每次500行事务短了锁持有时间就短别人等待的窗口就小了。5. 事务锁在真实业务里的取舍与远眺5.1 到底要不要手动加锁SELECT ... FOR UPDATE该用不该用我见过两种极端一种是什么都不加锁出了问题靠兜底重试另一种是什么地方都手动加锁导致并发惨不忍睹。正确姿势得看业务的核心诉求。SELECT ... FOR UPDATE本质是“对读加写锁”确保读取后一直到事务提交这段区间内没有别人能改这些行。它特别适合“读-判断-写”这种有顺序依赖的流程比如库存扣减、账户扣款、优惠券核销核心是不能并发地对同一条数据做重复操作。但FOR UPDATE有个明显的代价一旦锁了行所有涉及这行的读和写都要等它提交完。这会严重放大长事务的负面影响。所以在手动加锁之前先问自己三个问题能不能用一条UPDATE语句原子完成省掉先查再改的窗口能不能把“查改”流程放到一个更短的事务里能不能通过唯一键约束或版本号乐观锁思路来替代数据库行锁能就别用FOR UPDATE不能再用它并且要极其克制地控制事务时间。关于乐观锁很多团队用“版本号UPDATE ... WHERE version ?”的方式它不使用数据库行锁而是在提交时校验版本冲突了就让业务重试。它比行锁更轻但是要求业务能接受“重试”。悲观锁FOR UPDATE则更严格适合冲突概率高的场景。两者结合使用的场景也很常见读的时候用普通查询写的时候用带版本条件的UPDATE能兼容高并发和一致性。5.2 从单机事务锁看到分布式锁这条学习路径怎么走掌握了InnoDB锁这套机制后再去看公司里的分布式锁、Redis锁这类东西会更容易找到它们的相通之处。单机数据库里的锁是InnoDB帮我们在本地协调事务顺序到了多台机器、多个服务的场景没有共享内存没有统一的锁管理器只能把锁放到一个大家都能碰到的中心化组件中这就是分布式锁的由来。它解决的是跨进程、跨节点的互斥问题也天然地带上了“锁等待、锁超时、锁续期、锁误删”这些老问题。这里容许我多提醒一句很多人一上来就研究分布式锁的精细实现反而忽略了单体数据库锁的基础导致本可以用数据库唯一键、本地事务锁解决的方案被硬生生搬到了Redis上不仅没解决并发问题还带来了新的数据一致性问题。我的建议是先吃透InnoDB锁和事务相关机制遇到单机解决不了的横向扩展场景时再去考虑分布式锁学习路径别倒挂。5.3 还有两个容易被业务忽视的锁行为要点第一个是外键和唯一约束带来的隐式锁。如果表上有外键InnoDB在往子表插入或更新数据时会在父表的对应记录上自动加一个共享锁检查父表数据是否存在。这个隐式锁在业务上看不到却可能让父表的写入变为串行。很多人排查半天都找不到是哪条SQL占用了父表的锁最后才发现是外键约束在幕后添乱。如果业务对强外键没有硬性要求建议用应用层逻辑代替数据库外键减少这类隐式锁。第二个是DDL和普通DML的冲突。MySQL 8.0之前修改表结构的DDL需要拿MDL元数据锁的排他锁而MDL锁和事务中的任何DML语句都互斥。这就产生了一个常见场景一个小慢查询长时间不提交后面排着一个大DDLDDL又堵住了后面所有的新查询看起来像“数据库全表锁死了”。每次要做结构变更前我都习惯先看一遍performance_schema中MDL锁的等待情况确保没有长事务挂在表上再执行DDL能省很多排查时间。做一个带着“锁视角”去写事务的人数据库事务与InnoDB锁的关联说到底就一句话事务的隔离性承诺是通过锁的排他与等待来落地的。你要评估一条SQL的并发表现不能只看执行计划快不快还得看它锁了多少行、锁多久、锁的范围会不会堵住别人的插入点。我这些年踩过最大的坑就是以为“锁只影响写不影响读”直到线上接口频繁超时才明白InnoDB在当前读机制下的锁往往会波及更多查询。写SQL和设计事务时多问几遍“这个事务的锁会在哪里等待、等待到何时结束”很多数据库事故其实都可以提前避免。
返回列表