1. 事务到底在解决什么问题很多刚接触数据库的朋友会觉得事务这个概念有点虚,好像平时写业务代码的时候也没怎么直接用到。但只要你稍微往深了想一层,就会发现事务其实无处不在。你转账给别人,你的余额扣了,对方的余额加了,这两件事必须同时成功或者同时失败,否则账就对不上了。你在电商平台下单,库存要减一,订单要生成,积分要累加,这一连串操作要么全做完,要么一个都别做,不然系统数据就乱套了。事务要解决的核心问题就一个:把多个独立的数据库操作打包成一个不可分割的整体。这个整体要么全部生效,要么全部不生效,没有中间状态。听起来简单,但实现起来涉及的东西非常多,这也是为什么事务一直是数据库领域最核心也最复杂的话题之一。我在实际工作中见过太多因为对事务理解不到位而踩坑的案例。有人以为开了事务就万事大吉,结果因为隔离级别选错了导致出现幻读;有人在一个事务里做了大量耗时操作,把锁持有到天荒地老,直接把线上系统拖垮;还有人根本不知道事务的提交和回滚到底发生了什么,出了问题完全不知道怎么排查。这些问题的根源都在于对事务的底层机制缺乏理解。这篇文章我会从实际使用的角度出发,把MySQL事务的来龙去脉讲清楚。包括事务的四大特性到底是怎么实现的,不同隔离级别分别解决了什么问题又带来了什么新问题,MVCC这个核心机制是怎么运作的,以及在实际项目中怎么用好事务。适合有一定MySQL使用经验、想深入理解事务底层原理的开发者,也适合正在准备技术面试、需要系统梳理事务知识点的朋友。2. 事务的四大特性与底层实现逻辑2.1 原子性:要么全做要么不做原子性是事务最基本的特性。一个事务里的所有操作,要么全部执行成功,要么全部不执行。不存在执行了一半的情况。这个特性听起来理所当然,但实现起来并不简单。假设一个事务要执行三条SQL语句,第一条执行完了,第二条执行到一半数据库宕机了,怎么办?MySQL的做法是通过undo log来实现原子性。undo log记录的是数据修改之前的旧值,当需要回滚的时候,就用undo log里的旧值把数据恢复回去。我举个具体的例子。假设你执行了这样一组操作:BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;如果第一条UPDATE执行完了,第二条还没执行的时候系统崩溃了,MySQL重启后会根据undo log把第一条UPDATE的修改撤销掉,让balance恢复到原来的值。这样就保证了原子性。undo log的写入是跟数据修改一起进行的,也就是说,在修改数据之前,会先把旧值写到undo log里。这个过程是在同一个事务上下文中完成的,所以即使系统崩溃,也能通过undo log来恢复。注意:undo log本身也是要写磁盘的,但它是逻辑日志,记录的是“把id=1的balance改回原来的值”这样的逻辑操作,而不是物理层面的数据页变化。2.2 持久性:提交了就不能丢持久性说的是,一旦事务提交成功,它对数据库的修改就是永久的,即使之后系统崩溃了,修改也不会丢失。这个特性的实现靠的是redo log。MySQL在修改数据的时候,会先把修改操作写到redo log里,然后再去修改内存中的数据页。注意,这里修改的是内存中的Buffer Pool,不是直接写磁盘。那为什么提交了就不会丢呢?因为redo log在事务提交的时候会被刷到磁盘上(这个由innodb_flush_log_at_trx_commit参数控制)。这里有一个关键点需要理解:数据页的修改和redo log的写入是两回事。数据页可能在内存里待很久才被刷到磁盘,但只要redo log已经落盘了,即使系统崩溃,重启后也能通过redo log把数据页的修改重放出来。我画一个简单的流程帮助理解:事务修改数据,修改的是Buffer Pool里的数据页同时把修改操作写到redo log buffer事务提交时,redo log buffer的内容根据配置刷到磁盘后台线程在合适的时机把Buffer Pool里的脏页刷到磁盘这个机制叫做WAL(Write-Ahead Logging),先写日志再写数据。为什么要这么做?因为写日志是顺序写,写数据页是随机写,顺序写的性能远高于随机写。如果每次修改都直接写数据页,性能会非常差。innodb_flush_log_at_trx_commit这个参数有三个值:参数值含义安全性性能0提交时不刷盘,由后台线程每秒刷一次可能丢1秒数据最高1每次提交都刷盘不丢数据最低2提交时写到OS缓存,由OS决定何时刷盘系统崩溃可能丢数据中等生产环境一般建议设置为1,除非你对性能有极高的要求并且能接受少量数据丢失的风险。2.3 隔离性:并发事务互不干扰隔离性是说,多个事务并发执行的时候,一个事务的执行不应该被其他事务干扰。每个事务感觉就像在独占数据库一样。但完全的隔离性能太差了,所以SQL标准定义了四种隔离级别,让用户可以在隔离性和性能之间做取舍。这部分内容比较多,我在下一节单独展开讲。2.4 一致性:最终目标一致性是事务的最终目标,它保证数据库从一个一致的状态转移到另一个一致的状态。比如转账前后,两个账户的总金额应该是不变的。一致性其实是原子性、隔离性、持久性共同作用的结果,再加上应用层面的约束(比如外键约束、唯一索引等)来共同保证的。数据库本身能做的是一部分,应用代码的正确性也很重要。3. 四种隔离级别与并发问题详解3.1 三种并发问题在讲隔离级别之前,先搞清楚并发事务可能带来的三种问题。脏读:一个事务读到了另一个事务还没提交的数据。比如事务A把余额从100改成200但还没提交,事务B读到了200。如果事务A回滚了,事务B读到的就是脏数据。不可重复读:同一个事务内,两次读取同一行数据,结果不一样。比如事务A第一次读余额是100,此时事务B把余额改成200并提交了,事务A再读就变成了200。同一个事务内两次读到的结果不一致。幻读:同一个事务内,两次执行同样的范围查询,第二次查到了第一次没查到的行。比如事务A查询SELECT * FROM users WHERE age > 20得到3条记录,此时事务B插入了一条age=25的记录并提交,事务A再查就变成了4条。多出来的那条就像幻觉一样。这三种问题的严重程度是递增的:脏读最严重,读到的是完全不可信的数据;不可重复读次之,至少读到的数据是真实存在过的;幻读最轻,但也会影响业务逻辑的正确性。3.2 四种隔离级别的取舍SQL标准定义了四种隔离级别,MySQL的InnoDB引擎都支持:隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能InnoDB下不可能SERIALIZABLE不可能不可能不可能READ UNCOMMITTED基本没人用,因为它连脏读都不防,数据完全不可信。READ COMMITTED是很多数据库的默认级别(比如Oracle、PostgreSQL),它防止了脏读,但可能出现不可重复读和幻读。在这个级别下,每次SELECT都会生成一个新的ReadView,所以能看到其他事务已经提交的最新数据。REPEATABLE READ是MySQL InnoDB的默认级别。它在事务开始时生成一个ReadView,整个事务期间都用这个ReadView,所以两次读到的数据是一样的。而且InnoDB通过Next-Key Lock(间隙锁+行锁)在很大程度上解决了幻读问题。SERIALIZABLE是最严格的级别,所有读操作都加共享锁,写操作加排他锁,事务完全串行执行。性能最差,一般只在极少数对一致性要求极高的场景下使用。实操心得:大部分互联网业务用REPEATABLE READ就够了。但如果你做的是金融类业务,对数据一致性要求特别高,可能需要考虑SERIALIZABLE,或者至少在关键操作上手动加锁。3.3 怎么查看和设置隔离级别查看当前会话的隔离级别:SELECT @@transaction_isolation;查看全局隔离级别:SELECT @@global.transaction_isolation;设置当前会话的隔离级别:SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;设置全局隔离级别(需要重新连接才生效):SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;也可以在配置文件中设置:[mysqld] transaction-isolation = READ-COMMITTED我个人的建议是,如果你不确定用哪个,就用默认的REPEATABLE READ。如果发现性能有问题,再考虑降级到READ COMMITTED。但降级之前一定要确认业务逻辑能接受不可重复读和幻读。4. MVCC机制深度拆解4.1 MVCC是什么MVCC全称是Multi-Version Concurrency Control,多版本并发控制。它是InnoDB实现隔离级别的核心机制,核心思想是:通过保存数据的多个版本,让读操作和写操作互不阻塞。在没有MVCC的情况下,如果一个事务正在修改某行数据,另一个事务想读这行数据,要么等待(加锁),要么读到不一致的数据。有了MVCC之后,读操作可以读取数据的历史版本,不需要等待写操作完成,大大提高了并发性能。4.2 隐藏字段与版本链InnoDB的每行记录都有两个隐藏字段:DB_TRX_ID:最近修改这行记录的事务IDDB_ROLL_PTR:回滚指针,指向undo log中的上一个版本每次修改一行记录,都会产生一个新的版本,旧版本通过DB_ROLL_PTR串联起来,形成一个版本链。版本链的头部是最新版本,尾部是最旧的版本。假设有一行记录,初始值是name='A',事务ID为100。然后事务ID为200的事务把它改成了name='B',事务ID为300的事务把它改成了name='C'。版本链大概是这样:当前记录: name='C', DB_TRX_ID=300 ↓ DB_ROLL_PTR undo log: name='B', DB_TRX_ID=200 ↓ DB_ROLL_PTR undo log: name='A', DB_TRX_ID=1004.3 ReadView的可见性判断当一个事务要读取数据时,InnoDB会根据ReadView来判断哪个版本对当前事务可见。ReadView主要包含以下信息:m_ids:生成ReadView时活跃的事务ID列表(还没提交的事务)min_trx_id:m_ids中的最小值max_trx_id:生成ReadView时系统应该分配给下一个事务的IDcreator_trx_id:创建这个ReadView的事务ID判断一个版本是否可见的规则:如果版本的DB_TRX_ID等于creator_trx_id,说明是自己修改的,可见如果DB_TRX_ID小于min_trx_id,说明这个版本在ReadView创建之前就已经提交了,可见如果DB_TRX_ID大于等于max_trx_id,说明这个版本是在ReadView创建之后才产生的,不可见如果DB_TRX_ID在min_trx_id和max_trx_id之间,需要判断是否在m_ids中:如果在,说明事务还活跃,不可见;如果不在,说明已提交,可见如果当前版本不可见,就沿着版本链往下找,直到找到一个可见的版本。4.4 RC和RR的核心区别READ COMMITTED和REPEATABLE READ在MVCC层面的核心区别就是生成ReadView的时机不同:READ COMMITTED:每次SELECT都生成一个新的ReadView。所以每次读都能看到最新提交的数据。REPEATABLE READ:只在第一次SELECT时生成ReadView,整个事务期间复用这个ReadView。所以整个事务期间读到的数据是一致的。这就是为什么RC级别下会出现不可重复读,而RR级别下不会。因为RC每次读都重新生成ReadView,能看到其他事务新提交的数据;RR始终用同一个ReadView,看不到新提交的数据。注意:RR级别下,如果当前事务自己修改了数据,那么后续的SELECT能看到自己的修改。因为判断规则第一条就是DB_TRX_ID等于creator_trx_id时可见。4.5 当前读与快照读MVCC只适用于快照读,也就是普通的SELECT语句。对于当前读,MVCC是不起作用的。当前读包括:SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEINSERT、UPDATE、DELETE操作当前读读取的是最新版本的数据,并且会加锁。比如UPDATE操作,它会先读取最新版本的数据,然后加排他锁,再执行修改。这里有一个容易混淆的点:在RR级别下,如果你先做了一次快照读,然后另一个事务修改了数据并提交,你再做当前读,当前读会读到最新版本的数据。这算不算不可重复读?严格来说算,但这是当前读的语义决定的,当前读就是要读最新的。5. 锁机制与幻读的解决5.1 InnoDB的锁类型InnoDB支持多种锁类型,从不同维度可以有不同的分类。按锁的粒度分:表锁:锁整张表,开销小但并发度低行锁:锁单行记录,开销大但并发度高按锁的模式分:共享锁(S锁):允许其他事务读,但不允许写排他锁(X锁):不允许其他事务读也不允许写按锁的算法分:Record Lock:锁单条记录Gap Lock:锁一个范围,但不包括记录本身Next-Key Lock:Record Lock + Gap Lock,锁一个范围并包括记录本身5.2 间隙锁如何解决幻读幻读的问题是:事务A查询某个范围,事务B在这个范围内插入了新记录并提交,事务A再查就多出了记录。InnoDB解决幻读的方式是在RR级别下使用Next-Key Lock。当事务A执行范围查询时,不仅会锁住符合条件的记录,还会锁住这些记录之间的间隙。这样事务B就无法在这个范围内插入新记录了。举个例子。假设表里有id为1、5、10三条记录,事务A执行:SELECT * FROM t WHERE id > 3 AND id < 8 FOR UPDATE;InnoDB会锁住(1, 5]和(5, 10]这两个区间。事务B想插入id=6的记录,会被阻塞,因为6在(5, 10]这个区间内。实操心得:间隙锁虽然解决了幻读,但也带来了死锁的风险。两个事务如果以不同的顺序获取间隙锁,就可能互相等待。所以在实际使用中,尽量让事务以相同的顺序访问数据,减少死锁概率。5.3 死锁的排查与预防死锁是并发事务中不可避免的问题。两个或多个事务互相持有对方需要的锁,导致都无法继续执行。MySQL会自动检测死锁,当检测到死锁时,会回滚其中一个事务(通常是持有锁最少的事务),让另一个事务继续执行。被回滚的事务会收到一个错误:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction排查死锁的方法:SHOW ENGINE INNODB STATUS;这个命令会输出最近一次死锁的详细信息,包括涉及的事务、执行的SQL、持有的锁和等待的锁。通过分析这些信息,可以找到死锁的原因。预防死锁的几个原则:按固定顺序访问数据:如果所有事务都按id升序访问,就不会出现交叉等待缩短事务持有锁的时间:事务里尽量只放必要的操作,查询和业务逻辑放在事务外面使用较低的隔离级别:RC级别下没有间隙锁,死锁概率会低很多合理使用索引:如果查询没有走索引,会锁全表,大大增加死锁概率6. 事务在实际项目中的使用要点6.1 事务的粒度控制事务的粒度是一个需要仔细权衡的问题。事务太大,锁持有时间长,并发性能差;事务太小,可能无法保证数据一致性。我见过一个典型的错误案例:有人在事务里调用了外部接口,接口响应时间不稳定,有时候要好几秒。结果就是数据库锁被持有好几秒,其他请求全部阻塞,系统直接雪崩。正确的做法是:事务里只放数据库操作,而且尽量是必要的数据库操作。外部调用、文件IO、复杂计算这些都应该放在事务外面。// 错误做法 @Transactional public void processOrder(Order order) { orderDao.insert(order); inventoryService.reduceStock(order.getProductId()); // 调用外部服务 paymentService.pay(order); // 调用外部服务 notificationService.send(order); // 调用外部服务 } // 正确做法 public void processOrder(Order order) { // 先做外部调用 paymentService.pay(order); // 再在事务里做数据库操作 transactionTemplate.execute(status -> { orderDao.insert(order); inventoryService.reduceStock(order.getProductId()); return null; }); // 事务外做通知 notificationService.send(order); }6.2 长事务的危害与排查长事务是指执行时间很长的事务。长事务的危害包括:锁持有时间长,阻塞其他事务undo log无法及时清理,导致版本链过长,影响查询性能占用大量内存和磁盘空间排查长事务的方法:SELECT * FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;这个查询会找出执行时间超过60秒的事务。找到之后,可以通过trx_mysql_thread_id找到对应的连接,然后决定是kill掉还是等它执行完。KILL [connection_id];注意:kill事务之前一定要确认这个事务在做什么,不要贸然kill掉正在执行重要操作的事务。6.3 事务与索引的关系事务的锁是加在索引上的,不是加在记录上的。这一点非常重要。如果一条SQL没有走索引,InnoDB会锁住整张表的所有记录和间隙,相当于锁表。这就是为什么OLTP系统里一定要避免没有索引的更新和删除操作。假设有一张用户表,name字段没有索引。执行:UPDATE users SET status = 1 WHERE name = 'test';这条SQL会锁住整张表,因为InnoDB需要扫描所有记录来找到name='test'的行。在扫描过程中,所有记录都会被加上锁。如果name字段有索引,InnoDB只需要锁住name='test'对应的索引记录和对应的主键记录,锁的范围小得多。6.4 事务的提交方式选择MySQL支持两种提交方式:自动提交:每条SQL都是一个独立的事务,执行完自动提交显式事务:用BEGIN或START TRANSACTION开启,用COMMIT或ROLLBACK结束查看自动提交状态:SELECT @@autocommit;大多数ORM框架(比如MyBatis、Hibernate)都会自动管理事务,开发者只需要在Service层加一个@Transactional注解就行。但要注意,@Transactional默认只在遇到RuntimeException时回滚,如果抛的是Checked Exception,不会回滚。需要显式指定:@Transactional(rollbackFor = Exception.class)这个坑我踩过不止一次。有一次线上出了数据不一致的问题,排查了半天才发现是因为抛了一个Checked Exception,事务没有回滚。7. 常见问题速查与避坑指南7.1 事务不生效的常见原因问题现象可能原因解决方法加了@Transactional但没回滚抛的是Checked Exception指定rollbackFor = Exception.class加了@Transactional但没回滚方法不是public的改为public方法加了@Transactional但没回滚同类内部方法调用通过代理对象调用或拆分到不同类加了@Transactional但没回滚数据库引擎不支持事务确认使用InnoDB引擎事务超时事务执行时间超过timeout设置优化SQL或调整timeout参数7.2 隔离级别选择速查业务场景推荐隔离级别理由一般互联网业务REPEATABLE READMySQL默认,兼顾一致性和性能报表类查询READ COMMITTED不需要强一致性,性能更好金融交易SERIALIZABLE或RR+手动锁对一致性要求极高日志类写入READ COMMITTED并发写入性能更好7.3 实操避坑清单坑一:在事务里做远程调用。远程调用的响应时间不可控,会导致事务持有锁的时间不可控。解决方案是把远程调用移到事务外面。坑二:大事务批量操作。一次性更新几万条数据,会产生大量的undo log和redo log,不仅执行慢,还可能撑爆日志空间。解决方案是分批处理,每批几百到几千条。坑三:忘记设置rollbackFor。Spring的@Transactional默认只回滚RuntimeException,Checked Exception不会触发回滚。解决方案是显式设置rollbackFor = Exception.class。坑四:RR级别下用SELECT ... FOR UPDATE导致间隙锁范围过大。如果WHERE条件没有走索引,会锁住大量间隙,严重影响并发。解决方案是确保FOR UPDATE的查询走索引。坑五:事务里混合使用快照读和当前读。先快照读再当前读,可能读到不一致的数据。解决方案是明确业务需求,如果需要一致性读,全部用快照读;如果需要最新数据,全部用当前读。坑六:长事务导致undo log膨胀。长事务不提交,undo log无法清理,版本链越来越长,查询需要遍历更多版本。解决方案是监控长事务,及时处理。坑七:主从复制延迟导致读到旧数据。事务提交后,从库可能还没同步到最新数据。解决方案是强制走主库查询,或者使用半同步复制。7.4 监控事务状态的常用命令-- 查看当前运行的事务 SELECT * FROM information_schema.innodb_trx\G -- 查看锁等待情况 SELECT * FROM information_schema.innodb_lock_waits\G -- 查看当前锁信息 SELECT * FROM information_schema.innodb_locks\G -- 查看事务隔离级别 SELECT @@transaction_isolation; -- 查看锁等待超时时间 SELECT @@innodb_lock_wait_timeout;innodb_lock_wait_timeout默认是50秒,可以根据业务情况调整。如果业务对响应时间要求高,可以调小一些,比如10秒,让等待锁的事务尽快失败,而不是一直等。7.5 一个真实的生产案例之前遇到过一个线上问题:某个接口偶尔会超时,排查发现是一个更新操作被锁住了。通过SHOW ENGINE INNODB STATUS看到,有一个事务持有了某行的排他锁很长时间没有释放。进一步排查发现,那个事务里除了更新操作,还有一个HTTP调用。HTTP调用的超时时间设置的是30秒,也就是说,如果HTTP调用超时,事务会持有锁30秒。在这30秒内,所有需要更新这行数据的请求都会被阻塞。解决方案很简单:把HTTP调用移到事务外面。修改之后,事务持有锁的时间从最长30秒降到了几毫秒,接口超时问题彻底解决。这个案例告诉我们,事务的边界一定要控制好。事务里只放必要的数据库操作,任何可能耗时的操作都应该放在事务外面。8. 事务知识的延伸与进阶方向事务这块内容往深了挖还有很多可以聊的。比如分布式事务,当你的系统拆分成多个微服务之后,一个业务操作可能涉及多个数据库,这时候单机事务就不够用了,需要引入分布式事务方案。常见的分布式事务方案有2PC、TCC、Saga、本地消息表等,每种方案都有自己的适用场景和优缺点。再比如MySQL 8.0对事务做了一些优化,包括对只读事务的优化、对undo log的改进等。如果你用的是MySQL 8.0,可以关注一下这些新特性。还有一个方向是事务与复制的结合。MySQL的主从复制是基于binlog的,而binlog的写入时机和事务的提交时机是有关系的。理解这个关系对于排查主从一致性问题很有帮助。我个人在实际操作中的体会是,事务这个东西,光看文档是不够的,一定要在真实环境里多试。开两个会话,手动模拟各种并发场景,观察锁的等待情况和数据的可见性,这样理解才会深刻。遇到死锁不要慌,用SHOW ENGINE INNODB STATUS看死锁日志,分析清楚原因,下次就能避免了。最后分享一个小技巧:如果你不确定某个操作会不会加锁,可以在测试环境开两个会话,一个会话执行操作但不提交,另一个会话尝试修改同一行数据,看看会不会被阻塞。这个方法比看文档直观得多,能帮你快速建立对锁的直觉。
1. 事务到底在解决什么问题很多刚接触数据库的朋友会觉得事务这个概念有点虚,好像平时写业务代码的时候也没怎么直接用到。但只要你稍微往深了想一层,就会发现事务其实无处不在。你转账给别人,你的余额扣了,对方的余额加了,这两件事必须同时成功或者同时失败,否则账就对不上了。你在电商平台下单,库存要减一,订单要生成,积分要累加,这一连串操作要么全做完,要么一个都别做,不然系统数据就乱套了。事务要解决的核心问题就一个:把多个独立的数据库操作打包成一个不可分割的整体。这个整体要么全部生效,要么全部不生效,没有中间状态。听起来简单,但实现起来涉及的东西非常多,这也是为什么事务一直是数据库领域最核心也最复杂的话题之一。我在实际工作中见过太多因为对事务理解不到位而踩坑的案例。有人以为开了事务就万事大吉,结果因为隔离级别选错了导致出现幻读;有人在一个事务里做了大量耗时操作,把锁持有到天荒地老,直接把线上系统拖垮;还有人根本不知道事务的提交和回滚到底发生了什么,出了问题完全不知道怎么排查。这些问题的根源都在于对事务的底层机制缺乏理解。这篇文章我会从实际使用的角度出发,把MySQL事务的来龙去脉讲清楚。包括事务的四大特性到底是怎么实现的,不同隔离级别分别解决了什么问题又带来了什么新问题,MVCC这个核心机制是怎么运作的,以及在实际项目中怎么用好事务。适合有一定MySQL使用经验、想深入理解事务底层原理的开发者,也适合正在准备技术面试、需要系统梳理事务知识点的朋友。2. 事务的四大特性与底层实现逻辑2.1 原子性:要么全做要么不做原子性是事务最基本的特性。一个事务里的所有操作,要么全部执行成功,要么全部不执行。不存在执行了一半的情况。这个特性听起来理所当然,但实现起来并不简单。假设一个事务要执行三条SQL语句,第一条执行完了,第二条执行到一半数据库宕机了,怎么办?MySQL的做法是通过undo log来实现原子性。undo log记录的是数据修改之前的旧值,当需要回滚的时候,就用undo log里的旧值把数据恢复回去。我举个具体的例子。假设你执行了这样一组操作:BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;如果第一条UPDATE执行完了,第二条还没执行的时候系统崩溃了,MySQL重启后会根据undo log把第一条UPDATE的修改撤销掉,让balance恢复到原来的值。这样就保证了原子性。undo log的写入是跟数据修改一起进行的,也就是说,在修改数据之前,会先把旧值写到undo log里。这个过程是在同一个事务上下文中完成的,所以即使系统崩溃,也能通过undo log来恢复。注意:undo log本身也是要写磁盘的,但它是逻辑日志,记录的是“把id=1的balance改回原来的值”这样的逻辑操作,而不是物理层面的数据页变化。2.2 持久性:提交了就不能丢持久性说的是,一旦事务提交成功,它对数据库的修改就是永久的,即使之后系统崩溃了,修改也不会丢失。这个特性的实现靠的是redo log。MySQL在修改数据的时候,会先把修改操作写到redo log里,然后再去修改内存中的数据页。注意,这里修改的是内存中的Buffer Pool,不是直接写磁盘。那为什么提交了就不会丢呢?因为redo log在事务提交的时候会被刷到磁盘上(这个由innodb_flush_log_at_trx_commit参数控制)。这里有一个关键点需要理解:数据页的修改和redo log的写入是两回事。数据页可能在内存里待很久才被刷到磁盘,但只要redo log已经落盘了,即使系统崩溃,重启后也能通过redo log把数据页的修改重放出来。我画一个简单的流程帮助理解:事务修改数据,修改的是Buffer Pool里的数据页同时把修改操作写到redo log buffer事务提交时,redo log buffer的内容根据配置刷到磁盘后台线程在合适的时机把Buffer Pool里的脏页刷到磁盘这个机制叫做WAL(Write-Ahead Logging),先写日志再写数据。为什么要这么做?因为写日志是顺序写,写数据页是随机写,顺序写的性能远高于随机写。如果每次修改都直接写数据页,性能会非常差。innodb_flush_log_at_trx_commit这个参数有三个值:参数值含义安全性性能0提交时不刷盘,由后台线程每秒刷一次可能丢1秒数据最高1每次提交都刷盘不丢数据最低2提交时写到OS缓存,由OS决定何时刷盘系统崩溃可能丢数据中等生产环境一般建议设置为1,除非你对性能有极高的要求并且能接受少量数据丢失的风险。2.3 隔离性:并发事务互不干扰隔离性是说,多个事务并发执行的时候,一个事务的执行不应该被其他事务干扰。每个事务感觉就像在独占数据库一样。但完全的隔离性能太差了,所以SQL标准定义了四种隔离级别,让用户可以在隔离性和性能之间做取舍。这部分内容比较多,我在下一节单独展开讲。2.4 一致性:最终目标一致性是事务的最终目标,它保证数据库从一个一致的状态转移到另一个一致的状态。比如转账前后,两个账户的总金额应该是不变的。一致性其实是原子性、隔离性、持久性共同作用的结果,再加上应用层面的约束(比如外键约束、唯一索引等)来共同保证的。数据库本身能做的是一部分,应用代码的正确性也很重要。3. 四种隔离级别与并发问题详解3.1 三种并发问题在讲隔离级别之前,先搞清楚并发事务可能带来的三种问题。脏读:一个事务读到了另一个事务还没提交的数据。比如事务A把余额从100改成200但还没提交,事务B读到了200。如果事务A回滚了,事务B读到的就是脏数据。不可重复读:同一个事务内,两次读取同一行数据,结果不一样。比如事务A第一次读余额是100,此时事务B把余额改成200并提交了,事务A再读就变成了200。同一个事务内两次读到的结果不一致。幻读:同一个事务内,两次执行同样的范围查询,第二次查到了第一次没查到的行。比如事务A查询SELECT * FROM users WHERE age > 20得到3条记录,此时事务B插入了一条age=25的记录并提交,事务A再查就变成了4条。多出来的那条就像幻觉一样。这三种问题的严重程度是递增的:脏读最严重,读到的是完全不可信的数据;不可重复读次之,至少读到的数据是真实存在过的;幻读最轻,但也会影响业务逻辑的正确性。3.2 四种隔离级别的取舍SQL标准定义了四种隔离级别,MySQL的InnoDB引擎都支持:隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能InnoDB下不可能SERIALIZABLE不可能不可能不可能READ UNCOMMITTED基本没人用,因为它连脏读都不防,数据完全不可信。READ COMMITTED是很多数据库的默认级别(比如Oracle、PostgreSQL),它防止了脏读,但可能出现不可重复读和幻读。在这个级别下,每次SELECT都会生成一个新的ReadView,所以能看到其他事务已经提交的最新数据。REPEATABLE READ是MySQL InnoDB的默认级别。它在事务开始时生成一个ReadView,整个事务期间都用这个ReadView,所以两次读到的数据是一样的。而且InnoDB通过Next-Key Lock(间隙锁+行锁)在很大程度上解决了幻读问题。SERIALIZABLE是最严格的级别,所有读操作都加共享锁,写操作加排他锁,事务完全串行执行。性能最差,一般只在极少数对一致性要求极高的场景下使用。实操心得:大部分互联网业务用REPEATABLE READ就够了。但如果你做的是金融类业务,对数据一致性要求特别高,可能需要考虑SERIALIZABLE,或者至少在关键操作上手动加锁。3.3 怎么查看和设置隔离级别查看当前会话的隔离级别:SELECT @@transaction_isolation;查看全局隔离级别:SELECT @@global.transaction_isolation;设置当前会话的隔离级别:SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;设置全局隔离级别(需要重新连接才生效):SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;也可以在配置文件中设置:[mysqld] transaction-isolation = READ-COMMITTED我个人的建议是,如果你不确定用哪个,就用默认的REPEATABLE READ。如果发现性能有问题,再考虑降级到READ COMMITTED。但降级之前一定要确认业务逻辑能接受不可重复读和幻读。4. MVCC机制深度拆解4.1 MVCC是什么MVCC全称是Multi-Version Concurrency Control,多版本并发控制。它是InnoDB实现隔离级别的核心机制,核心思想是:通过保存数据的多个版本,让读操作和写操作互不阻塞。在没有MVCC的情况下,如果一个事务正在修改某行数据,另一个事务想读这行数据,要么等待(加锁),要么读到不一致的数据。有了MVCC之后,读操作可以读取数据的历史版本,不需要等待写操作完成,大大提高了并发性能。4.2 隐藏字段与版本链InnoDB的每行记录都有两个隐藏字段:DB_TRX_ID:最近修改这行记录的事务IDDB_ROLL_PTR:回滚指针,指向undo log中的上一个版本每次修改一行记录,都会产生一个新的版本,旧版本通过DB_ROLL_PTR串联起来,形成一个版本链。版本链的头部是最新版本,尾部是最旧的版本。假设有一行记录,初始值是name='A',事务ID为100。然后事务ID为200的事务把它改成了name='B',事务ID为300的事务把它改成了name='C'。版本链大概是这样:当前记录: name='C', DB_TRX_ID=300 ↓ DB_ROLL_PTR undo log: name='B', DB_TRX_ID=200 ↓ DB_ROLL_PTR undo log: name='A', DB_TRX_ID=1004.3 ReadView的可见性判断当一个事务要读取数据时,InnoDB会根据ReadView来判断哪个版本对当前事务可见。ReadView主要包含以下信息:m_ids:生成ReadView时活跃的事务ID列表(还没提交的事务)min_trx_id:m_ids中的最小值max_trx_id:生成ReadView时系统应该分配给下一个事务的IDcreator_trx_id:创建这个ReadView的事务ID判断一个版本是否可见的规则:如果版本的DB_TRX_ID等于creator_trx_id,说明是自己修改的,可见如果DB_TRX_ID小于min_trx_id,说明这个版本在ReadView创建之前就已经提交了,可见如果DB_TRX_ID大于等于max_trx_id,说明这个版本是在ReadView创建之后才产生的,不可见如果DB_TRX_ID在min_trx_id和max_trx_id之间,需要判断是否在m_ids中:如果在,说明事务还活跃,不可见;如果不在,说明已提交,可见如果当前版本不可见,就沿着版本链往下找,直到找到一个可见的版本。4.4 RC和RR的核心区别READ COMMITTED和REPEATABLE READ在MVCC层面的核心区别就是生成ReadView的时机不同:READ COMMITTED:每次SELECT都生成一个新的ReadView。所以每次读都能看到最新提交的数据。REPEATABLE READ:只在第一次SELECT时生成ReadView,整个事务期间复用这个ReadView。所以整个事务期间读到的数据是一致的。这就是为什么RC级别下会出现不可重复读,而RR级别下不会。因为RC每次读都重新生成ReadView,能看到其他事务新提交的数据;RR始终用同一个ReadView,看不到新提交的数据。注意:RR级别下,如果当前事务自己修改了数据,那么后续的SELECT能看到自己的修改。因为判断规则第一条就是DB_TRX_ID等于creator_trx_id时可见。4.5 当前读与快照读MVCC只适用于快照读,也就是普通的SELECT语句。对于当前读,MVCC是不起作用的。当前读包括:SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEINSERT、UPDATE、DELETE操作当前读读取的是最新版本的数据,并且会加锁。比如UPDATE操作,它会先读取最新版本的数据,然后加排他锁,再执行修改。这里有一个容易混淆的点:在RR级别下,如果你先做了一次快照读,然后另一个事务修改了数据并提交,你再做当前读,当前读会读到最新版本的数据。这算不算不可重复读?严格来说算,但这是当前读的语义决定的,当前读就是要读最新的。5. 锁机制与幻读的解决5.1 InnoDB的锁类型InnoDB支持多种锁类型,从不同维度可以有不同的分类。按锁的粒度分:表锁:锁整张表,开销小但并发度低行锁:锁单行记录,开销大但并发度高按锁的模式分:共享锁(S锁):允许其他事务读,但不允许写排他锁(X锁):不允许其他事务读也不允许写按锁的算法分:Record Lock:锁单条记录Gap Lock:锁一个范围,但不包括记录本身Next-Key Lock:Record Lock + Gap Lock,锁一个范围并包括记录本身5.2 间隙锁如何解决幻读幻读的问题是:事务A查询某个范围,事务B在这个范围内插入了新记录并提交,事务A再查就多出了记录。InnoDB解决幻读的方式是在RR级别下使用Next-Key Lock。当事务A执行范围查询时,不仅会锁住符合条件的记录,还会锁住这些记录之间的间隙。这样事务B就无法在这个范围内插入新记录了。举个例子。假设表里有id为1、5、10三条记录,事务A执行:SELECT * FROM t WHERE id > 3 AND id < 8 FOR UPDATE;InnoDB会锁住(1, 5]和(5, 10]这两个区间。事务B想插入id=6的记录,会被阻塞,因为6在(5, 10]这个区间内。实操心得:间隙锁虽然解决了幻读,但也带来了死锁的风险。两个事务如果以不同的顺序获取间隙锁,就可能互相等待。所以在实际使用中,尽量让事务以相同的顺序访问数据,减少死锁概率。5.3 死锁的排查与预防死锁是并发事务中不可避免的问题。两个或多个事务互相持有对方需要的锁,导致都无法继续执行。MySQL会自动检测死锁,当检测到死锁时,会回滚其中一个事务(通常是持有锁最少的事务),让另一个事务继续执行。被回滚的事务会收到一个错误:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction排查死锁的方法:SHOW ENGINE INNODB STATUS;这个命令会输出最近一次死锁的详细信息,包括涉及的事务、执行的SQL、持有的锁和等待的锁。通过分析这些信息,可以找到死锁的原因。预防死锁的几个原则:按固定顺序访问数据:如果所有事务都按id升序访问,就不会出现交叉等待缩短事务持有锁的时间:事务里尽量只放必要的操作,查询和业务逻辑放在事务外面使用较低的隔离级别:RC级别下没有间隙锁,死锁概率会低很多合理使用索引:如果查询没有走索引,会锁全表,大大增加死锁概率6. 事务在实际项目中的使用要点6.1 事务的粒度控制事务的粒度是一个需要仔细权衡的问题。事务太大,锁持有时间长,并发性能差;事务太小,可能无法保证数据一致性。我见过一个典型的错误案例:有人在事务里调用了外部接口,接口响应时间不稳定,有时候要好几秒。结果就是数据库锁被持有好几秒,其他请求全部阻塞,系统直接雪崩。正确的做法是:事务里只放数据库操作,而且尽量是必要的数据库操作。外部调用、文件IO、复杂计算这些都应该放在事务外面。// 错误做法 @Transactional public void processOrder(Order order) { orderDao.insert(order); inventoryService.reduceStock(order.getProductId()); // 调用外部服务 paymentService.pay(order); // 调用外部服务 notificationService.send(order); // 调用外部服务 } // 正确做法 public void processOrder(Order order) { // 先做外部调用 paymentService.pay(order); // 再在事务里做数据库操作 transactionTemplate.execute(status -> { orderDao.insert(order); inventoryService.reduceStock(order.getProductId()); return null; }); // 事务外做通知 notificationService.send(order); }6.2 长事务的危害与排查长事务是指执行时间很长的事务。长事务的危害包括:锁持有时间长,阻塞其他事务undo log无法及时清理,导致版本链过长,影响查询性能占用大量内存和磁盘空间排查长事务的方法:SELECT * FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;这个查询会找出执行时间超过60秒的事务。找到之后,可以通过trx_mysql_thread_id找到对应的连接,然后决定是kill掉还是等它执行完。KILL [connection_id];注意:kill事务之前一定要确认这个事务在做什么,不要贸然kill掉正在执行重要操作的事务。6.3 事务与索引的关系事务的锁是加在索引上的,不是加在记录上的。这一点非常重要。如果一条SQL没有走索引,InnoDB会锁住整张表的所有记录和间隙,相当于锁表。这就是为什么OLTP系统里一定要避免没有索引的更新和删除操作。假设有一张用户表,name字段没有索引。执行:UPDATE users SET status = 1 WHERE name = 'test';这条SQL会锁住整张表,因为InnoDB需要扫描所有记录来找到name='test'的行。在扫描过程中,所有记录都会被加上锁。如果name字段有索引,InnoDB只需要锁住name='test'对应的索引记录和对应的主键记录,锁的范围小得多。6.4 事务的提交方式选择MySQL支持两种提交方式:自动提交:每条SQL都是一个独立的事务,执行完自动提交显式事务:用BEGIN或START TRANSACTION开启,用COMMIT或ROLLBACK结束查看自动提交状态:SELECT @@autocommit;大多数ORM框架(比如MyBatis、Hibernate)都会自动管理事务,开发者只需要在Service层加一个@Transactional注解就行。但要注意,@Transactional默认只在遇到RuntimeException时回滚,如果抛的是Checked Exception,不会回滚。需要显式指定:@Transactional(rollbackFor = Exception.class)这个坑我踩过不止一次。有一次线上出了数据不一致的问题,排查了半天才发现是因为抛了一个Checked Exception,事务没有回滚。7. 常见问题速查与避坑指南7.1 事务不生效的常见原因问题现象可能原因解决方法加了@Transactional但没回滚抛的是Checked Exception指定rollbackFor = Exception.class加了@Transactional但没回滚方法不是public的改为public方法加了@Transactional但没回滚同类内部方法调用通过代理对象调用或拆分到不同类加了@Transactional但没回滚数据库引擎不支持事务确认使用InnoDB引擎事务超时事务执行时间超过timeout设置优化SQL或调整timeout参数7.2 隔离级别选择速查业务场景推荐隔离级别理由一般互联网业务REPEATABLE READMySQL默认,兼顾一致性和性能报表类查询READ COMMITTED不需要强一致性,性能更好金融交易SERIALIZABLE或RR+手动锁对一致性要求极高日志类写入READ COMMITTED并发写入性能更好7.3 实操避坑清单坑一:在事务里做远程调用。远程调用的响应时间不可控,会导致事务持有锁的时间不可控。解决方案是把远程调用移到事务外面。坑二:大事务批量操作。一次性更新几万条数据,会产生大量的undo log和redo log,不仅执行慢,还可能撑爆日志空间。解决方案是分批处理,每批几百到几千条。坑三:忘记设置rollbackFor。Spring的@Transactional默认只回滚RuntimeException,Checked Exception不会触发回滚。解决方案是显式设置rollbackFor = Exception.class。坑四:RR级别下用SELECT ... FOR UPDATE导致间隙锁范围过大。如果WHERE条件没有走索引,会锁住大量间隙,严重影响并发。解决方案是确保FOR UPDATE的查询走索引。坑五:事务里混合使用快照读和当前读。先快照读再当前读,可能读到不一致的数据。解决方案是明确业务需求,如果需要一致性读,全部用快照读;如果需要最新数据,全部用当前读。坑六:长事务导致undo log膨胀。长事务不提交,undo log无法清理,版本链越来越长,查询需要遍历更多版本。解决方案是监控长事务,及时处理。坑七:主从复制延迟导致读到旧数据。事务提交后,从库可能还没同步到最新数据。解决方案是强制走主库查询,或者使用半同步复制。7.4 监控事务状态的常用命令-- 查看当前运行的事务 SELECT * FROM information_schema.innodb_trx\G -- 查看锁等待情况 SELECT * FROM information_schema.innodb_lock_waits\G -- 查看当前锁信息 SELECT * FROM information_schema.innodb_locks\G -- 查看事务隔离级别 SELECT @@transaction_isolation; -- 查看锁等待超时时间 SELECT @@innodb_lock_wait_timeout;innodb_lock_wait_timeout默认是50秒,可以根据业务情况调整。如果业务对响应时间要求高,可以调小一些,比如10秒,让等待锁的事务尽快失败,而不是一直等。7.5 一个真实的生产案例之前遇到过一个线上问题:某个接口偶尔会超时,排查发现是一个更新操作被锁住了。通过SHOW ENGINE INNODB STATUS看到,有一个事务持有了某行的排他锁很长时间没有释放。进一步排查发现,那个事务里除了更新操作,还有一个HTTP调用。HTTP调用的超时时间设置的是30秒,也就是说,如果HTTP调用超时,事务会持有锁30秒。在这30秒内,所有需要更新这行数据的请求都会被阻塞。解决方案很简单:把HTTP调用移到事务外面。修改之后,事务持有锁的时间从最长30秒降到了几毫秒,接口超时问题彻底解决。这个案例告诉我们,事务的边界一定要控制好。事务里只放必要的数据库操作,任何可能耗时的操作都应该放在事务外面。8. 事务知识的延伸与进阶方向事务这块内容往深了挖还有很多可以聊的。比如分布式事务,当你的系统拆分成多个微服务之后,一个业务操作可能涉及多个数据库,这时候单机事务就不够用了,需要引入分布式事务方案。常见的分布式事务方案有2PC、TCC、Saga、本地消息表等,每种方案都有自己的适用场景和优缺点。再比如MySQL 8.0对事务做了一些优化,包括对只读事务的优化、对undo log的改进等。如果你用的是MySQL 8.0,可以关注一下这些新特性。还有一个方向是事务与复制的结合。MySQL的主从复制是基于binlog的,而binlog的写入时机和事务的提交时机是有关系的。理解这个关系对于排查主从一致性问题很有帮助。我个人在实际操作中的体会是,事务这个东西,光看文档是不够的,一定要在真实环境里多试。开两个会话,手动模拟各种并发场景,观察锁的等待情况和数据的可见性,这样理解才会深刻。遇到死锁不要慌,用SHOW ENGINE INNODB STATUS看死锁日志,分析清楚原因,下次就能避免了。最后分享一个小技巧:如果你不确定某个操作会不会加锁,可以在测试环境开两个会话,一个会话执行操作但不提交,另一个会话尝试修改同一行数据,看看会不会被阻塞。这个方法比看文档直观得多,能帮你快速建立对锁的直觉。