资讯详情

资讯详情

数据库并发控制:可串行化调度、冲突可串行化与两段锁协议

刚接手那套订单扣减服务的时候我踩过一个特别典型的坑两个请求同时读到库存 10各自减 1 再写回 9结果卖出两件只扣了一件。代码逻辑一行没错错在并发调度。那次事故之后我把数据库的并发控制理论从头捋了一遍发现所有绕不开的概念都指向同一把尺子——可串行性。这篇东西就是我当时那本笔记的整理版讲清楚三个层层递进的问题什么叫可串行化调度怎么用冲突可串行化调度做判定以及工业界真正落地用的两段锁协议到底靠什么保证正确性。适合正在啃数据库系统课程的同学也适合天天写业务代码、但没系统想过我的事务为什么是安全的的后端开发。不需要你先懂形式化证明我会用转账、扣库存这类例子把每个概念拆开也会贴出可以直接跑的判定代码和锁管理器模拟。1. 并发调度里的那把尺子可串行性到底在解决什么问题1.1 从一次丢更新说起假设库存表里stock 10两个事务 T1、T2 都要做读出来、减一、写回去。如果它们是串行执行的无论谁先谁后结果都是 8这是所有人都能接受的正确结果。但一旦调度器把它们交叉执行T1: R(stock) - 读到 10 T2: R(stock) - 也读到 10 T1: W(stock) 9 T2: W(stock) 9最终stock 9一件货凭空消失。这就是经典的丢失修改Lost Update。类似的还有脏读、不可重复读、幻读它们本质上都是同一件事的不同表现交叉执行的事务互相看到了对方不应该被看到的中间状态。教科书上给这类问题的定性是并发调度破坏了事务的隔离性。但这句话太笼统了工程上需要一个可判定的标准回答这个具体的交叉执行序列到底算不算正确。可串行性就是这个标准。它的思路很朴素既然串行执行一定是正确的那我就要求交叉执行的结果等价于某种串行执行的结果。只要等价就认为它是正确的调度术语叫可串行化调度serializable schedule。注意可串行性讨论的是调度层面的正确性跟事务内部逻辑对不对是两件事。事务本身写错了比如算错金额再可串行化也救不回来。1.2 可串行化调度的两种判定口径等价于某个串行调度这句话里的等价有两种解释方式对应两种粒度不同的定义。第一种是结果等价也叫视图可串行化View Serializable。它要求三个条件同时满足每个数据项的最终写入者相同、每个事务读到的每个数据项的值来自同一个事务、以及最后一个写操作的顺序一致。这个定义非常精确覆盖范围也最广——只要最终结果和某个串行调度一模一样它就认。第二种是冲突等价也就是本文重点讨论的冲突可串行化调度Conflict Serializable。它不看结果只看操作的先后顺序如果两个事务的冲突操作在调度里的相对顺序和某个串行调度里完全一致那它就冲突等价于那个串行调度。两者最关键的差别在于判定代价。视图可串行化的判定问题已经被证明是 NP 完全的也就是说数据量和事务数一上去判定时间会爆炸而冲突可串行化的判定只需要构造一张图然后判环是多项式时间的规模再大也能扛住。这就是为什么所有主流数据库、所有教材实操章节讨论的都是冲突可串行化。不是因为它更准而是因为它可算。视图可串行化在理论上更完备但工程上没人敢用它在线判定。还有一个反直觉的点值得记住冲突可串行化是视图可串行化的真子集。存在一些调度它视图可串行化但不冲突可串行化比如经典的盲写blind write场景。这些调度其实是正确的但会被冲突可串行化的判定器判成不安全。这是为可判定性付出的代价属于典型的工程妥协。1.3 为什么要把调度和事务的原子性分开看很多同学第一次学这块会有一个困惑事务不是已经保证了原子性吗那并发调度还有什么好担心的关键在于原子性描述的是要么全做、要么全不做的最终效果它不描述中间过程对外的可见性。事务执行到一半时它对数据的修改可能已经被写进了缓冲区、甚至被其他事务读走了。原子性解决的是回滚要能干净地撤销隔离性解决的是中间状态不能被别人看见。这两件事是正交的。调度器的职责就是在多个事务的读写操作之间做重排同时保证重排后的序列仍然是可串行化的。这个重排发生在两个层面一是数据库内部的锁管理器和并发控制协议二是应用层的连接池、重试逻辑、业务补偿。理解可串行性最大的好处是让你能判断哪些并发问题该交给数据库哪些必须自己在应用层兜住。比如后面要讲的 2PL它能保证调度可串行化但保证不了你的业务逻辑不会在重试时重复扣款。2. 冲突可串行化调度用一张优先图把问题变成拓扑排序2.1 冲突的三种组合别把不冲突的操作也算进去要说清冲突可串行化得先把冲突这个词严格定义下来。两个操作构成冲突必须同时满足三个条件它们属于不同的事务它们操作的是同一个数据项其中至少有一个是写操作。按这三条筛一遍实际存在的冲突只有三种组合读-写RW、写-读WR、写-写WW。而读-读RR永远不冲突因为两个事务都只是看谁先谁后对结果没有任何影响。为什么只有写会引发冲突可以用一个生活类比理解。数据项好比一块白板读操作是看白板写操作是擦掉重写。两个人同时看白板不管谁先看看到的内容都一样所以顺序无所谓。但只要有人擦改另一个人看到的内容就取决于他是在擦改之前还是之后看的。写操作的不可交换性才是冲突的根源。这里有个容易踩的坑冲突判定必须建立在同一次原子操作的粒度上而不是语句粒度。一条UPDATE ... SET a a 1在数据库内部其实是读 a、算、写 a 三步你如果按 SQL 语句粒度去画冲突图会漏掉大量实际存在的读写交叉。做理论题的时候按题目给的读写序列走做工程分析的时候一定要下沉到行级、页级的实际加锁对象上。2.2 优先图的构造规则三行就能记住冲突可串行化的判定工具叫优先图Precedence Graph有的教材叫前趋图。构造规则非常简单为每个事务建一个节点对每一对冲突操作如果 Ti 的操作先于 Tj 的操作就画一条 Ti 指向 Tj 的有向边判定图中有环则不可冲突可串行化无环则可冲突可串行化。无环时这张图的任意一个拓扑排序就是一个与该调度冲突等价的串行调度。这句话很重要它是后面所有优化和验证的依据——拓扑序不仅回答行不行还告诉你应该按什么顺序串行执行。判断有环的逻辑也很好理解。如果存在 Ti → Tj 和 Tj → Ti 双向可达就意味着Ti 必须在 Tj 之前和Tj 必须在 Ti 之前同时成立这在串行执行里是不可能出现的所以必然不可串行化。这就像排课表A 课必须在 B 课之前修B 课又必须在 A 课之前修那这个培养方案根本排不出来。2.3 手工推演一遍三个事务的调度判定光看规则容易飘我们走一个完整的例子。给定调度S R1(A) W2(A) R3(A) W1(B) R2(B) W3(B)第一步列出所有冲突对。为清晰起见我把同数据项的操作按时间排好数据项 A 上的操作顺序是R1(A) → W2(A) → R3(A)其中 R1/W2 冲突RWW2/R3 冲突WRR1/R3 不冲突RR。由此得到两条边T1 → T2T2 → T3。数据项 B 上的操作顺序是W1(B) → R2(B) → W3(B)。W1/R2 冲突WR得到 T1 → T2R2/W3 冲突RW得到 T2 → T3W1/W3 冲突WW得到 T1 → T3。汇总所有边T1 → T2、T2 → T3、T1 → T3。这张图显然无环拓扑序是T1, T2, T3。所以这个调度是冲突可串行化的等价于按 T1 → T2 → T3 串行执行。第二步做个对照实验把调度改成S R1(A) W2(A) R2(A) W1(A)数据项 A 上的顺序是R1 → W2 → R2 → W1。R1/W2 得到 T1 → T2W2/R2 同事务跳过R2/W1 得到 T2 → T1。两条边方向相反图里出现了 T1 → T2 → T1 的环判定为不可冲突可串行化。有意思的地方来了这个调度在结果上其实可能没问题因为 T1 先读后写、T2 先写后读最终 A 的值由 W1 决定。它可能属于视图可串行化但不冲突可串行化的那一小撮。判定器会保守地拒绝它这就是上次说的工程妥协。在实际系统里遇到这种判定失败不用慌先看业务上是否真的会产生不一致很多情况下重排一下语句顺序就能绕开。2.4 优先图判定的两个边界情况第一个边界情况是盲写。所谓盲写就是一个事务直接写某个数据项中间没有先读它。盲写会让视图可串行化和冲突可串行化的差距拉大也是最容易出题的地方。判定的时候照常按规则处理即可不需要特殊逻辑。第二个边界情况是单事务调度。只有事务内部的操作没有任何跨事务冲突优先图里一条边都没有自然无环也是可串行化的。这一点在写测试用例时很有用——你可以用它作为空跑基线验证判定器没有被误触发。还有一个实操层面经常被忽略的点优先图的无环性是存在性判定不是唯一性判定。无环的图可能有多个合法拓扑序每个都对应一个合法的串行执行顺序。这意味着一件事——同一个调度可能等价于多个不同的串行序。在做性能优化时如果能选一个让某些长事务排在前面或后面的拓扑序是有机会改善锁等待的。这是理论能给工程带来的实际红利之一。3. 两段锁协议把事后检查变成运行期约束3.1 增长期与收缩期一条不能回头的分界线优先图是事后检查调度跑完了我再来告诉你它合不合格。但数据库不可能等事务全跑完才决定要不要执行它必须在运行期就拦住危险操作。两段锁协议Two-Phase Locking2PL就是干这个的它是目前最主流的运行期并发控制方案。2PL 的规则只有一句话事务的所有加锁操作必须在所有解锁操作之前。也就是说事务的执行被分成两个阶段增长期Growing Phase只能加锁不能解锁锁越拿越多收缩期Shrinking Phase只能解锁不能加锁锁越放越少。一旦你释放了第一把锁就正式进入收缩期此后不允许再申请任何新锁。哪怕后面还要读一个全新的数据项也只能干等着——或者更准确地说这个事务已经被设计成不该再需要新锁了。为什么这条不回头的规则这么关键因为一旦允许解锁后再加锁就相当于允许事务在收缩期重新去竞争资源这会打破已经确定的操作顺序让原本排好的依赖关系发生逆转最终可能导致不可串行化的调度。两段锁的两段本质上是在用纪律换取可证明的正确性。3.2 为什么 2PL 能保证冲突可串行化这是整篇里最值得花时间理解的一段。结论是遵守 2PL 的调度一定是冲突可串行化的反证法是最清晰的思路。假设存在一个遵守 2PL 但不可冲突可串行化的调度 S那么它的优先图里必定有环环上至少存在一条边 Ti → Tj 和一条 Tj → Ti。先看 Ti → Tj 这条边。它意味着 Ti 和 Tj 有某个冲突操作且 Ti 的先执行。在 2PL 里任何冲突操作的执行都需要先拿到对应数据项的锁而同一把锁不可能被两个事务同时持有所以 Ti 必须先拿到锁、Tj 才能拿到。而 Ti 释放这把锁必然发生在这个冲突操作之后也就是在 Ti 进入收缩期之后。于是 Tj 拿到锁的时刻晚于 Ti 进入收缩期的时刻。再看 Tj → Ti 这条边同理能推出Ti 拿到锁的时刻晚于 Tj 进入收缩期的时刻。把两段结论串起来就有 Tj 加锁 Ti 收缩 Ti 加锁 Tj 收缩 Tj 加锁。注意最后出现的 Tj 加锁和最开始出现的 Tj 加锁是同一个事务的同一个动作这就得到了自相矛盾的结论。所以假设不成立。这个证明里藏着一个非常重要的细节关键在于释放锁这个动作对另一个事务的加锁有可见的时序影响。后面要讲的严格 2PL、强 2PL本质上都是在调节什么时候放锁因为放锁的时机直接决定会不会出现级联回滚和脏读。还想强调一个方向性的事实2PL 是冲突可串行化的充分条件不是必要条件。也就是说存在可以冲突可串行化、但不满足 2PL 的调度。2PL 只是最容易实现且可证明的充分条件选了它就意味着放弃了另一部分并发度。理解了这一点你就能明白为什么会有那么多 2PL 的变体它们都在做同一件事——在正确性不变的前提下把并发度一点点抠回来。3.3 严格 2PL、强 2PL、保守 2PL 到底怎么选变体核心规则解决的问题代价基础 2PL加锁全在解锁前保证冲突可串行化可能级联回滚、可能死锁严格 2PL排他锁持有到事务结束消除级联回滚保证可恢复锁持有时间变长并发度下降强 2PL所有锁含共享锁持有到结束消除级联回滚且严格可串行化并发度进一步下降保守 2PL开始前一次性申请所有锁从源头避免死锁需要预知全集实现成本高表格里的术语在不同教材里叫法会有些出入比如有的地方把严格 2PL和强 2PL合并成一个概念。工程上你只需要抓住一条主线锁放得越晚正确性保障越强并发度越低。这是一个没有免费午餐的权衡。我自己的判断原则很粗暴但管用业务对脏读零容忍、事务里有多个写操作时无脑用严格 2PL 那一档。原因很实际——级联回滚一旦发生回滚的不只是一个事务而是一串依赖它的事务这种连锁反应在排查线上问题时极其痛苦日志里全是因依赖事务回滚而回滚根本看不出真正的根因。宁可牺牲一点并发度也别给自己埋这种雷。保守 2PL 值得单独说一句。它的思路是开工前把所有要用的锁一次性拿齐拿不齐就一个都不拿。这确实从根子上消灭了死锁但代价是你必须事先知道事务要访问哪些数据项。对于按主键更新的简单事务这是可以提前算出来的但对于带子查询、带动态条件的复杂 SQL执行计划本身都还没定根本预知不了。这也是为什么现实中几乎见不到纯粹的保守 2PL 实现。3.4 封锁协议的三级分类和一致性级别的对应关系这部分是国内教材里特别经典的内容我觉得很值得单独拎出来说因为它直接解释了为什么有的系统只加写锁就够用。一级封锁协议事务修改数据时必须加排他锁X 锁并持有到事务结束。它只解决丢失修改问题读操作完全不加锁。二级封锁协议在一级基础上事务读取数据前必须加共享锁S 锁读完即可释放。它额外解决了脏读问题但读锁过早释放无法防住不可重复读。三级封锁协议在一级基础上读数据前加 S 锁并持有到事务结束。它同时解决了丢失修改、脏读和不可重复读。注意二级和三级对读锁释放时机的差异这是整个分类的核心。读锁早点放别的写事务就能早点进来并发度高但你自己同一事务里前后两次读可能结果不同读锁留到事务结束一致性最强但写事务会被堵住更久。提示三级封锁协议并不等于可串行化。它防住了那三类经典异常但更复杂的交叉调度仍可能出问题。真正的可串行化保障来自 2PL不要混为一谈。现实中大量系统用的是读不加锁 写加锁的一级方案配合版本号或 MVCC 来兜读一致性。这不是偷懒而是在读多写少的负载下做出的合理取舍——读锁的收益在写比例低的场景下非常有限而阻塞成本却是实打实的。4. 动手复现优先图判定器与 2PL 调度模拟4.1 数据结构的设计把调度表示成一串操作理论上讲得再清楚不如自己写一遍。我用 Python 写了一个约 200 行的最小实现全部只依赖标准库。调度的表示方式很直接用一个列表每个元素是(事务名, 操作类型, 数据项)三元组# 事务名用字符串操作类型只取 R 或 W schedule [ (T1, R, A), (T2, W, A), (T3, R, A), (T1, W, B), (T2, R, B), (T3, W, B), ]为什么用三元组而不是更复杂的对象因为冲突判定只需要这三样东西。事务名和数据项都用可哈希的字符串后面建图的时候可以直接当字典的键省掉一堆映射代码。做这类理论验证工具最忌讳一上来就设计过度先把判定逻辑跑通需要扩展再加字段。4.2 冲突检测与优先图判环冲突判定是核心中的核心三行就够但条件一个都不能少def conflict(a, b): 两个操作是否构成冲突 if a[0] b[0]: # 同一事务跳过 return False if a[2] ! b[2]: # 不同数据项跳过 return False return a[1] W or b[1] W # 至少一个写构造优先图就直接双指针扫一遍因为调度的长度在验证场景下通常很短O(n²) 完全够用没必要为了理论上的性能把代码写得难懂。至于判环我用的是三色 DFS比拓扑排序那个版本更容易定位到具体的环def build_graph(schedule): txns sorted({op[0] for op in schedule}) g {t: set() for t in txns} for i in range(len(schedule)): for j in range(i 1, len(schedule)): if conflict(schedule[i], schedule[j]): g[schedule[i][0]].add(schedule[j][0]) return g def find_cycle(g): 返回环上的事务列表无环返回 None WHITE, GRAY, BLACK 0, 1, 2 color {t: WHITE for t in g} stack [] def dfs(u): color[u] GRAY stack.append(u) for v in g[u]: if color[v] GRAY: # 撞到灰色节点成环 return stack[stack.index(v):] [v] if color[v] WHITE: r dfs(v) if r: return r color[u] BLACK stack.pop() return None for t in g: if color[t] WHITE: r dfs(t) if r: return r return None这里有个小技巧值得说返回环本身比只返回 True/False 有用得多。排查线上问题时你需要知道是哪几个事务互相卡死了而不是只知道有环。把find_cycle的结果打到日志里能直接看到T1 - T3 - T2 - T1这样的链条定位效率完全不是一个量级。用前面第 2.3 节的例子跑一遍第一个调度返回None无环可串行化第二个调度返回[T1, T2, T1]和手工推演完全一致。4.3 一个能跑的 2PL 锁管理器模拟 2PL 的关键是把是否已进入收缩期这个状态显式记录下来任何在收缩期发起的加锁请求都应该被拒绝——这本身就是违反 2PL 的信号。class LockError(Exception): pass class TwoPhaseLockManager: def __init__(self): self.owner {} # item - [事务名, 模式] 模式为 S 或 X self.held {} # txn - set(item) self.shrinking set() # 已释放过锁的事务 self.log [] def acquire(self, txn, item, mode): if txn in self.shrinking: raise LockError(f{txn} 已进入收缩期不能再申请 {item} 的锁) cur self.owner.get(item) if cur is None: self.owner[item] [txn, mode] self.held.setdefault(txn, set()).add(item) self.log.append((txn, ACQUIRE, item, mode)) return True if cur[0] txn: if mode X: cur[1] X # 锁升级 return True if cur[1] S and mode S: # 共享锁可以共存这里用列表简化实际实现要用队列 self.log.append((txn, WAIT, item, mode)) return False self.log.append((txn, WAIT, item, mode)) return False def release(self, txn, item): self.shrinking.add(txn) # 一放锁就进入收缩期 self.owner.pop(item, None) self.held.get(txn, set()).discard(item) self.log.append((txn, RELEASE, item, None))这段代码为了可读性做了简化共享锁的等待队列、锁升级的死锁风险、超时机制都没实现。但核心约束——收缩期不能再加锁——已经完整表达了。你可以拿它去跑第 2.3 节的两个调度会看到 T2 在W1(A)那个点上直接抛LockError这正好解释了为什么那个调度不可串行化。4.4 观测指标怎么定别只看对不对判定器写完只能告诉你行或不行但要评估一个并发控制方案好不好得看几个量化指标。我在自己那套模拟器里固定记录这三项指标含义观测价值平均持锁时间从首次加锁到事务结束的时长直接决定其他事务的等待时长冲突率冲突操作对数 / 总操作对数判断负载是读密集还是写密集死锁发生率出现等待环的次数 / 事务总数评估死锁检测策略的压力这三个数一起看才有意义。举个例子如果冲突率很低但平均持锁时间很长那说明瓶颈不在并发控制而在事务本身写得太长应该先去优化事务粒度而不是去调锁参数。很多人一遇到锁等待就去查数据库参数方向从一开始就错了。压测的时候建议至少跑三组对照纯读负载、读写比 9:1、读写比 1:1。你会发现纯读负载下 2PL 的代价几乎为零共享锁不互斥而一旦写比例上来锁等待时间会非线性上升。这个拐点就是你这个业务该做读写分离或者降级隔离级别的信号。5. 真实系统里的映射隔离级别、MVCC 与 2PL 的边界5.1 四种隔离级别和三大异常到底怎么对应学完理论最该问的问题是我平时用的那些隔离级别跟这里讲的到底是啥关系直接上表。隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不可能可能可能可重复读不可能不可能标准允许具体实现另说可串行化不可能不可能不可能这里必须点破一个关键事实只有可串行化级别才真正对应可串行化调度这个理论保证其余三个级别都是部分正确它们只是防住了特定几类异常并不是完整的可串行化。换句话说你在读已提交下跑事务虽然不会脏读但你的调度仍然可能是不可串行化的。这就解释了一个很常见的困惑为什么单看业务逻辑没问题、隔离级别也没问题数据还是不对。因为不可重复读和丢失修改是两码事读已提交防住了前者但写-写冲突的处理在有些实现里并不完全依赖锁。5.2 MVCC 时代2PL 还有用吗现在主流的存储引擎大量使用 MVCC多版本并发控制来做读操作读不阻塞写、写不阻塞读并发度比纯 2PL 高一个量级。于是很多人得出结论2PL 过时了。这个结论是错的。MVCC 解决的是读这一侧的问题写-写冲突的串行化保障本质上仍然要靠锁。你在数据库里看到的那些行锁、间隙锁、临键锁都是 2PL 思想的具体实现。也就是说MVCC 和 2PL 不是替代关系而是分工关系读走快照、写走加锁。理解了这一点很多现象就有解释了。比如为什么快照读普通 SELECT不会被阻塞但锁定读SELECT ... FOR UPDATE会。因为前者走版本链根本没碰锁后者显式地去申请了排他锁直接进入了 2PL 的规则体系。注意不同存储引擎对可重复读下幻读的处理方式差异很大。有的用间隙锁把范围堵住有的靠快照版本保证前后读到同样的集合。别把某一家的行为当成行业通例换引擎一定要重新验证。5.3 参数怎么定锁等待超时和死锁检测周期2PL 一定会带来死锁这是它的固有代价不存在既保证可串行化又完全无死锁的通用方案。既然躲不掉就得给它配好两套机制。第一套是死锁检测。主流做法是维护一张等待图定期检测有没有环有环就挑一个代价最小的事务回滚掉。以 InnoDB 为例死锁检测是默认开启的检测频率由引擎内部调度决定通常能做到秒级以内发现。第二套是锁等待超时。这是兜底机制防止某个事务无限期等下去。InnoDB 里对应innodb_lock_wait_timeout默认 50 秒。这个默认值在线上通常偏大——一个请求卡 50 秒上游早就超时重试好几轮了反而容易制造雪崩。我自己的调参思路是这样先统计线上事务的实际 P99 执行耗时假设是 200 毫秒那把超时设成 3 到 5 秒就足够宽裕了既不误杀正常事务又能快速失败。任何等一等说不定就好了的参数在分布式场景下基本都是灾难。超时时间宁可短一点让请求快速失败走重试也别让它长时间挂在那里占着连接。死锁检测本身也有成本的高并发下检测等待图会消耗 CPU。有些团队在高负载时选择关掉自动检测、纯粹依赖超时这是用更长的等待换取更低的 CPU 占用。这个取舍没有标准答案取决于你的负载特征和对延迟的容忍度但一定要在上线前压测验证别上线后才发现。6. 常见问题与排查技巧实录6.1 常见问题速查表现象可能原因排查方向优先图判定有环但业务结果正常属于视图可串行化但不冲突可串行化检查是否有盲写看结果是否真会不一致事务报错已进入收缩期不能再加锁代码里手动解锁后又发起了新查询检查是否混用了手动锁和自动事务大量事务在同一个点排队热点行争抢锁粒度太粗看是否能用分片键打散或改批量操作为单行死锁日志频繁出现但每次事务都不同并发度高、访问顺序不一致统一各事务对多表的加锁顺序换用快照读后仍偶发数据不一致快照读和锁定读混用检查事务里是否同时存在两种读方式隔离级别调高后吞吐骤降共享锁持有到事务结束评估业务是否真需要这么强的一致性这张表里的每一条我都在真实项目里碰到过尤其是第一条最容易被误判成 bug。判定器说不可串行化不等于业务结果一定错它只是说没通过我这条最保守的标准。遇到这种情况先别急着改代码先用具体数据验证一下结果对不对。6.2 我踩过的几个坑和一些实操心得第一个坑是加锁顺序不一致导致的死锁。当时有两个接口一个先更新订单表再更新库存表另一个反过来。单测全过压测一上并发就死锁。解决办法很土但很有效给所有涉及多表更新的事务规定统一的加锁顺序按表名或主键哈希值排序。这是成本最低、收益最高的死锁预防手段几乎零副作用。第二个坑是长事务。有一次排查锁等待查了半天发现罪魁祸首是一个批量导入接口它在同一个事务里处理了十万行数据锁持有时间长达几十秒。事务越长持锁时间越长等待链就越长死锁概率呈指数级上升。后来的做法是把大批量操作切成小批次每批一千行、各自独立事务牺牲了一点原子性换来了数量级的吞吐提升。切分之前一定要跟业务方确认中途失败能否接受部分成功别自己替业务做决定。第三个心得是用find_cycle这类工具把抽象问题具象化。教科书上的优先图是画在纸上的但线上出问题时你需要的是日志里一行清晰的依赖链。我在项目里加了一个只在测试环境开启的开关一旦检测到锁等待超过阈值就自动 dump 出当前的等待关系图。这个工具帮我省下的排查时间比我写它花的时间多得多。最后说一个容易被忽视的点别把可串行化当成万能解药。它能保证数据层面的一致性但保证不了业务层面的幂等。网络超时后的重试、消息队列的重复投递这些问题都发生在事务之外。真正稳的系统往往是数据库里用 2PL 或可串行化级别守住最后一道防线应用层再用业务唯一键和幂等表兜住重试。这两层各管一段谁也别指望谁全包。理论学扎实了你至少能清楚地知道自己踩的每一个坑到底是并发控制的问题还是别的什么问题。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →