1. 先从一次转账说起:事务到底是什么
1.1 一个让人后怕的线上场景
我之前在某公司的交易系统里排查过一个悬案:用户发起提现,后台一查,钱扣了,但账户余额增加了,两边对不上。一开始怀疑是并发扣款,查了半天才发现,扣款和加款两个操作写在了同一个方法里,方法中间的某一步抛了个异常,Java代码里catch住了,打印了一行日志就返回了。结果就是:扣款那条SQL已经执行成功并提交,而加款那条SQL根本没执行。用户的余额凭空少了。
这个场景放到今天,很多刚接触Java后端的朋友一眼就能看出问题:这俩操作应该放在同一个事务里。可“事务”这两个字,到底约束了什么、底层是怎么保证的、JDBC代码里应该怎么写才靠谱,很多人只是背了ACID四个字母,真到线上出问题时还是懵。这篇内容我打算用实际踩坑的视角,把Java、MySQL、JDBC三者之间的事务关系完整捋一遍。
事务的定义其实很直白:它是一组SQL操作的集合,要么全部成功,要么全部失败回滚。但“全部成功”和“全部失败”这几个字,在并发环境下要做到,背后涉及MySQL存储引擎的日志机制、锁机制、隔离级别,还涉及JDBC连接上自动提交开关、提交时机、异常回滚路径等一堆代码层面的细节。任何一个环节没想清楚,都会在某个夜深人静的线上故障里等着你。
1.2 事务的ACID:四个字母背后的硬约束
ACID是事务的四个核心特性,大学课本里都学过,但课本往往把简单的事情讲复杂了。我用自己的理解重新解释一遍。
原子性:事务里的操作是一整块,不能拆。就像你去柜台办业务,柜员要么把所有步骤都办完,要么一个都不办,不会出现“扣了你的钱但忘了办手续”的情况。MySQL里这靠undo log实现,后面会细讲。
一致性:事务执行前后,数据要符合业务规则。比如转账前后,总金额不能变。如果原子性保证了,一致性基本能保证,但要注意,有些一致性规则是业务自己定义的,数据库不知道,比如“余额不能为负”这种约束,你得用CHECK约束或者应用层判断,数据库的ACID不会替你做这个决定。
隔离性:多个事务同时操作同一批数据时,彼此不能互相干扰。比如两个人同时给同一个账户打钱,结果不能变成“只加了一笔”。隔离性由锁和MVCC(多版本并发控制)共同保证。
持久性:事务提交后,数据不能丢。哪怕下一秒机器断电,重启后数据依然在。MySQL里靠redo log保证。很多人以为持久性是“数据写进磁盘就安全了”,其实MySQL在内存里改了数据页之后,会先把redo log刷到磁盘,再异步把数据页刷盘,这套机制让性能和安全取得了平衡。
我之前面试时经常问一个细节:ACID这四个特性,到底哪个是MySQL通过日志实现的,哪个是通过锁实现的?其实原子性和持久性主要靠日志,隔离性靠锁和MVCC,一致性更多是前三个特性共同作用的结果,外加业务层的约束。搞清楚这一层,后面看JDBC代码就知道每一步在干什么了。
2. MySQL底层是怎么撑起一个事务的
2.1 存储引擎的分水岭:InnoDB与事务
在MySQL里聊事务,第一个前提是存储引擎。MySQL 5.5之后默认引擎是InnoDB,它支持事务、行锁、外键;而MyISAM引擎不支持事务,表锁为主,查询速度快但写入场景下数据一致性保护很弱。
选错引擎是很隐蔽的坑。我见过有项目建表时顺手用了MyISAM,代码里JDBC也写了事务,结果数据该不对还是不对。原因很简单:引擎根本不支持事务,你调用了connection.setAutoCommit(false),后面再执行UPDATE,MySQL直接当前语句就提交了,rollback根本无从谈起。事务框架跑在不支持事务的引擎上,纯属白费力气。
判断表引擎很简单,执行SHOW TABLE STATUS LIKE '表名',看Engine字段;或者直接查information_schema.TABLES表。在建表语句里也可以显式指定:ENGINE=InnoDB。生产环境我一般建议所有表都用InnoDB,查询性能的差异用索引和缓存设计来弥补,不要用牺牲一致性的方式换性能。
2.2 事务日志:redo log与undo log的分工
InnoDB事务的核心机制是两套日志:redo log和undo log。理解这两兄弟,几乎是理解MySQL事务的钥匙。
redo log是“重做日志”,记录的是物理层面的修改,比如“某个数据页的第某行第某列被改成什么值”。它解决的是持久性问题:事务提交时,不需要保证数据页立刻刷到磁盘,只需要保证redo log刷到磁盘就行,因为下次启动时可以根据redo log重放操作,把数据页恢复到最新状态。这有点像你做完决定后先写个备忘录,等有空了再照着备忘录慢慢执行,而不是当场把所有事都办完。
undo log是“回滚日志”,记录的是逻辑层面的逆操作,比如“某行原来的值是什么”。事务回滚时,通过undo log把数据倒回修改前的状态。同时undo log还承担着MVCC的职责,为一致性读提供旧版本数据。你可以把它看成账单的存根,每次改数据前先记录一笔,将来反悔时照着存根恢复。
看起来redo和undo都挺简单,但实际执行时有个关键点:redo log不是事务提交时才写的,而是在事务执行过程中持续写。比如一个事务更新了100行,每更新一行,redo log buffer里就写一条,提交时一次性刷盘。这样即使事务中途宕机,最多就是没提交,重启后数据不会出现“半更新”状态;而已经提交的事务,即使数据页没刷盘,也能靠redo log恢复。
2.3 锁与MVCC:隔离性从哪里来
隔离性不是单靠锁这一招,而是锁配合MVCC一起实现的。MVCC的思想是:数据行不是只有当前版本,而是有一串历史版本。读操作分两类:快照读和当前读。
快照读就是普通的SELECT,它通过undo log读取某个历史版本,不加锁。所以在一行数据被另一个事务修改但还没提交时,快照读依然能瞬间返回旧版本的数据,不会被堵住。当前读是SELECT ... FOR UPDATE、UPDATE、DELETE等操作,它们读取的一定是最新版本,并且会对涉及的行加锁。
锁方面,InnoDB有行锁、间隙锁和next-key锁。行锁保护单行,间隙锁保护一个范围区间,防止其他事务在这个区间插入新记录。这两个锁配合使用,才让InnoDB在默认的可重复读隔离级别下还能基本防止幻读。很多教程说“可重复读防不了幻读”,但这话在InnoDB这里不准确,因为next-key锁把范围锁住了,插入都插不进去,幻读自然也就没了。
不过要注意,锁这个东西带来并发控制能力,同时也带来死锁风险。两个事务分别锁住不同的行,然后又想锁对方持有的行,就会死锁。MySQL检测到死锁后会自动回滚其中一个事务,但另一个事务会收到错误码1213的异常,代码里要正确处理这种异常。
3. JDBC操作事务的核心流程
3.1 先把三样东西备齐:驱动、连接与URL参数
JDBC是Java操作数据库的标准接口,MySQL的事务控制能力通过它就能完整暴露出来。要连上MySQL,需要三样东西:驱动类、连接URL、用户名密码。Maven项目里加依赖时,groupId是mysql,artifactId是mysql-connector-java,这是MySQL官方的Connector/J驱动。
连接URL是很多人忽略细节的地方。最基本的形式是jdbc:mysql://localhost:3306/yourdb,但事务相关还有几个参数值得关注:
useSSL=false:本地开发或内网环境可以关掉SSL,省去证书配置的麻烦,生产环境按安全要求开启。serverTimezone=Asia/Shanghai:不设置时区参数在部分环境下会报错或时间错乱。allowMultiQueries=true:这个要谨慎,允许一条语句里写多个SQL,事务边界会变得不好控制,一般不建议开。
连接URL本质上就是给你的连接指定行为配置,很多隐蔽的“事务为什么不生效”问题,最后都查到是URL参数不对。比如autoReconnect=true这个参数,如果连接断掉后自动重连,之前的事务状态就丢了,代码里如果没有感知,后续操作会在新连接上自动提交,事务一样失效。
3.2 三步法:关自动提交、执行操作、提交或回滚
JDBC操作事务的标准流程,总结下来就是三步:先把连接设置为手动提交,然后执行你的SQL操作,最后根据执行情况决定是commit还是rollback。
第一步是connection.setAutoCommit(false)。默认情况下,JDBC连接拿到手是自动提交模式,每执行一条SQL就立刻事务提交,这就像你每说一句话就马上拍板,中间根本留不出反悔空间。所以要关闭自动提交,把多条SQL包进一个事务里。
第二步是执行操作,这里要注意顺序。先执行查询、再执行更新,或者先后执行多个更新,都会影响锁的持有顺序。我一般建议在事务里把查询写成SELECT ... FOR UPDATE,把要更新的行先锁住,防止其他事务在这期间改了这些数据,造成更新丢失。
第三步是提交或回滚。所有SQL正常执行完,调用connection.commit();任何一个环节抛出异常,调用connection.rollback()。这里有个工程上常见的错误:把commit()放在try块里,结果中途异常时已经跳过了commit,但rollback也没执行,连接一直挂着未提交事务,这在连接池下就是灾难。
3.3 我从工程实践中总结的标准模板
下面这套模板是我在多个项目里反复打磨后固定下来的写法,兼容了事务正确性和代码可读性:
Connection conn = null; try { // 从连接池获取连接,注意不是DriverManager.getConnection conn = dataSource.getConnection(); // 第一步:关闭自动提交 conn.setAutoCommit(false); // 第二步:执行业务操作 PreparedStatement ps1 = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE user_id = ?"); ps1.setBigDecimal(1, amount); ps1.setString(2, fromUserId); ps1.executeUpdate(); PreparedStatement ps2 = conn.prepareStatement("UPDATE account SET balance = balance + ? WHERE user_id = ?"); ps2.setBigDecimal(1, amount); ps2.setString(2, toUserId); ps2.executeUpdate(); // 第三步:全部成功,提交 conn.commit(); } catch (SQLException e) { // 任何一个环节失败,回滚 if (conn != null) { try { conn.rollback(); } catch (SQLException rollbackEx) { log.error("回滚失败", rollbackEx); } } throw new RuntimeException("转账失败", e); } finally { // 释放连接时,把自动提交状态恢复默认,否则连接池复用时会出问题 if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { log.error("关闭连接失败", e); } } }提示:
finally里恢复autoCommit为true这件事,大多数教程不会写,但连接池场景下必须做。假如某个连接回到连接池时autoCommit=false,下一个请求拿到这个连接,执行一条查询没提交也没回滚,那条查询的事务状态会一直挂着,数据看着没问题,但表被锁住的情况时有发生。
模板里的catch块,我只捕获了SQLException。业务代码里如果抛出的是自定义运行时异常,也要统一在这个catch里拦截,否则事务就悬在半空。更稳妥的做法是catchException,但要确保不是ThreadDeath这类虚拟机级错误,自定义异常又是另一码事。我的经验是:事务方法只捕获SQLException,业务异常在业务层抛出来,最外层由Spring事务切面统一处理。如果项目没用Spring,那就老老实实catch Exception并rollback。
4. 影响事务结果的四个隐形变量
4.1 隔离级别:从读未提交到串行化,你能防住什么
MySQL的事务隔离级别分为四档,从低到高分别是:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。默认是REPEATABLE READ,这是MySQL和Oracle的一个明显区别,Oracle默认是READ COMMITTED。
每一档能防住的并发问题不同,我用一张表来对照:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 事务里能读到别人未提交的数据,基本不用 |
| 读已提交 | 防住 | 可能 | 可能 | 每次查询看到的是最新已提交快照 |
| 可重复读(默认) | 防住 | 防住 | 基本防住 | 事务开始后查询看到的快照一致 |
| 串行化 | 防住 | 防住 | 防住 | 读写都串行,并发性能最低 |
脏读是指一个事务读到了另一个事务没提交的数据,如果对方回滚了,你读到就是“脏”数据。不可重复读是指同一事务内两次查询同一行,结果不一样。幻读是指同一事务内两次范围查询,第二次多出了一些行。
JDBC设置隔离级别用connection.setTransactionIsolation()方法,参数是Connection.TRANSACTION_READ_UNCOMMITTED等常量。但我建议,单个连接设置的隔离级别没有太大意义,因为连接池会复用连接,状态容易残留。更靠谱的做法是在MySQL全局配置里把transaction-isolation设为目标级别,或者用SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED在会话里设置。
4.2 自动提交开关:最被小看的一个坑
自动提交这个开关,名字听着简单,但在事务链路里它是第一道闸门。因为MySQL默认的autocommit=1,你执行一条UPDATE,MySQL自动帮你提交,这对于单条SQL来说是合理的;但对于业务上需要多条SQL一起成功的场景,这个默认值就是陷阱。
JDBC里如果你忘了调用setAutoCommit(false),那么后面不管写多少条SQL,每条执行完都自动提交了。曾经有个项目,事务代码明明写在Service层,结果排查下来发现连接是从一个老的工具类里拿的,工具类里给每条SQL都执行了一次setAutoCommit(true),把事务效果全冲掉了。
另外还要记住:MySQL里有几种操作会隐式提交当前事务,比如DDL语句(CREATE TABLE、ALTER TABLE)、GRANT、LOCK TABLES等。这些语句在执行前会自动把当前事务提交掉,代码里如果你的事务中间不小心夹了一条DDL,那之前的更新就全部落地了,回滚也回不掉了。
4.3 连接池里的代理连接问题
生产环境几乎没人直接用DriverManager.getConnection(),因为每次都新建物理连接太慢。常用的连接池有HikariCP、Druid、dbcp等。连接池的核心思想是复用连接,但这个“复用”恰恰是事务的隐形杀手。
一个连接在事务提交或回滚后,应该恢复到初始状态。但连接池不一定帮你做这件事,尤其是autoCommit、transactionIsolation、readOnly这些会话级别状态。HikariCP在连接创建时设置一次默认值,后续使用中如果你改了这些状态,还连接时它默认不清理。
我在项目里遇到过一次诡异故障:某个接口偶尔会读取到旧数据,排查发现是上一次请求把连接的隔离级别改成了READ UNCOMMITTED,还连接时没恢复,下一次请求拿到这个连接,一路读到未提交数据。从那以后,我在获取连接后做的第一件事,就是显式重置会话状态:setAutoCommit(true)、setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ)。虽然有点啰嗦,但稳定压倒一切。
Connection是java.sql.Connection接口,MySQL驱动有自己的实现,而连接池返回的是代理对象。这些代理对象包装了真正的连接。所以打印conn.getClass()时,你看到的不是MySQL的原生类,而是一个带$Proxy字样的类。这说明连接池接管了连接的创建和销毁,绕过连接池直接conn.close()关闭的,只是把连接还给池子,不是真的关掉。
4.4 事务边界:try块里到底该放哪些操作
很多新手容易把大量代码塞进事务里,尤其是把远程调用、文件上传、消息发送这类耗时操作也包在事务里,这是性能事故的典型来源。事务的本质是持有锁,持有锁的时间越长,其他事务等待的可能性越大,整个系统的并发能力就越差。
我给自己定过几条事务边界原则:
- 事务里只放数据库读写操作,远程调用必须放在事务外。否则你调用外部接口超时三秒,数据库连接和行锁就白占三秒,这种场景下系统一压测就死给你看。
- 查询操作看情况放事务里。如果这个查询是为了后面的更新做预校验,比如判断余额是否充足,那查询必须和更新在一个事务里,而且要用
SELECT ... FOR UPDATE锁定,否则校验完到更新之间数据可能已经变了。 - 循环里别做大量更新。一万条数据循环更新,意味着事务要执行一万条UPDATE,锁持有时间极长,redo log也会膨胀。改成批量更新,或者分成几个小事务提交。
- 大批量写操作拆事务。导入导出场景,建议每批次500到1000条提交一次,既保证效率,又避免一个异常导致全部回滚、重来成本过高。
事务边界设计,本质上是对业务和数据一致性级别的一个权衡。没有标准答案,但有一条红线:能短则短,能不包就不包。
5. 常见问题与排查技巧实录
5.1 事务“没生效”的三种典型情况
我总结过事务失效的高频原因,给团队培训时专门列过清单。
第一种:连接被提前关闭。代码里在事务执行完之前就调用了conn.close(),连接归池后,后续的SQL其实是在池子里的另一个连接上执行的,事务当然管不到后面的操作。排查时看线程栈,很容易看到关闭连接和后续SQL不在同一个调用栈。
第二种:异常被吞掉。catch (Exception e) { e.printStackTrace(); },这行代码在很多老项目里随处可见。异常被打印后没有被抛出,事务框架根本不知道出错,你以为会回滚,实际上commit()继续执行,之前的数据就提交了。我的建议是:事务方法里的catch,要么把异常重新抛出去,要么显式调用rollback,绝不要只打印日志。
第三种:跨连接操作。一个事务方法里,从连接池获取了多次连接,或者通过多数据源操作了不同的库,每个连接上的事务是独立的,A库提交了,B库回滚了,两端数据就不一致了。这种场景本质上是分布式事务问题,不是JDBC单连接事务能解决的。
5.2 死锁与锁等待超时
死锁在JDBC事务里是高频故障。常见的死锁模式是:事务A先更新表X再更新表Y,事务B先更新表Y再更新表X,两个事务互相等对方释放锁,谁也不让谁,形成死循环。
MySQL处理死锁的方式是死锁检测:它维护一个等待图,发现回路后,选择回滚undo量较小的那个事务,返回错误码1213,就是Deadlock found when trying to get lock; try restarting transaction。JDBC程序收到这个异常后,通常的解决方案是重试整个事务,我写代码时会做一个最多三次的重试循环。
锁等待超时是另一回事,它表示事务等待锁的时间超过了innodb_lock_wait_timeout参数(默认50秒),返回错误码1205。这种一般不是互相死锁,而是某个事务持锁时间过长,其他事务排队等太久。排查时优先查哪个事务持锁最久,而不是只看报错的事务。
排查锁问题,我最常用的SQL是查information_schema.INNODB_TRX表,看当前有哪些事务处于运行状态,再查INNODB_LOCK_WAITS看锁等待关系。生产环境发生死锁时,先把SHOW ENGINE INNODB STATUS的输出存下来,里面有最近一次死锁的详细记录,包括涉及的事务和SQL。
5.3 长事务引发的性能雪崩
一个事务长时间不提交,不只是自己性能差,它会拖累整个实例。长事务持有旧版本数据的访问权限,导致undo log不能清理,undo链越来越长,最终导致回滚段膨胀、磁盘空间告急,甚至引发查询变慢。
我遇到过一个小事故:某定时任务在事务里循环更新几十万行,每行之间还有轻微的线程sleep模拟外部调用,结果整个表被锁住,所有线上查询排队。查SHOW PROCESSLIST时能看到大量线程处于Waiting for table metadata lock状态。当时紧急处理方案,就是找到那个事务的进程ID,执行KILL命令终止它。
长事务更隐蔽的问题是影响主从复制。如果主库有一个大事务长时间未提交,从库可能无法追上主库的写入,主从延迟快速拉大。这个问题的根源不在于事务本身的数据量,而在于从库同步要等待该事务完成,不完成就卡住。
日常开发中控制长事务,我的做法是:给事务方法写注释时标注“预计执行代价”,如果超过几百毫秒的执行代价,就要考虑拆事务。另外,监控上对运行超过5秒的事务就要告警,超过30秒必须人工介入。
6. 我在实操中总结的经验与补充
事务这块内容,理论框架就是ACID、日志、锁、隔离级别,代码层面就是JDBC的那几步,但真正让代码在线上稳定运行,靠的是很多细碎的经验。最后再分享几个我个人总结的小技巧。
第一,连接池参数里设置connection-init-sql,比如HikariCP的connectionInitSql可以配置成SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED,这样每次连接创建后自动设置隔离级别,减少状态残留。
第二,SELECT ... FOR UPDATE不要滥用。它会在事务结束前一直持有行锁,如果只是做简单的存在性判断,考虑用SELECT ... LOCK IN SHARE MODE或者干脆不用锁,靠唯一索引约束兜底。
第三,测试环境可以开MySQL的general_log来观察提交和回滚的实际语句。我之前调试一个事务不生效的问题,就靠set global general_log = on,然后查看日志里是否有COMMIT和ROLLBACK,基本一眼看出问题。
第四,业务上要设计重试机制。网络抖动、死锁回滚、锁等待超时,这些异常在分布式环境下无法完全避免。代码层面捕获到MySQLIntegrityConstraintViolationException这类明确可以重试的异常时,做三次指数退避重试,能大幅提高任务成功率。
最后提一个纪录片式的细节:有一次排查线上数据不一致,最终定位到根因是某个开发在事务方法里用了Thread.sleep(3000)模拟耗时操作,那三秒里连接持有锁,其他请求全部等待,随后连锁反应引发锁等待超时。后来我把事务方法里不允许出现sleep、不允许出现远程调用写进了代码审查规则。事务是数据库层面的保证,但事务的生命周期是由代码决定的,不控制好代码,ACID再强大也救不了你。