想象一下,你们六个人合租一套公寓,只有一个公共厨房。每天晚饭时间,每个人都想用厨房:小王在切菜,小李在炒菜,小张盯着烤箱里的蛋糕。你们共享的不仅是灶台和案板,还有冰箱里的食材。如果没有任何规矩,就可能发生:小李顺手拿走小王刚切好、还没决定要不要用的洋葱;小张看到冰箱里只剩下半瓶牛奶,转头却被小王倒进了自己的咖啡——每个人眼里的厨房都不一样,混战在所难免。 数据库里的并发事务就是这个厨房。多个事务同时读写同一批数据,就像多个室友同时用一个厨房。如果数据库不制定“规约”,事务之间就会互相踩踏,读到彼此写到一半的数据,出现各种奇怪现象。SQL标准为此定义了四套“厨房使用规范”——隔离级别,从最宽松的“读未提交”到最严格的“串行化”。本文就用这个合租厨房的故事,带你彻底搞懂四种隔离级别、各自的副作用,以及MySQL等数据库保障性能的秘密武器——MVCC多版本并发控制。
隔离级别与MVCC是理解数据库并发行为的基石。掌握它们,你能快速定位线上“奇怪数据”问题,写出高并发下依然正确的事务代码,这是后端开发者和DBA必备的硬核技能。
可视化演示
可视化展示四种隔离级别(RU/RC/RR/SR)对脏读、不可重复读、幻读的防护能力,直观对比MVCC多版本并发控制机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
隔离级别矩阵 RU防止0种问题,RC防脏读,RR防脏读+不可重复读,SR全防 隔离强度:RU < RC < RR < SR MySQL默认RR级别,PostgreSQL默认RC级别
并发问题背景
合租公寓的厨房之所以能运转,靠的是一本《公寓管理规约》。规约规定了四条底线:做饭必须一气呵成,不能做一半就丢下不管;做出来的菜必须靠谱,不能把厨房搞成一锅乱炖;每个人用厨房时不能随意干扰别人;今天分配好的食材,明天依然有效。这四条底线,正是数据库事务ACID四大特性的生活版。
在数据库里,ACID分别指原子性(Atomicity,事务不可分割地全部成功或全部失败)、一致性(Consistency,事务前后数据始终合法)、隔离性(Isolation,并发事务之间互不干扰)、持久性(Durability,事务提交后数据不丢失)。四者之中,隔离性最难实现:因为多个事务真的会同一时刻发生。如果没有隔离,就会出现三类典型的“厨房事故”:脏读、不可重复读、幻读。下文讲的四个隔离级别,就是针对这三类事故的不同防范等级。
明确了规约底线和事故清单,我们就能为四套“厨房使用规范”打分:哪套规范能防住哪些事故,一目了然。
- 字母含义A、C、I、D四个字母分别代表事务的四大基本属性。
- 隔离性定位I是本文主角,它专门负责管理并发事务之间的互相干扰。
- 事故清单事务打架有三种典型事故,后面的所有隔离级别都在和它们博弈。
读未提交
若合租厨房采取“完全自由”模式,大家想怎么用就怎么用。小李正在炒菜,夹起一块肉尝了尝,觉得太咸,可能下一秒就重新做。可小王站得近,已经看到了那块肉的颜色和摆盘,把它当成了小李的招牌菜配方记在心里。更糟的是,小李最后觉得不好吃,把肉整个倒掉了,小王却还惦记着那块“从未存在于餐桌”的肉——他读到了一份凭空想象出来的数据。这就是脏读。
在SQL世界里,读未提交(READ UNCOMMITTED)是最低隔离级别。事务可以读取其他事务尚未提交的数据,如果那个事务随后回滚,本事务读到的就是彻底作废的脏数据。这个级别几乎不加任何锁或版本控制,并发性能最好,但数据一致性最差,现实中极少被采用,除非业务对数据正确性毫不在乎(比如纯日志采集、无脑统计大数)。
读未提交就像没有门锁的厨房,谁都能探头看你锅里的半成品——哪怕那盘菜可能下一秒就被倒掉。生产环境几乎见不到它的身影,但它作为“警示底线”,让我们清楚知道不设防的世界有多混乱。
- 级别符号字母L加下标RU,代表读未提交这个隔离级别。
- 允许集合三种并发事故在这一级别下全部可能发生,数据库不做任何拦截。
读已提交
室友们痛定思痛,投票通过新规约:“厨房里发生的一切,只有端上桌的菜才算数。”小李在灶台上煎牛排,加了多少次黄油、尝了几回咸淡,只要他没把牛排端上桌,小王就绝不会把它当成一道成菜。这样,脏读被成功杜绝。但新问题随之而来:小王做咖喱时,第一勺尝咸淡用的是旧盐罐,第二勺时小李恰好换了新盐罐。同一锅咖喱,两次尝出两种咸度——这就是不可重复读。
读已提交(READ COMMITTED)保证事务只能读到已经提交的数据,每次读取都获取当前最新的已提交值。它有效避免了脏读,但两个独立的SELECT之间,如果其他事务提交了修改,本事务前后就会读到不同的值。正因它在正确性与并发度之间取得了良好平衡,Oracle、PostgreSQL等数据库都把它设为默认隔离级别。
读已提交像一家“上桌才算菜”的餐厅后厨:你不会看到别人没端上桌的食材,却没法保证自己锅里两次调味的味道永远相同。对大多数业务来说,这个级别已经足够日常使用。
- 级别符号L加下标RC,代表读已提交隔离级别。
- 避免集合脏读被彻底堵住,事务读到的都是已提交的“餐桌成品”。
- 允许集合同一事务内多次读取,仍可能因为他人提交而读到不同数值。
可重复读
室友们又被不可重复读折磨得不轻,于是引入更聪明的制度:每个厨师进厨房的瞬间,管理员给他拍一张“厨房全景照片”作为快照。小王拿着照片,不管别人往冰箱里塞了什么、换了哪罐盐,他做菜时只认照片里的样子。两次尝盐,一定尝到同一罐盐——不可重复读被根治了。但如果小张在照片之外偷偷往菜单上加了一道“隐藏菜”,小王按照片对菜单,从头到尾都不知道这道菜的存在,直到做完饭对账才惊讶地发现:咦,怎么凭空多出一道菜?这就是幻读。
可重复读(REPEATABLE READ)是MySQL InnoDB的默认隔离级别。它通过MVCC在事务开始时生成一致性快照,事务内所有普通SELECT都基于该快照读取,因此前后读必然一致。但按SQL标准,其他事务新插入的行不会被本事务快照涵盖,因此幻读仍然可能发生。需要说明的是,MySQL InnoDB在可重复读下通过间隙锁(next-key lock)额外压制了大部分幻读,这是它对标准的一种增强——但这属于数据库实现层面的“加餐”,并非标准默认承诺。
可重复读就像给每个厨师发了一张入场快照:照片里的世界永远稳定,只是照片外的世界仍在悄悄生长。它非常适合对报表一致性有要求的读多写少场景。
- 级别符号L加下标RR,代表可重复读隔离级别。
- 避免集合脏读和不可重复读都被快照机制挡在门外。
- 可能异常快照之外的新增行不会进入视野,导致幻读。
串行化
最极端的规约来了:厨房门口挂上一把大铁锁,一次只放一个人进去做饭。小王用完了、菜端出来了、厨房收拾干净了,下一个小李才能进去。这样的世界绝对不会有任何干扰——你看到的、用到的、做成的一切,都是你独占厨房的成果。代价也惨痛:晚饭高峰期六个人排队,等最后一个人吃上饭,已经深夜了。
串行化(SERIALIZABLE)是最高隔离级别。事务逐个执行,最终效果等价于串行调度,彻底避免脏读、不可重复读、幻读。数据库通过加锁(表锁、区间锁)或冲突检测实现。它的一致性最强,但并发性能断崖式下降:锁等待、阻塞、超时频繁,吞吐量远低于其他级别。只有在正确性要求极高且并发量不大的场景——比如资金结算、账务对账、库存清点——才会选用。
串行化是“一个厨房一把锁”的终极方案:安全满分,效率垫底。它提醒我们,隔离级别不是越高越好,而是刚刚好才好——用最合适的成本换取业务可接受的一致性。
- 级别符号L加下标S,代表串行化隔离级别。
- 全面防护三类事故一条不漏全部禁止,事务之间完全互斥。
MVCC原理
可重复读的“入场快照”究竟是怎么实现的?总不能让数据库真的给每个厨师打印一张实体照片吧!MVCC的答案是:数据库为每行数据保存多个版本,就像冰箱里同一食材的多个“进货批次”。每个事务被分配一个专属的“食材视图”,你看到的不是冰箱的最新状态,而是进入厨房那一刻的食材批次。每份食材都贴着标签:是哪个事务放进来的(trx_id),它之前的版本在哪里(roll_pointer)。
MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代数据库的主流实现。每个数据行都有隐藏字段:DB_TRX_ID记录最近修改该行的事务ID,DB_ROLL_PTR指向undo log中旧版本的回滚指针。旧版本通过undo log串成一条版本链。事务读取时,沿版本链找到对当前事务可见的版本。可见性规则的核心是:版本必须由已提交且早于快照创建的事务产生,或者干脆就是当前事务自己写的。这样,读操作不加锁,写操作也不阻塞普通读,读写在MVCC下可以并行,极大提升并发度。
MVCC就是“多批次食材+专属视图”的数据库工程化实现。它让快照读与写入并行不悖,是读已提交和可重复读高效落地的幕后功臣,也是高并发系统能跑得又快又稳的秘密武器。
- 候选版本v代表版本链上某一个历史版本,每次读都需要逐个判断。
- 条件一该版本由更早的已提交事务写入,早于快照创建时间,因此对当前事务可见。
- 条件二版本来自当前事务自身,自己写的数据当然对自己可见。
- 其余不可见版本来自尚未提交或更晚的事务,直接跳过寻找上一版。
级别选择
四套厨房规约没有绝对优劣,只看你们的公寓是什么氛围。全是自己人、做饭随意尝——读未提交;有客人来访、至少要讲究“上桌才算菜”——读已提交;你们要开一家对外营业的私房菜馆、每道菜的口味必须前后一致——可重复读;做的若是手术室级别的营养配餐、一丝差错都不能有——串行化。规矩越严,心里越踏实,但大家做饭的速度也越慢。
实际工程中,隔离级别选择本质是正确性与性能的权衡。级别越高,锁越多,性能越差。主流数据库默认值各异:MySQL InnoDB默认REPEATABLE READ,配合MVCC和间隙锁,实际防护已接近串行化;PostgreSQL、Oracle、SQL Server默认READ COMMITTED。报表分析、订单查询等读多写少场景,可重复读的快照保证报表前后口径一致;高并发写入的核心交易链路,读已提交往往能在不损失必要一致性的同时给出最佳吞吐;资金对账等强一致场景才考虑串行化。
选隔离级别,本质是给公寓选“厨房规约”:既要吃得放心,又不能让室友们饿肚子。经验丰富的DBA会在正确性红线与业务吞吐之间,找到那条最舒适的平衡线。
- 级别上升从读未提交向串行化逐级递进。
- 一致性上升被防住的并发事故越来越多,数据越来越“干净”。
- 性能下行锁等待与版本管理开销同步增大,吞吐量明显回落。
实战排查
作为公寓管理员,你需要随时知道:现在执行的是哪套规约?哪间房的厨师在违规操作?出了问题去哪查记录?SQL世界也提供了一整套“管理工具”:查看当前的隔离级别、为会话切换隔离级别、诊断锁等待与死锁。
查看隔离级别,MySQL 8.0用SELECT @@transaction_isolation;,5.7用SELECT @@tx_isolation;;PostgreSQL用SHOW default_transaction_isolation;。设置隔离级别,执行SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;只影响当前会话,使用GLOBAL关键字则影响全局新连接。排查并发问题,SHOW ENGINE INNODB STATUS\G能查看锁与死锁信息,information_schema.innodb_trx能列出当前活跃事务及其持锁情况。
掌握了这套“管理员工具”,你就能在线上事故发生时快速判断:是哪个环节的规约出了漏洞,是脏读、不可重复读还是幻读,从而对症下药,让数据库恢复平稳运行。
- SET语句数据库提供的修改隔离级别的标准SQL命令。
- 四个取值从读未提交到串行化,四选一填入即可生效。
AI 应用场景
电商秒杀防超卖
阶段:业务数据一致性保障(SQL事务设计与隔离级别选型)
解决的问题:秒杀场景下,成千上万用户同时抢购同一件商品。传统不加控制的读写在“检查库存→扣减库存”之间容易被其他事务穿插,导致超卖。AI推荐系统触发的瞬时流量洪峰更放大了这个风险。利用可重复读配合行锁,把库存扣减写成UPDATE stock SET num = num - 1 WHERE num > 0这种当前读,写操作之间天然串行,就能守住库存底线。
具体案例:某电商平台晚8点秒杀,活动商品库存只有500件。后端服务用MySQL InnoDB默认的REPEATABLE READ,商品行作为热点行,所有扣减请求通过行锁排队执行。最终实际成交恰好500单,无一超卖,同时普通查询走MVCC快照,页面浏览毫秒级响应。
银行夜间批量对账
阶段:数据抽取与报表生成(隔离级别选择与MVCC快照)
解决的问题:夜间批处理任务要扫描全量用户余额生成对账单。若用读已提交,批处理期间用户仍在转账,同一账户两次读到不同余额,报表口径完全混乱。切换到可重复读,批处理事务启动时生成一致性快照,整个扫描过程读到的都是同一时点的余额,与线上交易互不干扰。
具体案例:某银行核心系统每天凌晨2点跑批,业务高峰期白天的流水达到千万级。DBA将对账批处理会话设为REPEATABLE READ,快照隔离保证对账单与交易流水完全对应,同时线上联机交易继续走READ COMMITTED保持高吞吐,两类任务井水不犯河水。
数据仓库增量抽取
阶段:数据集成与管道构建(可重复读快照保证抽取一致性)
解决的问题:数据同步任务在抽取一张大表时,源表仍在被持续写入。若每次SELECT都读最新值,抽取结果会出现“同一主键前后字段来自不同版本”的数据漂移。使用可重复读隔离级别的会话开启快照,一次性抽取出事务开始时刻的一致数据视图。
具体案例:某数据团队用SQL抽取订单表同步到数仓,抽取耗时3分钟。把抽数会话设为REPEATABLE READ后,3分钟内源表新产生的订单不会混入本次抽取,下游ETL不再出现主键冲突和字段错位,数据质量显著提升。
Python 演示
class MiniMVCC: """极简MVCC模拟器: 演示四种隔离级别下事务读到的数值差异""" def __init__(self, initial=100): self.base_value = initial # 已提交的最新值 self.buffer_map = {} # 未提交写入缓存 self.snapshot_map = {} # 各事务的快照 self.version_list = [] # 已提交版本链 self._trx_counter = 0 def begin(self): self._trx_counter += 1 return self._trx_counter def write(self, trx_id, new_value): self.buffer_map[trx_id] = new_value def commit(self, trx_id): value = self.buffer_map.pop(trx_id) self.version_list.append((trx_id, value)) self.base_value = value def rollback(self, trx_id): self.buffer_map.pop(trx_id, None) def read(self, trx_id, level): pending = list(self.buffer_map.values()) if level == "读未提交": return pending[-1] if pending else self.base_value if level == "读已提交": return self.base_value if level == "可重复读": return self.snapshot_of(trx_id) return self.base_value # 串行化: 等锁后读最新 def snapshot_of(self, trx_id): if trx_id not in self.snapshot_map: self.snapshot_map[trx_id] = self.base_value return self.snapshot_map[trx_id] if __name__ == "__main__": db = MiniMVCC(initial=100) t1 = db.begin() t2 = db.begin() db.write(t1, 500) # T1 将余额改为 500, 未提交 print("T1 未提交时 T2 读到的余额:") for level in ["读未提交", "读已提交", "可重复读", "串行化"]: print(f" [{level}] -> {db.read(t2, level)}") db.commit(t1) # T1 提交 print("T1 提交后 T2 读到的余额:") for level in ["读未提交", "读已提交", "可重复读", "串行化"]: print(f" [{level}] -> {db.read(t2, level)}")
self.buffer_map = {}未提交写入缓存,对应“厨房里没端上桌的半成品”,脏读的源头。self.snapshot_map = {}各事务的快照字典,可重复读的“入场照片”就存在这里。def write(self, trx_id, new_value)把新值写入未提交缓存,模拟事务修改数据但还没提交。def commit(self, trx_id)从缓存取出值写入版本链,已提交值更新,模拟“菜端上桌”。if level == "读未提交"直接返回未提交缓存里的最新值,完整演示脏读路径。return self.snapshot_of(trx_id)可重复读永远走快照,事务内读取结果前后一致。
常见误区
误区学完四个级别后,我认为可重复读既然“读到的都一样”,那就不可能再看到任何新数据,幻读一定不存在。
正解在SQL标准定义下,可重复读明确允许幻读:快照能挡住已存在行的变更,但挡不住其他事务插入新行。MySQL InnoDB的RR因为额外加了间隙锁才几乎消灭幻读,这是厂商增强而非标准默认。PostgreSQL的RR下,如果两个查询条件不同,仍可能看到新插入的行。判断级别能力要“先标准后实现”。
为何常见:很多人日常只用MySQL,把MySQL的实现行为当成了SQL标准的通用定义,忽略了标准与实现的差别。
误区串行化最安全,那生产环境干脆全部用串行化,一劳永逸不是更好吗?
正解串行化靠锁把并发变成排队,吞吐量骤降,锁等待和超时概率激增。一致性只是数据库追求的目标之一,可用性与性能同样重要。正确做法是分类对待:核心账务才用串行化,普通交易用读已提交,报表查询用可重复读,让每一类业务拿到恰到好处的隔离强度。
为何常见:初学者只盯着正确性看,忽略了数据库在高并发下“快”也是硬指标,没有形成权衡思维。
误区MVCC宣传“读写互不阻塞”,所以数据库在MVCC下根本不需要锁,所有并发问题都靠多版本解决。
正解MVCC让普通快照读不加锁、不阻塞写,但写与写之间、当前读(UPDATE、DELETE、SELECT …… FOR UPDATE)仍然必须加锁。MVCC解放的是“读写并发”,不是“写写并发”的免死金牌。两个事务同时改同一行,照样需要一个排队等待另一个。
为何常见:把“读写不互相阻塞”的营销话术扩大化理解,忽略了写写冲突这个最朴素的并发矛盾。
误区听名字以为脏读就是读到没来得及更新的旧数据,就像菜放久了不新鲜。
正解恰恰相反,脏读读到的是“太新”的数据——别人还没提交、随时可能回滚消失的数据。比如看到余额5000是别人转账未提交的结果,对方回滚后真实余额其实是0,你基于5000做的任何决策都建立在沙滩上。旧数据不可怕,假数据才致命。
为何常见:中文“脏”字让人联想到“脏乱、陈旧、过期”,但这里的“脏”指的是“未敲定、可能作废”,与新旧无关。
面试题
Q1. 在哪个隔离级别下,事务可以读取到其他事务尚未提交的数据?
- A.读未提交(READ UNCOMMITTED)← 正确答案
- B.读已提交(READ COMMITTED)
- C.可重复读(REPEATABLE READ)
- D.串行化(SERIALIZABLE)
答案:A。读未提交是唯一允许读取未提交数据的级别,其余三个级别都至少要求读到“已提交”的数据。就像只有“完全自由”的厨房才允许你窥探别人锅里的半成品。
Q2. 以下哪种现象描述的是“同一事务内两次查询返回不同结果”?
- A.脏读,读到未提交数据
- B.不可重复读,读同一行值变化← 正确答案
- C.幻读,查询行数变多
- D.死锁,互相持锁等待
答案:B。不可重复读的定义就是同一事务内两次读同一行,因其他事务提交修改而结果不同。脏读是读到未提交数据,幻读是结果集行数变化,死锁是锁资源循环等待,三者都不是“同一行值变化”的核心特征。
Q3. MVCC 中“多版本”的旧版本数据主要存放在哪里?
- A.回滚段中的 undo 日志← 正确答案
- B.主键索引的叶子节点
- C.客户端本地缓存区
- D.服务端全局缓存池
答案:A。每行数据的旧版本通过隐藏字段 DB_ROLL_PTR 指向 undo log 中的记录,旧版本串成的版本链就存放在回滚段里。它好比厨房管理员保存的一摞“食材历史进货单”,要吃旧批次随时可查。
Q4. MySQL InnoDB 默认的隔离级别是哪个?
- A.READ UNCOMMITTED(读未提交)
- B.READ COMMITTED(读已提交)
- C.REPEATABLE READ(可重复读)← 正确答案
- D.SERIALIZABLE(串行化)
答案:C。MySQL InnoDB 默认是 REPEATABLE READ,配合 MVCC 快照和间隙锁,实际防护能力接近串行化。Oracle、PostgreSQL 默认则是 READ COMMITTED,各数据库默认值需要分开记。
Q5. 按 SQL 标准,哪个隔离级别能够保证完全避免幻读?
- A.读未提交,最宽松级别
- B.读已提交,只读已提交数据
- C.可重复读,快照保证一致
- D.串行化,事务完全排队← 正确答案
答案:D。标准定义下,只有串行化通过完全互斥彻底避免幻读。可重复读只能保证已存在行的值不变,拦不住新插入的行;MySQL 的 RR 靠间隙锁增强了防幻读能力,但那是实现层面的加分项。
Q6. 在 MySQL 可重复读下,事务的普通 SELECT 基于什么读取数据?
- A.每次读取数据库当前最新已提交值
- B.事务开始时生成的一致性快照← 正确答案
- C.数据库全局唯一的缓存副本
- D.最新未提交的裸数据版本
答案:B。可重复读下普通 SELECT 是快照读,事务第一次读时生成一致性快照,后续读都基于它。这就像厨师进厨房时拍了一张全景照片,之后只认照片里的样子,不受他人后续操作影响。
Q7. 读已提交下,事务内两次执行相同 SELECT 读到不同值,这属于?
- A.脏读,说明隔离级别失效
- B.不可重复读,RC 下的正常现象← 正确答案
- C.幻读,需要升级隔离级别
- D.死锁,引擎自动回滚处理
答案:B。读已提交每次读都取最新已提交值,因此两次读之间若有其他事务提交,结果自然不同,这正是不可重复读。它是 RC 设计上允许的现象,不是故障。想避免它,需要升级到可重复读。
Q8. 读已提交隔离级别允许出现以下哪些并发现象?
- A.脏读,未提交数据被读到
- B.不可重复读,前后读数不同← 正确答案
- C.幻读,结果集行数变化← 正确答案
- D.丢失更新,写覆盖他人
答案:BC。读已提交避免了脏读,但允许不可重复读和幻读。丢失更新不属于SQL标准定义的三大并发异常集合,且在不同隔离级别和具体实现下表现复杂,不能简单归入RC的“允许清单”。
Q9. 关于 MVCC 的可见性判断,下列说法正确的是?
- A.版本早于快照且已提交则可见← 正确答案
- B.当前事务自己写入的版本可见← 正确答案
- C.未提交版本对他人同样可见
- D.版本链支撑快照读取与回滚← 正确答案
答案:ABD。可见性规则是“已提交且事务ID早于快照”或“自己写的版本”。未提交版本的写入对其他事务不可见,否则就成了脏读。版本链既支持快照读取历史版本,也支持事务回滚时恢复旧值,C选项正好说反了。
Q10. 从读未提交提升到串行化,可能带来哪些代价?
- A.并发吞吐量显著下降← 正确答案
- B.锁等待与死锁概率上升← 正确答案
- C.所有数据绝对安全
- D.事务响应时间变长← 正确答案
答案:ABD。级别越高锁越重,事务排队越多,吞吐量下降、等待与死锁概率上升、响应变长,这些都是代价。C选项“数据绝对安全”是收益而非代价,且“绝对”二字过于绝对,不能选。