深夜的急诊诊所一片忙碌。墙上挂着一块值班白板,李医生和赵医生的名字下都写着“在岗”,诊所铁律只有一条:任何时刻,急诊至少有一位医生留守。今晚两人都想早点下班。李医生看了一眼白板——赵医生还在岗,于是放心地擦掉了自己的名字。几乎同一瞬间,赵医生也看了一眼白板——李医生还在岗,也擦掉了自己的名字。等两人都走远,白板上一片空白,急诊室空无一人。两人都“看”过白板,都做了“正确”判断,却共同酿成事故——这就是并发事务里最阴险的陷阱之一:写倾斜。而另一个叫幻读的陷阱更诡异:护士长明明数过候诊名单上有 3 位病人,接个电话再回头,名单上竟多出 1 位——一个“凭空出现”的新行。这两起事故,都根植于数据库底层的“隔离级别”和 MVCC 机制。如果你管理过任何一块“白板”——库存表、排班表、余额表、任务表——这篇文章就是为你写的。

岗位关联

理解写倾斜与幻读的成因与对策,是后端开发与 DBA 设计高并发事务、快速定位数据不一致的必备内功,也能让你在面试中聊出真正的深度。

目录
  1. 可视化演示
  2. 并发与隔离:白板的看与写规则
  3. 写倾斜原理:两个医生同时下班
  4. 幻读原理:候诊名单上多出的病人
  5. 两者区别:擦名字 vs 加名字
  6. 隔离防护:从窗帘到铁门
  7. MVCC 支撑:白板背后的便利贴层
  8. 实战对策:让值班表不再失控
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示写倾斜异常产生原理、幻读范围查询不一致、四种隔离级别异常防范矩阵、SELECT FOR UPDATE等工程防范方案,交互式理解MVCC并发陷阱与隔离级别选择。

本节配有交互动效(进入视口后自动加载,约 3KB)。

写倾斜:快照隔离下的隐蔽异常 两个并发事务读取同一快照后各自写入不同行,各自合法但组合违反业务约束 修改不同行不触发写写冲突检测,快照隔离无法防范 T1: read → write(A); T2: read → write(B); A+B violates constraint 写倾斜是快照隔离的固有缺陷。

并发与隔离:白板的看与写规则

诊所的每一次看白板、写白板,都对应数据库的一次读、一次写。而“隔离级别”就是诊所制定的观看规则:你是能凑到白板前看别人写到一半的草稿(读未提交),还是只能看已经誊写好的最终结果(读已提交),还是进门时给白板拍一张照、之后全程只看这张照片(可重复读),还是干脆规定所有人必须排成单列、一个操作完下一个才能开始(串行化)。

数据库这门“看与写”的学科,围绕一个叫 ACID 的核心承诺展开:原子性、一致性、隔离性、持久性。其中隔离性(Isolation)决定并发时你能看到多少别人的“半成品”。隔离越严格,看到的越真实,但并发能力越差——就像排队看病虽然公平,效率却低。

四类并发异常按严重程度排列:脏读、不可重复读、幻读、写倾斜。隔离级别也分四档阶梯:读未提交(RU)、读已提交(RC)、可重复读(RR)、串行化(SR)。RC 防住脏读,RR 让快照读稳定,SR 则防住一切异常。这四档的关系,决定了后面两个陷阱出现的舞台。

规则越严,白板前越安全,但排队的队伍也越长。值得记住的是:MySQL InnoDB 默认 RR,PostgreSQL 默认 RC——默认档位不同,踩坑的概率也不同。理解这四档,是通向后面两个陷阱的入场券。

RU≺RC≺RR≺SR\mathrm{RU} \prec \mathrm{RC} \prec \mathrm{RR} \prec \mathrm{SR}
从左到右隔离越来越强:能看到的“真实程度”递增,允许的并发程度递减。
  1. RU 读未提交\mathrm{RU} 是最宽松的一档,医生可以直接看别人写到一半的白板,可能看到“脏”内容。
    RU:允许读到未提交的修改\mathrm{RU}: \text{允许读到未提交的修改}
  2. RC 读已提交\mathrm{RC} 只能看到已提交的结果,解决脏读,但同一页白板每次看都可能不同。
    RC:每次读都取最新的已提交版本\mathrm{RC}: \text{每次读都取最新的已提交版本}
  3. RR 可重复读\mathrm{RR} 给事务一张固定的快照照片,同一事务内重复读结果一致。
    RR:快照读结果在事务内保持不变\mathrm{RR}: \text{快照读结果在事务内保持不变}
  4. SR 串行化\mathrm{SR} 是最高档,并发效果等价于某种串行顺序,所有异常都消失。
    SR:并发调度的效果≡某个串行顺序\mathrm{SR}: \text{并发调度的效果} \equiv \text{某个串行顺序}

写倾斜原理:两个医生同时下班

回到开头那场事故。李医生的操作能拆成三步:看一眼白板(读);判断“赵医生还在岗,我可以走”(计算);擦掉自己的名字(写自己的行)。赵医生也一模一样。问题出在第一步:两人看的都是“进门时”的信息——在可重复读/快照隔离下,他们各拿着一张旧照片,照片上对方都还在岗。

数据库术语里,这叫作写倾斜(Write Skew):两个事务读了相同的行集,却写了不相交的行集,最终联合破坏了跨多行的业务约束。最阴险的是,全程没有任何锁冲突——两人锁的是不同的行,谁也不等谁,所以没有死锁、没有超时、没有报错,双方都愉快地提交了。等到应用层再去查“为什么没人值班”,事故早已发生。

写倾斜的配方恰好三个要素:共同的读集、不相交的写集、全表性的约束。快照隔离保证了“读一致”,却管不住“基于旧数据做出的判断”。它不像脏读那样明显,也不像死锁那样会报错,是并发数据库里最隐蔽的坑之一。

两人都拿着同一天拍的合影,照片里对方还在值班室,于是各自放心地关灯走人——白板最终空无一人。要阻止这场事故,光靠“发照片”的隔离级别不够,必须改变“读判断”与“写”之间的锁联动方式。

R1=R2=D⏟两人读到同一快照,W1∩W2=∅⏟各改各的行,⟹  C(D)=假\underbrace{R_1 = R_2 = \mathcal{D}}_{\text{两人读到同一快照}},\quad \underbrace{W_1 \cap W_2 = \varnothing}_{\text{各改各的行}},\quad \Longrightarrow\; C(\mathcal{D}) = \text{假}
相同的读集、不相交的写集,加上一条跨多行的约束,三者凑齐,写倾斜必然登场。
  1. 相同的读集R_1 = R_2 = \mathcal{D} 表示李和赵都读了整张值班表,都看到“对方在岗”。
    R1=R2=DR_1 = R_2 = \mathcal{D}
  2. 不相交的写集W_1 \cap W_2 = \varnothing 表示李只写李的行,赵只写赵的行,行锁互不干扰。
    W1∩W2=∅W_1 \cap W_2 = \varnothing
  3. 约束被破坏C(\mathcal{D}) = \text{假} 表示提交后“至少一人在岗”这条全表约束不再成立,而数据库毫无察觉。
    C(D)=假C(\mathcal{D}) = \text{假}

幻读原理:候诊名单上多出的病人

诊所角落里,护士长正在做重要统计:今晚 3 点以后挂号的病人有多少?她第一次数,候诊名单上有 3 位。正要录入,急救电话响起,新病人被推进来挂号——名字被写进名单。护士长回头再数一遍:4 位。名单上的旧行一行动过,却凭空多出一行。这个“多出来的幻影”,就是幻读(Phantom Read)。

幻读的本质是:同一事务内,同一条谓词查询(WHERE 条件),两次执行的结果集变了。它和不可重复读的区别在于:不可重复读是“同一行的内容变了”,幻读是“满足条件的行数变了——多出来或消失了”。引发幻读的操作几乎总是 INSERT 或 DELETE,而不是 UPDATE。

为什么快照读能防住它?在可重复读的快照机制下,护士长第二次数的确实还是旧名单。但如果她用的是“当前读”(加锁读),比如 SELECT …… FOR UPDATE 或 UPDATE …… WHERE,数据库必须看最新数据,此时并发插入的新行就像幽灵一样混了进来。MySQL InnoDB 用“间隙锁”给谓词区间筑墙;没有间隙锁的数据库,在 RR 下仍可能被幻读偷袭。

名单上每条记录都真实存在,但“两次统计结论不同”这件事本身,会让护士长做出错误的排班决策。幻读最危险的地方在于:它骗过你的眼睛,让你基于错误的总数做判断。

Q(t1)={ r∈候诊表∣挂号时刻>t0 },∣Q(t1)∣=3Q(t2)=Q(t1)∪{r新},∣Q(t2)∣=4\begin{aligned}Q(t_1) &= \{\, r \in \text{候诊表} \mid \text{挂号时刻} > t_0 \,\}, && |Q(t_1)| = 3 \\Q(t_2) &= Q(t_1) \cup \{r_{\text{新}}\}, && |Q(t_2)| = 4\end{aligned}
同一条查询在两次执行中,结果集从 3 行变成 4 行——多出的那行就是“幻影”。
  1. 谓词定义结果集Q(t_1) 是第一次查询按条件筛出的行集合,共 3 行。
    Q(t1)={ r∈候诊表∣挂号时刻>t0 }Q(t_1) = \{\, r \in \text{候诊表} \mid \text{挂号时刻} > t_0 \,\}
  2. 并发插入新行r_{\text{新}} 是另一事务在两次数数之间插入的新行,也满足谓词。
    Q(t2)=Q(t1)∪{r新}Q(t_2) = Q(t_1) \cup \{r_{\text{新}}\}
  3. 结果集变大|Q(t_2)| = 4,行数从 3 变 4,幻影出现。
    ∣Q(t2)∣=∣Q(t1)∣+1|Q(t_2)| = |Q(t_1)| + 1

两者区别:擦名字 vs 加名字

写倾斜和幻读,就像白板上的两类破坏:一类是“把已有的名字擦掉”(更新行值),一类是“悄悄添上新的名字”(插入新行)。一个改变字的颜色,一个改变名单的长度。很多初学者把两者混为一谈,其实它们发生的路径和防护手段完全不同。

从数据库角度看有三点区别。第一,操作类型:写倾斜是两个 UPDATE 作用于不同的行;幻读是一个 INSERT 引入了新行。第二,读取方式:写倾斜依赖快照读的旧数据做判断;幻读出现在对谓词区间重复执行查询时。第三,约束性质:写倾斜破坏的是“跨多行的业务约束”,幻读破坏的是“查询结果集的稳定性”。

正因为不同,防护手段也不同:写倾斜需要把“读判断”变成加锁读写,或锁住一条物化的控制行;幻读需要锁住谓词对应的整个值域区间(间隙锁),或者直接上串行化。用错手段会事倍功半——例如只给写倾斜加行锁完全无效,因为你锁的是自己正在改的行,而对方改的是另一行。

给白板“每一行”上锁(行锁),管得住有人偷偷改你的名字,却管不住护士在名单末尾新登记一位病人。想同时防住两件事,得把“行”和“范围”两个维度都锁住。

写倾斜:UPD(r1)×UPD(r2),行数不变,取值变化幻读:INS(r新),行数+1,结果集扩大\begin{aligned}\text{写倾斜} &: \mathrm{UPD}(r_1) \times \mathrm{UPD}(r_2),\quad \text{行数不变,取值变化} \\\text{幻读} &: \mathrm{INS}(r_{\text{新}}),\quad \text{行数} + 1,\text{结果集扩大}\end{aligned}
一个改变已有行的取值,一个让行数变多——两种破坏的维度根本不同。
  1. 写倾斜改值不改数UPD(r_1) \times UPD(r_2) 两个更新作用在不同行,行的个数不变,但取值翻转导致约束失效。
    UPD(r1)×UPD(r2)\mathrm{UPD}(r_1) \times \mathrm{UPD}(r_2)
  2. 幻读增行INS(r_{\text{新}}) 一次插入直接让谓词结果集增加一行。
    INS(r新)\mathrm{INS}(r_{\text{新}})
  3. 对比结论一个是取值变化,一个是行数变化,因此“行锁”只对前者有间接帮助、对后者几乎无效,防幻读必须上范围锁。
    取值变化≠行数变化\text{取值变化} \neq \text{行数变化}

隔离防护:从窗帘到铁门

诊所可以选择不同防护等级。RC 像半透明的窗帘,能拦住别人写到一半的草稿,但挡不住你看完一遍后内容又变。RR 像进门时发一张照片,看照片稳定,但照片总会过时。SR 则是铁门加登记处:所有人排成单列,一个接一个操作白板,任何并发事故都无从发生。

串行化的定义很漂亮:并发执行的效果,等价于把事务按某种顺序一个个执行。它不要求数据库真的一个一个来,而是保证“结果像排过队”一样。PostgreSQL 的 SERIALIZABLE 采用 SSI(可串行化快照隔离),通过检测读写依赖并主动中止一个事务来拦下写倾斜;MySQL InnoDB 的 RR 则用“间隙锁 + 行锁”组成 next-key 锁,在加锁读时锁住整个谓词区间。

间隙锁的精髓在于:它锁的是一个值区间 (a,b),即使区间里现在一行数据都没有,也能阻止未来插入。这就像护士长在“3 点到 5 点”的时段格里插了一面小旗:新病人的挂号单若落在这时段,必须等小旗撤掉。而写倾斜的防护,要靠显式地把“读+判断”变成加锁的原子步骤,或用版本号做乐观锁重试。

隔离级别不是越高越好,而是越贴合业务风险越好。高风险规则用铁门,普通查询用窗帘——取舍之道,在于理解每一档真正防住了什么、漏掉了什么。

SR  ⟺  并发执行的效果≡按某种顺序串行执行X-LOCK (a,b):禁止向区间 (a,b) 插入任何键\begin{aligned}\mathrm{SR} &\iff \text{并发执行的效果} \equiv \text{按某种顺序串行执行} \\X\text{-LOCK}\,(a,b) &: \text{禁止向区间}\,(a,b)\,\text{插入任何键}\end{aligned}
串行化保证“结果像排队”;间隙锁保证“空白区间也禁止插入”。
  1. 可串行化定义SR ⟺ 并发效果 ≡ 串行效果,只要结果等价于排队执行,怎么实现都行。
    SR  ⟺  并发效果≡串行效果\mathrm{SR} \iff \text{并发效果} \equiv \text{串行效果}
  2. 间隙锁定义X-LOCK(a,b) 锁定的是从 a 到 b 的取值范围,而不是某一行——空白区间也能锁。
    X-LOCK (a,b)X\text{-LOCK}\,(a,b)
  3. 组合拳行锁加间隙锁组成 next-key 锁,既防同行改写,又防区间插入,这正是 InnoDB 在 RR 下抑制幻读的机制。
    next-key lock=行锁+间隙锁\text{next-key lock} = \text{行锁} + \text{间隙锁}

MVCC 支撑:白板背后的便利贴层

为什么快照读能这么稳定?因为数据库并没有真的把旧字擦掉。想象白板背后藏着一摞便利贴:每次有人改名,只是把一张新便利贴贴上去,旧的还压在下面。每张便利贴都标着“谁贴的、第几张”。医生进门时,护士长递给他一副特殊眼镜——戴上后,他只看得见“贴得早于他进门”的便利贴,后来的新贴纸统统隐形。这副眼镜,就是 MVCC(多版本并发控制)。

MVCC 的表里,每一行都维护着多个历史版本,串成一条“版本链”。新增的隐藏字段包括事务 ID(trx_id)和回滚指针(roll_pointer)。每次 UPDATE 不是覆盖,而是生成新版本,通过回滚指针指向前一版,形成链表。事务做“快照读”时,按自己的快照 ID 沿链找第一个可见版本;做“当前读”时,绕过快照直接读最新已提交版本并加锁。

可见性判定的核心就一句话:只要这个版本的事务 ID 不晚于你的快照点,且没有删除标记,你就看得见。正是这种“按版本筛选”的机制,让快照读永远稳定——但代价是,一个事务永远感知不到快照之后别人提交的新版本,写倾斜的温床就此形成。MVCC 是利也是弊:它让读写互不阻塞、读性能极高,但“基于旧判断的写”可能悄悄破坏约束。

便利贴层让“看”飞快且稳定,但也意味着你看到的世界永远滞后一步。写倾斜的根,就埋在“滞后”这两个字里——而理解这一点,是选择防护方案的前提。

visible(v)  ⟺  v.trx_id≤snapshot_id∧v.deleted=假r新→  roll_ptr  r旧→  roll_ptr  r更旧\begin{aligned}\mathrm{visible}(v) &\iff v.\mathrm{trx\_id} \le \mathrm{snapshot\_id} \wedge v.\mathrm{deleted} = \text{假} \\r_{\text{新}} &\xrightarrow{\;\mathrm{roll\_ptr}\;} r_{\text{旧}} \xrightarrow{\;\mathrm{roll\_ptr}\;} r_{\text{更旧}}\end{aligned}
只看得见“快照点之前”写的、且未被删除的版本;一次更新贴一层便利贴,链头永远是最新。
  1. 可见性判定trx_id ≤ snapshot_id 表示“这个版本是快照点之前写的”,只有这样的版本才进入你的视野。
    v.trx_id≤snapshot_idv.\mathrm{trx\_id} \le \mathrm{snapshot\_id}
  2. 删除标记过滤v.deleted = 假 排除已删除的行,保证看不到“尸体”。
    v.deleted=假v.\mathrm{deleted} = \text{假}
  3. 版本链结构r新 → r旧 → r更旧,一次更新贴一层,链头永远是最新版本。
    r新→roll_ptrr旧→roll_ptrr更旧r_{\text{新}} \xrightarrow{\mathrm{roll\_ptr}} r_{\text{旧}} \xrightarrow{\mathrm{roll\_ptr}} r_{\text{更旧}}

实战对策:让值班表不再失控

护士长决定改革,拿出三样工具:第一样是“手按白板”——医生做判断时,必须用手按住相关区域,别人只能等他写完再看(悲观锁);第二样是“带编号的便利贴”——每次写都要先核对编号,发现编号被别人改过就撕掉重来(乐观锁);第三样是“值班哨岗”——把“至少一人在岗”这条规则物化成哨岗登记表,谁进谁出都必须先动哨岗那行记录。

三种工具对应数据库世界的经典解法。悲观锁:SELECT …… FOR UPDATE 把“读判断”变成加锁的当前读,命中行被加排他锁直到事务结束。乐观锁:加 version 字段,UPDATE 时带 WHERE version = 读到的值,影响行数为 0 说明冲突,重试即可。物化冲突:把多行约束浓缩成一行“控制行”,锁这行就等价于锁整个规则。

工程实践还要注意:加了锁可能死锁(互相等待形成环),数据库靠等待超时和死锁检测处理,被牺牲的事务抛出异常,应用层要设计重试;锁粒度太粗会拖垮并发,需要监控锁等待时间、死锁次数、冲突率。SQL 层面,EXPLAIN 能确认 UPDATE 是否走索引——锁的范围随索引而定,全表扫描会锁全表,这也是排查“锁风暴”的钥匙。

白板的秩序,终究要靠“看”与“写”的纪律来保证。选择悲观锁还是乐观锁,本质是选择“宁可排队”还是“宁可重试”——没有银弹,只有对业务并发模式的清醒认知。

SELECT…FOR  UPDATE⇒X-LOCK(命中行)T1→T2∧T2→T1⇒死锁⇒回滚牺牲者\begin{aligned}\mathrm{SELECT} \ldots \mathrm{FOR\;UPDATE} &\Rightarrow X\text{-LOCK}(\text{命中行}) \\T_1 \to T_2 \wedge T_2 \to T_1 &\Rightarrow \text{死锁} \Rightarrow \text{回滚牺牲者}\end{aligned}
显式加锁把“读判断”变成原子步骤;死锁形成等待环时,数据库必须回滚一个事务打破僵局。
  1. 显式加锁的当前读SELECT ... FOR UPDATE 让这次“读判断”带上排他锁,判断结果在提交前对别人既不可见也不可改。
    SELECT…FOR  UPDATE\mathrm{SELECT} \ldots \mathrm{FOR\;UPDATE}
  2. 死锁形成T1 → T2 ∧ T2 → T1 表示形成等待环,数据库必须打破僵局。
    T1→T2∧T2→T1T_1 \to T_2 \wedge T_2 \to T_1
  3. 牺牲者回滚死锁检测挑一个事务回滚放行另一个,应用层捕获异常后重试。
    回滚牺牲者=打破等待环\text{回滚牺牲者} = \text{打破等待环}

AI 应用场景

AI 数据标注平台的并发认领

阶段:数据准备 / 标注任务管理

解决的问题:标注平台维护一张“待标注样本池”,多个标注员并发“领取”任务。传统实现里每人先查询剩余样本数,再更新自己的任务行——这正是写倾斜:各改各的“认领行”,合起来却让样本池出现重复绑定或负数余量。

具体案例:某 CV 标注团队 20 个标注员同时领取新一批图片,任务表突然出现同一条 task_id 被两个人认领,剩余量计数变成 -3。排查后发现是两个事务基于同一份快照做判断,各写各的认领记录后都成功提交。

特征平台的训练与线上一致性

阶段:特征工程 / 离线-在线一致性保障

解决的问题:离线特征表做“按用户维度的聚合统计”时,实时链路并发插入新事件行,导致同一条 SQL 跑两次结果不同——幻读让训练样本的特征快照和线上推理时对不上,模型效果评估失真。

具体案例:推荐系统每天凌晨跑特征拼接任务,统计用户点击量时用 SELECT COUNT(*) 校验数据,恰好与实时写入的日志事务交错,两次统计差了 1 万行,最终训练样本和线上特征错位,A/B 实验指标出现“神秘波动”。

模型灰度发布与回滚

阶段:模型部署 / MLOps

解决的问题:两个发布器同时检查“集群中是否还有旧版本实例”,都决定下线自己负责的旧实例——写倾斜导致新旧比例瞬间失衡,流量全部打到尚未就绪的新模型。

具体案例:双机房并行灰度时,两个发布进程同时读到“旧实例还有 2 台”,各自下掉 1 台后,集群里 0 台旧实例、新实例健康检查还没通过,线上服务直接降级。修复方式是把“新旧实例比例”物化成一行控制记录,切换前先锁这行。

Python 演示

python46 行
import threadingimport time # 值班白板: 模拟数据库中的两行数据roster = {'李': True, '赵': True}  def transaction(doctor, use_lock=False, lock=None):    """模拟一个事务: 快照读 -> 业务判断 -> 写自己的行。"""    if use_lock:        lock.acquire()           # 悲观锁: 把判断和写变成临界区    try:        snapshot = dict(roster)  # 事务开始时的快照读        time.sleep(0.02)         # 放大并发窗口, 让两个事务互相交错        other_on = [d for d, v in snapshot.items() if d != doctor and v]        if other_on:             # 快照里还有别人在岗            roster[doctor] = False   # 只写自己的行            return doctor + ' 看到' + other_on[0] + '在岗, 已下班'        return doctor + ' 发现无人留守, 留下值班'    finally:        if use_lock:            lock.release()  def run_demo(use_lock=False):    global roster    roster = {'李': True, '赵': True}    lock = threading.Lock() if use_lock else None    t1 = threading.Thread(target=transaction, args=('李', use_lock, lock))    t2 = threading.Thread(target=transaction, args=('赵', use_lock, lock))    t1.start()    t2.start()    t1.join()    t2.join()    on_call = [d for d, v in roster.items() if v]    if on_call:        return '最终在岗: ' + '、'.join(on_call)    return '最终在岗: 空无一人!'  if __name__ == '__main__':    print('[不加锁: 快照隔离下的写倾斜]')    print(run_demo(False))    print()    print('[加锁: 判断与写入被串行化]')    print(run_demo(True))
目的:用多线程复现“快照隔离下的写倾斜”,并对比加锁修复后的效果——无锁时两人都下班、白板空无一人;加锁后第二人看到真相、留下值班。
  1. snapshot = dict(roster)事务开始瞬间把整张表复制成“快照”,之后的判断全部基于这份旧数据,这正是写倾斜的温床。
  2. time.sleep(0.02)人为放大两个事务的交错窗口,让现实中一闪而过的竞态稳定复现。
  3. roster[doctor] = False每个事务只写“自己的行”,互不冲突,因此两个写都能成功提交。
  4. lock.acquire()悲观锁把“读 + 判断 + 写”变成临界区,李的判断结果立刻对赵可见。
  5. run_demo(use_lock=False)对照组:无锁复现写倾斜,加锁后约束恢复,输出对比一目了然。

常见误区

误区“写倾斜和丢失更新不都是并发写搞出来的吗?反正都是后写覆盖先写呗。”很多人一看到“两个事务并发修改”就归类为丢失更新。

正解丢失更新指两个事务改同一行,后提交的覆盖先提交的,本质是“同一行的值被吞掉”;写倾斜是两个事务改不同的行,各自都成功提交,没有任何值被覆盖,但联合破坏了跨行约束。用白板比喻:丢失更新是两人抢同一支笔改同一行名字,后到者覆盖先到者;写倾斜是两人各擦各的名字,结果一栏全空。

为何常见:两者都表现为“并发下的数据不对”,且常出现在快照隔离的讨论里,初学者习惯按“结果异常”归类,而不是按“行集关系”归类。

误区“我都设成可重复读了,快照读那么稳,幻读哪还有机会?”不少开发把 RR 当成万能安全档。

正解快照读确实不会看到新插入的行,但“当前读”(加锁读)必须读最新数据,若没有间隙锁保护谓词区间,幻读依然可能出现;而且可重复读防不住写倾斜——照片不会变,白板本身在变。InnoDB 在 RR 下用 next-key 锁把加锁读也保护住,但这依赖实现细节,并非隔离级别的必然承诺(PostgreSQL 的 RR 就没有间隙锁)。

为何常见:很多人把“事务内重复读结果一致”等同于“任何读都一致”,忽略了加锁读与快照读的本质差异。

误区“写之前 SELECT …… FOR UPDATE 把要改的行锁住,不就安全了吗?”这是最常见的“想当然”。

正解行锁只锁你实际命中的行;写倾斜中两人锁的是各自不同的行,互不等待、都成功提交,约束照样被破坏。正确做法是锁住判断所依赖的整个范围(如锁谓词区间),或把“至少一人在岗”物化成一行控制记录,锁这一行即可。锁的粒度必须覆盖约束作用的范围,否则防线必然漏风。

为何常见:直觉上“锁住数据就安全”,但行锁的粒度小于约束作用的范围,粒度错配导致看似上了锁、实则形同虚设。

误区“既然串行化防一切,那所有事务都设成串行化不就好了?”课堂示例只看正确性,没见过生产环境的锁风暴。

正解串行化通过全局锁、谓词锁或 SSI 依赖检测来保证等价串行,高竞争场景下吞吐和响应时间会明显恶化,甚至引发大面积锁等待。正确姿势是:能优化并发模式就先优化(物化、乐观锁、显式范围锁),串行化作为特定风险的兜底而不是默认。

为何常见:安全感和性能的权衡常被忽略,因为初学者只在示例里看正确性,没在压测环境里看吞吐曲线。

面试题

Q1. 写倾斜的核心特征是下列哪一项?

单选题 · easy

  • A.两个事务改写同一行,后写覆盖先写
  • B.读同一快照各写不同行,合破约束← 正确答案
  • C.事务读到了另一个事务的脏数据
  • D.事务删除了另一事务刚插入的行

答案:B。写倾斜的配方是“相同读集 + 不相交写集 + 跨行约束被破坏”。A 是丢失更新,C 是脏读,D 与写倾斜无关。就像两个医生各擦各的名字,白板空了。

Q2. 幻读最典型的触发操作是?

单选题 · easy

  • A.两个事务并发修改同一行
  • B.并发插入满足查询谓词的新行← 正确答案
  • C.并发删除已经读到的数据行
  • D.并发执行同一条聚合统计

答案:B。幻读的“幻影”来自新插入(或删除)后满足谓词的行,使同一查询两次结果集不同。B 最典型;A 导致的是不可重复读或丢失更新。

Q3. 在快照隔离(如 PostgreSQL 的 REPEATABLE READ)下,下列哪项仍然可能发生?

单选题 · mid

  • A.读取尚未提交的脏数据
  • B.两个事务对同一行的丢失更新
  • C.读同快照写不同行的写倾斜← 正确答案
  • D.快照读重复执行出现新行

答案:C。快照隔离消除了脏读和快照读下的不可重复读,但著名的漏洞正是写倾斜:两人基于同一份旧快照做判断,各写各的行,约束被破坏。D 在快照读下不会发生。

Q4. 下列哪些是写倾斜发生的必要条件?

多选题 · mid

  • A.两事务都读到同一份旧快照← 正确答案
  • B.两事务修改的行互不相交← 正确答案
  • C.两事务都对同一行加行锁
  • D.存在跨多行的业务约束被破坏← 正确答案

答案:ABD。写倾斜需要“同快照、异行写、共约束”三要素。C 是写冲突/死锁的场景,与写倾斜的“互不等待”相反。

Q5. MySQL InnoDB 在可重复读下抑制幻读的主要机制是?

单选题 · mid

  • A.对整张表加全局排他锁
  • B.行锁配间隙锁的当前读机制← 正确答案
  • C.完全依赖无锁的快照读
  • D.临时禁止并发插入操作

答案:B。InnoDB 对当前读使用 next-key 锁(行锁+间隙锁),把谓词区间也锁住,新行无法插入。这就像护士长在时段格里插小旗。

Q6. 关于写倾斜与死锁的关系,下列正确的是?

多选题 · hard

  • A.写倾斜出现时数据库必然报死锁
  • B.死锁靠互相等待识别而写倾斜不等待← 正确答案
  • C.写倾斜必须回滚其中一个才能解决
  • D.写倾斜可全部提交成功却破坏约束← 正确答案

答案:BD。死锁是“我等你、你等我”的等待环;写倾斜中两人各写各的行,全程无等待,所以没有死锁、没有回滚、没有报错,两个事务都成功提交——这正是它最隐蔽的原因。

Q7. 快照隔离下解决写倾斜的“物化冲突”做法是?

单选题 · hard

  • A.将约束物化为一行并锁定这行← 正确答案
  • B.给每行添加更新前的触发器
  • C.把隔离级别调低到读已提交
  • D.只对各自修改的行加行锁

答案:A。物化冲突是把“至少一人在岗”这类跨行约束浓缩成一行控制记录,锁这行就等价于串行化所有决策。只锁各自修改的行(D)正是写倾斜防不住的原因。

Q8. 关于 MVCC 中“快照读”与“当前读”,正确的是?

单选题 · mid

  • A.快照读和当前读看到的数据一致
  • B.当前读基于事务起点版本不感知更新
  • C.当前读加锁取最新快照读看起点版本← 正确答案
  • D.当前读只能配合插入语句一起使用

答案:C。快照读沿版本链找“快照点之前”的版本,稳定但滞后;当前读直接读最新已提交版本并加锁。前者是写倾斜的温床,后者是加锁防护的基础。

Q9. 排查线上“数据不一致”问题时,哪些手段值得采用?

多选题 · hard

  • A.复核事务隔离级别与多次读的语义← 正确答案
  • B.用执行计划确认行锁实际锁定的范围← 正确答案
  • C.压测还原并发窗口并分析死锁日志← 正确答案
  • D.把隔离级别降到读未提交以提速

答案:ABC。排查并发问题要同时看“隔离级别语义、锁范围、并发窗口”。降到读未提交只会引入脏读,不可能解决问题。

Q10. 关于“可串行化”的保证,下列表述最准确的是?

单选题 · extreme

  • A.可串行化等价于所有事务都加全表锁
  • B.并发结果等价于某串行顺序逐个执行← 正确答案
  • C.可串行化之下不可能发生锁等待
  • D.可串行化不适用于 MVCC 数据库

答案:B。可串行化承诺的是“结果等价于某种串行顺序”,而非实现方式必须是锁;SSI、谓词锁都能实现它。实现过程中仍可能有锁等待与牺牲回滚。