
做后端开发的人几乎天天都在跟数据库打交道但真要一句话把“事务”讲明白还真没多少人能理直气壮。很多人写CRUD写得很溜一碰到并发扣库存、转账、订单状态流转就翻车多数时候不是SQL写错了而是事务处理没做对。这次我就拿“数据库事务处理”这个题目把事务从概念到实操、从锁到死锁、从单体到分布式串一遍顺便把我自己踩过的坑和排查思路也摊开讲。这篇东西适合刚接触事务的同学也适合写了好几年业务但从来没认真看过死锁日志的同行。哪怕你只是想把事务边界怎么划搞清楚也能捞到点东西。1. 事务的本质为什么说事务是数据库的“安全绳”1.1 一次银行转账里的微观世界事务这个概念几乎在所有数据库教材里都会用一个经典例子来讲账户A向账户B转账100元。拆开来看这条业务至少有两个写操作扣减A的余额、增加B的余额。如果第一个操作执行成功后、第二个操作执行前数据库突然宕机了会发生什么A的钱少了100B的钱没多账就平不上了。这时候事务的作用就出来了把一组操作打包成一个不可分割的单元要么全部成功要么全部失败。这就是ACID里的原子性Atomicity。注意这里的“不可分割”不是指物理上必须同时写完而是逻辑上对外表现为一个整体——任何一个环节失败系统都要能把之前已经做的修改撤销回去让数据回到事情没发生之前的样子。除了原子性ACID还包括一致性Consistency、隔离性Isolation和持久性Durability。一致性容易被误解成“数据库自己保证的规则”实际上它的意思是事务开始前和结束后数据都要满足业务定义的完整性约束。比如余额不能为负数、订单金额必须大于0、账户总数对得上。换个更直白的说法一致性靠的是业务代码 数据库约束一起守单纯依靠数据库的“事务”标签并不代表你的数据就不会坏。隔离性解决的是并发问题。多个事务同时读写同一批数据时互相之间不能产生致命干扰每个事务都像是“独占”了数据库一样。持久性则好理解事务一旦提交修改就要永久生效哪怕下一秒机器断电重启后数据也必须还在。数据库怎么做到持久性的后面讲InnoDB的redo log时会细说。这一套东西说穿了就是数据库给你的安全承诺。但承诺归承诺真要在高并发下把它兑现成本一点不比业务逻辑本身低。这也是为什么很多系统一上并发就出问题根子往往在事务的隔离和锁上。1.2 隔离级别不是越高越好脏读、不可重复读、幻读隔离性不是无限隔离的因为完全隔离等于把所有事务串行执行并发性能会惨不忍睹。于是SQL标准定义了四个隔离级别从低到高分别是读未提交Read Uncommitted、读已提交Read Committed、可重复读Repeatable Read、串行化Serializable。读未提交几乎没人正经用因为它允许事务读到另一个事务还没提交的数据也就是脏读。你拿别人准备回滚的数据去算账这不是给自己埋雷吗。读已提交是主流数据库的默认选择之一比如PostgreSQL、Oracle它解决了脏读但存在不可重复读同一个事务里第一次查A是100元过一会儿再查A变成了80因为另一个事务在中间提交了修改。可重复读进一步解决了这个问题保证同一事务多次读同一数据结果一致。MySQL的InnoDB默认就是可重复读。到了可重复读读的问题看起来解决了但还有个更隐蔽的幻读事务A查某一个范围的记录得到3条事务B往这个范围里插了一条新记录并提交事务A再次查同一个范围变成4条。如果把“记录”换成“符合条件的记录集合”幻读读的就是数据集合的变化。MySQL的InnoDB在可重复读级别下通过间隙锁Gap Lock和MVCC多版本并发控制把幻读按住了大部分场景体会不到这个问题。而理论上要彻底挡住幻读得上串行化——事务之间完全排队执行没有任何并发偷看的机会。这里要特别提醒一句隔离级别不是越高越好。隔离级别越高并发能力越差锁竞争越多响应延迟也越明显。生产环境里我见过有人为了“稳妥”把所有事务都打到串行化结果系统TPS直接掉了个数量级。正确的做法是按业务容忍度来选宁可去代码里处理重试和补偿也别在数据库层面一刀切锁死。2. 事务并发下的暗流锁、死锁与性能损耗2.1 锁的类型与加锁逻辑共享锁、排他锁、行锁、表锁事务处理一旦并发起来就绕不开锁。锁本质上是数据库的“排队门禁”你要修改某行数据就得先拿到这一行的排他锁X锁别人读这行只能拿共享锁S锁读锁和写锁互相排斥两个写锁也互相排斥。听起来简单实际执行时却有很多变数因为锁的粒度不同行锁、间隙锁、表锁、意向锁一层套一层。InnoDB的行锁是建立在索引上的。翻译成大白话就是如果你的更新语句没走索引数据库就得扫描整个表才能定位目标行这时候行锁就退化成表锁本来只锁两行的操作变成了锁整张表并发立刻雪崩。这也是为什么我一直强调事务里的SQL必须尽量走索引尤其是主键或唯一索引否则你以为自己在做精细行级控制实际上把整张表钉死了。除了锁本身很多数据库还引入MVCC来减少读写之间的阻塞。MVCC的大致思路是写事务修改数据时不是直接覆盖旧值而是生成一个新版本读事务去读的时候选择对自己可见的那个版本。这样读操作不用等写操作释放锁写操作也不用等读完的慢查询读写互不阻塞。我们平时感觉数据库“并发挺好”很大功劳来自这套机制而不是锁本身设计得多玄妙。在实际编码里还要区分悲观锁和乐观锁。悲观锁就是“默认会有人跟我抢”查询出来之后直接SELECT ... FOR UPDATE把行锁住直到事务结束。乐观锁则默认没人跟我抢更新时带上版本号或者时间戳UPDATE ... SET balancebalance-100, versionversion1 WHERE id? AND version?如果更新的行数为0说明被别人抢先改过了再由业务决定重试还是放弃。两种思路没有绝对好坏关键看冲突概率并发抢同一行的概率高悲观锁更省事概率低乐观锁的成本更低、性能更好。2.2 死锁的成因与排查实录一次经典的双向等待死锁是事务处理里最讨厌、也最经典的坑。简单说死锁就是两个事务互相持有对方想要的锁谁都不会先放手最后谁也跑不动只能靠数据库自己检测出来强制回滚其中一个事务另一个事务才能继续。我见过一个特别标准的死锁场景发生在两个账户互相转账的SQL上事务A执行先更新账户1再更新账户2。 事务B执行先更新账户2再更新账户1。-- 事务A UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2; -- 事务B UPDATE account SET balance balance - 100 WHERE id 2; UPDATE account SET balance balance 100 WHERE id 1;如果两个事务刚好交错执行A锁住了账户1等待账户2B锁住了账户2等待账户1环形等待形成死锁就出现了。MySQL检测到之后会立刻回滚其中一个小事务一般在毫秒级别业务端会收到一个1213的错误码。这也是为什么转账类业务里很多团队会把账户更新顺序强制统一先更新ID小的再更新ID大的从源头打掉环。排查死锁不能光靠猜。我自己的操作习惯是先打开MySQL的SHOW ENGINE INNODB STATUS看LATEST DETECTED DEADLOCK里面有当前死锁涉及的两条SQL、持有哪些锁、在等哪些锁基本一眼就能定位是哪个业务流程的锅。更细一点的可以查information_schema.innodb_trx看当前有哪些事务长时间没提交再配合performance_schema.data_locks查看具体锁信息。顺带吐槽一句很多线上死锁其实不是真的“两个事务互踩”而是同一个业务代码里嵌套了多个连接自己也把自己锁死了。光会看还不够得懂得预防。除了统一锁顺序还要注意事务里避免做太耗时的操作比如远程API调用、慢SQL、大范围更新。锁持有的时间越短死锁的概率越低并发吞吐也越高。2.3 数据库连接池与事务边界的关系很多人忽略的坑事务处理对连接的要求经常被忽视。连接池的核心作用是复用数据库连接降低频繁建立连接的开销。但“连接复用”和“事务”之间有个微妙的冲突事务是绑定在某个具体连接上的你做BEGIN之后一系列操作必须一直在同一个连接上执行再COMMIT或ROLLBACK事务才结束。如果你在事务中间把连接还回池子另一个请求拿到这个连接可能就接在了一个还没提交的事务上事务边界直接错乱。更隐蔽的坑是长事务把连接池耗光。我见过一个真实事故某个接口在事务里做了个外部HTTP调用上游服务超时3秒这个接口被疯狂请求几十个连接全卡在等外部服务上事务一直不提交连接池连接耗尽其他正常的数据库请求全部排队整个系统跟着雪崩。这个问题的根子不是连接池配置而是事务边界设计太粗糙——把网络调用塞进了事务里。连接池本身的参数也有一些讲究。像HikariCP建议maximumPoolSize不要盲目调大因为每个连接背后都是一个数据库端线程或者会话连接太多反而会加剧锁竞争、浪费内存。比较务实的做法是先按数据库CPU核数估算一个基准值再通过压测微调。还要设置合理的connectionTimeout和validationTimeout避免拿到的连接已经断了还继续用。另外如果事务里出现异常一定要在finally里做回滚否则连接归还时仍处于未提交状态脏数据就悄悄溜进库里了。3. 主流数据库的事务实现差异如何影响你写代码3.1 MySQL InnoDBMVCC Redo/Undo可重复读是怎么撑住的先看MySQL准确说是InnoDB因为它是大多数人默认选的存储引擎。InnoDB实现事务主要靠三样东西undo log、redo log、锁系统。undo log用来记录修改前的数据事务回滚的时候就拿它反向恢复同时也为MVCC提供历史版本。redo log用来记录已经执行成功的修改写入点设在磁盘上这叫预写日志WAL。因为磁盘随机写很慢但顺序写redo log很快所以真正提交时先保证redo log落盘数据文件脏页后面再慢慢刷即使这时候宕机重启时也能靠redo log把数据恢复过来。很多同学不理解“为什么MySQL 8.0默认双写机制这么重要”其实就是持久性落地的最后一环。InnoDB在可重复读隔离级别下读操作分两种快照读和当前读。普通SELECT是快照读走MVCC直接读事务开始时的快照版本不加锁UPDATE、DELETE、SELECT ... FOR UPDATE是当前读必须读最新版本并且加锁。这个设计让InnoDB在高并发下事务的读性能异常出色因为读不会阻塞写写也不会阻塞读。当然代价就是代码里需要时刻区分你看到的“旧数据”可能是快照拿去做实时计算就要小心了。MVCC的版本可见性简单说就是每个事务启动时会分配一个事务ID读数据时根据行的隐藏列创建版本号、过期版本号判断哪个版本对自己可见创建版本号大于当前事务不可见过期版本号小于当前事务也不可见。这是InnoDB实现快照的关键也解释了为什么可重复读级别下一个事务内多次普通SELECT结果永远一致因为事务一开始的快照就被固定住了。写到这里我突然想提醒一个实际场景很多同学写批量更新时习惯先SELECT查出数据再逐条UPDATE其实这中间有一条隐形的时间窗口别的连接可能已经改了数据。如果业务上要求绝对精确应该直接SELECT ... FOR UPDATE或UPDATE后检查影响行数别指望MVCC帮你挡掉所有并发更新冲突。3.2 PostgreSQL、SQLite等引擎的事务风格差异PostgreSQL是另一个被广泛使用的事务标杆。它同样用MVCC但实现方式和InnoDB有明显区别PostgreSQL的每个事务在修改数据时会创建新版本的行旧版本留在原处通过系统字段标记可见性InnoDB则倾向于在页内保留新旧记录通过undo log回溯历史版本。这导致PostgreSQL的写操作不会阻塞读读也不会阻塞写而且清理旧版本依赖VACUUM机制。如果你的系统用了PostgreSQL长事务要特别小心因为它会让旧版本堆积拖慢VACUUM甚至导致表膨胀。SQLite则是另一种极端。它是一个嵌入式单文件数据库为了极致的简单可靠采用了数据库级写锁同一时刻只允许一个写者其他写请求必须等待。不过它对读采用多版本方式读写之间可以不冲突。这个设计非常适合移动端、小工具、单机应用但绝不适合高并发写入的Server端场景。很多新手把SQLite当“文件型MySQL”用稍微上点并发写就报database is locked其实是没理解它的事务模型和锁粒度的定位差异。其他数据库像SQL Server、Oracle也各有各的事务实现细节比如Oracle默认读已提交、SQL Server有多种隔离选项。但这些差异没有本质性优劣真正影响你写代码的是事务隔离级别、锁粒度、以及数据库怎么处理回滚段。吃透一种再迁移到另一种工作量没你想得那么大。3.3 分布式事务本地事务解决不了的问题别硬上本地事务只能保证同一个数据库实例内的一致性。一旦业务拆成多个服务、多个数据库实例比如订单库和库存库分开了一次下单操作既要写订单又要扣库存本地事务就不够用了因为跨库无法直接用单库事务协调。这时大家会想到分布式事务。分布式事务的主流方案大致分两类一类是强一致性的两阶段提交2PC、XA强调所有参与者要么全提交、要么全回滚另一类是最终一致性方案比如本地消息表、消息事务、SAGA补偿。两阶段提交看起来很完美但网络分区、单点协调者、资源长时间占用等问题让它很难在高并发互联网场景下施展。而最终一致性方案虽然允许一段时间的账不平但通过消息、状态机、定时任务补偿最终能把数据收敛一致。这里我特别想说一句研发里的老实话分布式事务要能不上就不上能降级到“本地事务消息补偿”就尽量降级。很多系统的“分布式一致性”痛点其实是因为业务边界设计太差不该拆的库拆了不该异步的流程同步了。我在项目里最常做的优化就是把同一业务实体的多个操作尽量收拢到同一个数据库实例内减少跨库跨服务的强一致需求。真到了必须分布式事务的地步优先用事务消息本地消息表这类可控的方案别一上来就引入庞大的分布式事务中间件量级不够反而拖垮系统。4. 事务编码中的实战要点与避坑清单4.1 事务边界的正确设计写在Service层别写在DAO里事务处理最核心的一条经验事务边界要放在Service层也就是业务方法入口附近尽量让一个事务对应一个完整的业务操作。而不是在DAO层每个数据库方法上面都加事务也不是放在Controller里让一个HTTP请求霸占一个事务。为什么这么强调因为事务的本质是“锁”和“资源”的占位。事务开得越早、范围越大锁持有时间越长事务间互相等待的概率就越高。如果你在Controller层里开了事务结果方法里还在调外部服务、发短信、查第三方API等于把宝贵的数据库连接和锁白白浪费在无意义的外部IO上。这类问题在线下开发环境往往看不出来因为测试数据少、并发低、锁冲突不明显一到线上高峰期立刻原型毕露。一个我自己常用的经验法则是事务方法内只做数据库操作所有RPC、MQ发送、外部接口调用都放在事务提交之后再执行。如果外部调用失败那就通过异步重试、人工补偿或者状态机转移来兜底而不是让数据库事务一直吊着。举例来说下单接口里先扣库存、写订单、提交事务然后再发消息通知物流服务即使发送失败也还有一张待处理消息表可以重推完全不值得为了一次推送失败把整个事务回滚掉。除此之外事务方法的粒度也要控制。有些人喜欢一个超长方法里密密麻麻写了十几个数据库操作都说业务需要原子性其实很多操作根本不需要绑在同一个事务里。判断标准很简单如果其中一个操作失败了其他操作必须跟着一起失败吗如果答案不是100%就拆成多个独立事务。4.2 常见异常与处理策略死锁重试、锁等待超时、大事务我在日常排障中最常碰到的事务相关异常有这三类死锁、锁等待超时、大事务导致主从延迟或磁盘异常膨胀。死锁异常MySQL错误码1213在上面讲过业务层最好的应对就是捕获这个异常后做有限次重试。因为死锁被数据库自动回滚后你的事务没有产生任何副作用重试通常是安全的。但重试次数不要没上限我建议最多3次每次随机退避几十毫秒避免两个事务每次都同节奏重试再次撞在一起。锁等待超时MySQL错误码1205比死锁更常见原因是某个事务持锁时间太长另一个事务等待超过innodb_lock_wait_timeout默认50秒直接放弃。遇到这个异常第一步不是调大超时而是查innodb_trx看谁占着锁不放手找到那个睡眠了很长时间的事务并杀掉。调大超时只是治标而且会掩盖真正的问题。大事务则是另一个隐蔽杀手。一个事务里UPDATE了百万行或者一口气插入了大量数据锁范围大、redo log暴涨、binlog同步延迟飙升从库跟不上最后主从切换时数据分叉麻烦就大了。我自己定过一条规矩超过一万行级别的批量更新必须分批或者分表做绝不在单事务里扫全表。如果业务实在要求原子性至少也要在事务前评估锁范围和耗时给每一个大事务配置独立的监控报警。4.3 事务调优的实用思路从索引、隔离级别到监控事务处理做久了就会明白性能瓶颈很多时候不是事务本身而是事务“保护”的操作太慢。先看SQL是否走索引再看锁范围是否合理最后才轮到隔离级别和锁等待参数。比如一条UPDATE没走索引行锁变成表锁这时候你调任何事务参数都没用加个合适的索引比什么都强。隔离级别的选择也要按场景来。大部分业务用读已提交就够因为可重复读的快照版本会带来额外空间开销和并发限制除非有明确的同一事务多次读一致需求否则没必要追高。PostgreSQL用户常默认用读已提交体验就很好MySQL还有个小技巧全局或会话级别把transaction-isolation改成读已提交可以减少间隙锁带来的死锁和锁竞争。事务监控这块我建议至少在关键库上定时采集三张表的数据information_schema.innodb_trx当前所有事务的时长、状态、操作行数、performance_schema.data_locks当前锁持有和等待情况、performance_schema.data_lock_waits等待链路。把这些数据落到日志或监控平台里配合一个简单的告警规则事务运行超过5秒就报警超过30秒直接提示人工排查。因为正常事务都是毫秒级完成的长事务尤其出现在凌晨批量作业里更值得警惕。最后再分享一个小技巧是我实际处理过很多次线上问题之后养成的习惯每次写完跟事务相关的代码都顺手看一眼执行计划确认关键SQL有没有走索引。很多事务问题根本不在于事务本身而是SQL写得不好把锁的范围扩大了而已。把这条养成习惯事务相关的疑难杂症至少能提前挡掉一半。