数据库并发控制核心:封锁协议、两段锁与锁粒度实战解析 📅 发布时间:2026/9/18 21:08:04 👁 浏览次数: 1. 并发控制为什么是数据库的“命门”——先搞清楚要解决什么问题做数据库开发或者系统设计的人早晚都会撞上并发控制这堵墙。我刚工作那会儿接手过一个库存管理的接口上线第二天就出问题两个订单同时扣同一件商品的库存最后数据库里的库存数变成了负数。当时第一反应是代码写错了排查半天才发现问题出在多个事务并发执行时大家同时读到旧值、又同时写回把彼此的更新覆盖掉了。那次事故之后我才认真把所有并发控制的知识补了一遍也理解了为什么教科书要把封锁协议、两段锁协议、封锁粒度这些概念反复讲——它们不是考试用的理论是真实系统里每一条数据正确性的底线。一个事务本质上就是对数据库的一组读写操作并发控制要解决的核心问题是多个事务同时读写同一批数据时怎么保证结果和这些事务一个一个串行执行时完全一样。这个目标在数据库原理里有个专门的名词叫“可串行化”。可串行化是并发调度的最高标准但直接做全局串行化性能又太低所以数据库才设计了各种锁机制和协议在“保证正确”和“尽量快”之间找平衡。1.1 没有并发控制时数据库会乱成什么样要理解并发控制的必要性最好的办法是先看看没有它会发生什么。数据库界把并发事务互相干扰的问题总结成了三类经典异常第一类是丢失修改。两个事务T1和T2同时读到同一份数据AT1把A改成10写回接着T2把自己算出的值也写回T1的修改就这么被覆盖了。我在库存系统里碰到的问题就是典型的丢失修改两个订单都读到库存为5各自扣1后都写回4实际售出两件但库存只少了1。第二类是脏读。T1修改了数据A但还没提交T2读到了这个未提交的修改然后T1因为某种原因回滚了。T2读到的就是一个在数据库里根本不存在过的值。类比一下就是别人在一张纸上写了东西还没签字你照着这个草稿去办事结果对方把纸撕了重写你的活儿全白干。第三类是不可重复读和幻读。T1先读了一次A的值隔了一会儿又读A发现值变了原因是T2在中间提交了对A的修改。幻读则更隐蔽T1按某个条件查出了5条记录T2插入了一条新记录并提交T1再按同样条件查却多出一条。记录的数量像幻影一样变化业务逻辑很容易被这种变化搞崩。1.2 可串行化是标尺但不是所有场景都需要最高标准这里我要强调一个很多初学者容易忽略的点可串行化是理论上的理想标准但实际工程里并不是所有业务都需要它。银行转账、库存扣减这种强一致性场景当然必须达到可串行化但像统计报表、商品浏览量这种允许一定延迟和误差的场景用更低的隔离级别能换来高得多的并发性能。于是SQL标准定义了四个隔离级别从低到高分别是读未提交、读已提交、可重复读、可串行化。隔离级别越高并发控制越严格数据越安全但锁竞争也越激烈吞吐量随之下降。每个级别的差异本质上就是允许或禁止上述三类异常中的哪几种。读未提交会丢修改、脏读、不可重复读全占读已提交解决脏读可重复读解决不可重复读和丢失修改可串行化连幻读一起解决。MySQL默认用可重复读配合间隙锁其实能串行化很多场景但需要你用对索引和锁条件才行。1.3 封锁是最主流的并发控制手段解决并发问题的手段不止一种常见的有封锁、时间戳、多版本并发控制MVCC。MVCC在很多现代数据库里承担了读多写少场景的重任但封锁才是真正决定写入正确性的底线机制。封锁的基本思路粗暴直接事务要操作一个数据必须先申请对应的锁如果锁被别人持有那就排队等着。只要锁的规则设计合理并发事务之间的冲突就能被有效隔离开。封锁涉及三个核心问题锁的类型有哪些什么时候加锁、什么时候释放这就是封锁协议以及锁在多大范围上加封锁粒度。这三个问题互相耦合决定了并发控制的安全性和性能表现。想真正搞懂数据库并发这三个点缺一不可。2. 封锁协议从一把锁到一套规则体系2.1 共享锁与排他锁读写冲突的核心破解法封锁机制最底层的就是两种锁共享锁和排他锁。共享锁又叫S锁或读锁事务读取数据前需要申请它多个事务可以同时持有同一个数据的S锁彼此不冲突。排他锁又叫X锁或写锁事务修改数据前需要申请它同一时刻只能有一个事务持有X锁其他事务无论申请S锁还是X锁都得等待。这里有一个关键约束叫锁的兼容性矩阵S锁和S锁兼容S锁和X锁不兼容X锁和任何锁都不兼容。这个矩阵是整个封锁机制的基石理解了它就能推导出几乎所有的锁行为。我做系统设计时经常把这个矩阵贴在文档里业务方来问为什么某个接口串行执行给他看这个矩阵他就明白了。但光有S锁和X锁还不够锁的类型只是工具什么时候申请、什么时候释放才是协议的规则。不同协议对加锁释放时机的约束不同安全性等级也就不同。2.2 一级封锁协议只防丢失修改一级封锁协议是所有协议里最基础的一档核心规则就一句事务在修改数据之前必须先对该数据加X锁直到事务结束才释放。一级封锁协议解决的是丢失修改问题。因为两个事务要改同一份数据必须先加X锁而X锁互斥所以后到的事务只能等先到的事务提交或回滚、释放锁之后才能获得X锁。这样就不会出现两个人同时读到旧值、各自改完互相覆盖的情况了。但一级封锁协议有个明显缺陷它只要求在写之前加X锁读之前是不需要加锁的。也就是说T1改了数据但没提交T2照样能去读这个未提交的值脏读问题依然存在。所以一级封锁协议在隔离级别里对应最低的读未提交一般生产环境没人敢直接用。2.3 二级封锁协议加上读锁堵住脏读二级封锁协议在一级的基础上增加了一条规则事务在读取数据之前必须先对该数据加S锁读完之后马上释放S锁。注意这里的区别写锁要持有到事务结束因为一旦中途释放后面的写操作无法保证不和其他事务冲突读锁则可以在读取完成后立即释放不需要等到事务提交。这一点在实际系统里的影响非常明显短读锁能显著减少锁等待时间。加了这把读锁脏读就被堵住了T1修改数据时持有X锁T2要读这个数据必须申请S锁但S锁和X锁不兼容T2只能等。直到T1提交或回滚、释放X锁后T2才能读到而那时数据已经是提交后的合法值不存在“未提交的数据被读到”的问题了。不过二级封锁协议依然有问题T1第一次读数据用完就释放S锁了T2在中间修改了数据并提交T1第二次读同一个数据时发现值变了不可重复读依然存在。它在隔离级别中对应读已提交。2.4 三级封锁协议读锁也持有到事务结束三级封锁协议把二级的读锁规则改了一句话事务在读取数据之前加S锁但这个S锁要一直持有到事务结束才释放。别小看这个改动。读锁持有时间延长了就压住了不可重复读问题——T1第一次读A时加了S锁并且不释放T2想改A必须申请X锁而X锁和S锁不兼容T2只能等T1提交。这样T1第二次读A时值一定是原样未变的保证了可重复读。三级封锁协议对应SQL标准里的可重复读隔离级别同时因为写之前要加X锁丢失修改和脏读也被一并解决了。但在严格的可重复读级别下幻读依然可能发生。想消除幻读需要更高级的手段要么在范围上锁要么在表级别加锁这就是封锁粒度要解决的问题了。2.5 封锁协议的实际搭配心得我在实际项目里总结了一条经验不要死记协议对应哪个隔离级别而是要看业务允许哪类异常。比如一个报表查询任务允许读到某个时刻的近似值那我用二级协议对应的读已提交就够了但如果是余额查询就一定要用三级协议对应可重复读或干脆上串行化。还有一个容易被忽略的细节很多主流数据库并不是严格按教科书协议实现锁的比如MySQL的InnoDB就在可重复读级别下用MVCC实现了快照读的隔离不需要读操作加S锁。但写操作之间的X锁冲突还是全部依赖封锁协议来保证所以理解协议本身仍然是排查一切诡异问题的前提。3. 两段锁协议让并发调度可串行化的关键3.1 两段锁的定义扩展阶段与收缩阶段封锁协议解决了并发事务互相干扰的具体异常但它并没有从根本上回答一个问题什么样的并发调度是安全的两段锁协议就是为了回答这个问题而生的。两段锁协议要求每个事务的所有加锁操作都必须在第一个解锁操作之前完成。也就是说事务被分成两个阶段前半段只加锁、不解锁叫扩展阶段后半段只解锁、不加锁叫收缩阶段。一旦事务开始释放第一个锁就不允许再申请任何新锁了。这个规则比三级封锁协议更宏观。三级封锁协议关心的是某个锁的持有时机两段锁协议关心的是整个事务的加锁解锁节奏。它不规定具体加什么类型的锁只规定加锁和释放的先后顺序。3.2 为什么两段锁能保证可串行化这个问题的证明比较复杂但直觉上可以这样理解可串行化要求任意两个事务的冲突操作两个事务操作同一数据且至少有一个是写执行顺序一致。如果事务不满足两段锁就可能出现T1先读了A、T2写A、T1再写A这样的交错导致两个事务的冲突操作顺序不一致无法等价于某个串行顺序。满足两段锁后任何事务的锁都集中在扩展阶段其他事务无法在它的锁区间内插入冲突操作这样事务之间的关键操作顺序就不会被打乱。数据库领域最重要的定理之一就是任何满足两段锁协议的并发调度都是可串行化的。这句话值得反复读三遍它是整个封锁理论的核心。更严谨地讲还需要强调一点两段锁协议保证的是“存在某个串行顺序与调度结果等价”并不保证每个事务都按照特定的顺序执行。在实际系统中这已经足够安全了。3.3 两段锁与死锁的相爱相杀两段锁协议最大的问题也是所有用锁系统逃不掉的宿命就是死锁。一个经典场景T1先锁了AT2先锁了BT1接下来想锁B在等T2释放T2接下来想锁A在等T1释放。两个事务互相等下去谁也提交不了。死锁产生有四个必要条件互斥、持有并等待、不可剥夺、循环等待。两段锁协议天然满足前三个而循环等待在并发事务的随机交错下很容易出现所以两段锁系统几乎必然会发生死锁。解决死锁有两类策略。一类是预防比如让每个事务一次性申请所有需要的锁或者给锁设置超时时间。另一类是检测并解除数据库系统维护一张等待图定期检测有没有环发现环就牺牲其中一个事务强行回滚。InnoDB就是这样做的。给错过上线事故的人提个醒宁可让一个事务回滚重试也别让全表锁死。3.4 两段锁协议的变体严格两段锁与强两段锁教科书上的两段锁协议还有一个问题事务在收缩阶段已经释放了部分锁这时如果事务回滚可能产生级联回滚。因为其他事务已经读了它释放的数据而这些数据可能被回滚撤销读方就得跟着回滚。为了消除级联回滚工程界普遍用的是严格两段锁协议所有的排他锁一直持有到事务结束才释放。这样其他事务在它提交前完全碰不到它写的数据不会读到需要回滚的值。MySQL InnoDB实际采用的就是这种策略。强两段锁协议更严格要求所有锁包括S锁和X锁都持有到事务结束这也正是前面说的三级封锁协议。强两段锁的可串行化保证最强但并发度也最受限制。现实系统中业务方在可重复读或读已提交级别下只加X锁到事务结束就是这个道理的工程化妥协。4. 封锁粒度锁的“颗粒”大小怎么选4.1 多粒度封锁从行锁到表锁再到数据库锁封锁粒度是指锁作用的数据范围大小。最小可以锁一条记录行锁大一点锁一张表表锁再大可以锁整个数据库甚至整个实例。理论上粒度越小并发度越高因为不同事务可以同时操作同一张表的不同行但锁数量多、管理开销也大。粒度大了管理容易但并发度极低一个表锁就能把所有写操作全串起来。多粒度封锁解决了“全选还是全不选”的问题允许不同事务在不同粒度上加锁。比如一个报表事务锁整张表其他针对单行的小事务锁具体行两者需求不同但可以共存。数据库用锁的数量和使用规则来管理多粒度下的兼容性能让并发度和开销达到更优的平衡。4.2 意向锁避免逐级检查的性能噩梦多粒度封锁带来一个复杂的检查问题事务要锁某一行需要确认它的上层对象表、数据库没有被别人用不兼容的锁锁住反过来锁表也要确认没有下层对象被锁。如果每次加锁都把整棵锁树扫一遍性能会非常难看。解决思路是意向锁事务在锁定某个下层对象之前先在上层对象上加一个意向锁。意向锁表示“我要在这个层次下面加锁了”它本身不阻塞其他锁只用于让上层检查和下层检查快速完成。例如一个事务要锁某一行首先在表上加意向锁其他事务尝试锁整张表时看到意向锁就知道下面有行锁于是等待如果其他事务只锁其他行它加的表级意向锁也不冲突。意向锁分为意向共享锁和意向排他锁它们之间的兼容性规则略微复杂但核心目标一致以极小的锁开销实现多粒度的快速冲突判断。这也是为什么MySQL InnoDB的行锁实现底层同时用到了表级意向锁。4.3 粒度选择的权衡并发度与系统开销实际选型时封锁粒度本质上是个权衡题。行级锁并发度高但每行都占用内存、元数据锁竞争激烈时甚至可能因大量行锁而导致内存压力。表级锁实现简单对批量操作友好但会严重限制并发写。我建议按数据访问特征选粒度高频单点更新用行锁典型是订单表大量数据批量更新用表锁或分区锁比如定时任务全量刷新报表查询这种只读场景可以用表级快照锁配合MVCC来避免阻塞。另外数据库的锁升级机制也很重要比如某些数据库在行锁数量超过阈值时会自动升级成表锁你需要知道这个机制存在才能预判高并发下的行为。5. 实操中的常见问题与排查技巧5.1 死锁的四个必要条件与破解思路如果说协议是理论、粒度是策略那死锁就是在实际系统中最常遇到的实战考题。我在生产环境排查死锁时第一步永远先确认四个必要条件中哪个被满足了然后针对性割裂它必要条件破解思路互斥很难消除因为锁本身就要求互斥持有并等待所有锁一次性申请预分配不可剥夺引入锁超时超过时限自动释放循环等待规定全局锁顺序所有事务按同一顺序加锁理论上看预分配所有锁和全局锁顺序是根治循环等待的经典方案但实际工程中太僵硬很多场景没法预测需要用到的所有行。所以主流数据库更多采用“检测-回滚”的思路配合锁等待超时兜底确保系统不至于无限挂起。5.2 死锁与锁等待的排查步骤遇到数据库一直卡住或者报“Lock wait timeout exceeded”之类的错误时我一般按这个顺序排查先确认是不是死锁。MySQL里执行SHOW ENGINE INNODB STATUS查看最近一次死锁的信息里面会打印出互相等待的两个事务分别持有哪些锁、正在等待哪把锁往往一眼就能看出循环。如果是单纯的锁等待超时去information_schema的INNODB_TRX表查当前活跃事务再关联INNODB_LOCKS和INNODB_LOCK_WAITS找到谁阻塞了谁。8.0版本后可以用performance_schema.data_lock_waits信息更全。定位到阻塞源头后判断那个事务为什么迟迟不提交。常见原因有事务里做了大量无关查询拖长执行时间、事务里调用了外部HTTP接口导致长时间等待、应用程序忘记提交或回滚。最后才是考虑优化层面调整事务的执行顺序让所有事务按统一规则加锁或者拆大事务为小事务缩短锁持有时间。5.3 线上锁问题的常见诱因与避坑技巧这些年我总结出几个特别容易引发锁问题的编码习惯写出来给大家避坑。第一个是长事务。一个事务执行几秒钟它持有锁的时间就有几秒并发稍高就堆出大量等待。我见过有人在一个事务里循环处理几千条数据每条里面还查好几次数据库结果单事务跑了十秒整个表都被它锁死了。解法是尽量把读操作放到事务外面事务里只保留必要的写和校验。第二个是跨表加锁顺序不一致。两个事务都先更新A表再更新B表但顺序不同非常容易形成互等。解决办法是约定加锁顺序让所有事务都先锁A再锁B循环等待就自然消失了。第三个是同一个事务里先读后写导致的死锁。比如T1先SELECT出来一条数据不提交T2也SELECT出来这条数据不提交然后T1想UPDATE结果在等T2的读锁释放T2也想UPDATE在等T1的读锁释放。此时两个事务直接死锁。这种场景建议对要修改的数据直接使用SELECT ... FOR UPDATE一次性拿X锁避免先读后写的锁升级过程。第四个是索引失效导致的锁数量剧增。明明只想锁一行因为查询条件没走索引数据库只能扫描全表把扫描到的所有行都锁上。我排查过一起锁超时事故最后发现是OR关键字导致索引失效把整张表几十万行全锁了。所以写更新语句时务必EXPLAIN看执行计划确认命中了索引。5.4 常见问题速查封锁协议与粒度的实战解析问题现象可能原因排查手段两个事务互相等待超时报错死锁循环查看死锁日志找出等待环更新一条记录却锁了很久索引失效导致锁了全表EXPLAIN查看执行计划报表查询和业务写入互相阻塞表级锁与行级锁冲突考虑用MVCC快照读或调整隔离级别同一行数据被多个事务并发更新频繁报等待超时行锁竞争激烈缩短事务时间或按队列方式串行化批量更新任务执行时间过长阻塞前台长事务持有大量锁分批提交减少单批事务数据量两个事务先读后写同一行导致死锁读锁升级写锁互相等待使用SELECT FOR UPDATE提前上写锁6. 写在最后并发控制不是背概念是工程选择回过头看并发控制这套体系其实就是三件事决定锁的类型决定什么时候加锁释放决定锁影响多大范围。封锁协议管时机两段锁协议管可串行化的安全边界封锁粒度管性能。三者叠在一起就是数据库保证正确性和吞吐量的全部秘密。我个人在实际项目里最大的体会是不要一上来就追求最强的隔离级别或最严格的封锁协议而是先问自己三个问题业务允许读到多久之前的数据写冲突发生的频率有多高单次事务的执行时间能压到多少毫秒想清楚这三件事锁协议、锁粒度、隔离级别的选择方案基本自己就浮现出来了。最后再分享一个小技巧所有锁相关的配置都要在压测环境里验证过再上生产。我见过不少团队把MySQL的锁等待超时时间随便调大或调小结果上线后要么频繁报错要么死锁检测彻底失灵。锁是数据库最复杂的部分之一改任何参数之前先想清楚它会影响哪条链路上的哪一环。踩过几次坑之后你会发现自己对数据库的理解已经完全不一样了。