1. 为什么要专门研究并发控制:先想清楚事务并发时会发生什么
这门课学到并发控制,很多人的第一反应是“不就是加个锁吗”。但如果你真的直接跳到锁机制去看,大概率会卡在对概念的理解上:为什么有了共享锁还得有排他锁?为什么叫一级封锁协议、二级封锁协议?三级封锁协议到底解决了哪些问题?
我建议先把视角拉高一层想清楚:并发控制要处理的核心矛盾,其实是数据库系统里的多个事务同时操作同一份数据时,彼此之间会发生“互相干扰”,而这种干扰如果不管,数据的一致性就会被破坏,甚至出现账目对不上、库存越扣越负这类严重事故。
在陈红、卢卫老师这本《数据库系统概论》的第十二章里,并发控制的内容被拆成了基础概念、封锁机制、隔离性级别等几个层次。作为“上”篇,这节课重点解决的是一个很根本的问题:当多个事务同时跑的时候,数据库到底是靠什么规则来保证结果不出错的。我们常说的ACID特性里,原子性、一致性、隔离性、持久性中,真正受并发影响最大的是隔离性和一致性。原子性靠日志回滚来保证,持久性靠日志刷盘来保证,而隔离性和一致性就得靠并发控制机制了。
顺便说一个我观察到的现象。很多人学到这里会觉得“并发控制是数据库内部的事情,和平时写SQL关系不大”。但实际上你写出的每一个未加事务控制的批处理脚本、每一次在高并发下执行UPDATE然后接着再SELECT的代码逻辑,背后都在依赖数据库的并发控制机制在兜底。理解了它,你才能解释为什么某些线上偶发问题只在并发请求量大时出现,才能看懂隔离级别参数是怎么影响你程序最终的数据表现。
这一篇我们就把“上”的部分彻底讲透:先从事务并发带来的三种典型问题入手,再看锁机制怎么设计,封锁协议是怎么一层层把这些典型问题解决的,最后聊一聊大家比较容易混淆的活锁与死锁问题,并且把这些概念对应到数据库实践操作中去。内容可以直接对着教材第十二章前半部分来复习,也可以反过来当作先导理解材料。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 并发操作会产生什么问题:丢失修改、不可重复读与读脏数据
2.1 丢失修改:你改完了,但改了个寂寞
丢失修改是最直观、最容易用生活经验理解的一类并发问题。它的发生场景是:两个事务都读了同一条记录,各自在自己的会话里做了修改,然后先后把修改结果写回数据库。后写回的事务把先写回事务的更新覆盖掉了,前一个事务的修改就好像从来没发生过一样,所以我们把它叫作“丢失更新”。
举一个非常经典的火车票例子。假设某趟车次只剩最后1张票,两个用户在12306上同时发起购票请求。事务A读取到当前余票是1,在它的逻辑里执行“余票=余票-1”然后写回,余票变成0。事务B也在同一时刻读到余票为1,同样执行“余票=余票-1”然后写回,余票变成0。表面上看,两次购票都成功了,但实际只有一张票被扣减,另一张票凭空多出来了,这就是典型的丢失修改。线下场景里最容易感知到的版本是:两个人同时编辑同一个在线文档,后保存的人覆盖了先保存的人的全部修改。
如果分别只执行一条UPDATE语句,其实大部分数据库不会出现这种问题,因为单条语句自身就是原子的。但真实业务里事务必然包含多条语句,比如先SELECT余票、在程序里判断余票是否充足、再执行UPDATE、最后插入订单记录。从SELECT到UPDATE之间是有时间窗口的,并且这个窗口在应用层代码里可能长达几十毫秒甚至更多,并发冲突的概率就被放大了。这时候如果不做并发控制,丢失更新几乎必然发生。
注意:所有解决这个问题的机制,本质上都在做同一件事——把“读-判断-写”的操作序列保护起来,要么让多个事务没法同时操作同一条数据,要么让后操作的事务能感知到前一个事务已经改过数据。
2.2 不可重复读:同一条SELECT语句读出不一样的结果
不可重复读,它的关注点不在修改本身,而在“读的一致性”上。同一个事务内,对同一条数据执行两次相同的查询,却得到了不同的结果,这就叫不可重复读。为什么会不同?因为在两次查询之间,有另一个事务修改并提交了这条记录。
用银行转账来理解更贴切。你在一个事务里先查询账户A的余额是1000元,这个事务还没结束,另一个事务往账户A里转入500元并提交。然后刚才那个事务再次查询账户A的余额,发现变成了1500元。对一个要求逻辑一致性的业务来说,同一个事务内部前后读到不同状态的数据,会导致业务判断出现混乱。
不过这里有一个细节容易搞混:不可重复读、幻读、脏读这三个问题经常被放在一起讨论,但它们产生的原因和侧重点是不一样的。不可重复读强调的是同一条记录的内容发生了前后变化,核心是被UPDATE语句修改了;幻读强调的是同一个查询条件下,记录的行数变多了或者变少了,核心是被INSERT或DELETE语句影响了。比如说事务里先查某个班级有30个学生,另一个事务插入了一个新学生并提交,再查时变成31个,这就是幻读。
教材级别的问题定义里,通常会把不可重复读和幻读分开表述。早期很多教材把幻读当作不可重复读的一种特殊形式,但随着隔离级别概念的普及,越来越多的人倾向于把两者严格区分开,因为它们的解决手段完全不同:不可重复读通过对已存在的数据行加锁就能避免,而幻读必须靠锁住范围或使用更高级别的隔离手段才能解决。读陈红、卢卫老师这本教材时,你会看到它们在阐述异常现象时各有各的场景定义,需要注意区分不要混淆。
2.3 读脏数据:拿到了一个可能被回滚的结果
脏读的理解难度其实比前两个低,但它造成的后果非常隐蔽。脏读指的是一事务读取了另一个事务尚未提交的数据。为什么“未提交的数据”是脏的?因为那个事务可能接下来会回滚,一旦回滚,它之前所做的所有修改都会被撤销,数据库将恢复到它开始之前的状态。那么此前读了它未提交数据的那个事务,就相当于拿着一个不存在过的数据去做了业务处理。
举个实际点儿的例子:转账事务A先从账户X扣减500元(尚未提交),此时事务B读取账户X余额,发现少了500元,基于这个结果判断账户X有足够的钱,于是给账户X又转了1000元。这时候如果事务A突然因为后续步骤出错而回滚,账户X的500元扣减被撤销,余额恢复原状,但事务B已经基于错误的余额信息完成了另一笔操作。这笔业务决策所依赖的数据前提从头到尾就是错的。
在数据库隔离级别体系里,脏读是“最严重”的并发异常,任何主流数据库在RU(Read Uncommitted,读未提交)级别以上都不允许脏读发生。而解决脏读的手段,其实只需要一条原则:不让事务读取到其他事务尚未提交的修改。用锁的术语来表达就是:写数据时要加排他锁,并且这个排他锁要一直持有到事务结束,这样其他事务在整个期间都无法读到这条被修改但未提交的数据。
2.4 三种异常之间的联系和记忆方法
学完这三个问题,很多同学反而会觉得概念一团乱麻。我提供一个我自己复习时用的记忆框架。
你可以把并发事务的干扰理解成三个维度:没管住自己的写导致丢失修改,管不住别人的写导致不可重复读,管不住自己的读导致脏读。这三个维度分别对应着不同的锁策略:自己的写要加锁且锁要一致性保持到事务结束;事务内读到的数据不能被外部修改,锁要持有到事务提交;读取操作链路上的并发一定要保证每一个读到的数据都是已提交状态。
另外还要注意一个问题定义的边界:这三种异常通常都是站在“一个事务的视角”来描述的。数据库中异常是否发生取决于并发事务的隔离级别设定,而隔离级别的选取则是一个典型的“一致性换性能”的trade-off过程。设得越高,一致性越强,但并发能力下降明显;设得越低,性能越好,但你需要自己在业务代码层面承担部分修正工作。
3. 封锁机制:并发控制的基石到底长什么样
3.1 为什么是“锁”:用一个日常排队逻辑来理解
数据库解决并发问题最朴素的手段就是锁。它的思想跟现实世界中资源管理的逻辑几乎一样。想象有一个公共休息室的冰箱,里面放着大家各自带的午餐。如果两个人同时打开冰箱门,一个人拿走自己那份三明治,另一个人顺手把别人那份也吃了,那肯定乱套。所以大家约定:谁要从冰箱里取食物,先拿钥匙把冰箱锁打开,取完再锁上,别人只能等。
数据库的锁机制本质上就是这套规则的形式化。当事务需要操作某条数据时,先向锁管理器申请对应的锁,申请成功了才能继续操作,申请不到就进入等待状态,直到其他事务释放锁。
但数据库里的锁和现实世界的冰箱锁有个重要区别:现实中的锁通常非黑即白——锁上还是没锁上,而数据库的锁是有“类型”之分的。不同类型的锁之间有不同的兼容关系,这直接决定了同时有多少事务可以访问同一份数据,从而在保证一致性的前提下尽可能保留并发度。
3.2 共享锁与排他锁:一对经典的锁类型
标准的封锁类型主要有两种:排他锁(简称X锁,也叫写锁)和共享锁(简称S锁,也叫读锁)。
排他锁的含义很直接:事务T对数据对象A加上X锁之后,只有事务T能对A进行读和写,其他任何事务都不能再对A加任何类型的锁,直到T释放掉这个X锁。可以理解为“这把钥匙只有一把,谁拿到谁独占”。
共享锁则宽松一些:事务T对数据对象A加上S锁之后,事务T可以读A但不能修改A,其他事务可以同时对A再加S锁——也就是允许多个事务同时读同一份数据。但不能加X锁。这背后的逻辑是:如果多个事务都只读同一份数据,不涉及修改,那么它们之间不会产生冲突,完全可以并发执行,没必要互相阻塞。
你可能已经想到了:为什么不能把所有访问都统一成一种“大锁”?因为那样并发度会被压到极低,比如某个报表查询事务只需要读某一行数据,却被要求锁住整张表长达数秒,这期间全表数据都不能被其他事务更新,代价是完全不可接受的。共享锁和排他锁的设计,就是要实现“读读并行、读写互斥、写写互斥”的效果。
3.3 锁的相容矩阵:数据库并发度判断的底层依据
把上面说的规则整理成一张矩阵表,就是教材中常出现的“锁相容矩阵”。横轴和纵轴分别代表两个不同事务对同一个数据对象发出的锁请求。矩阵中的值是“相容”还是“不相容”,决定了两个事务能否同时持有各自的锁。
| 已有S锁 | 已有X锁 | |
|---|---|---|
| 请求S锁 | 相容 | 不相容 |
| 请求X锁 | 不相容 | 不相容 |
这个矩阵是所有后续封锁协议的基础。建议把这张表背下来,因为后面无论学三级封锁协议、两段锁协议,还是理解数据库隔离级别的底层实现,最终都可以映射回这张矩阵上。举个例子:MySQL的InnoDB引擎在可重复读隔离级别下,普通的SELECT是通过多版本并发控制(MVCC)实现的快照读,它根本不加S锁,因此不会阻塞其他事务的写操作;只有SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE才会走到加锁读的路径上。这就是为什么很多业务代码里明明执行了加锁读,却发现并发度远低于预期——因为你命中了“读写互斥”的不相容规则。
还有一个大家很容易忽略的点:加锁操作本身是一个需要谨慎设计的行为,不应该去“锁不需要的东西”。比如只需要读取一行统计信息,就加S锁而不是X锁,能读就不加锁,能用行锁就绝不用表锁。锁的粒度越粗,实现越简单但并发度越差;粒度越细,并发度越好但管理开销也越大。这一层意思在教材中属于后面“多粒度锁”会展开讨论的内容,但在学锁类型的时候先有这个意识,会帮助你形成一个完整的判断框架。
3.4 从锁类型到锁协议:光有锁还不够
讲到这儿,你会发现一个关键的问题:有了锁,我们能控制事务能不能访问某个数据对象,但还缺少另一条规则——什么时候加锁、什么时候释放锁。同样是对数据加X锁,如果一个事务在修改完数据后立刻释放锁,另一个事务就有机会在第一个事务提交前读到未提交的数据,脏读照样会发生。
加锁和解锁的时机策略,在数据库理论中叫作“封锁协议”。协议不同,能够保证的一致性程度不同。教材通常用“一级、二级、三级”来标记这三种不同严格程度的封锁协议,它们也是并发控制“上”篇里最核心的计算题和分析题考点。接下来我逐级拆解。
4. 一级到三级封锁协议:每一级到底挡住了哪个问题
4.1 一级封锁协议:先保证修改不丢失
一级封锁协议的内容很简单:事务在修改数据之前,必须先对该数据加X锁,而且这个X锁要一直持有到事务结束(提交或回滚)才释放。
它的目的只有一个:解决丢失修改问题。你可以回顾一下丢失修改发生的条件——两个事务同时读到了旧值,然后各自写回。一级封锁协议出现后,当第一个事务对某条记录加了X锁时,第二个事务在更新同一条记录前也试图申请X锁,但申请不到,只能等待。第一个事务提交并释放锁之后,第二个事务才拿到X锁,此时它再读取到的已经是第一个事务更新后的新值了,基于新值继续计算,自然不会覆盖掉前一个事务的成果。
实操经验:这部分是笔试和面试中非常喜欢考的判断题。针对“一级封锁协议能否防止脏读”这类问题,答案是不能。因为一级封锁协议只约束了“修改”需要加X锁,“读”操作完全是自由的,一个事务可以去读另一个事务尚未提交的修改结果。脏读的本质是读了未提交的数据,一级协议完全不拦这条路。
所以一级封锁协议可以这样总结:解决了丢失修改,但挡不住脏读,也挡不住不可重复读。
4.2 二级封锁协议:在基础之上兼容读安全性
二级封锁协议在一级封锁协议的基础上,增加了一条对读操作的要求:事务在读取数据之前,必须先对该数据加S锁,读完立刻释放S锁。
注意核心差异来了:二级封锁协议中S锁是可以“读完即释放”的,不需要等事务结束。为什么这样设计?因为它的核心目标只是防止脏读。考虑这个场景:事务A修改了一行数据,加了X锁且未提交。此时事务B想读取这一行,按照二级封锁协议,B需要先申请S锁,但A的X锁还持有中,两者不相容,B的S锁请求会被拒绝,B只能等待。所以B无论如何都没法读到A尚未提交的修改,脏读就被挡住了。
但为什么S锁读完就能释放呢?因为不可重复读的场景里,问题不在于“第一次读的时候数据不干净”,而在于“第一次读完之后到第二次读之前,数据被别人改了”。如果你第一次读完就把S锁释放了,后续其他事务自然可以对这条数据加X锁执行修改,于是下一次再读就可能读到不同的值——不可重复读依然存在。
学习二级协议时很多同学会犯一个理解上的错误:以为“二级协议中读要加S锁”就意味着同一事务多次读取之间数据不会被修改。这是不对的。S锁不是加了一个就一直拿着,需要看释放时机。二级协议里S锁读完即放,隔离效果是短暂的;只有S锁同样持有到事务结束的第三级协议,才能真正锁住两次读之间的空档期。
4.3 三级封锁协议:事务内的所有读都是一致快照
三级封锁协议在二级封锁协议的基础上进一步要求:事务在读取数据之前加S锁,且S锁也要持有到事务结束才释放。
加了这条规则之后,事务内任何一个S锁保护的数据,从第一次加锁到最后一次释放之间,其他事务都无法对这个数据加X锁,因此数据在事务执行期间不会发生内容改变。这就解决了不可重复读问题。
注意,我这里说的是“解决了不可重复读”——为什么不是幻读?因为在经典封锁协议框架里,三级封锁协议如果严格在表级或更粗粒度上加锁,它能够连带锁住范围,从而顺带解决幻读;但如果是在行级锁粒度上讨论,三级封锁协议只能保证已存在的数据行不被修改,无法防止其他事务插入满足同样条件的新行。幻读问题需要依靠范围锁或更高的隔离级别机制来解决,这也是为什么在一部分教材的系统论述中,会把幻读放在“可串行化”级别的语境里再详细展开。
到这里,三级封锁协议其实给我们展示了一个完整的递进逻辑:
| 封锁协议 | 对读操作的要求 | 对写操作的要求 | 解决的问题 |
|---|---|---|---|
| 一级 | 不加锁 | 修改前加X锁,持到事务结束 | 丢失修改 |
| 二级 | 读前加S锁,读完即释放 | 修改前加X锁,持到事务结束 | 丢失修改、脏读 |
| 三级 | 读前加S锁,持到事务结束 | 修改前加X锁,持到事务结束 | 丢失修改、脏读、不可重复读 |
这张表建议贴在书桌上反复看,期末考试简答题经常会让你对比各级协议的异同、说明各自能阻止哪些不一致现象。做题时我一般先用表格缕清条件,再去判断题干场景属于哪一种异常,速度会快很多。
4.4 为什么要分“级”:现实中不存在万能的银弹
如果把三级封锁协议直接看作从“不怎么安全”到“完全安全”的一根轴,核心的工程问题就浮现出来了:为什么不直接要求所有事务都采用三级封锁协议?让数据库的一致性安全拉满不好吗?
答案其实就藏在“性能”两个字里。三级封锁协议中,S锁和X锁都要持有到事务结束,这意味着两个事务即使只是纯读同一条记录,后到的事务也可能在等待上耗费大量时间;更不用说读写混合场景下,锁的互斥等待会极大地降低系统吞吐量。在实际数据库产品中,几乎不可能让你让所有事务统统使用最高级别的封锁协议,通常的做法是提供多个隔离级别,让业务开发者根据场景自己权衡。
我在实际做系统设计时通常把握这样的原则:涉及资金、库存、订单状态的写操作,必须保证强一致,宁可牺牲并发度,也要让锁持有到事务提交;而报表统计、数据分析类只读操作,尽量避免长时间持锁,可以接受一定程度的重复读不一致。这背后的取舍逻辑,和数据库封锁协议分级设计的思想可以说一脉相承。理解了分级的初衷,后续学数据库隔离级别时就能更快地将理论与现实对应起来。
5. 活锁与死锁:加锁并发下绕不开的两个坑
5.1 活锁:永远等不到的锁
在并发控制中,除了数据不一致的问题,加锁机制自身还会引入新的问题,最常见的就是活锁与死锁。
活锁发生的典型场景是这样的:事务T1对某条数据加了S锁,事务T2想对它加X锁,申请失败进入等待。在T2等待的过程中,事务T3又来了,它申请的是S锁,由于S锁与S锁相容,T3顺利拿到了锁。若干个事务陆续到来,都是申请S锁的读请求,导致T2的X锁请求一直排在队列尾部,迟迟得不到执行机会。从T2的视角看,它没有被系统判定为失败,它只是在不停地等待,但永远等不到自己的轮次——“活”着,却被锁“堵死”了。
解决活锁的常用策略是“先来先服务”原则,也就是让锁的授予严格按照请求到达的先后顺序排队,先申请X锁的T2排在等待队列的最前面,后续的S锁请求只能排在它后面。这样一来,T2只要等待当前持有锁的事务释放锁,就能立即获得执行机会,不会再被源源不断的读请求“插队”。
不过这属于教材层面的理想化设计。真正的大型数据库产品在管理锁时面对的是海量并发请求,实现绝对公平的先来先服务代价很高。工程上通常会给等待时间设置阈值,等待过久的事务会被判超时,返回错误由应用程序决定是否重试。这个逻辑你应该很熟悉——你在代码里设置数据库连接超时时间、锁等待超时时间,本质上就是在和活锁做斗争。
5.2 死锁:互相等对方释放锁的僵局
死锁比活锁严重得多,它需要至少两个事务互相持有对方需要的锁。举一个最经典的例子:事务T1先锁住了数据A,然后尝试去锁数据B;事务T2先锁住了数据B,然后尝试去锁数据A。T1在等T2释放B,T2在等T1释放A,双方都在等待,而且谁也没法主动让出自己已持有的锁,形成了一个循环等待环,整个局面彻底僵住。
死锁和活锁的本质区别在于:活锁中等待是可能结束的——只要系统加锁调度公平,它总有机会抢到锁;而死锁中等待是永久的——如果不借助外部干预,事务双方会无限期挂起。活锁浪费的是时间,死锁消耗的是整个系统的可用性。
数据库系统处理死锁通常有两条路线:预防和检测。预防的思路是设计一种不可能产生死锁的规则,比如“一次封锁法”,要求事务一次性将所有要使用的数据全部加锁,否则就都不执行;另一种是“顺序封锁法”,规定所有事务必须按照预先约定好的顺序访问数据,比如先锁A再锁B,不允许反过来。这两种方法在理论上是成立的,但工程上实现成本都很高。一次封锁法大幅降低了并发度,因为一个事务可能要提前锁定很多根本不会用到的数据;顺序封锁法在数据对象极多的真实系统里几乎不可行,而且一旦业务演进导致新的数据访问顺序出现,维护成本会爆炸。
因此现实世界的数据库系统普遍采取的是“检测并解除”的思路。数据库的锁管理器会维护一张“谁在等谁”的等待图,周期性检测图中是否存在环,一旦发现死锁就选择一个牺牲者事务将其回滚,释放掉它持有的锁,让其他事务继续推进。大多数数据库里牺牲者的选择会参考事务执行时长、已修改行数、回滚代价等指标,把损失降到最低。
5.3 死锁的经典案例:还是拿转账说事儿
网上讲死锁最常见的例子是账户转账:账户A向账户B转账,同时账户B向账户A转账。两个事务如果恰好都先给对方账户加锁再等对方账户的锁,就发生了环形等待。
真实业务里更常见的是多个账户的批量转账场景。一个批量任务里可能要对几十个账户分别做入账和出账操作,如果你不加约束地按照“先处理到哪个账户就锁哪个账户”的顺序来,两个不同的批量任务之间很容易出现锁顺序交错。比如任务1先锁了账户1再等账户2,任务2先锁了账户2再等账户1,死锁瞬间形成。
我的经验建议是:在批量处理资金路由或者库存回写这类场景时,尽量对所有操作对象先排序再统一按顺序加锁。比如对所有账户号先做升序排列,再依次给每个账户加锁。排序之后,任何两个事务对同一批账户的加锁顺序必定一致,环形等待的条件就被直接打破了。这个方法是个非常简单有效的工程技巧,比依赖数据库自动死锁检测更主动,也更好排查问题。
排坑提示:很多人在设计事务时遇到“偶尔报死锁错误”的问题,第一反应就是调大锁等待超时时间。如果你的死锁是由于多个事务的加锁顺序不一致导致的,调超时时间只会让问题更晚暴露,系统吞吐却进一步下降。正确的排查方式是把出现死锁的几条SQL日志拉出来,分析各事务的加锁顺序,从代码层面规范加锁路径。
6. 把理论对回实践:在真实数据库和课程实验中看到并发控制
6.1 在MySQL中直观感受锁与事务
学完上面的理论,如果只是在纸面上做题,很快就会忘。我建议你在自己的电脑上用MySQL实际动手跑几个实验。随便建一张表,开两个命令行客户端连接(模拟两个并发事务),亲手操作一遍就能把这些概念彻底变成自己的。
第一步可以先测脏读。把事务隔离级别临时调整成READ UNCOMMITTED,开两个会话:会话A执行BEGIN开启事务,然后UPDATE一行数据但先不提交;会话B在同一个条件下SELECT,看看能不能读到A未提交的修改。如果你发现B居然真的读到了A还没提交的数据,恭喜你,你亲眼见到了脏读现象。
第二步把隔离级别调成READ COMMITTED,再重复上面的步骤,你会发现会话B读不到A未提交的数据了。这说明什么?说明数据库层面已经帮你挡住了脏读。你可以借着这个实验思考一下:数据库内部很可能就是偷偷给A的修改加了X锁且没有释放,导致B的读被阻塞或走了多版本快照逻辑而看不到未提交数据。
第三步测试不可重复读。隔离级别保持READ COMMITTED不变,会话A开启事务后先SELECT一次某行数据,然后会话B去UPDATE这行数据并提交,然后会话A再次SELECT同一行。你会发现两次读的结果不一样——不可重复读现象在READ COMMITTED下是允许发生的。接着把A会话的隔离级别改成REPEATABLE READ,再重复整套流程,你会发现A会话内两次SELECT结果始终一致,MySQL的可重复读隔离级别通过快照读机制天然规避了这个问题。这个实验做完,你就不会再把“隔离级别”和“锁”当成两个孤立概念来背了。
6.2 观察锁等待与死锁错误信息
想看更底层的锁行为,可以借助MySQL提供的系统表。
执行下面这条命令可以查看当前正在发生锁等待的事务:
sql复制SELECT * FROM performance_schema.data_lock_waits;
执行下面这条可以看到当前持有锁和等待锁的事务信息:
sql复制SELECT * FROM performance_schema.data_locks;
如果你想让两个事务人为制造一个死锁,方法很简单:开两个会话,会话A执行UPDATE t SET value = value + 1 WHERE id = 1;,会话B执行UPDATE t SET value = value + 1 WHERE id = 2;,然后回到会话A执行UPDATE t SET value = value + 1 WHERE id = 2;,再回到会话B执行UPDATE t SET value = value + 1 WHERE id = 1;。此时其中一个会话会被数据库立刻判为死锁牺牲者,返回一个类似Deadlock found when trying to get lock; try restarting transaction的错误。你可以把这条错误信息切到MySQL错误日志里,找到对应的LATEST DETECTED DEADLOCK段落,里面会清楚地画出两个事务持有和等待的锁记录。这是理解死锁最直观的一手资料。
6.3 课程实验中的模拟方式
很多学校开设的数据库系统课程会要求用锁机制实现一个小型并发调度器。如果你正在做这类实验,通常建议不要直接在数据库引擎层修改源码,而是在应用层模拟一个锁管理模块。思路可以是:用一张内存表维护每个数据对象的锁信息,提供lock(tableName, rowId, mode, transactionId)和unlock(transactionId)两类接口,在自己的模拟事务执行读写前先向锁管理模块申请锁,申请结果和教材中的相容矩阵完全一致。
在模拟实验中你可以设一个观察点:当锁管理模块按FIFO方式处理请求时,长时间运行的读事务是否会被源源不断的读请求堵住。你可以试着在队列里加入优先级规则,对比两种规则下的平均等待时间,由此对活锁问题的工程意义产生直观认知。这种做法比单纯背概念更能锻炼真正的系统设计直觉。
至于死锁部分,如果实验允许,可以用Java或Python各起若干个线程,分别模拟事务,让它们按不同顺序对一组共享资源加锁,观察程序最终是否进入永久等待状态,再尝试用超时机制或循环检测来解除死锁。相信我,这个实验做完之后你对死锁的记忆会比看十遍教材都深刻。
7. 学习这一章时最值得注意的几个认知误区
7.1 别把数据库的隔离级别直接等同于封锁协议
教材先讲了封锁协议,再讲隔离级别,很多同学就觉得它们是一回事。实际上这两者是两个维度的概念。封锁协议是数据库实现层面“如何加锁、何时解锁”的一种策略描述;而SQL标准中定义的隔离级别,是数据库提供给使用者的一个事务隔离程度选项。不同的隔离级别内部可以采用多种实现方案,不一定就是简单套用某一个封锁协议,典型的例子是MySQL的REPEATABLE READ主要是靠MVCC快照实现的,并不是靠对所有读操作加S锁做的,而MVCC在教材里是后面章节的内容。
所以不要把READ COMMITTED简单理解成“二级封锁协议”,不要在考试简答时把两者混为一谈。正确的说法是:隔离级别越高,能够预防的并发异常越多;而封锁协议是达成这些目标的机制之一。
7.2 警惕“先读后写”类事务的隐蔽漏洞
真实开发里,很多事务的语义是“先查询判断,再执行更新”,比如先查库存再扣减、先查账户余额再转账。即使你把事务隔离级别设成了REPEATABLE READ,快照读保证了多次读一致,但如果“判断要基于最新数据”,快照读往往不是你想要的手段——因为快照读读到的可能是一个稍早的版本,在它和你真正执行UPDATE之间,数据可能已经被其他事务改过了。此时必须使用SELECT ... FOR UPDATE这样的加锁读,把相关行锁住,防止别人在你判断之后修改数据。
这个知识看起来和教材里的封锁协议关系不大,但它恰恰就是封锁协议在工程里的直接映射:SELECT ... FOR UPDATE加的是排他锁,效果近似于封锁协议里写的“写前加X锁并持有到事务结束”。
7.3 做课后习题时的套路总结
以我批改过的不少作业和考卷来看,学生在并发控制这一章的失分点主要集中在两类题:一是给一段并发调度序列,要求判断可能出现什么问题,二是给出多组加锁顺序,要求说明使用某级封锁协议后哪种异常被解决了。
我的建议是拿到题目第一步先不动笔,把三种异常的定义默念一遍:丢失修改是要看两个“写”是否都基于旧值;脏读是要看有没有“读到未提交数据”;不可重复读是看同一个事务内两个“读”之间数据是否被提交了更改。第二步再去套协议内容:写前加X锁持到结束解决哪个,读前加S锁持到结束又解决哪个。按照这个顺序解题,准确率会明显提升。
8. 结尾:一点个人学习的体会
“并发控制”这一章我第一次学的时候只觉得锁的规则密集又琐碎,矩阵表背得头大,总是记混。后来到实际做项目时踩了一次库存超卖的坑,回头再翻教材,突然就把所有概念都串起来了。你会发现教材里讲的每一个问题都不是编造出来的理论场景,而是你线上环境随时可能遇到的事故本身。所以如果你现在觉得某个概念还没吃透,先别急着背下一段,建议搭个最简单的数据库环境,开两个终端窗口亲手复现一下丢失修改、脏读、不可重复读,很多疑虑会瞬间消失。
这一章虽然是“上”,但后面紧接着要学的可串行化调度、两段锁协议、隔离级别的实现原理,跟前半部分内容是环环相扣的。把“上”篇的知识框架扎稳了,再往后推进会轻松很多。我个人习惯的建议是:先把封锁协议的三级递进关系复述给别人听,看能不能用一个简单的例子讲明白每一级多加了什么约束、堵住了什么问题。如果能做到这一点,这一章的基础就算真正过关了。
