晚上八点,“星河剧场”开放热门演出的售票。几千名用户同时选座,后台还在处理退款、释放超时订单、统计余票和更新场次报表。每个请求单独运行时都很简单;真正难的是这些请求交错以后,数据库不能把同一座位卖给两个人,也不能让退款读到一半更新的订单状态。
并发控制要解决的就是这个问题:允许事务重叠执行,同时约束那些会互相影响的读写。理想结果是,外部看到的效果等价于事务按照某个顺序逐个执行。不同机制会在等待、回滚、存储空间和实现复杂度之间做不同取舍;理解这些取舍,比死记某个隔离级别的名字更有用。

图:购票、退款、余票统计和报表事务同时进入数据库;并发控制器负责决定哪些操作可以同行、哪些必须等待或重试。
锁把“是否允许访问”变成一个明确的协议。事务读取数据项 前申请共享锁 S(Q),修改前申请排他锁 X(Q)。共享锁允许多个事务同时读取;排他锁意味着持有者既可以读也可以写,而且其他事务不能再取得 S 或 X。
以座位 A-08 为例,场次页只读取座位状态,可以取得共享锁;购票事务要把状态从“可售”改成“已占用”,必须取得排他锁。兼容关系只有四种:
这里的“不兼容”通常表示等待,并不自动表示事务失败。锁管理器会把请求挂到数据项的等待队列,等冲突锁释放后再授予。锁必须至少覆盖实际访问期间;但“最后一次读写后马上释放”并不总是安全,因为另一个事务可能在本事务结束前观察到不一致的中间状态。

图:多个共享读者可以并行;排他请求会等待已有读者离开,后来的共享请求也不能无限插队。
只检查“当前已授予的锁是否兼容”还不够。假设退款事务已经排队等待 X(订单42),此后不断有新的报表事务申请 S(订单42)。若这些共享请求都直接越过排他请求,退款事务可能永远拿不到锁,这叫饥饿。
一种简单的公平规则是:新请求既要与已授予锁兼容,也不能越过更早到达且尚未满足的请求。这样,每个数据项的队列顺序就参与了授予判断。锁管理器通常维护一张以数据项为键的锁表;每个表项记录事务、模式、到达顺序和“已授予/等待”状态,并额外按事务建立索引,以便提交或回滚时迅速释放该事务的全部锁。
锁表的典型处理过程是:
仅仅“访问前加锁”不能保证可串行化,因为事务可能过早释放一个锁,随后又去申请另一个锁。两阶段锁协议把每个事务分成两个阶段:
事务取得最后一个锁的时刻叫锁点。把事务按锁点先后排序,就能得到一个与该调度冲突等价的串行顺序。直觉是:若 Ti 的操作先于 Tj 形成冲突,Ti 必须先持有相应锁并在 Tj 获锁前释放;因此 Ti 的锁点一定早于 Tj 的锁点,所有冲突边都沿锁点顺序向前,不会成环。

图:基本 2PL 约束“先增后减”;严格 2PL 把排他锁留到结束;强严格 2PL 把全部锁留到结束。
基本两阶段锁仍可能让其他事务读到尚未提交的写入,引发级联回滚。严格两阶段锁在“先增后减”之外,要求所有排他锁一直保留到提交或中止。这样,未提交的新值始终被 X 锁保护,其他事务无法读写,调度自然无级联。
强严格两阶段锁进一步要求共享锁和排他锁都保留到事务结束。它更容易实现:提交或回滚时一次释放全部锁;事务也可以按提交顺序串行化。代价是读锁持有更久,可能降低并发度。
余票统计事务可能先读取某个订单,随后才决定是否修改它。若一开始就申请 X,会不必要地阻塞其他读者;可以先申请 S,在增长阶段执行 upgrade(Q) 升级为 X。升级时若还有其他事务持有 S,当前事务必须等待。反过来,downgrade(Q) 把 X 降为 S,只能发生在缩减阶段,否则事务可能在降低保护后又扩张锁集合,破坏两阶段结构。
数据库可以根据操作自动插入锁请求:读操作前取得 S;写操作前,若已经持有 S 就升级,否则直接取得 X;提交或中止后统一释放。应用代码通常看不到这些底层指令,但 SELECT ... FOR UPDATE 会显式表达“读取后准备修改”,避免先读后升级带来的竞争窗口。
若系统预先知道数据项的父子顺序,还可以使用树协议。事务第一次可以锁任意节点;此后只能在仍持有父节点锁时锁孩子;节点可以提前释放,但释放后不能再次加锁。只使用排他锁时,这些规则仍保证冲突可串行化,并且不会产生死锁。
树协议的优点是可提前释放锁、无需死锁回滚;缺点是事务可能为了访问叶子而锁住并不真正使用的祖先。若不知道访问路径,只能从根开始,根锁会把并发度压得很低。树协议本身也不自动保证无级联;可以延迟排他锁释放,或记录提交依赖,让读取未提交值的事务等其依赖事务提交后才能提交。
设购票事务 T1 已锁住座位行,等待用户账户行;退款事务 T2 已锁住账户行,等待同一座位行。两者都在等对方释放资源,正常执行再也不能前进。更一般地,若存在事务集合 ,其中每个事务都等待集合中另一个事务,且依赖首尾相接,就发生了死锁。

图:T1 与 T2 以相反顺序锁定账户和座位,等待图形成环;回滚一个受害者即可打断环。
一种办法是事务开始前一次取得全部锁,拿不到就一个也不拿。它从根本上消除“持有一部分再等待另一部分”,但应用往往无法预知全部访问对象,而且提前占锁会降低利用率。
更常见的办法是给资源规定全序,例如始终先锁较小的账户 ID,再锁较大的账户 ID。所有转账都沿同一方向申请锁,等待边就不可能绕回起点。树协议使用的是类似的偏序思想。
也可以给事务分配不可变的年龄时间戳,并在冲突时决定等待还是回滚:
wait-die 是非抢占方案。老事务请求年轻事务持有的锁时可以等;年轻事务请求老事务持有的锁时立即回滚。等待边只从老指向年轻,不会成环。wound-wait 是抢占方案。老事务请求年轻事务的锁时“伤害”年轻事务,使其回滚;年轻事务请求老事务的锁时等待。等待边只从年轻指向老,同样不会成环。被回滚事务重启时要保留原时间戳,否则它每次都变成最新、最年轻的事务,可能永远失败。两种方案都可能产生本可避免的回滚。超时也能粗略打破死锁,但超时时间太短会误杀正常等待,太长又会延迟恢复,还可能造成饥饿。
允许死锁发生时,系统维护等待图 。每个顶点是事务;若 Ti 正等待 Tj 释放某个锁,就添加边 。锁释放或请求取消时删除对应边。图中存在有向环,当且仅当系统存在死锁。
检测频率取决于死锁概率与影响范围。死锁频繁时可以在每次请求受阻后检查;死锁很少时周期检测更划算。检测过慢会让死锁事务长期占用锁并拖住更多请求,检测过频则消耗 CPU。
发现环后,系统选择一个或多个事务回滚。成本可以综合已执行时间、已修改数据量、剩余工作量、释放哪些锁以及会牵连多少事务。完全回滚实现简单;部分回滚只撤销到足以释放关键锁的位置,但要求系统记录锁申请、更新顺序和可恢复的执行状态。
若总挑“回滚成本最低”的同一个事务,它可能永远无法完成。把历史回滚次数纳入成本,或限制同一事务成为受害者的次数,可以缓解饥饿。
票务数据可以组织成“数据库 → 场次表 → 数据页 → 座位行”的层级。生成全场次报表时,逐行申请几万把锁会让锁表膨胀;购买一个座位时,锁住整张表又会挡住所有其他买家。多粒度锁允许事务在不同层级选择锁定范围。
锁住祖先节点会隐式覆盖后代。例如对“场次表”取得 S,相当于共享锁住其全部行;直接锁某一座位行则是显式细粒度锁。困难在于:当事务想锁整张表时,系统不能每次遍历所有后代,检查是否已有行锁。意向锁就是祖先上的路标,表示“我在更低层持有或准备取得某类锁”。

图:细粒度事务沿根到叶子放置意向锁;粗粒度事务只检查祖先节点即可知道子树中是否存在冲突。
IS 表示后代只会出现共享锁;IX 表示后代可能出现共享锁或排他锁;SIX 表示当前子树整体已有共享锁,同时某些后代还会取得排他锁。兼容矩阵如下:
IX 与 IX 兼容,是因为两个事务可能修改不同的后代;S 与 IX 不兼容,因为 S 已经隐式读取整棵子树,而 IX 允许在其中写。SIX 常用于“扫描整张表并更新少数行”:表上取得 SIX,目标行再取得 X,比给每行都加 S 更省锁。
协议要求先锁根,再沿路径向下。对子节点申请 S 或 IS 时,父节点必须持有 IS 或 IX;对子节点申请 X、IX 或 SIX 时,父节点必须持有 IX 或 SIX。事务仍遵守两阶段原则,并且只有在所有已锁孩子都释放后才能释放父节点。
例如修改座位 A-08,路径可以是:数据库 IX → 场次表 IX → 数据页 IX → 座位行 X。读取整张场次表只需数据库 IS → 场次表 S。两者是否并发,由路径上的兼容关系直接决定。
当事务已拿到大量行锁、锁表接近容量阈值时,锁管理器可以执行锁升级,用一把表锁替代许多行锁。这样减少元数据开销,却扩大冲突范围。升级阈值不能只看锁数量,还应考虑热点程度和预计剩余扫描范围。
读取和更新都以数据项已经存在为前提。删除一条座位记录会与对同一记录的读、写、删除和重新插入冲突;两阶段锁下,删除前必须取得 X。时间戳协议则把删除按写操作处理:若较新的事务已经读过或写过该项,较老的删除会破坏时间戳顺序,必须拒绝并重启。
插入创建了原本不存在的数据项,也要按写入处理。锁协议给新记录排他锁;时间戳方案把新版本的读、写时间戳初始化为插入事务的时间戳。多版本系统中的删除通常不是立即抹掉旧版本,而是创建带“已删除”标记的新版本,使旧快照仍能判断该记录在当时是否存在。
假设风控事务两次执行:
SELECT COUNT(*)
FROM ticket_order
WHERE show_id = 2088 AND amount >= 1000;第一次查询锁住了当前找到的订单行。与此同时,另一个事务插入一张金额 1280 元的新订单;第二次查询就多出一行。两个事务没有访问同一条既有记录,却在逻辑上冲突:查询读取的是“所有满足谓词的记录集合”,插入改变了这个集合。把其他场次的订单更新为 show_id=2088 也会产生同类幻影。

图:只锁已命中的行挡不住新区间成员;锁定索引叶节点或键间隙,才能让范围读取与插入在真实对象上发生冲突。
最粗的办法是把“关系中有哪些元组”视为一个可锁数据项。谓词查询对它取 S,插入、删除或改变成员资格的更新取 X。这能消除幻读,却会让两个互不相关的插入也相互阻塞。
更实用的方法利用 B+ 树索引:
这样,读取 amount >= 1000 与插入 1280 会在相同索引区域上冲突。直接锁整个叶节点仍可能造成假冲突,于是可以把粒度缩小到键值和键间隙。下一键锁要求范围查询锁住命中键以及范围末端之后的第一个键;插入和删除也锁住目标键及其下一键。插入到范围内部时,双方会在相邻下一键上相遇,从而保护“目前不存在的键”。
谓词锁更接近业务语义:直接锁 show_id=2088 AND amount>=1000,任何可能进入或离开该集合的插入、删除、更新都要检查谓词冲突。它表达准确,但通用谓词相交判断成本高,实际系统更常使用索引范围锁近似实现。
锁协议通常在冲突发生时,通过谁先拿到锁决定顺序。时间戳排序则在事务开始前分配唯一且固定的 TS(Ti),要求最终效果等价于按时间戳递增串行执行。时间戳可以来自单调逻辑计数器,也可以来自能保证唯一和有序的时钟机制。
每个数据项 维护两个值:

图:基础时间戳协议在每次读写时检查顺序;Thomas 规则忽略过期写;验证协议把冲突检查推迟到提交前。
事务 Ti 读取 时:若 ,说明一个逻辑上更晚的事务已经覆盖了它应读的旧值,读取被拒绝,Ti 回滚;否则允许读取,并更新:
事务写入 时依次检查:
Ti;协议从不等待,所以不会死锁;代价是冲突直接变成回滚。长事务可能被一连串短事务反复打断,出现饥饿,此时可以暂时阻止新的冲突事务,让长事务完成。
基础规则也不自动保证可恢复与无级联。常见补强方式包括:把实际写集中到事务结束并原子发布;未提交版本的读取暂缓到写者提交;或者记录提交依赖,让读过未提交值的事务等待写者提交后才能提交。谓词查询还要把关系元数据和索引节点也纳入时间戳对象,否则仍会遗漏幻影冲突。
基础协议遇到 会回滚,但这个写有时只是过期结果。若较新的事务已经写入 ,并且没有更晚事务读过“本应由 Ti 产生”的值,那么 Ti 的旧写永远不会被任何合法事务读取,可以直接忽略。
Thomas 写规则把写入判断改为:
忽略盲写会允许某些不满足冲突可串行化、但满足视图可串行化的调度。它并不是随意丢数据:被忽略的写既不会被任何事务读取,也不会成为最终写。
如果大多数事务只读或冲突很少,提前加锁会为大量“本来就不会冲突”的事务支付等待和维护成本。乐观并发控制先假设事务能成功:
每个事务记录 StartTS、ValidationTS 和 FinishTS。若以 ValidationTS 作为串行顺序,事务 Ti 验证时,对每个更早验证的 Tk,至少要满足下列之一:
即 Tk 在 Ti 开始前已经完全结束;或者:
并且:
第二组条件表示两者执行时间有重叠,但 Tk 的写没有影响 Ti 的读取,且 Tk 写阶段在 Ti 验证前结束。实际实现还会对并发写阶段做更严格的互斥或扩展验证,以确保提交发布原子。
真实数据库直到验证成功才收到更新,因此不会有其他事务读取到该事务的未提交写,天然避免级联回滚。冲突少时,事务一路无等待地执行,效果很好;冲突多时,失败事务已经完成的大量计算全部浪费。
长报表或复杂定价事务尤其容易在最后一刻被短更新击败。可以把历史失败次数、事务长度纳入调度,在连续失败后短暂阻止冲突写。若使用开始时间作为串行时间戳,较早开始但尚未完成的事务会让后来的验证等待其读写集确定;用验证时间通常能避免这种等待。
单版本系统中,旧值被覆盖后,较早事务只能等待或回滚。多版本并发控制让每次写创建新版本,读操作按自己的逻辑时间选择版本。若版本 的写时间戳最大且不超过事务时间戳,则:
Ti 读取 ,并把该版本的读时间戳更新为读过它的最大事务时间戳。写入时,若 ,说明已有较新的事务读过 本应更新的时间区间, 必须回滚;若时间戳恰好等于版本写时间戳,可覆盖自己的版本;否则创建新版本。
版本的有效区间可以写成 : 是本版本创建时间, 是下一版本创建时间;最新版本的右端点是无穷。读请求总能找到覆盖自身时间戳的版本,因此不会因写者而等待。

图:不同开始时间的事务沿同一版本链读取不同节点;早于最老活跃快照且已被后继覆盖的版本可以回收。
旧版本不能无限保留。若两个版本的写时间戳都早于系统中最老活跃事务,较旧者以后不再可能被读取,可以回收。实际系统常维护“最老仍可能访问旧版本的快照水位”,后台清理水位之前的无用版本与索引项。
多版本会改变约束和索引实现:同一主键的多个物理记录只要有效区间不重叠,就不是逻辑重复;删除用墓碑版本表示;检查外键时要判断引用版本与被引用版本在目标时间是否同时有效;被索引列发生改变时,新旧版本可能需要不同索引项。
一种组合方案让更新事务使用强严格 2PL,让只读事务使用版本快照。更新事务读最新版本并取 S,写时取 X 并创建时间戳暂为无穷的新版本;提交阶段串行增加提交计数器,再把本事务创建的版本标成新的提交序号。只读事务开始时读取当前计数器,只访问时间戳不超过该值的最新版本。
这样,更新事务之间仍可能等待或死锁,但只读事务不会等更新锁,也不会读到半提交数据。更新锁保留到结束,调度可恢复且无级联。代价是版本存储、垃圾回收和索引维护更加复杂。
快照隔离给事务一个 StartTS。事务读取每个数据项时,选择提交时间不超过 StartTS 的最新版本,因此它看到的是事务开始时已经提交的完整状态。事务自己的修改先放在私有工作区,验证通过后再以一个原子动作提交:其他快照要么看见本事务全部更新,要么一个也看不见。
更新事务还会取得 CommitTS。为了防止两个并发事务更新同一项而丢失更新,常见验证有两种:
主键、唯一键和外键等约束不能只对旧快照检查。提交时必须根据数据库当前状态验证,否则两个快照可能各自认为某主键尚不存在并都插入成功。
星河剧场规定:每个场次至少保留一个人工售票窗口。当前窗口 A、B 都开放。两个管理员同时开始事务:T1 在快照中看到 B 开放,于是关闭 A;T2 在相同快照中看到 A 开放,于是关闭 B。它们写不同记录,没有写写冲突,都能提交,最终却没有任何窗口开放。
这叫写偏斜:两个事务都读取对方将要修改的数据,但各自写入不同数据项。冲突图中存在两个相反的读写依赖,任何串行顺序都不可能得到该结果。连续票据号也可能出现类似问题:两个事务都读取当前最大号并插入相同的下一号,本质上又结合了谓词幻影。
如果数据库支持可串行化快照隔离,可以跟踪并发事务间“读了旧版本,而另一个事务随后写入新版本”的读写反依赖。非串行化快照执行中会出现危险结构:某个事务同时具有进入和离开的读写反依赖。检测到该结构时回滚一个参与事务,比跟踪全部边并做完整环检测便宜,但可能产生保守回滚。
若只能使用普通快照隔离,可以主动制造冲突:
SELECT window_id, is_open
FROM ticket_window
WHERE show_id = 2088
FOR UPDATE;让两个事务都把约束涉及的行视为更新对象,首更新者/首提交者规则就能阻止同时提交。也可以把跨行不变量收束到一条“场次状态”记录并锁住,或用数据库能够原子验证的约束表达。关键是锁住约束真正依赖的集合,而不是只锁最终修改的那一行。
有些报表允许同一事务内两次读取不同,但不能接受脏读。二级一致性要求访问数据前取得合适锁,排他锁一直保留到提交或中止,共享锁却可以读完即释放,也允许之后再申请新锁。它不会读到未提交写,因此可实现读已提交;但不遵守两阶段规则,同一行可以前后不同,调度也不一定可串行化。
更隐蔽的问题出现在索引扫描:并发事务把一条记录的索引键从旧位置移动到新位置,扫描可能先越过新位置、再看到旧位置被删除,于是完全漏掉该行;也可能先在旧位置读到它,之后又在新位置读到一次。
游标稳定性是面向逐行处理的变体:游标当前行持有 S,游标移动后即可释放;被修改的行持有 X 到事务结束。它能提高热点表的并发度,却要求应用明确容忍非可串行化结果。若系统支持快照读取,通常能以相似并发度提供更稳定的查询视图。
选座页面可能停留几分钟,甚至被用户直接关闭。如果从展示座位到用户最终付款一直保持一个两阶段锁事务,整片座位会被长期锁住。时间戳或完整乐观验证也可能因为任何中间变化而让用户最后一步全部失败。
更合理的做法是拆成短事务:第一个事务读取可售座位并返回页面;用户确认后,第二个事务再次检查目标座位仍可售,再原子占用。第二步不能无条件覆盖,否则会丢失其他用户的更新。
应用层常在记录中加入版本号:读取待修改记录时保存版本 ;提交时执行带条件更新:
UPDATE seat
SET status = 'HELD', version = version + 1
WHERE seat_id = 'A-08'
AND status = 'AVAILABLE'
AND version = :old_version;受影响行数为 1 表示提交成功;为 0 表示期间有人修改过,需要重新读取或提示用户。这个方案等价于对写集执行首提交者胜,但默认不验证只读数据,因此只能防止丢失更新,不能自动防止写偏斜。若提交时也验证所有读集版本,才接近完整的乐观并发控制。
隔离级别是业务风险选择,不是越高越好或越低越快。可接受的弱一致性必须写清楚:哪些读允许过时、哪些约束绝不能破坏、冲突时由谁重试,以及用户能否理解重试后的结果。
给数十亿订单创建索引可能持续数小时。全程锁住表虽容易保证一致,却会让业务无法更新。在线创建通常分三步:先对关系快照构建索引,同时记录快照之后的增量更新;再回放更新日志追赶当前状态;最后短暂阻止新更新,应用尾部增量并原子发布元数据。物化视图在线构建、在线添加唯一约束也可以采用“快照 + 增量日志 + 短暂收口”。
添加或删除列还可以给每条记录保存模式版本。后台逐步转换旧记录,或在记录被访问时按需转换,避免一次性长时间锁表。
B+ 树是数据库内部结构。一次查询前后看到树节点布局改变并不一定有问题,只要每次查找返回正确记录,最终树结构也正确。这里关注的是索引操作可串行化,而不是把内部每个节点访问都纳入事务两阶段锁。
蟹行协议向下遍历时先锁父节点,再锁子节点,随后释放父锁;插入或删除到达叶子后取得排他闩锁,若分裂、合并或重分布向上扩散,再锁父节点。闩锁只保护很短的内部临界区。向下搜索与向上分裂仍可能死锁,常用办法是释放闩锁并从根重试。
B-link 树让每个节点都保存右兄弟指针。遍历时可以先释放当前节点再锁下一个节点;若期间发生分裂,查找沿右链修正位置。它一次只需持有一个内部节点闩锁,避免上述死锁并提高并发,但实现需要正确处理父子关系已变化的情况。
事务层面的范围查询仍要防幻影。内部闩锁保证树结构不坏,下一键锁保证业务调度可串行化,两者保护目标不同,不能互相替代。
数据全在内存时,磁盘 I/O 不再掩盖锁管理开销。对很快的索引操作,一把粗粒度短闩锁有时反而比许多细粒度闩锁更快。另一条路线是使用原子比较并交换:CAS(var, old, new) 仅在 var 仍等于 old 时把它更新成 new,否则失败并重试。
无闩锁链表删除要警惕 ABA:线程先读到头指针 A;另一线程把 A、B 删除后又把 A 插回头部;第一个线程看到指针仍是 A,误以为什么都没变,于是把头指向已经失效的 B。给指针附带每次更新都递增的版本计数,并对“指针 + 计数”一起 CAS,可以识别这种变化。无锁结构极易出现罕见竞态,工程上应优先使用经过验证的标准实现。
热门场次的“已售总额”若每笔订单都对同一行使用普通 X 锁到提交,会形成热点。若事务只需要执行 increment(total,n),并不读取中间值,可以定义增量锁 I:I 与 I 兼容,因为加法次序不影响结果;I 与 S、X 不兼容。事务回滚时执行 increment(total,-n) 作为补偿操作。
条件扣减 decrement_if_enough(remaining,n) 则不满足交换性:只剩一张票时,两个请求的先后决定谁成功。系统仍可用短闩锁串行执行单次操作,操作完成即释放,从而提高吞吐;但整个事务可能不再满足传统串行化,必须确认这种弱化在业务上可接受,并为后续付款失败准备反向补偿。
长事务还会暴露未提交协作数据、包含可独立撤销的子任务,并要求崩溃后尽量保留人工工作。它们往往需要工作流、保存点、补偿和版本控制,而不是单个长数据库事务。
实时系统还把截止时间纳入正确性:硬截止错过会造成严重故障,坚定截止错过后结果无价值,软截止的价值随延迟下降。此时“等待还是抢占持锁事务”要比较双方错过截止时间的风险。内存数据库能减少 I/O 抖动,但锁等待与回滚仍会造成延迟方差;基于截止期的优先级和乐观协议常用于降低超时数量。
TiTiTi