先泼一盆冷水:SQL事务如果只停留在“BEGIN、COMMIT、ROLLBACK”这三板斧,那确实没什么好写的。但我在实际项目里见过太多表面光鲜、一到并发就翻车的代码——库存扣成负数、订单重复支付、对账怎么都对不上。问题往往不是SQL写错了,而是事务根本没被当回事。这篇文章我打算抛开教科书式的定义,直接从业务场景和实操细节出发,把事务的适用边界、隔离级别的选择、代码里的正确写法、以及线上最容易踩的坑完整过一遍。内容会比较长,建议收藏后慢慢看。
1. 先想清楚:你的操作真的需要事务吗
1.1 事务不是数据库的“保底机制”
很多开发者的习惯是:只要写UPDATE、DELETE就套一个事务,仿佛加了事务就万事大吉。但事务不是保险柜,它本质上是一套并发控制协议,是有成本的。每一次事务提交都要经历日志写入、锁释放、隔离检查,事务开得越久、涉及的行越多,数据库的负担就越重。
举个最直观的例子:一个纯查询接口,比如根据用户ID查订单列表,完全没有写操作,你给它强行套上事务,除了白白占用连接、增加锁竞争,没有任何收益。MySQL里一条SELECT如果不在显式事务中,走的是快照读,根本不需要你手动BEGIN。
反过来,有些场景必须用事务但经常被忽略:比如“先扣库存,再生成订单,最后清空购物车”,这三步任何一步失败,数据都会处于中间状态——库存扣了但订单没生成,或者订单生成了但购物车没清掉。这种多步写操作,才是事务真正的用武之地。
1.2 判断是否需要事务的三个信号
我判断一个操作要不要开事务,基本就看三点:
第一,是否涉及多个写操作且这些操作存在逻辑上的整体性。所谓整体性,指的是“要么全部成功,要么全部失败”。最典型的就是转账:A账户扣钱、B账户加钱,只成功一半就是事故。
第二,是否处于并发写竞争的场景。比如秒杀场景下的库存扣减,多个请求同时更新同一条库存记录。没有事务的隔离性保护,两个请求同时读到库存为10,各自扣1,最后库存变成9而不是8,数据就错了。
第三,是否存在先读后写的依赖关系。比如“查一下当前剩余名额,如果有剩余再插入一条预约记录”。这种“读-判断-写”的流程,事务和锁必须配合使用,否则中间时刻可能有其他人插入,导致名额超卖。
1.3 单条SQL自动提交的错觉
这里要特别提醒一个初级开发者常有的误解:以为单条UPDATE自带事务保护。没错,单条UPDATE确实是原子的——要么整行更新成功,要么失败回滚。但这和“事务”完全是两码事。
单条SQL的原子性只保证了“这条语句”的完整性,一旦你需要两条SQL协同工作,比如先更新订单状态,再扣减库存,第一条成功了、第二条失败,单条SQL的原子性就帮不了你了。数据照样处在不一致的中间状态。所以事务的最小单位,取决于你的业务逻辑需要多少条SQL共同完成一个不可分割的操作,而不是SQL本身的单条语义。
2. ACID四个特性到底在保护什么
2.1 原子性:要么全做,要么全不做
原子性其实是个很朴素的承诺:事务里的所有操作被视为一个不可分割的整体。实现它的机制是回滚日志,概念上类似“操作前的快照记录”。一旦事务中某一步失败,数据库会沿着回滚日志把已执行的操作全部还原。
我在实际工作中见过一个比较典型的错误:业务代码里手动捕获了SQL异常,日志里打了一句“更新失败”,然后继续往下执行,把后续的SQL也跑了。这样事务虽然存在,但你主动破坏了原子性——因为异常被你吃了,事务感知不到失败,自然也就不会回滚。正确的做法是只要任何一个环节失败,都要让异常冒泡,或者显式调用ROLLBACK。
2.2 一致性:最容易被忽视的业务红线
相比另外三个特性,一致性更偏向业务层面。数据库只能保证事务从一个合法的状态转移到另一个合法的状态,至于什么是“合法”,需要业务代码自己定义。比如转账前账户余额为100,转出50后余额为50,这是合法状态;但如果余额变成负数而业务规则不允许透支,数据库本身不会自动拦截,它需要你在SQL里用约束或者业务判断来保证。
一致性经常被忽视,是因为它不像原子性那样有明确的回滚日志作为技术支撑。我在订单系统里见过一句话总结得很到位:“约束是数据库替你把关,校验是应用自己把关,缺一个都谈不上一致性。”比如库存表加了一个“剩余数量大于等于0”的检查约束,那么超卖在数据库层面就会被硬性拦截;如果没加,你就必须在应用代码里用锁或条件更新来保证。
2.3 隔离性:并发环境下不互相干扰
隔离性解决的是“多个事务同时操作同一批数据时,彼此怎么不产生干扰”。干扰的表现有好几种,脏读、不可重复读、幻读都属于这一类。数据库通过锁和多版本并发控制(MVCC)共同实现隔离性。MVCC的思路是:每个事务看到的数据不是最新的,而是自己开始时点的一个快照,就像你拍了一张照片,之后别人怎么改都不影响你看到的画面。
但这引出一个重要结论:隔离性并不是越强越好。隔离级别越高,并发能力越差。把隔离级别调到最高“串行化”,确实不会出现任何并发问题,但代价是事务之间几乎完全排队执行,性能大幅下降。所以隔离性的本质是“在数据正确性和并发性能之间做权衡”。
2.4 持久性:数据写入不能只活在内存里
持久性最直观的理解是:事务一旦提交,数据就不能丢,即使数据库瞬间断电。它的实现依赖预写式日志:先写日志,再改数据。日志一旦落盘,事务就算提交成功,之后系统崩溃了也能根据日志把数据恢复回来。
这里有一个实操上的教训:有些数据库连接池配置了自动事务提交关闭,但业务代码提交事务后立即返回成功给前端,前端又马上通知用户“操作成功”。如果此时数据库日志还没来得及真正落盘(比如配置了异步刷盘),极端情况下的确可能出现数据丢失。虽然这种场景很少见,但在金融类系统里,“事务提交成功”和“数据可持久化”必须画等号,配置上要确认日志刷盘策略。
3. 隔离级别:最常被误解和忽略的设计决策
3.1 四种隔离级别与三类并发异常
SQL标准定义了四种隔离级别,从低到高分别是:读未提交、读已提交、可重复读、串行化。不同级别对不同异常现象的容忍度如下表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能出现 | 可能出现 | 可能出现 |
| 读已提交 | 不会 | 可能出现 | 可能出现 |
| 可重复读 | 不会 | 不会 | MySQL中基本不会(间隙锁) |
| 串行化 | 不会 | 不会 | 不会 |
脏读是指读到了另一个事务未提交的数据;不可重复读是指同一个事务内两次读取同一行,结果不一样;幻读是指同一个事务内两次执行同一条件查询,返回的行数不一样。三类异常里,脏读后果最严重,因为它读到的可能是最终被回滚的数据,属于彻底不可信的信息。
3.2 数据库默认隔离级别的差异
这也是最容易踩的跨数据库坑:MySQL默认隔离级别是可重复读,PostgreSQL默认是读已提交,Oracle同样默认读已提交,SQL Server默认也是读已提交。
很多从Oracle跳到MySQL的开发者觉得可重复读“更安全”,从MySQL跳到PostgreSQL的开发者觉得读已提交“有风险”。其实两种默认值都是各自数据库基于MVCC实现和用户习惯权衡后的选择。关键在于你要清楚自己所在的数据库默认是什么级别,并在部署说明里写清楚,防止团队里不同人有不同理解。
3.3 MVCC解决了什么,没解决什么
MVCC(多版本并发控制)的核心思想是让读操作不阻塞写操作、写操作不阻塞读操作。它通过保存数据的多个历史版本,让每个事务在合适的时机选择自己应该看到的版本。
举例来说,事务A在10点查询订单表,看到订单状态为“待支付”。随后事务B把该订单状态改为“已支付”并提交。如果隔离级别是可重复读,事务A再次查询时看到的仍然是“待支付”——因为A的快照在10点就定下来了;如果隔离级别是读已提交,A的每次查询都会生成新的快照,第二次查询就会看到“已支付”。
但MVCC解决不了写冲突。两个事务同时对同一行做更新时,后更新的事务会阻塞等待,直到第一个事务提交或回滚。MVCC只是把“读”和“写”解耦了,“写”与“写”之间依然要靠锁来协调。
3.4 实战中的隔离级别选择建议
从实际项目出发,我的建议是:
- 绝大多少业务用数据库默认隔离级别就够了。不要因为担心并发问题而随便提升隔离级别,那是用性能换安全感。
- 如果需要避免不可重复读,先考虑业务上是否能用唯一键、版本号等手段在应用层解决,而不是直接上串行化。
- 如果判断一个事务必须使用可重复读,要在代码注释里写明原因,比如“此查询依赖事务内的两次读取结果一致,必须使用RR”,避免后续维护者“优化”掉了必要的隔离级别。
4. 事务代码的正确打开方式
4.1 隐式事务、显式事务与自动提交
日常开发中接触事务有三种方式。第一种是隐式事务,每一条SQL单独作为一个事务,执行完自动提交,这是很多数据库客户端的默认行为。第二种是显式事务,通过BEGIN或START TRANSACTION手动开启,最后以COMMIT或ROLLBACK结束。第三种是自动提交模式,在连接层面设置autocommit参数,决定每条SQL是否自动提交。
在应用代码里,我强烈不建议依赖隐式事务。因为每条SQL自动提交意味着多步操作之间隔着一个隐形边界,一旦中途失败,前面已提交的SQL无法回滚。显式事务把边界写得明明白白,团队协作时别人一看代码就知道哪些操作是一个整体。
4.2 一个惨痛教训:隐式提交的坑
隐式提交还有一个非常隐蔽的坑:某些数据库在执行DDL语句时,如CREATE TABLE、ALTER TABLE,会自动提交当前事务。这意味着你开了一个事务,执行了几个UPDATE,中间混入一个ALTER TABLE,前面的UPDATE全被自动提交了,后续再ROLLBACK根本无济于事。
MySQL官方文档明确把这类语句称为“隐式提交触发语句”。我当时排查一个线上数据回滚失败问题,查了许久才发现是事务里嵌了一条建临时表的语句,把事务提前提交了。从那以后,我给自己定了一条规矩:事务里绝不混入DDL,临时表也尽量在事务开始前建好,或者用事务内的临时表时提前确认数据库行为。
4.3 标准的事务边界代码模板
以应用语言里常见的事务管理为例,一个规范的事务模板大致如下:
// 以Java的伪代码为例 public void transfer(Long fromAccount, Long toAccount, BigDecimal amount) { Connection conn = dataSource.getConnection(); boolean originalAutoCommit = conn.getAutoCommit(); try { conn.setAutoCommit(false); // 1. 扣减转出账户余额,并检查结果 int fromRows = accountDao.decreaseBalance(conn, fromAccount, amount); if (fromRows == 0) { throw new BizException("转出账户余额不足"); } // 2. 增加转入账户余额 accountDao.increaseBalance(conn, toAccount, amount); // 3. 插入流水记录 accountDao.insertTransactionLog(conn, fromAccount, toAccount, amount); // 4. 全部成功才提交 conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(originalAutoCommit); conn.close(); } }几个关键细节:第一,事务必须从获取连接后立即开始,不要在业务代码里先查一堆数据再开事务,这样会扩大事务边界,增加锁持有时间。第二,finally里恢复原始autocommit设置很重要,否则连接归还连接池后状态是脏的,下一个人复用时会出莫名其妙的问题。第三,回滚之后一定要把异常继续抛出去,让上层感知到失败,而不是吞掉异常继续执行。
4.4 事务里千万别干耗时的事
一条评测标准我反复给团队强调:事务应该像“过安检”一样快进快出。在事务里做远程调用、HTTP请求、消息队列发送、复杂的文件处理,都是大忌。
原因很简单:事务持有数据库连接和锁资源,外部调用的耗时不可控,一旦对方响应慢,事务就长时间不提交。高并发下,连接池被占满,其他请求拿不到连接,系统就大面积卡死。我处理过一次线上事故,就是因为下单事务里嵌了一个发送短信的HTTP调用,服务商响应超时几十秒,导致数据库连接池耗尽,整个下单接口全部超时。
正确的做法是把事务外操作移到事务生命周期之外。例如,事务里只更新订单状态和库存,事务提交成功后再推送消息、发送通知。如果后续操作失败,可以用补偿任务处理,而不是拖住事务。
5. 经典场景拆解:超卖、转账与订单
5.1 库存扣减:少用一个条件就超卖
秒杀场景的库存扣减是最经典的事务问题。错误示范是这样的:先查询库存,判断大于0,再执行UPDATE减少库存。
-- 错误写法 SELECT stock FROM product WHERE id = 1; -- 业务判断 if (stock > 0) ... UPDATE product SET stock = stock - 1 WHERE id = 1;问题在于“查询判断”和“更新”之间存在时间窗口,两个并发事务可能同时读到stock为1,都判断可以扣减,各自执行UPDATE,最终stock变成-1。超卖就是这么来的。
正确写法有两种。第一种是条件更新,把判断放到UPDATE语句里:
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;受影响行数为0就说明库存不足,不需要查出来再判断。第二种是使用悲观锁,配合事务里的SELECT ... FOR UPDATE,把目标行锁住,防止其他事务同时修改。条件更新的写法简单且性能更好,我优先推荐。如需更强的防重语义,可以配合唯一索引、结果幂等表等方法兜底。
5.2 转账:数据库只会保证一致性,不会代替业务判断
转账场景我拿它讲一致性:A给B转账100元,事务里扣减A、增加B、写流水。数据库保证这三个操作要么都提交、要么都回滚,但数据库不会自动判断“A余额够不够”。
所以转账事务里必须有余额校验,而且这个校验要做成原子操作,而不是先查再判断。你可以把扣减写成:
UPDATE account SET balance = balance - 100 WHERE id = 'A' AND balance >= 100;如果返回0行,说明余额不足,直接抛异常回滚。这种通过UPDATE本身携带条件的方式,把业务校验和数据操作合并成一条原子SQL,比“查询-判断-更新”可靠得多。
另一个容易忽略的问题是“ABA”:并发下余额被改了个来回,你以为数据一致,实际中间状态被其他事务看到了。如果业务允许,给账户表加一个版本号字段,每次更新带上version = version + 1,可以更稳妥地解决这类并发更新问题。
5.3 订单创建:事务里的状态机流转
订单系统里事务又是一个典型场景:创建订单、扣库存、锁优惠券、加积分,多个写操作来自不同领域,但必须在同一事务里完成,否则会出现“订单建了但优惠券没用掉”的脏数据。
我的建议是:订单状态字段尽量使用状态机驱动,事务中只允许合法的状态迁移。比如新订单只能从“待支付”流转到“已关闭”或“已支付”,不能从“已支付”跳回“待支付”。在事务里更新订单状态时,SQL里带上旧状态条件:
UPDATE orders SET status = 'PAID' WHERE order_id = 123 AND status = 'WAITING_PAY';返回0行说明状态已被其他事务改动,此时要回滚,避免覆盖他人的更新。这种方式把状态机的约束落到了数据库层,比纯应用层判断更可靠。
6. 事务实战中的常见坑与排查思路
6.1 长事务:连接池和日志的双重杀手
长事务指的是从开启到提交之间持续很久的事务。它的危害主要体现在三处。第一,长时间持有锁,拖垮并发;第二,回滚日志不断膨胀,因为回滚信息要保留到事务结束才能清理,数据量一大就可能撑爆磁盘;第三,主从延迟加剧,从库要等主库提交后才能应用对应变更。
排查长事务的方法,以MySQL为例,可以查询当前运行中的事务列表:
SELECT * FROM information_schema.innodb_trx;重点关注trx_started字段,看事务已经运行了多久。定位到具体事务后,结合trx_query或应用日志找出对应的业务代码,检查事务边界是不是划得太大、事务内有没有外部调用。
6.2 死锁:两个事务互相“占座”
死锁的经典场景是:事务A锁了表1,想再锁表2;事务B锁了表2,想再锁表1。两边互相等待,谁也不让。解决死锁的根本思路是让所有事务以相同的顺序访问资源。
比如一组业务涉及账户表和订单表,所有代码都先锁账户表、再锁订单表,就不会产生循环等待。另一个降低死锁概率的方法是尽量缩小事务范围、缩短锁持有时间,减少锁重叠窗口。数据库本身也会检测死锁并让其中一个事务回滚,所以应用层必须处理死锁异常的重试逻辑。我的经验是:死锁异常出现后,捕获它、等待一小段随机时间、重试整个事务,通常就能绕过冲突窗口,但重试次数要有上限,防止无限循环。
6.3 保持点(SAVEPOINT):部分回滚的妙用
SAVEPOINT是一个很少被讲透但又很实用的能力。它允许你在一个事务内设置保存点,回滚时可以只回滚到保存点,而不影响保存点之前已执行的操作。
BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 'A'; UPDATE account SET balance = balance + 100 WHERE id = 'B'; SAVEPOINT sp1; INSERT INTO transfer_log (from_id, to_id, amount) VALUES ('A', 'B', 100); -- 如果插入流水失败,只想回滚插入这一步 ROLLBACK TO SAVEPOINT sp1; COMMIT;上面的例子中,如果流水表插入失败,回滚到保存点后,前面两笔余额更新仍然有效,事务最终依然可以提交。这种能力在处理“主操作必须成功、从操作可重试”的场景很好用,但要注意保存点会随着事务的提交或完全回滚而释放,不要试图跨事务使用。嵌套事务在很多数据库里其实没有真正独立的事务语义,用SAVEPOINT模拟部分回滚才是标准做法。
6.4 排查事务问题的调查思路
如果线上出现事务相关问题,比如数据不一致、锁等待、性能劣化,我建议按这个顺序排查。
先看监控指标:活跃连接数是否接近连接池上限、有无锁等待超时告警、主从延迟是否变大。再看当前事务和锁:查询正在运行的事务状态,检查有没有长时间未提交的事务,查看当前有哪些锁等待。然后顺着具体SQL找代码:把SQL放到事务上下文里看它的执行时间,看是否锁定了过多行,看事务边界是不是被拉得过长。最后确认隔离级别和参数配置:数据库默认隔离级别、锁等待超时时间、事务超时时间等,这些参数决定了事务遇到冲突时是立刻报错还是长时间卡住。
这套流程排查过几个问题案例,基本都能快速定位到根因。建议开发同学在本地就养成习惯,写代码时多想想“我这个事务有多大范围、锁多少行、持续多久”,很多线上事故根本不会发生。
7. 事务实践里,最后想说的几条经验
做数据库开发这几年,事务相关的问题带给我的教训比任何一本教科书都深刻。哪怕你现在把所有隔离级别背得滚瓜烂熟,到了线上,一个毫不起眼的连接配置、一条藏在深处的DDL,照样能让整个系统崩溃。
我自己常用的几条土办法,分享给大家。第一,所有写接口的代码评审,先问三个问题:事务边界在哪里?事务里有没有外部调用?失败后能不能可靠回滚?第二,在测试环境专门做并发压测,模拟“同一用户重复下单”、“同一商品大量抢购”的场景,很多问题只有并发才会暴露。第三,数据库设计的兜底原则:能用约束实现的合法性校验,比如库存非负,就不要只依赖应用代码,数据安全永远要留一道数据库层面的防线。
如果你把事务当成一种思维方式,而不是一组命令,那么从设计表结构的第一天起,你就会开始思考“失败的时候数据怎么办”。这种思路,比记住任何一条SQL语法都更值钱。