资讯动态

脏读、不可重复读、幻读:一个电商场景彻底搞懂事务隔离级别

发布时间:2026/10/15 5:08:35 来源:尧图企业网站定制
上周二凌晨两点值班电话把我从被窝里拽了出来。某商城项目报了一个很怪的问题好几个用户账户余额明明是六七百块下单买三百多的商品系统却提示“余额不足下单失败”过一会儿他们再重试一次又成功了。后台日志里没有死锁SQL 语句也看不出毛病但把两个请求的时间线点一对问题就浮出水面了——两个事务在同时读同一个余额字段其中一个事务读到了另一个事务“改了一半、还没确认”的数字。这类问题在数据库开发里有三个经典名字脏读、不可重复读、幻读。它们都是事务并发导致的“读异常”对应的治理手段就是四种隔离级别读未提交、读已提交、可重复读、串行化。很多讲事务的文章会把这三个概念分开举例子看完特别容易串台。这篇文章我换一种方式用一个贯穿始终的电商下单场景把三个问题一次串起来。你如果跟着把实验复现一遍以后遇到类似线上问题第一眼就能判断出是哪一个。1. 凌晨两点的告警余额充足却提示“不够”先说说这个项目当时的业务逻辑其实非常常见用户账户里有钱下单时先扣余额再生成订单余额不够就直接拒绝。被投诉的用户余额都超过六百下单金额三百多理论上完全够。可为什么会被拒绝我最初怀疑是死锁回滚导致下单失败但查了数据库错误日志没有死锁记录又怀疑是触发了什么奇怪的优惠规则翻了代码也没发现问题。后来把用户请求的访问日志按时间拉出来发现猫腻藏在两个请求的时间点上有两个请求几乎同一秒落到了同一个账号上一个请求正在执行“冻结余额扣款四百”的操作事务还没提交另一个请求在同一瞬间执行“校验余额是否足够支付当前订单”的操作。如果第二个请求正好落在第一个请求扣完钱、还没提交的时间窗口里它读到的余额就已经变成“减掉四百”之后的临时值。第一个请求最后如果提交了那还没什么可它偏偏是回滚的。于是出现了这样一条让人抓狂的时间线用户的实际余额从头到尾都是 1000 元但第二个请求读到了被改成 600 元的中间状态基于这个 600 元做了“余额不足”的判断把用户原本能下的单给拒了。这就是脏读最典型的现场一个事务读到了另一个事务尚未提交的数据。那会儿我一边看日志一边感叹如果当时对隔离级别多留个心眼根本不用熬到天亮。问题背后的三个概念咱们接下来用同一个场景一个一个拆开看。2. 实验台搭建两张业务表加两个会话光讲理论很难形成直觉我建议你直接开两个数据库会话跟着做。下面这套实验我用的是 MySQL 8.0 加 InnoDB 引擎两个 bash 窗口各开一个 mysql 客户端连接。你在 Navicat、DataGrip 里开两个查询标签页也一样能复现。2.1 业务模型与建表为了把三个问题讲得连贯我统一用一套业务模型一张用户余额表一张订单表。用户余额表只存账户当前可用金额订单表记录用户每一笔已支付订单的金额和支付时间。建表语句如下CREATE TABLE account ( user_id VARCHAR(32) PRIMARY KEY, balance DECIMAL(10,2) NOT NULL ); CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL, status VARCHAR(16) NOT NULL, create_time DATETIME NOT NULL );初始化一条用户数据和两条订单数据INSERT INTO account (user_id, balance) VALUES (u_1001, 1000.00); INSERT INTO orders (user_id, amount, status, create_time) VALUES (u_1001, 400.00, 已支付, 2024-10-01 10:00:00), (u_1001, 300.00, 已支付, 2024-10-02 11:00:00);也就是说用户 u_1001 账户里有 1000 元历史上已经支付过两笔订单一共 700 元。后面的实验全围绕这个账号展开。2.2 实验方法与数据重置实验的关键操作只有一个在时间线的某个节点上执行完 SQL 后不提交把事务停在原地让另一个会话插入查询。这样就能精确模拟“两个事务并发访问同一份数据”的瞬间。实验前记得重置数据。最简单的做法DELETE FROM orders; DELETE FROM account; INSERT INTO account (user_id, balance) VALUES (u_1001, 1000.00); INSERT INTO orders (user_id, amount, status, create_time) VALUES (u_1001, 400.00, 已支付, 2024-10-01 10:00:00), (u_1001, 300.00, 已支付, 2024-10-02 11:00:00);注意实验里改隔离级别只改当前会话不要直接动数据库全局配置。生产环境的默认值改起来影响面太大实验归实验线上归线上。3. 脏读实验你看到的余额可能是对方“还没想好”的数字回到开头那个事故我把它压缩成一张完整的时间线。实验开始前账户余额是 1000.00 元。3.1 完整时间线时间会话 A扣款事务会话 B校验余额事务T1BEGIN;T2UPDATE account SET balance balance - 400 WHERE user_idu_1001;T3BEGIN; SELECT balance FROM account WHERE user_idu_1001;T4查询结果600.00。业务代码判断 “余额不足 700不能下单”直接返回失败T5ROLLBACK;T6余额恢复为 1000.00会话提交但错误的“余额不足”提示已经返回给用户在默认隔离级别下跑这套流程会话 B 在 T3 时刻拿到的是 600.00也就是会话 A 刚扣了 400 但还没提交的中间值。会话 A 一旦回滚这个 600.00 就是凭空捏造的数据数据库领域管它叫“脏数据”。读到脏数据就是脏读。3.2 脏读的底层原因与危害为什么会有脏读因为读写之间没有做任何隔离。会话 B 的 SELECT 直接返回了当前内存里最新版本的余额完全不关心这个版本是不是已经通过 COMMIT 变成“正式版”。如果把事务提交比作“签订合同”脏读就是在合同还没签字盖章之前就拿着草稿去做后续决策。脏读的危害不只是“误拒订单”这么简单。我见过更严重的案例一个风控系统在判断用户是否存在套现风险时读到了另一笔未提交的大额扣款据此判定用户资金异常直接冻结了账户。结果那笔扣款后来回滚了用户什么都没做却被风控误伤。这类问题一旦发生解释成本极高。3.3 切到读已提交之后解决办法很直接把会话隔离级别升到 READ COMMITTED也就是“读已提交”。SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;同样 T2 时会话 A 把余额改成 600 且未提交T3 时会话 B 再查读到的就依然是 1000.00。因为 READ COMMITTED 有一条铁律所有事务只能看到已经提交的数据未提交的修改一律不可见。脏读在这一个级别上被彻底挡死。但注意READ COMMITTED 只是每一条 SQL 执行时生成一个新的“读视图”它并不保证同一个事务里两次读取结果一致。这就引出了第二个问题。4. 不可重复读实验对账事务里两次读到的金额不一样脏读解决后我以为问题完了结果没过多久又接到了类似的排查单。这次不是下单被拒而是财务对账出现了金额差一个对账事务先读了一遍余额中间另有一个充值事务提交了对账事务再读一遍数字居然变了。4.1 完整时间线还是同一个账号初始余额 1000.00 元两个会话都设置隔离级别为 READ COMMITTED。时间会话 A对账事务会话 B充值事务T1BEGIN; SELECT balance FROM account WHERE user_idu_1001;T2第一次读取1000.00T3BEGIN; UPDATE account SET balance balance 200 WHERE user_idu_1001;T4COMMIT;余额变成 1200.00T5SELECT balance FROM account WHERE user_idu_1001;T6第二次读取1200.00会话 A 从头到尾只有一个事务读的还是同一行可两次拿到的金额一个是 1000一个是 1200。这就是不可重复读。4.2 不可重复读和脏读到底差在哪很多初学者会把脏读和不可重复读混在一起其实核心差异很清楚脏读读到了未提交的数据那份数据可能是假的。不可重复读读到的都是已提交的数据但在这个事务的两次读取之间另一笔事务把数据改了并提交了。打个比方脏读是你拿到了还没签字的合同草稿不可重复读是你签完字确认了报价过了十分钟再翻开同一份合同发现报价被人改过了而且这次改动是正式生效的。两份数据都是“有效”的但它们不是你第一次看到的那份。不可重复读在真实业务里的危害往往比脏读更隐蔽。比如一个结算服务先读余额算出手续费然后做一笔资金划转流水再读余额做二次校验结果第二次读到的余额已经包含了一笔并发充值的金额整个校验逻辑就错乱了又比如在一个事务里生成报表前后两个子查询数据对不上报表直接作废。4.3 为什么读已提交防不住可重复读却能防住READ COMMITTED 的实现机制决定了它只做一件事让 SELECT 只能看到已提交的数据。每次查询都会重新生成一个新的读视图因此事务 B 提交后事务 A 的下一次 SELECT 自然能看到新值。它没有承诺“事务内多次读取结果一致”。要解决不可重复读就得升级到 REPEATABLE READ可重复读SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;这个级别的关键机制是 MVCC多版本并发控制。用 InnoDB 的话说事务在第一次执行快照读的时候会生成一个 read view后面整个事务的所有快照读都复用这份视图。通俗理解REPEATABLE READ 相当于在第一次查询时给数据拍了一张“截图”后面再查同一份数据永远返回截图里的内容不管别的事务中间改了多少次。所以上面时间线切到 REPEATABLE READ 后会话 A 在 T5 再查拿到的依然是 1000.00两次读的结果完全一致。不可重复读在这一级别下也被解决了。这里有一个非常容易踩的坑REPEATABLE READ 只对“快照读”有效也就是普通 SELECT。如果你在事务里用的是SELECT ... FOR UPDATE、UPDATE或DELETE这类“当前读”它们读的是最新已提交版本不走那份截图。业务代码里如果有“先查后改”的写法即使隔离级别是可重复读依然可能读到新数据。这一点在产品文档里写得不太显眼但线上问题十有八九出在这里。5. 幻读实验统计好的订单下一秒多了一行前两个问题解决之后我本来以为高枕无忧了直到项目里上线了一个返利统计功能又炸了一次。运营同学反馈同一个时间段内同一份订单统计结果跑了两遍出来两个不一样的总数后面那一遍莫名其妙多了一笔订单。5.1 READ COMMITTED 下的原始复现幻读最经典的场景藏在范围查询里。为了把幻读的原始面目完整露出来这次把两个会话都切回 READ COMMITTED重新看时间线时间会话 A统计事务会话 B新订单事务T1BEGIN; SELECT COUNT(*), SUM(amount) FROM orders WHERE user_idu_1001 AND status已支付;T2第一次统计2 笔订单总金额 700.00T3BEGIN; INSERT INTO orders (user_id, amount, status, create_time) VALUES (u_1001, 300.00, 已支付, NOW());T4COMMIT;T5同样的 SELECT 再执行一次T6第二次统计3 笔订单总金额 1000.00同一个事务里两次统计的 SQL 一摸一样但第二次结果里凭空多出了一行 300 元的订单。这一行数据就像幽灵一样第一次查询时它还不存在第二次查询时突然冒了出来所以叫“幻读”。5.2 幻读与不可重复读的本质区别这个区别必须掰开揉碎讲否则你真的会记混不可重复读别的事务把已经存在的一行改了UPDATE你两次读同一行值不一样。幻读别的事务新增或删除了某行INSERT / DELETE你两次范围查询结果集合的行数不一样。一个盯着“行”一个盯着“集合”。幻读更阴险的地方在于行锁根本管不住它。行锁一把锁只能锁住“已经存在的行”可幻读里多出来的那行在你第一次查询时压根不存在它不在任何行锁的管辖范围内锁都无从谈起。5.3 InnoDB 在可重复读下的特殊表现如果你用的是 MySQL InnoDB会有一个特别有意思的体验在默认的 REPEATABLE READ 下把上面这个实验原封不动跑一遍你大概率复现不出幻读。因为第二次统计走的还是快照读拿的是事务第一次查询时的“截图”新插入的订单对该事务不可见所以第 T5 步依然只看到 2 笔订单。但这不代表万事大吉。如果事务 A 用的是当前读比如SELECT * FROM orders WHERE ... FOR UPDATE它读的是最新已提交数据新区间内的新插入行就会出现在结果里。InnoDB 为了堵住当前读下的幻读引入了间隙锁和 next-key lock它会把查询条件范围内所有可能插入记录的位置也锁住让别的事务没法在这个区间里插入新行进而保证当前读也不会幻读。标准 SQL 定义里REPEATABLE READ 是允许幻读的。InnoDB 是依靠 MVCC 加间隙锁在默认隔离级别下把幻读的概率压到了极低但它锁的是索引区间不是魔法。如果查询条件没有合适索引间隙锁的效果会大打折扣这一点只有做 DBA 和资深后端才讲得清。到了 SERIALIZABLE串行化级别数据库直接给读写操作排队所有事务的读写冲突都老老实实按顺序来幻读、不可重复读、脏读一个都发生不了。代价也最直观并发能力骤降锁等待和超时明显增多。6. 隔离级别对照与选型四种档位一套取舍看到这里四种隔离级别其实已经全部登场了读未提交、读已提交、可重复读、串行化。它们的关系是一层一层递进的每一层解决上一层的漏洞代价是牺牲一部分并发能力。6.1 四档隔离级别对照表隔离级别脏读不可重复读幻读生产定位READ UNCOMMITTED读未提交会会会基本不用READ COMMITTED读已提交不会会会大多数 OLTP 系统的常见选择REPEATABLE READ可重复读不会不会InnoDB 下基本不会MySQL 默认级别适合对账、统计类SERIALIZABLE串行化不会不会不会资金强一致、结算类场景这张表一定要结合“场景”来记而不是死背结果。读未提交那一行会让脏读、不可重复读、幻读全发生因为它几乎什么都没隔离串行化那一行全不会因为它在并发上按了暂停键。6.2 不同数据库的默认行为同样叫 REPEATABLE READMySQL 和 PostgreSQL 的默认行为不完全一样。MySQL InnoDB 的默认隔离级别就是 REPEATABLE READ所以大部分业务从建库开始就处在“同一事务内多次快照读结果一致”的保护下PostgreSQL 默认是 READ COMMITTED如果不显式改隔离级别它只保证单条 SQL 能看到已提交数据不保证整个事务内多次读取一致。这意味着你把一套依赖 MySQL 默认行为的系统迁到 PostgreSQL 上表面 SQL 没变底层语义已经变了。最容易踩雷的就是报表类事务同一事务里先算一遍、再算一遍在 MySQL 上两次结果一致换到 PostgreSQL 上却可能不同。遇到这种差异不要慌先确认目标数据库的默认隔离级别再决定要不要在事务开头显式指定。6.3 查看与设置隔离级别的命令排查问题时第一件事永远是确认当前会话或全局的隔离级别-- MySQL 8.0 SELECT transaction_isolation; -- MySQL 5.7 及更早版本 SELECT tx_isolation; -- 查看全局配置 SELECT global.transaction_isolation;修改隔离级别一般有两种粒度-- 只对当前会话生效 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 对全局新连接生效谨慎使用 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;PostgreSQL 里查看和设置的方式不太一样SHOW default_transaction_isolation; -- 当前事务指定隔离级别 BEGIN ISOLATION LEVEL REPEATABLE READ;生产环境改全局隔离级别务必先评估影响面。我见过有人为了修一个局部问题把全局改成 READ COMMITTED结果其他依赖可重复读快照语义的业务全乱了最后深夜回滚配置。改之前一定确认这个变更会影响哪些业务、是否会破坏既有的事务一致性预期。6.4 生产环境怎么选我的选型原则很简单按业务对“一致性”的容忍度分三档第一档大多数普通读写业务订单、商品、用户信息这类READ COMMITTED 是平衡并发与一致性的首选。它挡住了最致命的脏读性能代价又不大。第二档同一个事务内要多次读取、且要求结果不变的业务比如对账、优惠计算、库存预占至少选 REPEATABLE READ。同时要注意在代码里避免用当前读能走普通 SELECT 的尽量走普通 SELECT否则快照保护形同虚设。第三档资金结算、转账、库存扣减这类强一致场景如果并发量不大直接上 SERIALIZABLE 图个安心如果并发量很大就不要只指望数据库而是把事务拆短或者引入更明确的行锁、乐观锁机制让冲突尽可能在业务层解决。评论区当年有人问我“哪种隔离级别最好”我的答案一直是没有最好只有最匹配。隔离级别本质上是拿并发换正确性你的业务能容忍多旧的数据、能接受多大程度的锁冲突决定你应该站在哪个档位上。别看着默认值顺手就以为万无一失上线之前多问自己一句这个事务里我到底允不允许读到别人改完的数据多问这一句至少能帮你躲开我在故事开头踩过的那种坑。我自己排查线上问题时有个习惯遇到任何“数据看着不对”的工单先看两个东西——当前隔离级别以及问题现象到底是“读到未提交的数据”“同一行两次读不一样”还是“结果集合多了或少了行”。只要把这三点对号入座根因基本已经定了一半。这套用一个业务场景串起来复现问题的方法后来我讲给几个后端同事听他们都觉得比对着文档一条条背概念要好记得多。你要是再遇到类似问题也可以按这个顺序把实验跑一遍比你熬一整夜看日志管用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑