模块三:事务与隔离级别
那次支付单丢了我才发现事务隔离级别不是用来背的一、钱扣了订单没生成——一个真实得让人想哭的翻车现场先给你讲个真事儿。上个月我们组刚来没多久的一个小伙子能力挺强代码写得飞快人也精神。结果有一天下午运营小姐姐急吼吼地跑过来拍桌子用户反馈银行卡已经扣款成功了但咱们系统里压根没这笔订单货没发用户炸了客服电话被打爆了我当时心里咯噔一下。赶紧查日志、对账、回溯代码最后定位到问题的根因跟事务这俩字脱不了干系。具体是什么坑我先卖个关子等你看完这篇自己就能分析出来。但这事儿让我意识到一个问题很多同学背得溜ACID隔离级别但一写代码就漏气。不是不认真是没把这些概念跟真实场景挂上钩。今天我就用这次翻车当药引子把事务与隔离级别这摊事儿掰开揉碎了讲给你听。二、ACID事务界的四大铁律讲事务绕不开ACID。但我不建议你死记硬背咱们用点外卖来串一遍。原子性——要么全到要么全退你点了一份黄焖鸡一份米饭一瓶可乐。要是黄焖鸡到了米饭和可乐没到你肯定不干——要么全送来要么全退回去少一样都不行。事务的原子性就是这个意思一个事务里的所有SQL要么全部执行成功要么全部回滚不存在一半成功一半失败的中间态。底层实现靠的是Undo Log——你执行INSERT它就记一条DELETE的反向操作你执行UPDATE它就记一条改回去的操作。一旦需要回滚重放这些反向操作数据就回去了。你可以把它理解为数据库的CtrlZ想反悔了随时撤销。一致性——钱对得上库存不能负这个有点跨层——它不光是数据库的事儿更是业务的事儿。比如你下单买了瓶水订单表里扣了库存支付表里扣了余额这两笔账必须对得上。你总不能让库存减了但钱没扣或者钱扣了但库存没减吧数据库只能帮你保证不违反约束唯一键、外键、非空等但业务逻辑的一致性得你自己写代码保证。就好比外卖送到了你得自己检查下菜对不对不能光看包装挺好看。隔离性——你送你的我吃我的别串台这是今天的重点。并发事务之间如果完全不管不顾就会出现各种串台问题——你读到我没提交的数据我改了你正在读的数据搞得一团糟。MySQL实现隔离性的两把刷子锁 MVCC。锁粗暴但有效类似于我先用这个厕所你在外面等着。MVCC优雅得多相当于我给你一份数据的副本你对着副本看不耽误我改原件。持久性——提交了就赖不掉了事务一旦提交数据就得永久落盘哪怕数据库下一秒就宕机了重启后数据也必须在。这靠的是Redo Log WALWrite-Ahead Logging技术。简单说就是改数据之前先把我要改什么记到Redo Log里。这样就算内存里的数据还没来得及刷到磁盘机器就断电了重启后也能根据Redo Log把数据恢复回来。就好比外卖员每次送货前先在系统里点一下已取餐就算手机没电了平台也知道这单该送哪。特性大白话MySQL靠啥实现原子性要么全成功要么全回滚Undo Log一致性业务规则不能被破坏应用层代码 数据库约束隔离性并发事务互相不干扰锁 MVCC持久性提交了就永久保存Redo Log WAL三、并发事务的三大翻车事故隔离级别之所以有四种就是为了解决下面这三个问题。这三个问题面试必问线上必踩你得刻在脑子里。1. 脏读——偷看了别人没写完的草稿事务A读取了事务B还没提交的数据然后事务B回滚了——A读到的就是个幽灵数据。场景事务B给用户余额加了100块还没提交事务A读到余额多了100拿去用了。结果事务B回滚了……白高兴一场。就像你抄同桌的作业结果他交上去以后发现是错的你也跟着一起挨批。解决隔离级别至少要到READ COMMITTED。2. 不可重复读——同一份报告前后看两遍不一样同一个事务里执行两次同样的SELECT结果不一样——因为中间被其他事务更新并提交了。场景你在对账第一次查账户余额是100块对账过程中财务给这个账户打了50块并提交了你再查变成了150块。对账单对不上了。就像你数完钱包里的钱放进抽屉再打开一看钱变多了——虽然变多是好事但数了个寂寞。解决隔离级别至少要到REPEATABLE READ。3. 幻读——见鬼了凭空多出来几条数据同一个事务里执行两次同样的范围查询结果集条数不一样——因为中间被其他事务插入或删除了数据。场景你统计今天的订单数第一次查是10条统计过程中运营插入了一条新订单并提交了你再查变成了11条。就像你数教室里有几个人数完一遍去上了个厕所回来发现多了一个人——关键是门还锁着你说吓人不吓人。解决SERIALIZABLE级别或者在InnoDB的REPEATABLE READ级别下用间隙锁Gap Lock来锁住可能插入的范围。问题描述怎么解决脏读读到别人还没提交的数据隔离级别 ≥ READ COMMITTED不可重复读两次读到的值不一样被更新了隔离级别 ≥ REPEATABLE READ幻读两次读到的条数不一样被插入/删除了SERIALIZABLE 或 InnoDB RR 间隙锁四、四种隔离级别从裸奔到全副武装READ UNCOMMITTED——啥锁都没有谁都能看能避免啥啥都避免不了。会出现脏读 ✅、不可重复读 ✅、幻读 ✅。性能最高因为不加锁。生产能用吗基本不能。除非你的数据完全不重要丢了也无所谓。相当于你写日记不锁抽屉谁路过都能翻而且翻的还是你没写完的草稿。READ COMMITTEDRC——只读已提交的但不保证前后一致能避免啥脏读。会出现不可重复读 ✅、幻读 ✅。性能高。生产能用吗能阿里规范就推荐这个。相当于你只看已发布的公众号文章但同一篇文章你早上看和晚上看内容可能不一样作者更新了。REPEATABLE READRR——快照一开谁都不爱MySQL默认能避免啥脏读、不可重复读。会出现幻读InnoDB在RR级别下通过间隙锁把幻读也堵住了所以MySQL的RR≈串行化级别的读安全性能中等。生产能用吗能MySQL默认就是它。相当于你把一本书拍照存手机里之后就一直看这张照片书本身再怎么改你手机里的照片不变。SERIALIZABLE——全员排队一个一个来能避免啥脏读、不可重复读、幻读全部搞定。性能极低。所有SELECT都会隐式加锁并发基本等于串行。生产能用吗几乎不用除非你完全不在乎性能。相当于图书馆只有一张桌子一次只允许一个人进去看书其他人门口排队。那生产环境到底用哪个MySQL默认是RR原因有历史包袱但也确实好用。阿里规范建议用RC理由是RC能减少间隙锁导致的死锁配合Binlog格式设为ROW后主从复制也不会出问题。我个人的习惯是新项目用RC Binlog ROW老项目保持RR别乱动。五、MVCC原理不用锁也能保证隔离性的骚操作你要理解RR和RC的区别就得搞懂MVCC。MVCC全称Multi-Version Concurrency Control翻译成人话就是每条数据都保留多个历史版本不同事务看到不同版本。每个数据行身上藏了三个隐藏列隐藏列大小存的是啥DB_TRX_ID6字节最后一次修改这行数据的事务IDDB_ROLL_PTR7字节指向Undo Log里上一个版本的指针DB_ROW_ID6字节如果没有主键这列就当主键用当你修改一行数据时InnoDB不会直接覆盖旧值而是把旧值先塞进Undo Log然后更新当前行的DB_TRX_ID和DB_ROLL_PTR让它指向前一个版本。所有历史版本通过DB_ROLL_PTR串成一条版本链就像Git的提交历史一样。ReadView——我是哪个年代的人我就看哪个年代的数据ReadView是一个快照的上下文结构如下m_ids当前所有活跃未提交的事务ID列表min_trx_id活跃事务里的最小IDmax_trx_id下一个将要分配的事务IDcreator_trx_id当前事务自己的ID可见性判断规则简化版如果数据行的DB_TRX_ID min_trx_id说明这个版本在快照生成时已经提交了→ ✅ 可见如果数据行的DB_TRX_ID max_trx_id说明这个版本是在快照生成之后才产生的 → ❌ 不可见如果数据行的DB_TRX_ID在m_ids列表里说明这个版本还没提交→ ❌ 不可见如果数据行的DB_TRX_ID creator_trx_id说明是自己改的→ ✅ 可见看不懂没关系记住结论就行RC每次SELECT重新生成ReadViewRR只在第一次SELECT时生成ReadView并一直用到事务结束。这就是为什么RC能读到最新已提交的数据每次刷新快照RR读到的永远是事务开始那一刻的数据老照片一直不换六、当前读 vs 快照读这俩搞混了BUG就来了快照读——看照片普通的SELECT语句就是快照读不加锁读的是MVCC快照。当前读——看直播SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT都是当前读会加锁读的是最新已提交的数据。⚠️ 重点来了在RR级别下快照读读到的是历史快照但UPDATE执行的是当前读这就可能导致一个经典问题更新丢失。你读数据的时候看到的是旧值但更新的时候却基于最新值去改中间被别人改掉的部分就可能被覆盖。举个例子事务ASELECT balance FROM account WHERE id1; -- 读到100快照读事务BUPDATE account SET balance150 WHERE id1; -- 提交了事务AUPDATE account SET balancebalance50 WHERE id1; -- 当前读读到150改成200事务A以为自己是10050150结果变成了200把事务B的更新给覆盖了。这种坑写业务代码时特别容易踩尤其是在先查后改的场景。七、事务隔离级别到底怎么配置MySQL端-- 查看当前隔离级别MySQL 8.0SELECT transaction_isolation;-- 全局设置重启失效要永久改得改配置文件SET GLOBAL transaction_isolation READ-COMMITTED;-- 会话级设置只对当前连接生效SET SESSION transaction_isolation REPEATABLE-READ;Spring Boot端yamlspring: datasource: hikari: transaction-isolation: TRANSACTION_READ_COMMITTED或者用注解在方法级别指定javaTransactional(isolation Isolation.READ_COMMITTED) public void doSomething() { // ... }八、长事务——那个占着茅坑不拉屎的隐形杀手啥是长事务运行时间很长、一直没提交的事务。可能是业务逻辑复杂执行慢事务范围太大包含了RPC、MQ等外部调用忘了commit或者没正确处理异常它有什么危害锁资源堆积事务不提交锁就不释放后面的人全在排队。Undo Log膨胀在RR级别下事务没提交它需要的旧版本数据就不能被Purge线程清理。时间一长磁盘空间被撑爆。主从延迟大事务在从库重放需要时间可能造成主从延迟。连接池耗尽每个事务占一个数据库连接事务不释放连接就不释放连接池很快见底。怎么排查-- 查看运行超过5分钟的事务 SELECT * FROM information_schema.INNODB_TRX WHERE trx_started NOW() - INTERVAL 5 MINUTE;Java开发的最佳实践事务要短平快❌ 错误示范事务里调RPCTransactional public void processOrder(Order order) { saveOrder(order); // DB操作 remoteService.sendMQ(); // RPC调用可能耗时3-5秒 }这3-5秒里事务一直开着锁一直占着Undo一直攒着。✅ 正确姿势RPC放外面DB操作放里面public void processOrder(Order order) { remoteService.sendMQ(); // 先发RPC耗时再长也不占数据库连接 saveOrder(order); // DB操作单独开事务快速提交 } Transactional public void saveOrder(Order order) { orderDao.insert(order); }原则就一句话事务里只干数据库的事其他的统统挪出去。九、回到开头的翻车现场现在你知道那个钱扣了订单没生成的问题是怎么发生的了吧我直接告诉你答案那个小兄弟在事务里先扣了库存然后调用了外部支付接口确认支付再写订单表。结果外部接口超时了事务一直没提交连接超时后事务回滚了——但支付接口那边其实已经扣款成功了。这就是分布式事务的问题单机事务管不了跨系统的调用。后来我们引入了TCC 本地消息表才彻底解决。这个故事告诉我们两件事事务的范围要尽量小RPC、MQ、HTTP调用一律不要放在事务里。跨系统的调用要假设它会失败做好补偿和重试机制。最后唠叨两句事务和隔离级别这块看起来是背背就行的理论但线上80%的数据不一致问题都跟它有关。我给你的建议就三条搞懂RC和RR的区别知道自己用的到底是个啥。事务范围能小就小别在里面搞RPC、发消息、查大表。监控长事务把它当作数据库告警的第一梯队指标。好了这篇就到这儿。下回你要是再遇到数据对不上的诡异BUG别急着甩锅给DBA先看看代码里的事务注解是不是贴错了地方。