ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数据库事务处理实战:ACID、隔离级别与死锁排查指南

数据库事务处理实战:ACID、隔离级别与死锁排查指南 数据库事务处理这个话题我干了十年数据库相关的工作之后依然觉得它是整个数据系统的承重墙。很多人第一眼看到“数据库存事务处理”这个标题会以为打错字了但把“事务处理”四个字放大来看你会发现几乎所有线上数据事故最后都要归因到事务边界、隔离级别或者锁的姿势不对上。事务处理本质上是在回答一个问题当数据库同时面对成千上万个读写请求怎么保证数据不错乱、业务不会稀里糊涂。这篇文章不打算从教科书概念念起而是从实际开发里最常碰到的转账场景、并发扣库存、死锁报错入手拆开事务的原子性、隔离性、持久性和你写的每一行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有1000B有0转账100后A有900B有100总额还是1000这就是一致性的体现。数据库通过事务的原子性、隔离性、约束机制帮你把数据库从一种合法状态推向另一种合法状态。如果你在应用层写了“余额可以为负”的逻辑事务再强大也救不了你。ACID这四个字母学的时候感觉是概念用的时候才知道每一条背后都有对应的场景。我调过很多线上问题到最后查出来的原因都是这四个特性被破坏而且大部分不是数据库坏了是开发没理解特性边界。2. 事务处理里的核心细节从BEGIN到COMMIT2.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 LoggingWAL先写日志再改数据。理解了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.0SELECT * 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秒尽早失败也好尽早重试。监控层面建议把这几项做成指标活跃事务数、最长事务运行时间、锁等待次数、锁超时次数、死锁次数。不用搞得多复杂只要能观察到趋势就能在事故出现前提前处理。最后再分享一个体会事务处理这件事写代码之前想清楚比写代码时补救重要得多。事务边界、索引设计、隔离级别、锁顺序这些东西在表结构设计阶段就该定好而不是等线上崩了再去改。每一条踩过的坑本质上都是没有提前理解数据竞争的本质。希望这篇内容能帮你少走一点弯路至少遇到死锁和长事务的时候心里能多条排查路径。
返回列表