ARTICLE DETAIL

资讯详情

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

数据库事务深度剖析:从隔离级别到分布式事务的工程实践

数据库事务深度剖析:从隔离级别到分布式事务的工程实践 这几年我整理了不少事务相关的笔记从最开始的事务不就是要么全成功要么全失败吗到后来真正在并发环境里被脏读、死锁、长事务轮番教育才意识到数据库事务这四个字背后的深度远比教科书上写的要复杂。这篇笔记不是教科书式的概念罗列而是把我在支付、订单、库存这类高并发业务里真正踩过的坑和验证过的结论沉淀下来。适合刚接触事务概念的开发同学也适合已经写了一段时间业务代码、但对隔离级别、锁机制、事务失效这些为什么还有疑惑的人。1. 从钱丢了开始事务到底解决什么问题1.1 一次线上转账事故的复盘我以前维护过一个账户系统线上出现过一次事故用户A给用户B转账500元代码逻辑是先扣A的余额再给B的余额增加。故障发生时扣款SQL已经提交成功了但紧接着给B加余额的那条SQL因为数据库连接池里的连接被重置抛出异常后程序直接返回了错误。结果就是A的钱少了500B的钱一分没多。这个问题的根源不在于SQL写错而在于两个SQL操作没有处于同一个事务边界内。如果扣款和入账放在同一个数据库事务里要么两条SQL一起提交要么一条失败时另一条也跟着回滚就不会出现这种中间状态暴露给外部的情况。这是理解事务最直接的业务场景事务的本质是把一组读写操作打包成一个不可拆分的执行单元这个单元只有两种结局——全部生效或者全部不生效。1.2 ACID四兄弟原子性、一致性、隔离性、持久性教科书里ACID四个特性每个都有严格定义但在工程实践里很多人对它们的理解停留在表面我逐个拆开讲。**原子性Atomicity**指的是事务内所有操作作为一个整体提交或回滚InnoDB通过undo log实现事务执行过程中记录逆操作一旦需要回滚就顺着undo log把数据恢复到事务开始前的状态。注意这里的恢复只针对已经执行过的修改如果事务还没提交外部是看不到这些修改的。**一致性Consistency**是最容易被误解的一个。它不是说数据库会自动保证数据合理而是指事务执行前后数据要满足应用定义的约束条件余额不为负、库存不小于零、外键约束完整等。这些约束一部分靠数据库的约束机制保证但更多部分要靠应用代码在事务内部做判断。数据库只保证从一个一致状态到另一个一致状态不负责替你判断业务上什么状态才是对的。**隔离性Isolation**解决的是多个事务同时执行时的相互干扰问题具体体现为脏读、不可重复读、幻读、更新丢失这四类并发异常。隔离性的强弱由隔离级别决定这是后面要重点展开的部分。**持久性Durability**指事务提交成功后即使数据库崩溃修改也不能丢失。InnoDB靠redo log保证这一点提交时先把日志刷到磁盘数据页的落盘可以延后崩溃恢复时用redo log重放即可。这也是为什么把innodb_flush_log_at_trx_commit改成0或2能提升性能但代价是提交瞬间崩溃可能丢最近一秒的日志——生产环境我基本不敢动这个参数。1.3 一个常见的理解误区数据库不是万能的保险箱不少人有种错觉只要把代码包进事务数据就绝对安全了。我在实际项目中至少遇到过两种打破这种错觉的情况。第一种是事务里混入外部调用。比如事务内先扣库存然后调用第三方支付接口第三方接口超时异常导致事务回滚。数据库这边确实回滚了但第三方支付平台那头可能已经完成了扣款两边数据就打架了。数据库事务只能管理数据库自身的数据管理不了HTTP请求、MQ消息、Redis缓存这些外部系统的状态。第二种是跨库操作。业务拆分后订单库和库存库是独立的代码开了两个事务订单事务提交了库存事务回滚了这时候没有一个数据库层面的机制帮你保证全局一致性。所以遇到这类场景正确思路不是硬塞一个长事务而是用分布式事务方案去解决后面第6章我会细讲。2. 隔离级别与并发异常默认配置不等于最安全配置2.1 四种并发异常到底长什么样隔离级别定义的是事务之间允许看到多少对方未提交或正在修改的数据。SQL标准把隔离级别从低到高分为四档每一档解决特定的并发异常我先把对照关系列出来。隔离级别脏读不可重复读幻读READ UNCOMMITTED读未提交可能可能可能READ COMMITTED读已提交不可能可能可能REPEATABLE READ可重复读不可能不可能可能InnoDB下已解决SERIALIZABLE串行化不可能不可能不可能这里必须把三种异常讲清楚因为很多人把不可重复读和幻读混为一谈。脏读事务A修改了一行数据但还没提交事务B读到了这条未提交的修改。如果A回滚B之前读到的数据就成了凭空捏造的脏数据。这个异常最严重所以除了READ UNCOMMITTED其他隔离级别都从机制上杜绝了它。不可重复读事务A在一个事务内两次查询同一行数据第一次查到的是旧值第二次查到的是新值——新值是别的事务已提交的修改。问题在于同一行里的值变了。幻读事务A用同一个条件执行两次范围查询第一次返回5行第二次返回10行——多出来的行是别的事务插入的。问题在于多了一行或少了一行而且锁定的行范围本身被改变了。举一个具体场景更容易理解订单系统里事务A要统计所有金额大于100元的订单数量第一次统计出来8条统计过程中事务B插入了一条金额150元的新订单并提交A再统计变成了9条。如果A需要基于固定数量做业务判断或写文件前后不一致就会产生严重问题。2.2 为什么MySQL默认RR而PostgreSQL默认RC历史问题不背锅很多刚接触多种数据库的人会有个疑问既然READ COMMITTED已经能避免脏读为什么MySQL InnoDB的默认隔离级别却是REPEATABLE READ这背后有历史原因也有现实考量。早年MySQL主从复制采用基于语句的binlog格式STATEMENT如果隔离级别设为READ COMMITTED主库上某些事务的执行顺序和日志记录顺序在从库重放时可能产生不一致。比如一个UPDATE语句在事务A提交前执行又受事务B的影响主库的最终结果和从库按binlog重算的结果会不一样。为了在主从数据一致性上更省心MySQL将默认隔离级别设为REPEATABLE READ配合binlog使用。但注意InnoDB的可重复读已经通过间隙锁机制把幻读问题一并解决了所以MySQL的RR实际上是1.5档的强度比SQL标准定义的RR更强。这也是为什么这套默认配置在日常业务里顶得住的原因。PostgreSQL则更保守默认用READ COMMITTED。它对RR的定义也更严格如果读到有并发修改它会直接报错让你重试不像InnoDB那样靠MVCC快照硬撑。所以做跨数据库迁移时如果只把应用代码挪过去而不检查隔离级别差异很容易出现行为不一致的坑。2.3 隔离级别选型按业务场景而不是按习惯我见过不少团队不管什么业务都无脑保持默认RR这也是一种偷懒。选隔离级别本质上是在一致性、并发性能和开发复杂度之间做权衡我给一个实践层面的判断标准。读多写少、报表统计、搜索筛选这类场景改成READ COMMITTED往往更合适。因为每次读只看到已提交的最新数据不需要维护长事务的MVCC快照锁竞争也更少。账户余额、库存扣减、转账这类涉及资金或超卖风险的操作RR配合行锁/间隙锁可以给业务设计先查后改留出更简单的口径不容易出现竞态条件。秒杀、抢购这类极端热点场景可以考虑把隔离级别提到SERIALIZABLE只做单行更新放弃并发反而可控前提是吞吐量要压得住。一个容易被忽略的点隔离级别和更新丢失没有一一对应的关系。就算在MySQL RR下两个事务同时读取一个余额为100的账户各自加50后提交也完全可能最终只剩150而不是200。解决更新丢失靠的不是调隔离级别而是显式加锁SELECT ... FOR UPDATE或乐观锁版本号这点一定要在脑子里挂个钩。3. 锁与MVCC事务并发控制的底层博弈3.1 悲观锁、乐观锁两种控制并发冲突的路线并发控制有两条截然不同的路线悲观锁和乐观锁。悲观锁的思路是我怀疑你会改所以我先把数据锁住你不准动。实现上就是InnoDB的共享锁S锁读锁和排他锁X锁写锁比如SELECT ... FOR UPDATE会加排他锁直到事务结束。这适合冲突概率高的场景比如扣库存、转账冲突一旦发生代价很大不如直接串行化。乐观锁是我信任你但提交前要验证一下版本。常见实现是给表加一个version字段更新时UPDATE ... SET fieldnew, versionversion1 WHERE id? AND version?影响行数为0就说明版本变化重试或失败处理即可。它适合冲突概率低的场景比如修改个人资料、编辑文档大部分时候不会两个人同时改。此前我在一个优惠券系统里用乐观锁处理用户领取接口扛住了高并发因为同一张券被两个人同时修改的概率本来就不高没必要引入行锁排队。选型时要算一笔账悲观锁的代价是锁等待期间的吞吐量损失和死锁风险乐观锁的代价是并发大时大量请求做无用UPDATE然后重试。生产环境没有一个万能答案只能按冲突概率来。3.2 MVCC版本链快照读与当前读的配合MVCC多版本并发控制是InnoDB隔离性实现的核心也是很多人看执行计划时忽略的东西。它做的事很简单每次修改不是覆盖旧数据而是生成一个新版本旧版本数据保留在undo log里形成一个版本链。普通SELECT是快照读它不用加锁而是根据事务启动时生成的read view可见性判断去版本链里找此时此刻我应该看到哪个版本。这就是为什么RR级别下事务内多个SELECT返回结果完全一致因为read view在事务第一次执行快照读时就固定了。UPDATE、DELETE、SELECT ... FOR UPDATE则是当前读读的是最新版本并且要加锁防止其他事务同时修改。理解快照读和当前读的区别能帮你排查一个非常隐蔽的性能症状一个RR隔离级别的事务先执行普通SELECT后执行UPDATE明明查询出来的数据是对的UPDATE却等了好长时间。原因就在于快照读看到的是旧版本而UPDATE要做当前读发现行上有别的事务加的锁在排队。所以排查这种问题不要盯着SQL本身要想到读和写走的根本不是同一条路径。另外提醒一个细节MVCC在RR下的快照时机是事务内第一次SELECT不是事务开始的时刻。如果在同一个事务里先做了UPDATE再SELECTSELECT看到的是更新后的最新版本这个行为和很多人理解的RR就是全程一个快照并不一样实际测试过就会留下深刻印象。3.3 死锁的成因与破解习惯死锁是事务并发里最让运维头疼的问题之一。两个事务各持有一把锁又都在等对方持有的另一把锁于是永远等下去。InnoDB默认开启死锁检测会主动回滚代价较小的事务来打破僵局这时业务端会收到一条Deadlock found when trying to get lock; try restarting transaction的错误。从我处理过的死锁案例来看最常见的成因有三个事务访问表/行的顺序不一致、事务内锁定的行集存在交叉、依赖间隙锁的范围更新互相重叠。举个例子事务A先更新用户表再更新订单表事务B先更新订单表再更新用户表两个事务同时进行时就可能卡在对方已锁住的那张表上。破解死锁依靠的是工程习惯而不是某个神奇参数。我在负责的团队里立了四条规矩一是多行更新必须按固定顺序比如主键升序执行二是事务尽量短小把锁持有时间压到最低三是减少范围更新能用唯一索引精确定位的就不要扫区间四是在报错日志里保留死锁现场比如开启innodb_print_all_deadlocksON出问题时有完整的锁等待链路可查。死锁无法完全消灭但靠这四条能把概率降好几个数量级。4. 事务看起来生效了实则没有框架失效场景的排错复盘4.1 自调用绕过代理的经典坑用Spring这类框架时事务是通过AOP动态代理实现的方法进入前开启事务方法退出后提交或回滚。但如果你在一个类内部用this.method()调用带Transactional的方法事务会静默失效。原因很简单Transactional是加在代理对象上的而this指向的是原始对象调用完全不经过代理自然也没有事务拦截。我在一个定时任务里踩过这个坑。任务类里有一个executeBatch()方法内部循环调用同一个类的processOne()——后者标注了Transactional。结果批量处理2000条数据处理到第800条时遇到异常前面的799条全部提交成功没有回滚。后来定位时发现this.processOne()根本没有走代理链事务还没开始就结束了。解决办法有几种把需要事务的方法拆到另一个Bean里注入那个Bean再调用或者从容器里拿到自身的代理对象来调用最省事的是把Transactional提到入口方法上让整个批量操作作为一个事务单元但这会放大事务粒度要权衡。我建议优先拆Bean职责清晰还不容易再踩坑。4.2 异常处理与传播行为回滚条件比你想的严苛Spring默认只在抛出运行时异常RuntimeException或错误Error时回滚受检异常checked exception比如Exception本身如果被捕获后正常返回事务照样提交。这就是第二个高频失效点方法里执行了业务逻辑调用了一个声明throws Exception的方法异常被上层吞掉或就地catch住打成日志事务根本感知不到出错了数据照常提交。所以我建议团队里统一两条约定业务异常一律用自定义运行时异常抛出不要在事务方法里catch后打印日志就算了如果确实需要catch要手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()来标记回滚。这两条能避免90%的数据怎么错了类事故。再讲一下传播行为。REQUIRED表示如果没有事务就新建有则加入当前事务这是默认值也是绝大多数场景的正确选择。容易出问题的是REQUIRES_NEW它会挂起外层事务并另开一个真正的新事务进去。如果你在外面的事务里调用了REQUIRES_NEW的方法内层事务提交后外层事务再失败回滚内层已提交的部分是回不来的。上面提到的批量任务里如果processOne()用了REQUIRES_NEW批量处理到一半失败前800条也会一直留在库里。遇到一部分成功一部分失败且无法回滚的问题时先查的不是SQL而是事务传播属性。4.3 快速验证事务是否真正生效的三个手段框架级失效排查靠猜是低效的我一般用三个手段做快速验证。第一个是看日志。开启Spring的TransactionInterceptor日志logging.level.org.springframework.transaction.interceptorDEBUG方法执行时会输出Getting transaction for ...和Completing transaction for ...没出现这两行说明方法根本没有被事务代理拦截。第二个是查库。在事务方法内人为抛出异常前手动执行一个INSERT然后观察information_schema.innodb_trx里是否出现了一条未提交的事务记录。出现说明事务已经开启并持有连接没出现说明这个方法压根不在事务里。这个方法也适用于排查自调用问题。第三个是写一个最小复现用例一个Transactional方法先INSERT一条数据然后throw new RuntimeException()跑完去看库里是否有脏数据。注意不要直接在生产环境用测试库跑一遍一分钟就能给出结论。这三板斧配合下来事务是否生效基本没有悬念。5. 长事务与热点更新生产环境的隐形事故源5.1 长事务为什么是运维杀手事务不是越久越好。生产环境里长事务带来的连锁反应往往比单条SQL慢更致命一是锁持有时间太长后续所有访问同一行或同一区间的请求全部排队数据库连接池很快被占满整个服务开始雪崩二是undo log不断膨胀历史版本链拉长导致全表扫描变慢、回滚变慢三是主从复制延迟加大因为binlog要等事务提交后才落盘四是所有等待这些锁的事务回滚概率变大死锁检测触发也更频繁。我排查过一个典型的页面卡死事故起因是调用方在事务方法里通过HTTP调了一个外部接口外部接口响应超时了30秒这30秒内订单表上几十行记录一直处于锁等待状态所有读取这些订单的接口全部排队。修复方案很粗暴但也有效事务方法内部不放任何远程调用先把事务要用的数据查出来提交事务后再去调外部接口用消息队列做解耦。要实时发现长事务我习惯固定跑两条监控SQL一条查information_schema.innodb_trx里trx_started距离当前时间超过N秒的事务一条查performance_schema.events_statements_current里执行时间最长当前正在跑的SQL。监控阈值按业务定我个人建议事务内总耗时时长远超1秒的就要重点审查这种事务几乎必然在某个时刻成为排队瓶颈。5.2 热点行更新的排队效应与优化思路和长事务相关但更隐蔽的是热点行更新。同一个商品库存、同一个账户余额在高流量下会变成单行串行更新所有并发请求都去抢同一行的排他锁InnoDB的锁机制保证同一时刻只有一个事务在更新这一行请求多时只能排队吞吐量被锁钉死在每秒几百次CPU和连接池看起来都没满但业务就是上不去。这个问题的典型场景是秒杀100个用户抢1个商品库存行的更新是绝对瓶颈。我见过三种有效的优化手段。第一种是在应用层做库存预扣减用Redis的原子操作先扣减再异步把扣减结果同步到数据库数据库只做最终记账。这种方式注意不要把Redis当成持久化存储万一Redis宕机要能重新从数据库加载回真实的库存快照。第二种是拆分库存桶。把原来是总库存1的单个热点行拆成100个桶每个桶库存对应1/N更新请求随机落到不同的桶上锁竞争就摊开了。这个方案有两个成本一是库存汇总时要查询所有桶再加总二是有可能出现部分桶已分空但另一些桶还有剩余需要考虑流量分配策略。第三种是减少锁等待时间把innodb_lock_wait_timeout从默认的50秒调低到5-10秒让等待锁的请求快速失败并返回降级提示而不是无限占用连接池。这对全链路健康非常有效——宁可让一小部分用户看到请稍后重试也不能让整个服务的连接被拖垮。5.3 监控事务与连接池写进SOP的三条守则在事务使用层面我把长期的经验沉淀成了三条守则建议团队上线前逐条自查。**守则一事务内禁止有外呼。**包括HTTP、RPC、Redis操作尤其是有网络延迟的、消息发送、Sleep等待一律移出事务边界。可以让事务只负责数据库的写入和校验外呼放到事务提交之后用对账和补偿机制处理失败。**守则二事务粒度必须可控。**批量操作不要轻易用一个巨型事务包住全部数据。合理做法是设定批次大小比如每500条一个事务失败时只回滚当前批次用日志记录处理到哪个偏移量重跑时从断点继续。这比要么全成要么全败在工程上靠谱得多。**守则三隔离级别和锁等待必须有基线。**每个项目上线前DBA要在发布说明里确认事务隔离级别和innodb_lock_wait_timeout的设定值这两个参数直接决定该服务在锁竞争场景下的行为。不要等到线上发生大面积超时了才想起来查参数到那时候往往已经形成了半个小时的故障窗口。6. 跨库事务怎么办从本地消息表到TCC/Saga的取舍6.1 2PC/XA在真实业务中的尴尬处境业务拆分成微服务后一个操作往往要写多个数据库——订单库、库存库、积分库。这时候数据库单机事务就无能为力了需要分布式事务方案。最教科书式的方案是2PC两阶段提交XA协议就是它的典型实现。但说实话我在真实业务里很少直接用XA原因很现实第一2PC的prepare阶段锁资源要一直持有到全局事务commit/rollback决定做出资源被锁定时间非常长跨两个库时尤其明显第二协调者变成单点协调者宕机可能导致参与者长期停留在prepare状态手动处理悬空事务极其痛苦第三两阶段交互的额外轮次大幅增加延迟在高频交易场景下很难接受。X/Open DTP那种形式今天更多还是出现在数据库教材和银行核心系统的老架构里新业务基本不碰。所以现代分布式系统的主流方向是放弃强一致转向最终一致性。核心思想很简单不必保证所有节点在同一时刻看到相同的数据只要经过一段时间和补偿机制数据能够自我收敛到一致状态。这也是BASE理论在实际场景里落地的方式不是它走得慢而是它在可用性和一致性之间选择了更符合互联网业务的平衡点。6.2 本地消息表最朴素但最稳的方案如果让我从零开始设计一个分布式事务方案第一个推荐的不是什么框架而是本地消息表。它的思路是这样的业务表和消息表放在同一个数据库中业务操作和写消息在同一个本地事务里完成。比如下单时在事务里插入订单数据同时插入一条下单成功的消息记录。事务提交后由一个后台任务扫描消息表把消息投递到MQ或者直接通过HTTP发给下游服务。这个方案的巧妙之处在于它复用了一个本地数据库事务天然解决了业务成功了但消息没发出去的成本问题。投递失败也没关系消息表记录还在定时任务可以反复重试直到下游返回成功再把消息标记为已处理。幂等性由下游通过业务唯一键比如订单号来保证重复投递不会造成重复入账。我观察到的现象是很多团队上来就引入RocketMQ事务消息或者Seata这类框架但实际上他们的业务量根本用不到那么重的诉求又没有足够的运维能力去兜底框架本身带来的复杂度。本地消息表虽然是老办法但它简单、可控、容易排查对于绝大多数业务我的建议是先把这个方案跑通跑稳不够了再升级别一上来就追求技术上的高大上。6.3 TCC与Saga什么时候值得上分布式事务框架业务确实复杂、对一致性的实时性要求也足够高时可以考虑TCC和Saga两种框架化方案。TCCTry-Confirm-Cancel把一个分布式操作拆成三个动作Try阶段预留资源比如冻结账户资金、锁定库存Confirm阶段真正执行把冻结资金扣掉Cancel阶段释放预留解冻资金。它适合强约束、资金敏感的场景账户转账、钱包支付、跨行交易等。TCC需要业务团队为每个参与方写Try/Confirm/Cancel三段逻辑工作量不小而且对代码侵入性强所以要评估清楚再决定是否采用。Saga的思路则完全相反它把长事务拆成一系列正向操作每个正向操作都对应一个补偿操作。比如下单流程有创建订单→扣库存→扣余额→发物流如果扣余额失败就调用释放库存的补偿操作。它适合流程长、中间步骤多但不需要强一致锁定的业务比如预订流程、下单履约流程。Saga的难点在于补偿逻辑很难写完美——比如用户已经收到优惠券后取消订单优惠券要回收吗回收后用户已经把券用了怎么办这属于业务设计层面的事情框架只能帮你编排补偿的调用补偿的正确性只会由你的代码承担。我个人在选型时的判断顺序是本地消息表能解决的不上框架非资金强一致场景优先考虑Saga资金强一致且实时性要求极高才上TCC。同时永远给所有分布式方案配一套对账任务——不管多好的框架最终发现数据不一致时对账补偿才是兜底的那张安全网。最后再分享一个我坚持了很久的习惯每当改完事务相关的代码我都会在测试环境做两个反向验证——先制造一个异常确认数据能回滚再并发跑一批相同请求确认接口幂等。这两个验证加起来不超过十分钟却能在上线前拦截掉大部分事务失效和数据错乱的问题。事务这东西学习笔记可以写得很厚但真正值钱的永远是那几条在事故中换来的红线。
返回列表