数据库并发控制核心:封锁协议、两段锁与锁粒度实战解析
2026/9/18 21:07:58 网站建设 项目流程

1. 并发控制为什么是数据库的“命门”——先搞清楚要解决什么问题

做数据库开发或者系统设计的人,早晚都会撞上并发控制这堵墙。我刚工作那会儿,接手过一个库存管理的接口,上线第二天就出问题:两个订单同时扣同一件商品的库存,最后数据库里的库存数变成了负数。当时第一反应是代码写错了,排查半天才发现,问题出在多个事务并发执行时,大家同时读到旧值、又同时写回,把彼此的更新覆盖掉了。那次事故之后我才认真把所有并发控制的知识补了一遍,也理解了为什么教科书要把封锁协议、两段锁协议、封锁粒度这些概念反复讲——它们不是考试用的理论,是真实系统里每一条数据正确性的底线。

一个事务本质上就是对数据库的一组读写操作,并发控制要解决的核心问题是:多个事务同时读写同一批数据时,怎么保证结果和这些事务一个一个串行执行时完全一样。这个目标在数据库原理里有个专门的名词,叫“可串行化”。可串行化是并发调度的最高标准,但直接做全局串行化性能又太低,所以数据库才设计了各种锁机制和协议,在“保证正确”和“尽量快”之间找平衡。

1.1 没有并发控制时,数据库会乱成什么样

要理解并发控制的必要性,最好的办法是先看看没有它会发生什么。数据库界把并发事务互相干扰的问题总结成了三类经典异常:

第一类是丢失修改。两个事务T1和T2同时读到同一份数据A,T1把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先锁了A,T2先锁了B;T1接下来想锁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”之类的错误时,我一般按这个顺序排查:

  1. 先确认是不是死锁。MySQL里执行SHOW ENGINE INNODB STATUS查看最近一次死锁的信息,里面会打印出互相等待的两个事务分别持有哪些锁、正在等待哪把锁,往往一眼就能看出循环。
  2. 如果是单纯的锁等待超时,去information_schema的INNODB_TRX表查当前活跃事务,再关联INNODB_LOCKS和INNODB_LOCK_WAITS,找到谁阻塞了谁。8.0版本后可以用performance_schema.data_lock_waits,信息更全。
  3. 定位到阻塞源头后,判断那个事务为什么迟迟不提交。常见原因有:事务里做了大量无关查询拖长执行时间、事务里调用了外部HTTP接口导致长时间等待、应用程序忘记提交或回滚。
  4. 最后才是考虑优化层面:调整事务的执行顺序,让所有事务按统一规则加锁;或者拆大事务为小事务,缩短锁持有时间。

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的锁等待超时时间随便调大或调小,结果上线后要么频繁报错,要么死锁检测彻底失灵。锁是数据库最复杂的部分之一,改任何参数之前,先想清楚它会影响哪条链路上的哪一环。踩过几次坑之后,你会发现自己对数据库的理解已经完全不一样了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询