数据库事务处理这个话题,我干了十年数据库相关的工作之后,依然觉得它是整个数据系统的承重墙。很多人第一眼看到“数据库存事务处理”这个标题,会以为打错字了,但把“事务处理”四个字放大来看,你会发现几乎所有线上数据事故,最后都要归因到事务边界、隔离级别或者锁的姿势不对上。事务处理本质上是在回答一个问题:当数据库同时面对成千上万个读写请求,怎么保证数据不错乱、业务不会稀里糊涂。这篇文章不打算从教科书概念念起,而是从实际开发里最常碰到的转账场景、并发扣库存、死锁报错入手,拆开事务的原子性、隔离性、持久性和你写的每一行SQL之间的关系。适合刚接触数据库的开发,也适合想把自己的事务经验整理成一套方法论的同学。
1. 先弄懂事务到底在解决什么问题
1.1 一个转账场景逼你理解原子性
很多年前我接手过一个支付系统的改造,核心链路就是一个用户把钱从A账户转到B账户。当时数据库里只有两张表,一张用户余额表,一张流水表。转账这件事拆成两个操作:从A账户扣钱,往B账户加钱。如果这两步之间完成第1步之后,程序突然抛异常,或者数据库连接断掉,那么A的钱少了,B的钱没多,账就平不了。没有事务之前,开发只能靠手工对账去补差价,一次两次还行,并发一上来就彻底没法看。
我当时给团队的方案极其简单:把这两条update语句包进事务里,任何一步失败就整体回滚。
START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 'A'; UPDATE account SET balance = balance + 100 WHERE user_id = 'B'; COMMIT;这就是事务的原子性:一组操作要么全部成功,要么全部不生效。别觉得只有银行才需要这个,任何涉及“多个写操作必须同时成功或同时失败”的业务都需要,比如订单和库存同步扣减、创建主表记录和子表明细、给用户加积分同时写积分流水。你如果靠try-catch加上人工补偿,等于把数据库该干的活硬揽到自己身上,早晚要出事。
1.2 ACID不只是四个字母,每条都是血泪教训
教科书说事务有ACID四个特性,原子性、一致性、隔离性、持久性。我见过不少简历上写“熟悉事务”,但一细问就露馅。原子性刚才说了,靠undo log回滚保证。隔离性是说多个事务并发执行时,互相之间不能产生乱七八糟的干扰,这就要靠锁和MVCC。持久性最简单也最容易踩坑:事务一旦提交,数据必须永久保存,哪怕下一秒数据库宕机。这个不是靠你以为的“commit了就是写进磁盘了”来保证,而是靠redo log先落盘。
一致性最容易被人说成玄学。我自己的理解是,一致性不是数据库单方面能保证的,它需要应用配合。比如转账前A有1000,B有0,转账100后,A有900,B有100,总额还是1000,这就是一致性的体现。数据库通过事务的原子性、隔离性、约束机制,帮你把数据库从一种合法状态推向另一种合法状态。如果你在应用层写了“余额可以为负”的逻辑,事务再强大也救不了你。
ACID这四个字母,学的时候感觉是概念,用的时候才知道每一条背后都有对应的场景。我调过很多线上问题,到最后查出来的原因都是这四个特性被破坏,而且大部分不是数据库坏了,是开发没理解特性边界。
2. 事务处理里的核心细节:从BEGIN到COMMIT
2.1 三条基本命令,撑起所有事务控制
无论是MySQL、PostgreSQL还是Oracle,事务控制的基本动作大同小异:开启事务、提交、回滚。
-- 方式一:显式开启 START TRANSACTION; ... 你的SQL ... COMMIT; -- 或者 ROLLBACK;MySQL里有个特别坑的设置,叫autocommit。默认值是1,意味着你执行的每一条单句SQL都被当成一个独立事务自动提交。很多新手以为我写了一个update没提交,别人应该看不到,但实际因为autocommit的关系,数据早就落库了。这个问题在写存储过程、调试脚本时特别容易炸。所以如果你要手动控制事务,第一条命令最好是先确认当前会话的autocommit状态:
SHOW VARIABLES LIKE 'autocommit'; SET autocommit = 0;还有一个很有用的细节,就是使用SAVEPOINT。比如你要批量导入1万条数据,每处理1000条设置一个保存点,一旦某批次出错,只需回滚到上一个保存点,而不是整批数据全部推倒重来。
START TRANSACTION; INSERT INTO t VALUES (1); SAVEPOINT sp1; INSERT INTO t VALUES (2); -- 假设这里出错了 ROLLBACK TO sp1; COMMIT;这样既保留了保存点之前的数据,又舍弃了出错之后的部分。我在做数据清洗脚本时经常用这招,比整段回滚再重新跑效率高得多。
2.2 事务边界怎么划:别再一个请求一个事务了
我见过太多从Spring项目里学了个@Transactional注解就开始到处用的开发,最后线上一个慢接口,数据库锁竞争一堆。事务不是越多越好,也不是越“大”越好。事务执行时间越长,它持有的连接和锁就越久,其他事务就得等,系统整体吞吐量瞬间被拖垮。
一个典型的坏案例:用户注册接口里,发现要做的事很多——写user表、写user_detail表、发短信验证码、调外部风控接口、写一条日志。有人一股脑全包在事务里,觉得“一步成功才算成功”。结果短信接口超时3秒,数据库事务里那几把行锁就被捏了3秒。想一下高并发场景下,几百个用户同时注册,后面的人全堵在锁上,数据库连接池也会被占满。
我后来给团队定的规矩很简单:
- 事务里只放数据库读写操作,远程调用、消息发送、文件处理全部挪到事务外。
- 如果业务确实需要“先写主表,再发消息,消息处理失败要回滚”,应该用最终一致性方案,比如本地消息表+定时任务,而不是死抱着数据库事务不放。
- 一个方法只做一个完整业务动作,比如“扣库存+生成订单”可以被认为是一个动作,“扣库存+生成订单+发优惠券+通知用户”绝对不是,后者应该拆成多个事务。
事务边界划得好不好,在线下很难看出来,一旦上了线上,高并发一压过来,差的就是一个数量级的性能。
2.3 回滚日志与崩溃恢复的底层逻辑
事务为什么能回滚?靠的是undo log。数据库在修改数据之前,会先把修改前的旧值写入undo log,如果事务回滚,就把旧值再写回去。为什么commit之后断电了,数据还能找回来?靠的是redo log。提交事务的时候,数据库并不是把所有的数据页都刷新到磁盘,而是先把一条“我做了这个修改”的日志刷到磁盘。等系统崩溃重启时,数据库根据redo log重放这些修改,这就叫Write-Ahead Logging(WAL),先写日志,再改数据。
理解了WAL,你就明白为什么生产环境MySQL的参数innodb_flush_log_at_trx_commit必须设置为1。这个参数控制每次事务提交时redo log刷盘的方式:
| 参数值 | 含义 | 风险 |
|---|---|---|
| 1 | 每次事务提交都把redo log刷到磁盘 | 性能略低,但最安全,断电不丢已提交事务 |
| 0 | 每秒刷一次,由后台线程刷盘 | 性能好,但数据库进程崩溃可能丢1秒内提交的数据 |
| 2 | 每次提交写入操作系统缓存,每秒刷盘 | 操作系统崩溃可能丢数据,MySQL进程崩溃不丢 |
很多开发为了压测数据好看,偷偷把参数改成0或2,结果赶上机器宕机,数据丢了才开始追责。我个人的建议是:核心业务永远用1,非核心的日志流水可以接受秒级丢失,才去考虑0或2。你既然要讨论事务处理,就得对持久性有敬畏心。
3. 并发控制:为什么事务慢你更该开心
3.1 三类经典并发问题:脏读、不可重复读、幻读
事务处理之所以难,是因为并发。数据库里同时有多个事务操作同一批数据时,会出现三种经典问题:
脏读:事务A改了一行,还没提交,事务B读到了这个修改后的值。然后事务A回滚了,B刚才读到的就是一个不存在的数据,拿去用了就要出大事。
不可重复读:事务A先读了一行,事务B改了这一行并提交,事务A再读同一行,发现内容变了。A在同一事务里,两次读到不一样的东西,这叫不可重复读。
幻读:事务A按条件查询得到10行,事务B插入一条新记录并提交,事务A再按同样条件查,发现多了一行,就像见了鬼一样。这就是幻读。
还有一个很容易被忽略的丢失更新:两个事务同时对同一行做“读出来-修改-写回”这种操作,后提交的覆盖了先提交的,谁都没错但数据就是错了。解决的方式可以是乐观锁(版本号)或者悲观锁(SELECT FOR UPDATE)。
3.2 隔离级别怎么选:可重复读不是万能
SQL标准定义了四种隔离级别,实际上就是允许不同的问题发生或不发生:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | 可能(InnoDB实际可避免) |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
MySQL InnoDB默认是REPEATABLE READ,并且通过MVCC加上间隙锁,实际把幻读也解决了。PostgreSQL和Oracle默认是READ COMMITTED。所以你要问“到底选哪个”,我的答案是:不要无脑上SERIALIZABLE,那会牺牲大量并发性能。读多写少、允许同一事务内第二次查询结果略有一些变化的业务,用READ COMMITTED就够。需要保证事务内多次查询结果必须完全一致,比如统计报表、金额计算,用REPEATABLE READ。
MVCC(多版本并发控制)是理解这些问题的钥匙。InnoDB里每行数据有多个历史版本,普通select是快照读,读的是事务开始时的快照,不加锁;而update、delete、select for update是当前读,必须读到最新版本并且加锁。读写并不完全互斥,这也是为什么MySQL默认隔离级别下,普通的select不会堵住别人的更新。
3.3 行锁、间隙锁与临键锁的取舍
并发控制落到锁的层面,InnoDB有几种锁:Record Lock锁住一条记录,Gap Lock锁住一个范围但不管范围内的记录本身,Next-Key Lock是前两者的组合,既锁记录也锁范围,用来防止幻读。
最容易引发生产事故的,是“更新语句没有走索引”。InnoDB的行锁是建立在索引上的,如果你更新数据时where条件没有可用的索引,数据库找不到要锁的精确行,就只能退化成锁表。我遇到过不止一次,一条update不带where条件或者where条件列无索引,直接把整张表锁住,所有select全卡住,线上业务直接停摆。排查时看进程列表,一堆SQL都在等锁。
所以加索引这件事,不只是为了查询快,更是为了让锁的粒度尽可能细。另外要注意,当你通过二级索引更新行时,InnoDB除了锁二级索引记录,还会锁对应的主键索引记录,如果SQL设计复杂,可能造成比预想更多的锁等待。锁粒度越细,并发能力越强,这是数据库优化里性价比很高的方向。
4. 我的真实踩坑记录:死锁与长事务
4.1 死锁排查全过程:从死锁日志到解决方案
有一次线上系统突然出现大量“Deadlock found when trying to get lock; try restarting transaction”的报错,我当时第一个反应就是死锁了。打开MySQL的InnoDB监控:
SHOW ENGINE INNODB STATUS\G;在输出最下方有一块LATEST DETECTED DEADLOCK,里面列出了两个事务分别持有什么锁、等待什么锁、然后哪一条SQL被选为牺牲者回滚。那次场景是库存扣减:
事务1先更新商品ID为1的库存,再更新商品ID为2的库存。 事务2先更新商品ID为2的库存,再更新商品ID为1的库存。
两个事务互相持有对方要的锁,谁也不让谁,InnoDB死锁检测机制介入,回滚其中一个,另一个继续。回滚的那个应用层收到异常,但如果你没有重试机制,用户就看到了失败。后来我给的方案是统一加锁顺序,订单里涉及多个商品时,先对所有商品ID排序,再依次更新库存。此后这个问题再没出现过。
4.2 死锁的常见套路和预防策略
除了上面那种经典的交叉锁死锁,还有几种高频死锁套路:
- 同时更新一张表的多个行,但SQL里的条件顺序不一致。比如一个事务先
UPDATE t SET ... WHERE id IN (2, 1),另一个先id IN (1, 2),加锁顺序不同,就可能死锁。 - 范围查询加上间隙锁,两个事务同时往一个区间插入数据,互相阻塞。
- 同一行先SELECT后UPDATE,一个用快照读,一个用当前读,最后升级锁时产生冲突。
预防经验,整理成我自己的检查清单:
- 所有事务脚本,涉及多行更新的,先排序再执行,统一资源访问顺序。
- 事务尽可能短,锁持有时间越短,死锁概率越低。
- 尽量通过唯一索引命中记录,减少间隙锁范围。
- 能不用
SELECT FOR UPDATE就不用,非要用必须走索引。 - 如果并发极其密集,可以在应用层引入分布式锁或队列,把并发变成串行。
看到死锁先别慌,大多数死锁都是可以通过调整顺序或者缩小锁范围解决的。真正可怕的是锁等待超时,那通常意味着有长事务或者漏索引。
4.3 长事务与连接池的“相爱相杀”
长事务就是长时间不提交也不回滚的事务。它的危害是隐蔽的:一直占着一个数据库连接,连接池慢慢被耗尽;一直持有锁,阻塞别人;还会让undo log不断膨胀,因为MVCC需要保留旧版本,事务不结束,历史版本就不让清理。MySQL 8.0里可以直接查当前事务信息:
SELECT * FROM information_schema.innodb_trx\G;重点看trx_started,如果某个事务的trx_started特别早,而trx_state还是RUNNING,基本就是长事务逃不掉了。再配合performance_schema.data_lock_waits可以看到它在等谁的锁。
有一次我排查线上连接池被打满,发现一个开发同学写了个批处理,在一个for循环里开了事务,每处理一条数据还调一次外部接口,接口超时后事务一直没提交,连接直接占住不放。批处理跑了一个下午,连接池里的连接全被他一个人占光了。处理方式很简单:批处理每处理一定数量提交一次,外部接口的等待时间设置上限,超时就抛异常回滚当前批次。
5. 不同数据库的事务实现差异
5.1 MySQL InnoDB:经典MVCC加聚簇索引
MySQL的InnoDB是把数据和主键索引存在一起的,也就是聚簇索引。每一行上有隐藏的DB_TRX_ID记录最后一次修改这个事务的ID,还有DB_ROLL_PTR指向undo log中旧版本的位置。普通select会通过版本链判断哪些版本对当前事务可见,这就是快照读。而写操作是当前读,要拿最新的锁。
理解了这套结构,你就明白为什么InnoDB默认的REPEATABLE READ下,普通的并发读和写可以同时进行。快照读完全走历史版本,读不会挡写,写也不会挡读。这也是为什么InnoDB在高并发OLTP场景下能撑住的原因。
5.2 PostgreSQL与Oracle:各有各的脾性
PostgreSQL的MVCC跟InnoDB不一样。它把历史版本直接存在表数据文件里,通过xmin和xmax系统列判断行版本的可见性。所以一次update在PG里背后是“插入新版本+标记旧版本失效”,表会不断膨胀,需要定期跑VACUUM来清理垃圾版本。如果你遇到过PG表越跑越大,但数据量没怎么涨,多半就是版本堆积没清理。
Oracle的事务一致读则依赖undo表空间,事务启动时会根据SCN构造一个一致性快照,读到的都是这个时间点的数据。undo表空间太小会导致“快照太旧”的报错。
国产数据库这里不展开讲,但大部分基于PostgreSQL或InnoDB架构,事务行为跟上游保持一致。你在迁移时不要想当然,先查默认隔离级别、锁实现、死锁检测方式,再动手改写SQL,能省很多麻烦。
6. 事务处理排查工具箱:SQL与监控指标
我平时排查事务问题,最常用的就是这几条SQL,建议直接存进自己的工具包:
查看当前正在运行的事务:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;查看锁等待关系(MySQL 8.0):
SELECT * FROM sys.innodb_lock_waits\G;直接输出InnoDB状态,重点看死锁、锁等待、事务池等段落:
SHOW ENGINE INNODB STATUS\G;确认死锁检测和锁等待超时时间:
SHOW VARIABLES LIKE 'innodb_deadlock_detect'; SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';死锁检测默认是ON,我之前也想过要不要关掉,因为高并发下死锁检测本身有开销,但后来发现关了之后,可能死锁会拖到锁超时才能解,系统卡顿时间更长,还是保持默认最好。锁等待超时默认50秒,业务侧等不了这么长,可以根据容忍度调到5秒或10秒,尽早失败,也好尽早重试。
监控层面,建议把这几项做成指标:活跃事务数、最长事务运行时间、锁等待次数、锁超时次数、死锁次数。不用搞得多复杂,只要能观察到趋势,就能在事故出现前提前处理。
最后再分享一个体会:事务处理这件事,写代码之前想清楚比写代码时补救重要得多。事务边界、索引设计、隔离级别、锁顺序,这些东西在表结构设计阶段就该定好,而不是等线上崩了再去改。每一条踩过的坑,本质上都是没有提前理解数据竞争的本质。希望这篇内容能帮你少走一点弯路,至少遇到死锁和长事务的时候,心里能多条排查路径。