ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据库事务全面解析:隔离级别、锁机制与分布式一致性

数据库事务全面解析:隔离级别、锁机制与分布式一致性 1. 事务到底在解决什么问题1.1 从一个扣款失败的故事说起你先想一个场景你在电商平台下单金额200元平台会同时做两件事——从你的账户扣掉200元给商家的账户加上200元。如果扣款成功了但是加钱这一步因为数据库连接超时失败了会发生什么钱凭空消失了商家没收到钱你的余额却少了200。整个开发团队排查了半天最后发现数据库里根本没有一个机制保证这两个操作“要么都做、要么都不做”。这就是数据库事务存在的意义。数据库事务的本质是把一组数据库操作打包成一个不可分割的执行单元。这个单元里的所有操作要么全部成功提交Commit要么全部回滚Rollback不存在中间状态。你甚至可以把它理解成“把鸡蛋放在同一个篮子里要么一起摔碎要么一起完好”。事务能帮你解决的根本问题就三个字一致性。在并发访问和数据异常的场景下保证数据从一个一致状态转到另一个一致状态中间发生任何错误都能兜底。这个能力对金融、电商、订单、库存这类系统是命根子没有事务数据早就乱成一锅粥了。1.2 事务的ACID不只是四个单词教科书上会说事务有ACID四大特性原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。但我这么多年实践下来发现很多人对这四点的理解停留在背概念层面根本没抓到实质。原子性说的是事务内所有操作要么全部成功要么全部失败这里最关键的是“回滚”能力。MySQL里的InnoDB通过undo log实现回滚数据改了之后会把修改前的记录写到undo log里一旦需要回滚就用undo log把数据还原回去。很多人以为回滚是数据库自动完成的其实它需要额外的日志支撑这些机制是性能开销的来源之一。一致性是目的不是手段。你数据库设了外键约束、非空约束事务执行完这些约束都应该满足。值得特别注意一致性和应用层逻辑强相关数据库只能检查约束级的错误业务上“扣款是否多了”数据库并不知道这块得靠开发者的逻辑去保证。隔离性是并发控制的关键。多个事务同时操作同一份数据如果没有隔离就会互相干扰。数据库提供多种隔离级别供你选择级别越高数据越安全但并发性能越差。这里面有很深的水后面我会专门展开。持久性最好理解事务一旦提交数据就不能丢了。InnoDB里有redo log事务提交时先写重做日志再异步刷盘确保宕机也能恢复。这里有个常识很多人不知道——Commit并不保证数据已经落到了磁盘它保证的是日志落盘了靠日志恢复数据。这个差别在实际故障排查中非常关键。2. 脏读、幻读和不可重复读都是血泪教训换来的名字2.1 三个典型的并发读异常隔离性讲得再天花乱坠落在具体问题上就是三类读异常。先把它们彻底搞懂很多数据库问题的排查思路自然就通了否则你连错误日志都看不懂。脏读事务A修改了数据但还没提交事务B此时读到了A修改后的数据。如果A之后回滚了B读到的就是一条从未真正存在的“脏数据”。这就像一个同事把工资表改成8000还没确认保存你顺手拿过去看了个数字结果人家后来改回了5000你拿着8000跟别人对账对不上。不可重复读事务A先读了一条记录隔了一会儿在同一事务里再读发现数值变了——因为中间被事务B更新并提交了。注意这里读到的是已经提交的数据不脏但同一事务内两次读结果不一致一些统计计算就会出错。幻觉发生在统计性的读取场景比如先查到订单总金额是10000过一秒再查变成15000中间别人插了单。幻读事务A执行一个范围查询比如“查余额大于100的用户”事务B插入了一条余额200的新用户并提交事务A第二次执行同样的范围查询发现多出了一行。幻读的难点在于它针对的是“集合”的变化而不是某一行数据。锁住已有行没有用因为新插入的行根本不受锁控制。2.2 隔离级别怎么选SQL标准与数据库实践的差异理论上SQL标准定义了四种隔离级别从低到高依次是读未提交Read Uncommitted、读已提交Read Committed、可重复读Repeatable Read、串行化Serializable。它们对三类异常的容忍程度如下表所示隔离级别脏读不可重复读幻读读未提交可能可能可能读已提交不会可能可能可重复读不会不会可能InnoDB中已解决串行化不会不会不会这里要敲黑板MySQL的InnoDB默认隔离级别是可重复读但它在可重复读级别下通过间隙锁Gap Lock把幻读也解决了。所以你在MySQL里做范围查询如果不走索引或者范围很大锁的范围可能远远超出你预想的行数这就是“为什么我的update语句把整个表锁住了”的经典原因。而Oracle和PostgreSQL默认使用读已提交。不同默认值的背后是各家数据库对并发性能和一致性的权衡不同。你在选隔离级别时先回答这几个问题这个业务允许读到已提交但稍纵即逝的旧数据吗如果统计报表跑偏了损失有多大系统的并发量会不会因为锁升级而被打垮实际项目里我给的建议是大多数业务用读已提交就够了这是性能和安全的平衡点需要严格的批量统计一致性比如对账、结算、库存快照用可重复读串行化尽量别碰性能代价太高多半说明业务设计有问题应该从代码层面改造而不是靠数据库兜底。3. 隔离级别升上去性能降下来的真相3.1 为什么“更安全”意味着“更慢”安全感从来不是白来的。隔离级别越高数据库为了保证一致性就需要做更多的同步和等待这是并发世界里的物理定律。读未提交几乎不加锁读操作想做啥做啥性能最高但数据随时可能读飞。读已提交需要加行级的共享锁和排他锁写操作和写操作之间互斥但读操作不受已提交写操作的影响。可重复读增加了更严格的一致性视图在MySQL里配合MVCC读操作走快照读不需要加锁但写操作之间的锁冲突还是存在。串行化是终极方案连读都要加锁所有操作变成了一条单行道车再多也得排队过性能自然最差。很多人的误区是隔离级别越高越好。你想想一个高并发电商系统每秒几千个订单如果用串行化数据库连读都要排队吞吐量直接崩到惨不忍睹。实际项目的核心矛盾从来都是“要在什么样的数据准确性前提下达到多大的并发量”而不是“怎么最安全”。3.2 悲观锁和乐观锁两种博弈策略锁是隔离性的实现工具也是性能开销的主要来源。数据库领域锁分两大类悲观锁和乐观锁。这个名字非常形象一种默认“一定会有人跟我抢”所以先下手为强一种默认“冲突很少发生”所以最后对一下账就行。悲观锁在SQL层面长这样SELECT ... FOR UPDATE。事务A锁住了一行数据事务B要更新这行就必须等待。一个经典的库存扣减场景BEGIN; -- 锁定库存行 SELECT stock FROM product WHERE id 123 FOR UPDATE; -- 在应用层判断库存是否充足 -- 执行更新 UPDATE product SET stock stock - 1 WHERE id 123; COMMIT;通过FOR UPDATE先锁定目标行保证从“查询库存”到“扣减库存”这个区间内其他事务碰不了这行。但你得清楚锁的是索引记录如果WHERE条件没走索引InnoDB会锁住全表这就是“明明只操作一行数据整个表都卡死”的经典原因。乐观锁的思路反过来不加锁而是在更新时检查数据是否被改动过。最常见的实现就是版本号机制UPDATE user SET balance balance - 200, version version 1 WHERE id 100 AND version 5;如果表中当前version已经不是5这个UPDATE的受影响行数是0你就能判断出有别人抢先改过了再做重试或报错。还有一个变体是用时间戳或者状态字段代替version但版本号最通用、最可靠。用悲观锁还是乐观锁我的经验判断标准是冲突频率高、更新操作多比如高并发秒杀扣库存用悲观锁更稳虽然排队但不会反复重试冲突频率低、读写比悬殊比如文章阅读量、用户资料更新用乐观锁成本更低、体验更好。当然了具体选型还要看你用的是MySQL还是PostgreSQL还是别的不同数据库的锁实现细节真不一样下面细说。3.3 MVCC为什么MySQL的读操作不阻塞写操作MVCC多版本并发控制是InnoDB最核心的并发机制。它最牛的地方在于普通的SELECT操作不需要加锁也不会被写操作阻塞同时还能读到一致性的数据。原理用一句话概括数据库在底层为每一行数据保存了多个历史版本不同事务在合适的时机看到合适的版本。每次事务修改一行数据InnoDB不会直接覆盖旧值而是生成一个新版本并把旧版本链通过undo log串联起来。每行还隐含两个关键字段trx_id最近修改这行的事务ID和roll_pointer指向undo log中旧版本记录的指针。当一个SELECT查询发生时InnoDB会生成一个“一致性视图”Read View里面记录了当时活跃事务的ID列表。判断一条记录对当前事务是否可见核心规则是三条该记录的事务ID小于当前事务ID且不在活跃事务列表中说明是已提交的历史数据可见该记录的事务ID等于当前事务ID说明是自己改的可见该记录的事务ID大于当前事务ID或者正在活跃事务中说明后面改的或者还没提交完不可见。这个视图在“可重复读”级别下是在事务第一次SELECT时生成之后整个事务都用同一个视图在“读已提交”级别下是每一条SELECT都生成新视图。这就是为什么同一条SQL在不同隔离级别下会看到不同结果的根本原因。MVCC还有一点刷新认知它让读和写不互斥但写和写还是互斥的。因为在MVCC里新版本只能由持有当前版本排他锁的事务来创建。两个事务同时修改同一行还是要排队等锁。4. 事务的实操落地从代码到底层配置4.1 代码层面控制事务的正确姿势纸上谈兵没意义真刀真枪写代码时事务控制最容易出问题。下面以最常用的Spring声明式事务为例讲实操。Spring用Transactional注解管理事务使用方式特别简单但坑特别多。很多人以为加上这个注解就万事大吉了结果事务根本不生效数据照样乱。最常见的失效场景有三个第一注解加在了非public方法上。Spring事务基于动态代理只有通过代理对象调用的public方法才会被拦截处理事务。同类内部调用或private方法代理不生效事务就形同虚设。第二异常被吞掉了。Spring事务默认只在RuntimeException和Error时回滚如果代码里捕获了异常但没有抛出事务提交时根本不知道业务失败了。Transactional public void pay(Order order) { try { // 扣款 accountMapper.deduct(order.getUserId(), order.getAmount()); // 加钱 accountMapper.credit(order.getMerchantId(), order.getAmount()); } catch (Exception e) { log.error(支付失败, e); // 这里没有把异常抛出去事务会正常提交扣款加钱都没了 } }上面这段代码如果扣款成功了加钱失败了异常被吞掉后事务正常提交结果就是钱丢了还没人知道。正确做法是捕获异常后或者重新抛出或者手动设置回滚标记try { // 业务逻辑 } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw e; }第三自调用绕过代理。在一个Service类的内部方法里调用另一个带Transactional的方法绕过了代理对象事务同样不生效。解决方式是把方法拆到不同的Bean或者自己注入代理对象。这也是很多“事务不生效”排查半天找不到原因的场景之一。4.2 事务的持久层配置redo log与binlog的配合说完代码层我把InnoDB落盘机制的大致流程整理一下。你写了一条UPDATE底层其实干了很多事先把要修改的数据页从磁盘读入内存Buffer Pool在内存里修改数据页同时记录undo log用来回滚和redo log用来重做。事务提交时redo log必须落盘刷到磁盘这一步通过参数innodb_flush_log_at_trx_commit控制设置为1默认每次事务提交都把redo log刷入磁盘最安全但性能开销最大设置为2每次提交只把日志写入操作系统的页缓存不主动刷盘MySQL崩溃不丢但操作系统崩溃可能丢一秒左右的日志设置为0提交时完全不写磁盘由后台线程定时刷性能最好但可能丢最近一秒的事务。和redo log配合的还有binlog。redo log是InnoDB层的物理日志binlog是MySQL Server层的逻辑日志。事务提交时为了保证两者一致MySQL有“两阶段提交”机制先写redo log进入prepare状态再写binlog最后把redo log置为commit状态。这个机制保证了不管哪个日志先写了、系统在哪一步崩溃了都能通过日志恢复出一致的数据。开发中你不用管这个机制但排查数据丢失问题时要明白两个日志各有角色相互对不上往往就是问题所在。4.3 事务超时与连接池的微妙关系事务超时是个容易被忽视的坑。比如Transactional(timeout 5)设置5秒超时业务逻辑执行超过5秒事务会回滚。但这里的“超时时间”从事务开始计算包含等待锁的时间。在高并发下事务A持有一行数据锁迟迟不提交事务B同一行数据排队等待B的超时计时器一直在走一旦超时直接报锁等待超时异常。这个现象在线上太常见了。你在压测一个秒杀接口发现大量Lock wait timeout exceeded错误不一定是SQL效率低更有可能是前面有一个长事务一直占着锁不放。连接池里的连接是有限的一个事务占着一个连接长时间不归还连接池耗尽之后新来的请求全部排队接口响应就越拖越慢最终可能拖垮整个服务。实际操作经验排查长事务MySQL里可以直接执行SELECT * FROM information_schema.innodb_trx查看当前运行的事务以及执行时间。看到trx_runtime_time特别大的记录基本就是锁风暴的根源顺着连接ID找到对应线程再分析它执行了什么SQL、为什么不提交。4.4 分布式事务从本地事务到跨服务的一致性难题微服务架构下一个业务操作可能横跨多个服务、操作多个数据库。本地的数据库事务管不了这种跨库场景分布式事务就是来解决这个问题的。常用的方案有好几种我挑两种典型说清楚。两阶段提交2PC是数据库原生支持的做法有协调者和参与者两个角色。第一阶段协调者问所有参与者是否可以提交各个参与者准备好后回复“OK”第二阶段协调者通知所有参与者真正提交。如果有任何一个参与者回复“不行”全部回滚。2PC的正确性没问题问题是同步阻塞协调者和参与者之间要来回通信整体性能差而且协调者宕机会导致所有参与者处于不确定状态只能阻塞等待。2PC在跨数据库的原生场景里用得比较多但跨服务跨系统时就不太适合了。本地消息表是我非常推荐的一种“最终一致性”方案它设计非常朴素实现也简单。比如支付服务要扣款并通知库存服务减库存做法是在支付服务的数据库里创建一张本地消息表把“减库存”这个操作作为一条消息记录到本地消息表里扣款和写消息在同一个本地事务里提交。然后通过定时任务把消息表里状态为“待发送”的消息批量发送给库存服务库存服务处理成功后回调确认消息状态如果发送失败或者回调失败定时任务会不断重试直到成功。本地消息表方案的优势是不需要额外的消息中间件也不会出现扣款成功但消息丢失的情况因为消息和业务操作是同一个本地事务天然保证了一致性。代价是需要自己写定时任务和重试逻辑不能做到强一致只能保证最终一致。这个方案在订单超时自动关单、跨服务状态同步这类场景中比强一致方案实用得多。5. 事务隔离级别与锁机制的踩坑实录5.1 定时任务里的“不可重复读”我之前帮一个团队排查过一个诡异问题半夜的定时任务统计订单金额偶尔会出现统计结果对不上的情况。日志里看起来同一批数据前一次跑出来的总额和后一次差了几块钱。查了半天问题出在统计数据时用了读已提交级别而定时任务执行期间恰好有用户在改订单退款。用读已提交级别做统计两次SELECT之间数据被其他人改了就会出现金额不一致。解决方案很直接把统计事务的隔离级别提升到可重复读保证一次事务内每次快照的数据是一样的。但注意可重复读只保证“读到的快照”是同一个如果在同一个事务里既有读又有写还是可能出问题。这个方案可以做到统计结果稳定、不阻塞其他事务。5.2 间隙锁与“死锁”那些事MySQL可重复读级别下的间隙锁是新手最容易翻车的点。间隙锁锁的是索引记录之间的“空隙”防止其他事务在这个空隙里插入数据从而解决幻读。但它也会导致你明明只查一个不存在的记录锁的范围却可能是整个索引范围。我遇到过最典型的一个死锁场景两张表A和B事务1先更新A再更新B事务2先更新B再更新A。两个事务各自拿着对方的资源的锁不撒手互相等待数据库检测到死锁后会牺牲其中一个事务直接报Deadlock found when trying to get lock。如何避免多个事务访问多张表时尽量保持相同的访问顺序在事务里尽量一次性锁住所有需要的资源控制事务持有锁的时间快速提交用更细粒度的锁避免无谓的大范围锁。死锁不一定是设计缺陷数据库会通过回滚一个事务自动解除。遇到死锁把事务重试机制做好在应用层捕获异常后重试一次通常就能过去。5.3 一个“SELECT不带FOR UPDATE”的连环坑有个真实的案例一个货币兑换业务余额表只有一行主账户记录并发量极大每秒几百个请求都要读写这行数据。一开始开发人员都觉得“一行数据而已用乐观锁就够了”每次update前查一下version然后带version条件update。但问题是高并发下两个请求同时拿到同一个version只有其中一个能update成功另一个不断重试重试多了就把数据库的连接池和CPU拖垮了。最后方案是改成悲观锁每次事务开始先用SELECT balance FROM account WHERE id ? FOR UPDATE把主账户锁住后面的读改写操作排成队列牺牲一些并发量换来最终一致的余额。虽然吞吐量下降但这个业务场景下一致性远比吞吐量重要。以后遇到类似场景可以先问自己业务允许失败重试吗如果允许重试乐观锁就够了如果不允许老老实实上悲观锁。6. 事务配置与性能优化的工具箱6.1 MySQL事务相关核心参数速查日常维护和排查问题MySQL这块建议把这些参数牢牢记住它们直接决定事务的行为和性能表现参数名默认值作用调整建议innodb_flush_log_at_trx_commit1控制每次提交时redo log的刷盘策略要求严格不丢数据时保持1可以容忍系统崩溃丢一秒时改为2sync_binlog1控制binlog刷盘策略保持1否则binlog和redo log可能不一致innodb_lock_wait_timeout50秒事务等待锁的超时时间短事务可以调小避免长时间阻塞transaction_isolationREPEATABLE-READ当前会话的隔离级别按业务场景调整查主从复制和数据一致性时特别重要innodb_rollback_on_timeoutOFF锁超时后是否回滚整个事务建议开启否则可能只回滚失败语句留下半截状态autocommitON每个语句是否自动提交事务场景手动控制避免一个语句意外提交autocommit这个参数经常被忽略。MySQL默认开启自动提交你执行一条UPDATE如果没有显式BEGIN执行完就自动提交了。这个特性在误操作时很危险——一条大范围UPDATE下去没有回滚机会。开发环境和线上环境都建议养成显式开启事务的习惯。6.2 事务性能优化的三个方向事务代码写得再漂亮底层性能跟不上核心指标还是会拉胯。我总结了一个“事务性能三板斧”实战里特别有效。第一缩小事务范围。这算是最廉价也最有效的优化。常见反例在循环里插入几千条数据每条数据开一个事务性能奇差。正确做法是把这批操作放一个事务里合理的话每条数据提交一次。更常见的另一种反例是把不相关的查询、外部接口调用也算进事务里一个HTTP请求要等外部系统响应事务一直不提交连接一直被占用同时行锁一直不放。你想想一个要等外部系统5秒的接口里挂着一个数据库事务数据库锁就要等5秒整体并发能力直接被这个设计阉割。用Spring的Transactional可以给某些复杂方法划分更细粒度的事务也可以把远程调用放到事务方法之外的私有方法里等方法规避。第二减少锁的持有时间。锁持有时间越长后面排队的请求就越久系统的并发上限就越低。如何在服务端侧把“检查和更新”之间间隔拉开锁自然就释放得早。具体做几条SQL尽量走索引减少锁定的行数量把复杂的查询放在事务之前执行让事务只做必要的更新对于一次写多行数据的用批量UPDATE取代逐行UPDATE。第三避免长事务。长事务不仅持有锁时间长还会让undo log堆积膨胀占用大量磁盘空间还会让MVCC的版本链变长读操作要回滚更久远的版本才能拿到历史快照性能逐渐恶化。MySQL里长事务是排查性能问题时最先要定位的目标。我在前面提到过用information_schema.innodb_trx找出正在执行的长时间事务另外还要注意如果你的连接使用了连接池池中的连接会复用长事务不提交连接会被事务一直占用直接拖垮连接池。6.3 读多写少场景下事务如何“减负”读多写少是大多数业务系统的常态。“减负”不是说不用事务而是要想办法让事务操作的数据面更小、粒度更细。具体可以分几层SQL层多用索引覆盖查询减少回表次数对高频查询字段建联合索引应用层引入缓存Redis来承接读流量不需要频繁命中数据库数据库层读写分离把读请求分发到从库主库专心处理写事务架构层对热点数据分库分表把并发压力打散到多个数据库节点上。但要注意读写分离之后从库和主库之间存在复制延迟。如果刚写完主库立刻去从库读可能读到旧数据。对这种“写后读”强一致的场景要做一致性路由必须走主库读或者在代码层面等待复制完成再读。7. 事务日志机制崩溃恢复与数据安全的最后防线7.1 三个日志三个不同职责数据库里日志多到让人头大但事务相关的核心就三个undo log、redo log、binlog。它们的分工和协作关系搞清楚你基本能应对90%的数据恢复和一致性排查问题。undo log是“后悔药”记录的是数据修改前的旧值服务于事务回滚和MVCC的快照读。事务运行中修改记录写一条undo回滚时把记录还原成undo里的旧值。事务提交之后undo log也不是立刻清理因为其他并发事务可能还需要读到这个历史版本得等所有可能用到它的读视图都结束了才能清除——这也是长事务会导致undo膨胀的根源。redo log是“重做记录”记录的是物理页的修改操作比如“把页X的偏移量Y处改成值Z”。它的核心目标是崩溃恢复事务提交时把redo log刷盘即使数据页还没刷到磁盘数据库崩溃重启后可以用redo log把修改重放一遍保证已提交事务的数据不丢。redo log是物理日志、循环写入容量固定。binlog是“流水账”记录的是逻辑SQL语句或行变更服务于主从复制、数据恢复和数据分析。它是Server层的日志与存储引擎无关追加写入保存完整历史。事务提交时两阶段提交保证了redo log和binlog的一致这一块前面已经提过。你只要记住一个原则日志先行Write-Ahead Logging。在修改数据页之前先写日志在事务提交之前先刷日志。这个原则是所有保证持久性和原子性机制的基石。7.2 用日志定位“丢数据”的经典思路线上偶尔会遇到一个局面明明业务日志显示插入成功了翻数据库确查不到。遇到这种问题第一反应不要是“数据库吞了数据”而是要先回答几个问题事务提交了吗如果应用报了成功事务一定提交了如果应用报了超时异常但最终成功那事务也提交了。接下来用日志来定位看MySQL的binlog有没有这条写入记录有说明主库确实写入成功没有说明应用事务根本没提交如果主库binlog有记录但查不到数据看是不是查错了库、查错了分表、查了从库而从库复制延迟或者复制中断如果是从库查不到检查主从复制状态看SHOW SLAVE STATUS里的Seconds_Behind_Master是不是持续增长是不是SQL线程停了。这个排查思路用多了你会发现数据库本身很少“吞数据”大多数“数据消失”都是连接到了错误的实例或者主从复制延迟或者本地事务没提交但你误以为提交了。8. 分布式事务的演进从XA到最终一致性8.1 别忘了还有一个“本地事务消息事务”的中间态分布式事务的终极目标都是数据一致性。但实际开发里很多系统根本不需要强一致最终一致就够了。用本地消息表就是典型的最终一致方案。还有一个思路叫“事务消息”把业务操作和消息发送放在同一个本地事务里消息先发送给消息中间件但处于“半消息”状态不可见等本地事务提交后再确认投递。这种方案的关键在于“本地事务和消息状态绑定”本地事务成功消息才可见本地事务失败消息不投递。如果消息一直没有被确认消息中间件会回查业务系统的本地事务状态决定是投递还是丢弃。事务消息本质上是本地消息表的中间件化好处是不用自己写定时任务和回调接口坏处是需要引入消息中间件多了一层依赖。8.2 什么时候必须上分布式事务这个问题我在方案评审会上被问过无数次。我给一个朴素判断标准如果业务操作只涉及一个数据库老老实实用本地事务别想着什么分布式事务白白增加复杂度如果确实涉及多个服务、多个数据库且对一致性要求极高比如资金类业务可以考虑TCC或Saga方案如果是普通业务订单状态同步、库存扣减、积分累计这类能接受几秒甚至几十秒的最终一致那就用本地消息表或事务消息别把系统搞得过度复杂。我见过很多项目因为架构师一句“一定要保证一致性”给一个根本不需要强一致的业务上了分布式事务框架结果系统复杂度陡增、性能骤降、线上问题频发。分布式事务是最后的手段不是炫耀的技术。你优化好本地事务的隔离级别和锁粒度有时候就能顶住绝大多数业务需求。过度设计是比事务机制本身更危险的敌人。8.3 如何从原理角度理解各种方案的适用性我在这里把这些方案的适用性做个总结方便你做技术选型时快速决策方案一致性类型性能开销实现复杂度适用场景本地事务强一致最低最低单库操作2PC/XA强一致高中跨多个数据库、系统规模小TCC最终一致但有中间态高高对一致性要求很高如资金、交易Saga最终一致中中长事务流程允许补偿本地消息表最终一致低中异步跨服务同步状态事务消息最终一致低中有消息中间件基础我在实际工作中的习惯是能本地事务解决的就不上分布式能异步解决的就不用同步强一致消息能解决的就不引入复杂的协调框架。这个原则帮我避免了很多没必要的系统复杂度。9. 写给你的一些实操建议做数据库事务相关的工作哪怕解决的是很小的问题都要提前把可观察性基础设施准备好。MySQL的performance_schema里可以监控锁等待情况通过SHOW ENGINE INNODB STATUS能直接看到最近的死锁信息包括死锁涉及的事务、持有的锁、等待的锁这是排查死锁的第一手资料。应用开发时一定要加好日志每个事务的核心步骤都要打日志这样出了数据问题能快速定位是在哪一步、哪个事务、用了什么隔离级别。回滚代码一定要写得够健壮。我见过不少开发为了省事捕获异常后直接返回错误不重试、不定时补偿一旦某些操作在业务层面失败数据错位了都不知道。一个合格的事务流程设计离不开“当某一步失败时我如何补偿让它回到正确状态”的思考。这时候即使是简单的定时扫描补偿任务或者消息队列的自动重试都能发挥很大作用。最后想说的其实还是那道老生常谈的“技术选型观”事务机制是为业务服务的不是业务为事务机制服务。理解清楚每个隔离级别的语义知道自己系统里每一条关键SQL在什么隔离级别下跑、会加什么锁、有哪些异常风险比死记硬背什么参数都有用。多从“这个业务数据错乱最坏会带来什么后果”的角度反推设计你做出的方案才经得起并发和故障的考验。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进