
事务调度这个东西大学数据库原理课上都讲过但很多人是考完试就还给老师了。直到某天线上系统突然卡死监控里全是“锁等待超时”日志里躺着一条“detected a deadlock”你才会意识到课堂上那几页纸其实是数据库并发问题的全部答案。我最早接触“事务调度与封锁”这两个词的时候也觉得它们就是理论概念离工程很远。后来排查过几次慢查询和死锁事故才明白调度决定了并发事务的执行顺序是否正确封锁则决定了系统用什么代价换取正确性。这篇文章我想把事务调度与封锁从头到尾讲透不只是背定义而是把它们和数据库实际行为一一对应起来。无论你是在准备面试还是正在处理线上锁问题应该都能从中捞到点能直接用的东西。1. 并发事务一多为什么“调度”就成了绕不开的问题1.1 三个经典事故脏读、不可重复读和幻读先看一个最简单的例子。假设有两个事务T1把账户A的余额从1000改成800T2读账户A的余额。如果T2在T1提交之前去读它可能读到800也可能读到1000取决于T1的写入是否已经生效。这个“读到别人还没提交的数据”就是脏读。脏读的危险在于如果T1后面回滚了A的实际余额还是1000但T2已经基于800做了后续动作整条业务链就全乱了。相当于你根据一条还没盖章的合同做了决策对方转头把合同撕了。比脏读更隐蔽的是不可重复读。T1在同一个事务里先读了一次商品价格得到100元T2这时把价格改成了120元并提交T1再去读同一行得到120元。同一个事务里两次读的结果不一样这就叫不可重复读。它的问题在于事务的“一致性视图”被破坏了。想象你在结账时先看了小票金额收银员偷偷改了个数字再让你看第二眼你会不会觉得这家店不可信事务也是这样说好的隔离结果内部数据还在变。还有幻读。它不是针对某一行而是针对一个范围。T1执行SELECT COUNT(*) FROM orders WHERE status pending得到10条T2插入了一条新订单并提交T1再数一遍变成11条。多出来的那一条就像幽灵一样凭空出现没法用行锁挡住因为它本来就不在T1锁定的行集合里。幻读和不可重复读很容易混区分方式很简单不可重复读是同一行内容变了幻读是查询结果集合的行数变了。1.2 正确的并发结果等于串行执行可串行化如果并发事务会带来这一堆问题最省事的办法是什么不要并发一个接一个地执行。这就是串行调度T1跑完T2再跑大家排队。串行调度的结果一定是正确的因为每个事务都看到了它应该看到的一致状态。可问题是数据库不可能真的把所有事务都排成一队那样吞吐量会低到没法用。于是有了可串行化的概念如果一个并发调度执行完后的数据库状态和某个串行调度执行完的状态一致那么我们就认为这个并发调度是正确调度。换句话说并发执行只是“看起来像”串行执行但谁先谁后不需要真的排队。这个定义非常重要它把正确性从“结果必须符合某种业务逻辑”简化为“结果必须等价于某种串行顺序”。数据库不需要理解你的业务只需要保证这个等价关系成立就能给你一个正确的并发环境。但是“和某个串行调度结果一致”这个问题直接验证起来是极其困难的。事务的操作那么多组合爆炸数据库总不能每次执行前把所有可能性都试一遍。所以理论家们又往前走了一步把判断条件从“结果等价”弱化成更容易验证的“冲突等价”。1.3 调度只是看上去公平真正的判定标准是冲突两个操作之间什么时候才会互相影响不是所有操作都值得关注。如果两个操作访问的是不同的数据项它们谁先谁后都无所谓。如果两个操作来自同一个事务它们的执行顺序本来就是事务内部规定好的也不用管。只有当两个操作来自不同事务又访问同一个数据项并且至少有一个是写操作时顺序交换才会改变结果。这种情况就叫冲突。冲突判定里有三种组合读读不冲突读写冲突写写冲突。读读随便并发因为大家看到的是同一个值没人改变它。读写冲突是因为先读后写和先写后读读到的内容完全不同。写写冲突更严重最后保留的是谁的值完全取决于执行顺序。有了冲突的概念调度理论突然就变得清晰了。一个调度如果能通过交换相邻的非冲突操作变换成某个串行调度那它就是冲突可串行化的。这种可串行化比“结果等价”更容易检查而且范围更实用。后续我们提可串行化默认指的都是冲突可串行化。数据库里的封锁协议、MVCC隔离级别本质上都是在朝这个目标努力。2. 串行化的判定工具冲突可串行化与优先图2.1 什么样的操作才算“冲突”先把冲突的定义抠细一点。假设两个事务T1和T2它们都访问数据项A。T1写AT2写A这两个操作冲突因为谁后执行A的最终值就归谁。T1读AT2写A也冲突因为读操作看到的是旧值还是新值完全取决于先后。T1读AT2读A不冲突两个读操作谁先谁后结果都一样。这里有一个很容易忽略的细节同一事务内部的操作不参与冲突判断。T1先写A再读A这个顺序是T1自己的逻辑调度器不允许把它拆开所以不用考虑。判断两个操作是否冲突其实就是问一个问题如果把它们交换执行顺序结果会不会变会变就冲突不会变就随便。这个判断方法在构造优先图的时候特别有用。你不需要记住复杂的定义只要拿着调度序列逐对检查操作能换就换不能换就画一条有向边最后看能不能得到一个无环图。2.2 用优先图判定一个调度是否可串行化优先图precedence graph是检查冲突可串行化的标准工具。构造方法很机械每个事务是一个节点如果T1的某个操作和T2的某个操作冲突而且T1的那个操作在调度序列中排在T2前面那就从T1画一条有向边到T2。等所有冲突关系都画完看这个图有没有环。无环则调度是冲突可串行化的有环则不是。我举个具体的例子。调度序列是T1写AT2写BT1写BT2写A。先找冲突T1写A和T2写A冲突T1在前所以T1指向T2。T1写B和T2写B冲突但序列里T2写B在前T1写B在后所以T2指向T1。两个节点之间形成了双向边这就是环。结论是该调度不是冲突可串行化的。实际执行中这种交叉写会造成什么结果T2写B后T1覆盖了BT1写A后T2覆盖了A两个事务互相踩脚最终状态既不像先执行T1再执行T2也不像先执行T2再执行T1。如果只有一个方向比如T1写AT1写BT2写AT2写B冲突关系只会产生T1指向T2没有环那这个调度就冲突可串行化等价于先跑完T1再跑T2。优先图的意义在于它把“正确性”变成了一个图论问题而图论里判环的算法成熟得不能再成熟。数据库内部判断调度是否安全用的就是这类方法只不过包装在锁和MVCC机制里。2.3 不可串行化调度的典型形态与预防思路不可串行化最常见的一种形态就是前面提到的交叉写事务T1和T2互相覆盖对方访问过的数据项形成一个环。还有一种形态是“读了过期数据”T1读A然后T2写A并提交之后T1再写B而B的计算依赖它最开始读到的A。这样一来T1的写入其实是基于一个已经被更新掉的值结果自然和任何串行调度都不一样。怎么预防理论上可以设计调度器在运行时就做冲突检查发现会产生环就阻止某个操作执行这在实际系统中几乎不可行因为代价太高。现实中的做法是用封锁协议来限制事务获取资源的方式让产生环的调度在结构上就被排除掉。下一章讲封锁就是把“检查调度结果”这一步前置成“提前阻止危险操作”代价是牺牲一部分并发度。3. 封锁的实现路径从读写锁到两阶段封锁协议3.1 共享锁与排他锁封锁操作的最小集合封锁是数据库用来控制并发访问的基本手段。它的思路很简单事务在访问某个数据项之前先申请这把数据的锁拿到锁以后才能操作。锁分两类共享锁S锁也叫读锁和排他锁X锁也叫写锁。共享锁的意思是“我允许别人和我一起读但不允许别人改”排他锁的意思是“这块地盘我独占谁也别想读谁也别想写”。两种锁之间的兼容关系可以用一张小表概括当前持有的锁新请求共享锁新请求排他锁共享锁兼容不兼容排他锁不兼容不兼容也就是说读读可以共存读写和写写都不能共存。这个设计背后的道理很直接多个人同时读同一个值不会出错但只要有一个人写读到的内容就可能前后不一致更不用说两个写同时发生了。值得注意的是传统锁模型要求事务在读之前申请S锁写之前申请X锁而不是等到真正访问时才临时判断。这套规则看着简单可如果只规定“访问前要加锁”还是无法保证可串行化因为可能出现一个事务先释放了锁再申请新锁另一个事务趁机插进来导致调度不可串行。真正的保障来自锁的获取和释放顺序规则也就是两阶段封锁。3.2 两阶段封锁为什么有效又为什么会有级联回滚问题两阶段封锁2PL规定每个事务的加锁操作必须集中在一个阶段解锁操作必须集中在另一个阶段中间不能交替。具体来说事务进入“增长阶段”后只能加锁不能解锁到达某个点后进入“收缩阶段”只能解锁不能再加锁。这个规则保证了什么它保证了如果两个事务的锁有交集那么它们在时间上不会交错得太乱。一个事务开始释放锁时说明它的核心操作已经完成另一个事务此时才能拿到锁开始工作整体执行顺序就有了清晰的先后。2PL解决了可串行化问题但它有一个很头疼的副作用级联回滚。假设T1写A后释放了X锁T2立刻申请到锁并读了A接着T1因为某种原因回滚A恢复原值那T2读到的就是一个“从未存在过”的中间值。为了保持一致性T2也必须回滚而T2可能已经做了大量后续操作回滚成本一路传导下去像多米诺骨牌一样。这个场景在工程上非常致命尤其是长事务多的系统一个事务回滚可能拖垮一整批事务。3.3 严格两阶段封锁让“未提交数据”不再被别人看见为了避免级联回滚人们把2PL稍微改了一下变成严格两阶段封锁事务持有的写锁X锁一直保持到事务结束提交或回滚才释放而不是在进入收缩阶段就释放。这样一来其他事务不可能读到另一个事务未提交的修改也就不会因为别人的回滚而被迫回滚。这个改动看起来只是“写锁晚一点释放”却能从根本上消除级联回滚。还有更严的版本叫强两阶段封锁所有锁包括读锁都保持到事务结束。它的隔离性最强但并发度最低因为读锁也会挡住别人的写。大多数主流数据库不会一刀切地采用强两阶段封锁而是配合多版本并发控制MVCC来缓解读锁带来的阻塞。这一点到第6章再展开。两阶段封锁里面还有一个容易被人忽略的点锁释放的时机决定了隔离级别。如果读锁读到就释放事务内部两次读之间就可能被别人改数据这就是读已提交如果读锁要等到事务结束才释放就能保证事务内部反复读到一致的结果可重复读也就成立了。隔离级别的实现细节本质上就是把锁的持有时间调整成不同档位。4. 封锁粒度与多粒度封锁表锁、行锁和意向锁如何协同4.1 加锁力度越大越安全代价是并发度下降锁不只是分读写两种还要分力度。数据库里可以锁整个表可以锁一个数据页也可以只锁一行。粒度越大管理越简单冲突概率越大并发度越低粒度越小并发度越高但管理成本也越高。表锁的实现很直接一个事务锁住整张表其他事务对该表的任何操作都排队。行锁则精细得多两个事务可以同时操作同一张表的不同行只要不碰同一行就行。很多人以为行锁是“万灵药”其实行锁挑场景。如果业务请求经常批量更新同一个范围内的数据行锁的维护开销会让性能反而变差。如果事务持有行锁的时间很长其他事务哪怕只是读相邻一行被阻塞的概率也不大但一旦有索引失效之类的意外行锁可能退化成全表锁定那是另一场灾难。4.2 意向锁为什么需要在表级别提前声明行锁和表锁同时存在时会出现一个尴尬的问题事务A锁住了表里的某一行事务B想锁整张表那B怎么知道表内有没有行锁如果B逐个去扫描每一行有没有锁成本高到无法接受。意向锁就是来解决这个问题的。意向锁不是真正的资源锁它更像一个“路标”告诉后来者这个表里已经有行被加锁了或者即将有行被加锁。意向锁分意向共享锁IS和意向排他锁IX还有更复杂的SIX锁。规则是事务要往某一行加共享锁必须先给所在表加意向共享锁要往某一行动加排他锁必须先给表加意向排他锁。其他事务想对整个表加排他锁时只需要查看表上的意向锁发现有IX或S锁之类的存在就知道不能加从而避免了逐行扫描。这套“表级意向锁 行级普通锁”的层级组合叫作多粒度封锁协议。它给数据库提供了一种能力既支持精细的并发控制又不会让表级操作变得低效。4.3 从锁定粒度看不同隔离级别底层的锁策略隔离级别是事务调度与封锁在工程层面的直接体现。SQL标准定义了四个级别每个级别挡住的并发问题不一样隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不会可能可能可重复读不会不会可能视实现而定可串行化不会不会不会读未提交级别下事务读数据时不加S锁写数据时加X锁所以能读到别人未提交的修改。读已提交级别下读操作加S锁但读完立刻释放写锁保持到事务结束所以脏读被挡住事务内部两次读之间还是可能被改。可重复读级别下读锁保持到事务结束挡住了不可重复读但如果只锁行而不锁范围仍可能出现幻读。可串行化级别下需要对查询涉及的范围加范围锁如间隙锁把可能新增数据的缝隙也封死才能真正挡住幻读。实际数据库很少完全按这个标准模型走有的在可重复读级别下已经通过间隙锁或MVCC消除了幻读有的在读已提交级别下依靠“一致性读”实现无锁快照。理解标准锁模型的意义在于当你去查某款数据库的隔离级别说明时你能看懂它到底是在哪个环节做了改动不会因为文档里“没有锁”三个字而对并发安全掉以轻心。5. 死锁和活锁封锁方案制造的新麻烦5.1 死锁的四个必要条件与典型场景封锁机制保证了正确性却也引入了新的问题死锁。死锁的经典定义需要四个条件同时满足互斥、持有并等待、不可剥夺、循环等待。放在数据库场景里翻译一下资源不能被多个事务同时排他访问事务拿着自己的锁不放又去要别人的锁别人不能强行把锁抢走大家都按各自的顺序申请锁结果你等我、我等你形成闭环。最典型的发生场景是交叉更新。事务T1先更新表A的某一行再更新表B的某一行事务T2刚好反过来先更新表B再更新表A。两个事务同时提交T1拿着A行的锁等B行T2拿着B行的锁等A行谁也没法继续僵住了。数据库引擎很快就会监测到这种状态但业务代码往往一脸懵明明两个独立操作怎么就能互相卡死原因就在于加锁顺序不一致。这个例子也引出了一个非常实用的经验在业务代码里让所有事务按照相同的顺序访问资源死锁概率会大大降低。5.2 死锁预防、死锁检测与超时机制的取舍处理死锁有三条路线预防、检测和超时。预防的思路是从源头破坏死锁条件。比如要求所有事务按固定顺序加锁或者规定事务在等待锁时主动放弃已有锁回滚重来。这种方案在分布式系统里比较常见因为协调成本高宁可让事务多失败几次也不愿做全局检测。坏处是误伤率高一个事务等锁等久了就白干一次系统整体开销偏大。检测的思路是维护一张“谁在等谁”的等待图每次出现新的等待关系就往图里加边。如果在图中发现了环就说明死锁发生数据库会从环里挑一个事务作为牺牲者回滚它释放它的锁让其他事务继续跑。现代主流数据库大多采用这种方式优点是精确不会误杀正常的等待事务缺点是每个锁请求都要更新等待图高并发下有一定开销。超时机制最粗暴每个事务等待锁的时间超过阈值就判定为死锁并报错。实现成本最低但容易误判。慢事务正常持有锁、另一个事务等得久一点超时机制就可能把一个没死锁的场景当成死锁处理。实际工程里有些系统把检测和超时结合起来用检测负责抓真死锁超时负责处理检测链路本身出问题或等待时间异常的情况。5.3 活锁与公平调度为什么不是每次等待都能等到锁死锁之外还有一个容易忽略的兄弟活锁。活锁里的事务并没有被卡死而是永远等不到锁因为它每次快要获得资源时都被另一个事务抢先。好比你去排队买限量商品每次排到队首总有一个人插队抢先买走最后一件你永远买不到但队伍也一直在动。活锁的根源在于加锁的优先级策略不公平。比如数据库为了减少长事务饿死可能会偏向让持有锁时间长的事务优先结果短事务反复被挤掉。解决活锁最常用的方法是先来先服务FIFO让锁的等待队列严格按申请顺序出队。别看这个方案朴素它保证了每个事务最终都能拿到锁消除了无限期等待的可能。在实际数据库运维中活锁比死锁少见得多但一旦遇到症状表现为“某个事务长时间处于锁等待但数据库中并没有死锁环”排查起来容易走弯路。6. 现代数据库的调度实践MVCC与锁的配合6.1 多版本并发控制用历史版本换读写的并行空间如果完全依赖S锁和X锁读写之间必须互斥读操作会阻塞写写操作会阻塞读。这对绝大多数以读为主的业务来说是灾难。于是现代数据库普遍引入MVCC核心思想是写事务在修改数据时不直接覆盖旧值而是生成一个新版本读事务看到的是某个一致性快照里的旧版本。这样一来读和写可以同时进行互不阻塞。MVCC和封锁并不是二选一它们是配合关系。写操作之间仍然需要排他锁来互斥因为同一时间只能有一个事务修改同一个数据项并产生新版本。读操作则多半可以走快照不需要加锁。这种“写用锁、读用版本”的设计让可重复读的隔离级别在保证一致性的同时也保住了很高的并发度。但要注意MVCC不是万能的。快照读读到的历史版本无法感知并发事务尚未提交的修改这是一致性读的代价。对于某些业务比如“读后再决定写”的逻辑不能盲目依赖快照因为在快照读之后到真正写入之前其他事务可能已经改了同一数据你的更新可能覆盖别人的提交结果。这种场景需要走当前读加锁机制。6.2 快照读与当前读两种读操作对锁的需求完全不同很多人在数据库并发问题上栽跟头都是因为没分清快照读和当前读。普通SELECT在MVCC环境下是快照读从头到尾不加锁读的是一个一致性的历史快照。而SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT这些操作是当前读必须读取最新的已提交版本并且要加锁保护。为什么更新要加锁因为你要修改的行可能正在被别人修改不加锁就会出现“最后提交者覆盖”或“两个人同时判断条件都通过”的典型竞态。这个区分解释了一个很常见的现象两个事务同时读同一行都没有问题但一旦都试图更新这行其中一个就会被阻塞。不是数据库“故意找麻烦”而是更新操作从快照读切换到了当前读当前读就需要X锁X锁不兼容另一个事务持有的锁。以此类推如果一个事务先在快照读里看到某个状态再根据这个状态决定是否更新它必须手动用SELECT ... FOR UPDATE提前加锁否则判断和更新之间就会被人插队。这个坑我在好几个线上事故里都见过看起来像业务Bug根子其实是隔离级别和锁语义没对应上。6.3 锁等待监控与典型死锁排查思路工具能帮我们把调度问题从理论拉回现实。我在处理一次线上卡顿的时候第一件事不是看慢SQL而是查当前有哪些事务、各自持有哪些锁、谁在等待谁。常见的排查路径是先看锁等待超时的报错和死锁日志日志里通常会记录两个事务分别执行了哪几条SQL再把这两条SQL还原到业务代码里看它们是否以不同顺序访问了同一组资源。锁定范围过大的问题往往要从执行计划入手。如果一条UPDATE语句在WHERE条件里用了函数、隐式类型转换或非前缀列索引可能失效优化器只能走全表扫描加锁范围也跟着从一行扩展到全表。这时候你看到的表象是“整个表都被锁住了”但你一开始只是改了某一行的数据。找到执行计划里的全表扫描再通过改写SQL或建合适索引让锁精确落到目标行上很多“锁问题”其实是索引问题。监控锁等待的时候尤其注意那些“持有锁时间特别长”的事务它们在等待图里往往不是受害者而是锁的源头。7. 我自己在项目里处理锁问题的一些经验7.1 大事务是锁问题的根源先拆事务再查锁处理了好几次锁相关的事故后我形成了一个判断顺序遇到锁等待或死锁先查有没有大事务再查具体的锁竞争。一个事务如果涉及几千行甚至几万行的更新它持有的锁数量多、时间长别人被阻塞的概率自然指数上升。更麻烦的是大事务回滚成本极高一旦成为死锁牺牲者回滚期间几乎能把整个数据库拖住。拆事务不是简单地把一个大事务拆成几个小事务而要结合业务语义。比如批量更新就可以分批提交每批几百条把单次事务的锁持有时间从分钟级压到秒级。注意分批后要自己处理“一半成功一半失败”的补偿问题不能因为拆了事务就丢掉了原子性。这个权衡很多时候没有标准答案需要根据业务对一致性的容忍度来定。7.2 索引失效会让行锁变成“伪表锁”行锁听起来比表锁精细但它有个前提数据库必须能通过索引定位到要锁的那些行。如果索引失效执行计划走了全表扫描数据库只能把扫描过程中碰到的所有行都锁住甚至在整个表上加强锁。结果就是你只想锁一行实际效果却和锁全表差不多。这个问题在数据量大、并发高的表上暴露得特别明显。我排查过一个案例一条UPDATE语句在WHERE条件里对字段做了字符串拼接导致该字段的索引不被使用数据库只好全表扫描并锁定了大量行所有针对这张表的写操作都被堵住。解决办法很简单改写成不破坏索引结构的等价条件锁的范围立刻缩小了几个数量级。这个经验告诉我们分析锁问题不能只看锁本身执行计划才是根源。7.3 配置项与业务降级把调度问题纳入系统设计最后谈谈配置和业务层面的配合。数据库通常提供了几个关键参数来缓解锁风暴比如锁等待超时时间、死锁检测周期、死锁牺牲者的选择策略等。改这些参数要非常谨慎锁等待超时调大一点能减少无辜事务被杀但也会让真正的死锁更长时间得不到释放调小一点系统响应变快但误杀率上升。比较稳妥的做法是先用默认参数跑再根据监控数据微调而不是一开始就把时间设成很大的值。高于参数层面我觉得真正的解法是把调度问题纳入系统设计。比如热点行的更新可以通过异步化、排队、合并写等手段降低并发争抢跨行更新尽量保持一致的资源访问顺序重试机制要设计成“检测到死锁后整个事务重来”而不是只重试那条失败的SQL。事务调度与封锁学到深处你已经不是在背数据库原理了而是在培养一种并发思维看到任何两个可以并发执行的操作先问它们会不会冲突冲突了拿什么锁锁拿不到了怎么办。这套思维方式才是这两章内容留给工程现场最值钱的东西。我在实际项目里的另一个体会是尽量不要等到线上出了死锁再翻书复习。平时做压测的时候把锁等待和死锁日志的开关打开模拟一下并发更新同一批数据的场景用不了多长时间就能摸清系统在极端争抢下的脾气。掌握了这些底层的调度和封锁逻辑再去看数据库报错日志你会觉得每一个错误都变得有理可循连带着写业务代码的时候也会更敏感这里会不会有并发问题是不是需要加锁加锁会不会造成死锁多问自己几个为什么很多线上事故根本走不到上线的哪一步。