尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MySQL事务核心机制与实战排障:从隔离级别到分布式事务

MySQL事务核心机制与实战排障:从隔离级别到分布式事务 聊到MySQL事务我第一反应不是那几个字母ACID而是当年半夜被一条“扣款成功但余额没变”的线上告警炸醒的场景。数据库里同一行数据前面一条UPDATE执行完了后面一条SELECT却读不到所有人在群里疯狂问“是不是缓存没刷新”。排查到最后问题出在一个同事把两条SQL写在了两个独立session里中间没有开启事务而且隔离级别还用了默认的可重复读于是快照读一直在看旧数据。那是我第一次意识到事务不是教科书上空泛的概念它是一个和每一行数据、每一个并发请求都强相关的工程机制。这篇文章我想把MySQL事务的原理、隔离级别、日志落盘、锁机制、分布式事务以及日常排障经验串起来讲一遍。适合刚接触MySQL的读者理解整体框架也适合写过一段时间SQL但只停留在“会开事务”阶段的同学把这套东西真正内化成排查问题时的底层直觉。我会把关键命令、参数、排查手段都贴出来同时给出大量踩坑记录保证不是干巴巴的理论搬运。1. 先搞清楚事务到底在解决什么问题1.1 从“扣款成功但余额没变”说起事务最经典的场景就是转账A账户扣100B账户加100。如果这两步之间数据库突然崩溃或者第二步执行失败就会出现A的钱没了但B没收到钱的严重事故。事务要保证的就是“要么都成功要么都失败”不允许出现中间状态。放到业务系统里这类问题比想象中常见得多。下单扣库存、订单状态流转、优惠券核销、积分增减几乎任何涉及到两条以上数据变更的操作都有事务需求。还有一个容易被忽略的场景即使只有一条UPDATE如果它执行了一半时数据库宕机也会出现“索引更新了但数据页没写”这种半成品状态所以事务并不是“操作多才需要”的东西单条SQL同样依赖事务机制来保证数据页的一致性。用生活话来说事务就像一份带“后悔药”的操作清单。你在清单上写下要做的事全部做完之后才真正生效做到一半发现不对可以一键撤销当作什么都没发生过。所有使用MySQL做数据存储的系统最终数据能不能对得上都依赖这层保障。1.2 ACID四个字母背后的严苛约定事务的四个核心特性是原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability合称ACID。每次面试都会被问但很多人只是背定义理解得不够透彻。原子性事务里的所有操作是一个整体全部提交或全部回滚。这条靠undo log实现回滚时根据undo log把数据还原到操作前的状态。一致性事务执行前后数据满足所有约束业务规则没有被破坏。一致性其实更像“业务层目标”它是ACID的结果原子性、隔离性、持久性共同保证了一致性。隔离性并发事务之间不能互相干扰。这个由锁和MVCC实现也是四种隔离级别的设计源头。持久性事务提交后修改要永久保存即使系统崩溃也不能丢。这条靠redo log和binlog配合实现。很多人会混淆原子性和持久性。原子性强调的是“操作可以回滚”持久性强调的是“提交后不能丢”。一个管前滚一个管回滚方向完全不一样。MySQL在这两个方向的实现机制也不同回滚靠undo log崩溃恢复靠redo log加binlog。我在第三节会详细拆解。1.3 和Redis事务做个对比经常有人把MySQL事务和Redis事务混在一起聊因为它们都叫事务。这里我直接给个对比表省得大家面试时被绕晕。对比项MySQL事务Redis事务核心保证ACID尤其强调原子性、持久性仅保证命令按顺序执行没有回滚机制回滚能力通过undo log完整回滚不支持出错的命令不影响其他命令执行隔离性四种隔离级别MVCC加速读单线程执行机制本身天然隔离持久性redo log binlog双重保障依赖RDB/AOF且AOF默认每秒刷盘极端情况可能丢数据使用姿势BEGIN / COMMIT / ROLLBACKMULTI / EXEC / DISCARDRedis事务更准确的说法是“命令批处理队列”它用单线程执行模型避免了并发冲突但做不到像MySQL那样精细的并发控制和崩溃恢复。技术选型时涉及资金、库存、订单这类强一致数据老老实实用MySQL事务Redis事务适合那些“顺序执行、允许少量命令失败不影响整体”的轻量场景比如限流计数器批量自增、缓存标记位批量设置。2. 隔离级别与MVCC并发场景下的读与写2.1 四种隔离级别到底隔离了什么SQL标准定义了四种隔离级别MySQL的InnoDB都支持按隔离强度从低到高分别是读未提交READ UNCOMMITTED、读已提交READ COMMITTED、可重复读REPEATABLE READ、串行化SERIALIZABLE。读未提交一个事务能读到另一个事务未提交的修改这就是脏读。实际业务里几乎不会用因为读到很可能是最终被回滚的数据。读已提交只能读到已提交的数据解决了脏读。但同一个事务里两次相同的SELECT可能读到不同结果这就是不可重复读。可重复读事务启动后多次查询同一范围的数据结果保持一致解决了不可重复读。但存在幻读问题事务里第一次SELECT没有某行第二次SELECT突然多了一行就像出现了幻觉。串行化所有事务串行执行彻底解决所有并发问题但并发能力也降到最低生产环境很少使用。MySQL默认隔离级别是REPEATABLE READ这和Oracle等数据库默认用READ COMMITTED不一样。原因很关键InnoDB在RR级别下引入了间隙锁Gap Lock和MVCC快照机制能在RR级别就解决大部分幻读问题保证主从复制时binlog里记录的SQL执行结果一致。所以即使标准里RR允许幻读MySQL也已经把幻读处理得相对干净了。这是面试时高频追问点为什么MySQL默认RR而很多数据库默认RC。2.2 MVCC原理版本链与ReadViewMVCC全称多版本并发控制核心思想是读操作不阻塞写操作写操作也不阻塞读操作。它让普通的SELECT走“快照读”不需要加锁极大提升了并发性能。InnoDB每一行记录都带有两个隐藏列一个是最近修改它的trx_id一个是用来找历史版本的roll_pointer。当一行数据被修改时旧版本会写入undo log并在行头形成一个版本链链条上每一层都记录了当时的trx_id。读操作执行时会生成一个ReadView它快照了当前系统里“活跃事务ID列表”再根据一系列比较规则判断当前行版本是否可见。到了这一步必须区分两种读法快照读普通SELECT语句走MVCC不加锁读到的是事务启动时RR或语句开始时RC的可见版本。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这类操作读取最新版本并加锁阻止并发修改。MVCC之下RR和RC的差异主要在ReadView的生成时机。RC是每条语句执行前都生成一个新的ReadView所以能看到其他事务每次提交后的新结果于是不可重复读RR是事务第一次执行快照读时生成ReadView之后整个事务都复用这一个快照所以同一个事务里多次SELECT结果稳定。这个机制理解了隔离级别就不再是死记硬背了。2.3 亲手复现脏读、不可重复读和幻读理论讲再多不如自己动手跑一遍。我通常在测试库里建一张最简单的表CREATE TABLE t_account ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50), balance DECIMAL(10,2) ) ENGINEInnoDB; INSERT INTO t_account(user_name, balance) VALUES (zhangsan, 100.00);然后开两个MySQL会话模拟并发。脏读复现把会话A和会话B都设为READ UNCOMMITTED在会话A里BEGIN; UPDATE t_account SET balance 50 WHERE user_namezhangsan;不提交。此时会话B执行SELECT balance FROM t_account WHERE user_namezhangsan;如果读到50就说明发生了脏读。然后把A回滚B读到的50就是脏数据。不可重复读复现两边都设为READ COMMITTED。会话A先BEGIN; SELECT balance ...读一次得到100会话BUPDATE t_account SET balance 80 ...; COMMIT;会话A再SELECT一次发现变成80。同一个事务里两次读不一致就是不可重复读。幻读复现两边设为REPEATABLE READ。会话ABEGIN; SELECT COUNT(*) FROM t_account;会话B插入一条新用户INSERT INTO t_account(user_name, balance) VALUES (lisi, 200.00); COMMIT;会话A再查COUNT发现数量变了。因为A用的快照读没加锁B插入的是新版本行A的旧快照里没有它所以第二次查询会多出一条。实际生产环境里真正需要处理的往往是“当前读加锁之后的幻读”。比如SELECT * FROM t_order WHERE order_no xxx FOR UPDATE如果这条SQL在RR级别下没有命中记录InnoDB会对range加间隙锁阻止其他事务插入新的同条件数据从而避免第二次当前读时出现新行。这也是InnoDB能在RR下基本解决幻读的原因之一。3. 事务日志三剑客崩溃恢复背后的真相3.1 binlog、redo log、undo log各管一摊MySQL日志体系里最核心的三类日志是binlog、redo log、undo log。三者的定位完全不同一个是归档日志两个是InnoDB引擎日志。binlog是MySQL Server层维护的二进制日志记录的是逻辑操作比如“某行数据被UPDATE成什么值”。它主要用于主从复制和数据恢复也就是把主库上所有变更同步到从库或者用mysqlbinlog工具在灾难后把数据回放到某个时间点。redo log是InnoDB引擎专用的物理日志记录的是“某个数据页的某个偏移位置被改成了什么”。它存在的根本原因是数据页的修改是随机写磁盘性能差而redo log是顺序写速度极快。事务提交时只需要先把变更写入redo log不必立刻把数据页刷到磁盘系统就能在崩溃后通过redo log恢复未落盘的数据这就是WALWrite-Ahead Logging机制。undo log也是InnoDB引擎日志但它记录的是“如何把数据改回去”用于事务回滚和MVCC版本链。事务执行过程中每修改一行都会生成一条逆向操作记录如果是INSERT回滚时DELETE如果是UPDATE回滚时恢复旧值。可以这么记忆redo log是“后悔之后重做”的凭据undo log是“后悔之后撤销”的依据binlog是“把发生过的事广播出去”的档案。3.2 两阶段提交协调binlog与redo log的关键设计既然redo log管崩溃恢复binlog管主从复制那问题来了一个事务提交时先写哪个如果先写binlog引擎还没来得及提交崩溃了从库拿到了binlog但主库数据没生效主从不一致如果先写redo logbinlog没写从库丢了这个事务同样不一致。MySQL解法是内部的两阶段提交在事务提交时先把redo log写入并处于prepare状态再写binlog最后把redo log标记为commit。如果崩溃发生在写binlog之前恢复时事务回滚如果崩溃发生在binlog写完但redo log未commit时恢复时检查binlog是否存在该事务存在就补提交不存在就回滚。这样保证了binlog和redo log最终一致。注意这个“两阶段提交”说的是MySQL内部协调redo log和binlog的机制和分布式事务里的两阶段提交协议2PC是两码事后者协调的是多个独立的数据库或服务。很多文章容易把这俩混在一起讲面试时如果被问起先明确说的是哪个场景。3.3 刷盘参数与性能取舍MySQL提供了几个关键参数控制日志刷盘策略直接关系性能和安全性生产环境配置时要特别上心。# redo log刷盘策略 innodb_flush_log_at_trx_commit1 # binlog刷盘策略 sync_binlog1innodb_flush_log_at_trx_commit有三个值0表示每秒刷一次redo log崩溃时最多丢1秒事务1表示每次事务提交都刷盘最安全但性能开销最大2表示提交时先写入操作系统缓存每秒再刷盘性能较好但宕机时可能丢1秒数据。sync_binlog类似1表示每次事务提交都同步binlog到磁盘。只有当innodb_flush_log_at_trx_commit1且sync_binlog1时MySQL才能做到事务提交后不丢数据这也是金融类系统的标准配置。做了这个设定之后写入吞吐会受到明显限制所以高并发业务里常会配合“组提交”来缓解性能损耗多个事务的提交在极短时间内合并成一次日志刷盘这属于InnoDB内部优化一般不需要人工干预。如果业务允许小概率丢数据来换取更高TPS例如日志、点赞、非核心状态记录这类场景可以调成innodb_flush_log_at_trx_commit2配合sync_binlog0但我建议任何涉及钱的系统都不要这么干省出来的性能早晚会在故障时加倍还回去。4. 锁与死锁并发事务下的隐形裁判4.1 行锁、间隙锁、next-key lock的边界事务的隔离性一半靠MVCC一半靠锁。MVCC管的是快照读锁管的是当前读和写操作。InnoDB的锁从粒度上分有表锁和行锁行锁是核心常见的有三种形态记录锁Record Lock锁定索引上的一条具体记录。SELECT * FROM t WHERE id 1 FOR UPDATE就是典型的记录锁。间隙锁Gap Lock锁定一个范围但范围里没有具体记录用来阻止其他事务在间隙里插入新行。它只存在RR隔离级别下。临键锁Next-Key Lock记录锁加间隙锁的组合锁定左开右闭的区间。InnoDB在RR下默认用临键锁锁住扫描范围既禁止修改已存在的记录也禁止插入新记录。很多人搞混间隙锁我举个例子。假设表里id有4、7、9三条记录执行SELECT * FROM t WHERE id BETWEEN 5 AND 8 FOR UPDATE。因为没有命中任何一条记录InnoDB不会锁具体行而是锁住(4,7)和(7,9)两个间隙让其他事务无法插入id为5、6、7、8的数据。这就是间隙锁的威力锁的是“空的未来”而不是“已有的现在”。4.2 一个死锁案例的完整排查过程死锁是并发热点场景的高发问题。有一次线上订单系统频繁报错错误日志里出现Deadlock found when trying to get lock; try restarting transaction。我看了一下两张表订单表和库存表业务逻辑是“创建订单时扣库存”和“取消订单时释放库存”两个操作都按“先更新订单表再更新库存表”的顺序执行。按理说顺序一致不该死锁后来翻日志发现有一条定时任务走了另一套逻辑更新库存表时刚好和订单创建事务形成交叉事务AUPDATE t_order SET status 1 WHERE order_no A; -- 获得订单行锁 UPDATE t_stock SET stock stock - 1 WHERE sku_id X; -- 等待库存行锁 事务BUPDATE t_stock SET stock stock 1 WHERE sku_id X; -- 获得库存行锁 UPDATE t_order SET status 0 WHERE order_no A; -- 等待订单行锁两边各持有一把锁又互相申请对方手里的锁于是陷入僵局。InnoDB的锁检测机制检测到死锁后会立刻选择回滚其中一个事务释放锁让另一个事务继续执行然后把错误抛给被回滚的一方。排查死锁最直接的方式是看InnoDB最近一次死锁报告SHOW ENGINE INNODB STATUS\G里面LATEST DETECTED DEADLOCK段落会列出两个事务分别持有的锁、正在等待的锁、以及对应的SQL语句。把这个报告拿来回溯业务代码一般都能找到问题根源。4.3 日常降低死锁概率的几个习惯死锁没法完全消除但概率可以压到很低。我自己总结了几条非常实用的习惯多个事务涉及多张表时保持一致的访问顺序。都按A表、B表、C表的顺序加锁交叉等待的概率大幅下降。单个事务里的SQL尽量一次性锁定需要的行减少执行过程中的锁升级和锁扩展。比如用SELECT ... FOR UPDATE提前锁行而不是UPDATE时再做行锁。缩小事务范围。事务里只放必要的读写网络调用、外部接口、远程RPC全部挪到事务外。事务持续时间越长锁持有的时间就越长死锁概率越高。更新操作尽量走主键或唯一索引避免扫描范围过大导致间隙锁太多。出现死锁后业务层要设置重试机制。死锁本身会被InnoDB检测并回滚一方被回滚的业务如果直接报错给用户体验很差加一层重试逻辑能自动恢复。5. 分布式事务当订单和库存不在一个库5.1 为什么单库事务解决不了微服务问题单体时代订单表和库存表在一个库里一个本地事务就搞定扣库存和下单。微服务拆分后订单服务管订单库库存服务管库存库业务操作变成了跨库调用。此时本地事务只能保证自己数据源范围内的原子性管不到另一个服务里的数据变更。打个比方你在两个银行账户之间转账如果两个账户属于同一个银行银行内部系统一次搞定但如果一个账户在A银行一个在B银行就必须通过跨行转账系统协调两家银行。分布式事务干的就是跨行转账系统这个角色目标是让多个独立数据源的数据变更同时成功或同时失败。业内常用方案主要有XA协议、Seata AT模式、TCC补偿、可靠消息最终一致性。它们解决的问题一致但一致性强度、侵入性、吞吐量差异很大选型时不能看宣传要对照业务场景判断。5.2 常用方案拆解XA、Seata AT、TCC、可靠消息XA是最早的分布式事务规范数据库原生支持采用真正的两阶段提交协议。阶段一叫prepare所有参与者准备好事务并锁定资源阶段二叫commit/rollback协调者根据所有参与者的准备结果统一决定提交或回滚。XA的优点是强一致缺点是2PC准备阶段会锁住资源性能和并发能力差而且协调者单点故障会导致事务悬挂。生产环境里MySQL的XA用得不多更多是和其他方案结合做底层保障。Seata是阿里开源的分布式事务框架其中AT模式在业界使用最广。它的核心思路是“一阶段提交业务SQL二阶段异步回滚”。具体来说AT模式通过数据库代理解析业务SQL在操作数据前后生成undo镜像一阶段把业务数据的变更和undo镜像写入同一个本地事务并提交二阶段如果全部成功就异步删除undo日志如果有分支失败就根据undo镜像反向补偿。业务代码基本无侵入这是它最大的优势。AT模式底层依赖全局锁事务并行度会受影响但对多数业务来说是划算的。TCCTry-Confirm-Cancel是一种应用层补偿模式。Try阶段预扣资源Confirm阶段真正扣减Cancel阶段释放预扣资源。业务方需要自己实现三个接口控制粒度可以做到非常精细但开发成本高每个参与方都要有这套接口还要处理空回滚、悬挂、幂等等问题。可靠消息最终一致性方案通常配合本地消息表实现。核心思想是业务操作和写入消息表在同一个本地事务里完成然后异步把消息投递到MQ下游消费消息执行业务成功后回调确认。只要下游最终执行成功数据就会一致。它无法做到实时强一致但吞吐量高非常适合购物车、积分、异步通知这类场景。5.3 怎么选型强一致还是最终一致方案一致性侵入性性能适合场景XA强一致低数据库原生低跨少数数据库且并发不高的场景Seata AT最终一致实际表现为较强一致低注解即可中微服务间跨库业务落地成本低TCC强一致高需实现三接口中高资金类、预订类需要精细管控可靠消息最终一致最终一致中等高可容忍短暂不一致的高吞吐场景我自己的选型经验是能单库就不分布式能用最终一致就不强一致。分布式事务是复杂度放大器每增加一个参与方故障排查难度都以指数级上升。资金清结算这类硬性要求强一致的才考虑TCC或XA订单、库存、积分这类业务先看能不能把库存扣减和下单合并到同一个应用内事务不行再上Seata AT如果业务可以容忍几秒的不一致可靠消息方案是吞吐和复杂度最平衡的选择。6. 工程实战中的坑与排查技巧6.1 Transactional失效的六种常见场景Java应用里最常用的事务注解是Spring提供的Transactional但它有几个非常隐蔽的失效场景几乎每个团队都踩过至少一个。方法没有被Spring管理也就是类上没有Service、Component等注解Spring压根不会为这个类创建代理事务自然不生效。方法不是public的。Spring默认用JDK动态代理或CGLIB代理只能拦截public方法protected或private直接失效而且不会报错。同类内部调用。一个类里方法A调用方法BB上有Transactional但A调用B时并没有经过代理对象而是直接调用了目标对象的方法事务失效。解决方法是把B拆到别的类或通过ApplicationContext拿到代理对象再调用。异常被业务代码吞掉了。自己catch住了异常又没有重新抛出Spring感知不到异常事务就无法回滚。见下面代码Transactional public void createOrder() { try { orderDao.insert(order); stockDao.reduceStock(stock); } catch (Exception e) { log.error(下单失败, e); // 异常被吞掉事务不会回滚 } }抛出的异常类型不对。Spring默认只对RuntimeException和Error回滚如果抛出的是受检异常如Exception的直接子类事务不会回滚。需要指定rollbackFor Exception.class。数据库表引擎不是InnoDB。MyISAM不支持事务用这个引擎时事务注解和手动事务都一样无效。6.2 大事务是慢SQL的隐形推手大事务指的是执行时间长、影响数据量大的事务。它的危害是多方面的持有锁时间太长阻塞其他事务undo log膨胀占用大量磁盘空间和内存binlog和redo log产生量剧增磁盘IO压力升高主从同步延迟被拉大因为从库要重新执行涉及大量数据的变更。常见的控制手段包括删除数据时不要一个事务里删几十万行分批处理每批几百上千条就提交一次避免在事务里调用第三方HTTP接口、发短信、发MQ需要先查再更新时尽量用SELECT ... FOR UPDATE锁定必要行避免事务执行期间其他事务乱入把唯一性校验尽量前置避免在事务里因为重复数据频繁回滚。排查大事务最直接的一条SQLSELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;如果发现某个事务running_seconds特别长优先从代码层面找长事务源头而不是盲目杀事务。6.3 面试与排查高频问题速查这些年我被问到过很多MySQL事务相关的问题也作为面试官问过别人。高频问题的核心答案我整理成一个速查清单方便大家面试前快速过一遍。问题核心答案要点MySQL默认隔离级别是什么为什么RR。InnoDB用间隙锁和MVCC解决了大部分幻读保证主从复制binlog执行结果一致读已提交和可重复读的区别RC每条语句生成新ReadViewRR事务首次快照读生成ReadView并复用什么是当前读和快照读快照读走MVCC不加锁当前读读最新版并加锁MySQL如何实现原子性undo log记录回滚日志出错时反向执行恢复旧数据崩溃恢复怎么保证不丢数据redo log记录物理变更WAL机制恢复时重放未落盘的日志什么是间隙锁锁住扫描范围里的空间隙禁止其他事务插入新行只存在于RR级别死锁怎么排查SHOW ENGINE INNODB STATUS看LATEST DETECTED DEADLOCK检查持锁和等锁的SQL主从复制依赖什么日志binlog从库拉取binlog并重放分布式事务有哪些方案XA、Seata AT、TCC、可靠消息最终一致性Transactional为何不生效常见原因非public、同类调用、异常被吞、异常类型不符、非InnoDB每次排查线上问题时我都会回到一句话先定位SQL在哪个隔离级别下执行走的是快照读还是当前读锁了哪些行或者哪个间隙。把这几个问题在脑子里过一遍绝大多数事务相关的疑难杂症都能找到明确方向。MySQL事务这套东西没有多高深但真不是看一眼文档就能吃透的它需要你在真实的并发场景里蹲几次坑、翻几次InnoDB状态报告才能真正建立起直觉。
返回列表