资讯动态

MySQL事务详解:从ACID到JDBC实操,避开事务失效的坑

发布时间:2026/10/17 17:18:51 来源:尧图企业网站定制
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)后面再执行UPDATEMySQL直接当前语句就提交了rollback根本无从谈起。事务框架跑在不支持事务的引擎上纯属白费力气。判断表引擎很简单执行SHOW TABLE STATUS LIKE 表名看Engine字段或者直接查information_schema.TABLES表。在建表语句里也可以显式指定ENGINEInnoDB。生产环境我一般建议所有表都用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是mysqlartifactId是mysql-connector-java这是MySQL官方的Connector/J驱动。连接URL是很多人忽略细节的地方。最基本的形式是jdbc:mysql://localhost:3306/yourdb但事务相关还有几个参数值得关注useSSLfalse本地开发或内网环境可以关掉SSL省去证书配置的麻烦生产环境按安全要求开启。serverTimezoneAsia/Shanghai不设置时区参数在部分环境下会报错或时间错乱。allowMultiQueriestrue这个要谨慎允许一条语句里写多个SQL事务边界会变得不好控制一般不建议开。连接URL本质上就是给你的连接指定行为配置很多隐蔽的“事务为什么不生效”问题最后都查到是URL参数不对。比如autoReconnecttrue这个参数如果连接断掉后自动重连之前的事务状态就丢了代码里如果没有感知后续操作会在新连接上自动提交事务一样失效。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这件事大多数教程不会写但连接池场景下必须做。假如某个连接回到连接池时autoCommitfalse下一个请求拿到这个连接执行一条查询没提交也没回滚那条查询的事务状态会一直挂着数据看着没问题但表被锁住的情况时有发生。模板里的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默认的autocommit1你执行一条UPDATEMySQL自动帮你提交这对于单条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再强大也救不了你。

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

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

免费获取报价 →
↑