1. 事务的本质为什么数据库需要这顶“保护伞”你打开数据库客户端敲下一行UPDATE语句数据变了。再敲一行DELETE数据没了。但如果这两行操作之间程序崩了、网络断了、磁盘满了呢数据库里留下的可能是一半修改账户扣了钱但订单没生成库存减了但流水没记录。这就是最常见的“半截操作”现场。数据库事务Transaction就是用来解决这个问题的。它把一组操作绑成一个不可分割的单元要么全部成功要么全部回滚不存在“做了一半”的状态。2000年前后我刚开始写数据库代码时有个老前辈跟我打了个比方事务就像银行柜台的“一笔业务”——客户转账时不管中间走了多少系统、查了几张表最终要么钱到账要么双方余额都不变绝不允许“转出成功但转入失败”的结果被提交到账本上。1.1 事务的 ACID 四重身份教科书里把事务的特性归纳为 ACID这四个字母背起来容易真正理解需要结合真实场景原子性Atomicity事务是不可分割的最小单元。事务里有三条语句任何一条失败前面两条的修改也要撤销。这个“撤销”不是靠程序去补一条反向语句而是数据库通过日志机制自动倒回。一致性Consistency事务执行前后数据库必须从一种一致状态变成另一种一致状态。比如约束、触发器、外键都满足。你可以把一致性理解为“规则的守护者”它保证不管怎么并发最终账目是平的。隔离性Isolation多个事务并发时彼此不能互相干扰。事务 A 读到的数据不应该因事务 B 还没提交的修改而改变。隔离性实现起来最复杂也是后面我们要重点拆解的部分。持久性Durability事务一旦提交数据就永久保存即使系统马上崩溃也不会丢失。这背后依赖的是事务日志Transaction Log和存储引擎的刷盘机制。我在笔记里写了一句送给自己也送给新手的话原子性解决“半截操作”一致性解决“规则破坏”隔离性解决“并发打架”持久性解决“断电失忆”。1.2 一句话判断该不该用事务很多人问我什么操作需要事务我的判断标准很简单如果这个操作涉及“两个以上数据变更”并且“变更之间存在逻辑关联”就一定要用事务。举几个典型场景转账A 账户扣减、B 账户增加两笔 update 必须在一个事务里。下单库存表扣减、订单表插入、流水表插入三个动作必须同时成功。批量更新比如把“所有状态为 0 的单据”改成“状态为 1”中途发生唯一键冲突就应该全部回滚而不是改一半留一半。反过来单纯一条SELECT查询、只读操作不需要事务。一些“独立且逻辑无关”的多条插入也可以不用事务但如果有外键关联还是建议包一层事务获得保证。2. 事务日志数据库的“黑匣子”也是9002报错的根源开头提到的那个错误——数据库 ais20221123194008 的事务日志已满只要在线上环境待过几年的人都见过。报错信息里“9002”是 SQL Server 的错误号属于事务日志问题。要搞懂它得先弄明白事务日志到底是干嘛的。2.1 先搞懂日志是怎么服务的数据库有两种核心文件数据文件.mdf和日志文件.ldf。数据文件存的是“最终结果”日志文件存的是“操作过程”。现代数据库普遍采用Write-Ahead LoggingWAL策略在把数据页真正写入磁盘前先把这个操作记录写到日志文件里。这样如果系统崩溃数据库重启时可以通过日志进行“前滚”或“回滚”保证已提交的不丢失、未提交的不生效。你可以把事务日志想象成飞机的“黑匣子”飞机坠毁时客舱和引擎的具体状态可能已经无法恢复但黑匣子记录了所有操作指令和飞行参数。数据库崩溃时也一样可能内存里有些脏页还没刷到数据文件但日志文件里有完整记录重启后靠它就能重演。事务日志还有一个关键动作叫检查点Checkpoint。当检查点发生时数据库会把当前内存中已经提交的脏页批量刷到数据文件同时把日志文件中检查点之前的部分标记为“不再需要”。检查点越频繁日志回收越及时日志文件增长越慢检查点太少日志就会越积越多。2.2 事务日志为什么会满日志文件满的原因可以归为三类日志无法自动增长很多数据库实例配置了日志文件初始大小但没有设置自动增长或者增长步长太小写满后无法扩展直接报9002。日志无法被截断回收最常见的原因数据库处于“完整恢复模式”且长期没有进行日志备份或者存在长时间运行、未提交的活跃事务。日志备份是唯一能真正截断日志的操作我自己就吃过亏——某测试库用了完整恢复模式但没人做过日志备份跑了一个批量更新后日志爆掉数据库直接只读。手动增长被限制磁盘空间不足、文件设置了最大大小MAXSIZE或者文件组设置为只读都会导致日志无法增长。我见过最惨的案例是同事跑一个数据订正脚本循环里有个事务一直不提交日志文件从 10GB 疯涨到 100GB直接把 D 盘怼满整个实例的服务都停了。后来排查才发现他开启事务的方式是在循环外面写了一个BEGIN TRAN循环里有一半数据有外键冲突但他只做了异常输出没有执行ROLLBACK事务就一直在那挂着日志自然不会截断。2.3 日志文件和事务之间的关系每一个事务开始后日志会记录它的操作序列事务提交前日志记录不能被覆盖。多个事务的日志是交错追加到同一个日志文件里的但每个事务都有自己的LSNLog Sequence Number日志序列号作为唯一标识。数据库在崩溃恢复时会从最后检查点开始扫描日志用这些 LSN 判断哪个事务已提交、哪个未提交从而决定是回滚还是前滚。理解这个机制后9002 报错的应对思路就清楚了先查日志使用量确认是不是长时间未备份导致再做一次日志备份或收缩把空间释放出来最后设置合理的自动增长并检查是否有活跃事务拖后腿。3. 隔离级别与并发控制4×4的排列组合事务并发时互相影响的程度由隔离级别控制。标准 SQL 定义了四个隔离级别不同数据库实现略有差异但概念都类似。这一块特别容易学完就忘我结合真实业务梳理一次。3.1 并发问题图鉴先认识一下不设防的并发会带来哪些问题脏读Dirty Read事务 A 修改了数据但还没提交事务 B 读到了这个未提交的修改。如果 A 回滚B 就拿着一个“不存在的值”做了后续操作。竞品里最典型的就是商品库存B 看到库存剩 1赶紧下单其实 A 马上要回滚库存其实还有 100结果 B 下了单但库存没扣成。不可重复读Non-Repeatable Read事务 A 同一个 SQL 查两次结果却不一样因为中间事务 B 提交了修改。比如查订单总金额第一次查 100 元第二次查 200 元应用层可能就乱了。幻读Phantom Read事务 A 用条件status待发货查了 10 条记录事务 B 插入了 1 条新的待发货记录并提交A 再查变成 11 条。新增的这 1 条像幻觉一样出现所以叫幻读。3.2 四种隔离级别隔离级别脏读不可重复读幻读实现方式常见Read Uncommitted读未提交可能可能可能不加锁直接读最新版本Read Committed读已提交不可能可能可能读操作加共享锁读完即释放Repeatable Read可重复读不可能不可能可能InnoDB 通过间隙锁解决读操作加共享锁事务结束才释放Serializable可串行化不可能不可能不可能全部加锁或 MVCC 锁实现这里重点解释两个容易混淆的点。第一个同样是“不可重复读”普通读和当前读的处理方式不同。InnoDB 在 Read Committed 和 Repeatable Read 下普通查询走的是 MVCC多版本并发控制通过版本号快照实现一致性读取。在 Repeatable Read 下普通查询的快照在事务首次读取时生成之后一直复用所以普通查询不会看到别的事务新提交的数据。但如果执行SELECT ... FOR UPDATE或UPDATE、DELETE走的是当前读必须读取最新版本并加锁。这就是为什么有些场景下同一事务内先普通查询、再FOR UPDATE查询结果可能不一致。第二个重复读和串行化的区别。Repeatable Read 锁住了已有记录但没法防止别人往“范围空隙”里插入新记录。InnoDB 引入了间隙锁Gap Lock来解决这个问题当查询条件是一个范围时不只是锁已存在的记录还把记录之间的间隙也锁住。比如查WHERE id BETWEEN 1 AND 5间隙锁会把 1 到 5 之间的所有未插入位置锁住别人插不进记录幻读就被挡掉了。但间隙锁也有代价并发度降低且死锁概率上升。这也是为什么很多高并发场景宁愿降低隔离级别也不追求可串行化。3.3 实际业务里怎么选隔离级别我常用的选择原则绝大多数业务系统Read Committed是底线。MySQL 默认是Repeatable ReadSQL Server 默认是Read CommittedOracle 也是。如果你的系统没有特殊需求保持默认即可。报表类、统计类应用如果对数据一致性要求很高可以用Repeatable Read或Serializable但要接受锁等待、性能下降的代价。高并发、大数据量的互联网项目经常退一步到Read Uncommitted甚至用 NoSQL 绕过数据库层因为账出错可以由对账系统兜底。但自己写的库存扣减、资金转账千万别用这个级别。用一条实际业务线说明用户下单流程涉及扣库存、扣余额、生成订单。我会选Repeatable Read因为在整个事务里库存和余额不允许被人插一脚。但在一个读多写少的博客系统里Read Committed足够重要的是性能。4. 实战把事务写进代码的正确姿势理论说了那么多最终要落实到代码里。很多人踩过的坑往往不是因为不懂 ACID而是因为代码里的事务边界划错。4.1 两种开启事务的姿势以 Java 的 Spring 为例最常见的是通过Transactional注解声明式让容器管理事务。这个方法的问题在于很多新手不知道它默认只回滚RuntimeException和Error如果业务代码里抛了一个自定义的CheckedException事务是不会回滚的。这时候就得指定rollbackFor参数Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { Order order new Order(); order.setUserId(dto.getUserId()); // ... orderMapper.insert(order); stockMapper.decreaseStock(dto.getSkuId(), dto.getCount()); // 如果这里抛异常上面两条插入会被回滚 }另一种是编程式事务自己控制begin和commit/rollback。这种方式更灵活适合在方法内部有复杂分支逻辑时手动控制。但要警惕如果忘写rollback或者rollback被catch住了没执行事务就会一直挂起。4.2 事务边界的设计心法边界划错是隐蔽的 bug 来源。我总结了三类踩过的坑坑一把远程调用放进事务。比如下单时先扣库存再调外部支付接口如果支付接口超时事务一直不结束。因为远程调用的网络等待时间非常不可控长时间持有数据库锁会拖垮吞吐量。正确做法是本地数据库操作放在事务里远程调用放在事务提交之后失败时通过补偿机制处理。坑二事务方法内部做耗时操作。比如循环调接口、批量导入十万条数据全部包到一个事务里。日志记录海量增长锁范围巨大后续请求全部阻塞。解决办法是把大事务拆分成多个小事务或者分批提交。坑三事务中嵌套其他事务方法被同一个类调用。比如createOrder()内部调了this.updateStock()但updateStock()上也标注了Transactional。由于 Spring 代理机制的局限性同类内直接调用不会走代理内部事务注解失效。最终表现是“看起来有事务其实没有”。4.3 隔离级别在代码里怎么设置Spring 的Transactional里可以直接指定isolationTransactional(isolation Isolation.READ_COMMITTED)不过我的建议是尽量别在代码层面频繁调整全局隔离级别而是在数据库连接串或数据库实例级别配置好。因为开发同事对这种设置的口径不一致容易产生“同一个库不同连接不同隔离级别”的混乱。如果确实需要调整最好在 SQL 层面显式声明并写清楚理由。5. 救火实录消息9002“事务日志已满”的完整排查回到开头那个报错。出现这个错误时数据库并不会自动丢数据但会进入一种“只能读不能写”的状态所有修改操作报错。如果不处理业务基本就停摆了。下面是一套我已经用了无数次的排查处理流程。5.1 第一步快速定位日志占用SQL Server 里用DBCC SQLPERF(LOGSPACE)查看各库日志文件使用百分比DBCC SQLPERF(LOGSPACE);结果里能看到每个数据库的日志文件大小和已使用百分比。如果日志已使用比例达到 99% 甚至 100%基本可以断定问题就是日志无法截断。同时看一下database_id对应的恢复模式SELECT name, recovery_model_desc FROM sys.databases;如果显示FULL说明日志需要备份才能截断。如果显示SIMPLE理论上日志会自动截断但依然可能出现“日志文件物理大小不缩小”的问题即逻辑使用率低但文件巨大。5.2 第二步找出阻塞日志截断的活跃事务日志没法截断最常见的原因是存在未提交事务。用下面这条语句查看DBCC OPENTRAN(ais20221123194008);或者用更精细的动态管理视图SELECT s.session_id, s.login_name, s.status, t.text, s.last_request_start_time FROM sys.dm_exec_sessions AS s LEFT JOIN sys.dm_exec_connections AS c ON s.session_id c.session_id LEFT JOIN sys.dm_exec_sql_text(c.most_recent_sql_handle) AS t WHERE s.session_id IN (SELECT session_id FROM sys.dm_tran_active_transactions);如果看到某个会话的status是sleeping但last_request_start_time很早而且它持有事务那么大概率就是它在拖住日志。处理方法是如果事务已无用用KILL session_id强制终止。注意KILL前务必和项目负责人确认强制终止可能让应用抛出异常。但相比日志爆满导致整个库不可用通常还是值得的。5.3 第三步按恢复模式对症下药如果是 SIMPLE 恢复模式这种模式下日志在检查点后就会自动截断。物理文件仍然很大的原因是截断了但空间没还给操作系统。重点不是“截断”而是“收缩”DBCC SHRINKFILE (Nais_log, 100);SHRINKFILE第二个参数是目标大小MB注意目标值不要设太小否则会导致日志频繁增长影响性能。如果是 FULL 恢复模式先做一次事务日志备份日志备份后会把不活跃部分截断BACKUP LOG [ais20221123194008] TO DISK ND:\backup\ais_trn.trn WITH NOFORMAT, INIT, NAME Nais-事务日志备份;备份成功后再收缩文件USE [ais20221123194008]; DBCC SHRINKFILE (Nais_log, 100);执行完你再看DBCC SQLPERF(LOGSPACE)通常日志使用率会大幅下降。收缩到合理大小后再去检查数据库的自动增长设置。右键数据库 - 属性 - 文件 - 日志文件将“自动增长”改为“按百分比 10% 增长”并设置最大文件大小上限比如不限制防止下次爆掉前磁盘先满。5.4 第四步预防复发的手段救火之后必须还债否则下次百分之百还会满。我把自己的预防清单写在这里生产库使用完整恢复模式时必须配置定期日志备份频率不要低于 15 分钟一次。日志备份不会影响业务成本也低但能有效控制文件大小。监控脚本定时检查日志空间。我用过一个简单的 SQL 作业每 10 分钟执行一次DBCC SQLPERF(LOGSPACE)如果日志使用率超过 80%就发告警邮件。设置日志文件的自动增长。不是所有环境都默认开启尤其要注意增长步长如果设成固定 1MB高并发下增长日志文件会频繁分配拖慢事务提交速度。检查计划任务里有没有长事务。比如月结、年终汇总的脚本如果里面有WAITFOR或者死等锁的循环很可能会产生超长事务这是日志爆掉的元凶之一。6. 常见问题速查表与避坑心得我把这些年遇到的事务相关问题整理成速查表方便大家遇到问题时直接对标。问题现象可能原因排查与解决9002 事务日志已满FULL 恢复模式无日志备份长事务磁盘满备份日志KILL 长事务扩容磁盘事务没回滚数据被改了Transactional只回滚 RuntimeException指定rollbackFor Exception.class锁等待超时事务过大死锁隔离级别过高拆分事务减少锁粒度降低隔离级别同一事务内查询结果不一致MVCC 快照和当前读混杂理解普通查询与FOR UPDATE的区别日志文件很大但使用率低SIMPLE 模式下从未收缩DBCC SHRINKFILE收缩批量更新时崩溃部分生效没有开启事务将循环操作包进事务或分批提交事务长时间无响应出现未提交事务且持锁等待DBCC OPENTRAN查看活动事务并处理方法内事务嵌套失效同类内直接调用不走代理拆分到不同类或注入自身 Bean 再调用6.1 关于死锁的实战心法死锁是事务并发下的经典问题我碰到最多的场景是两个事务分别持有不同表的锁然后再同时申请对方的锁形成循环等待。比如事务 A先更新订单表再更新库存表。 事务 B先更新库存表再更新订单表。如果 A 和 B 几乎同时执行A 锁了订单、B 锁了库存然后 A 等库存B 等订单两边都互不相让死锁就产生了。我的解法不靠调超时而是从代码层面统一加锁顺序——所有的业务方法都按照“订单表先、库存表后”的顺序执行死锁概率直接降了一个数量级。另一个经验是尽量用短的持锁时间特别是在索引不完善的情况下更新语句可能锁全表把并发全部堵死。看到死锁日志里两条语句都扫全表时先看看是不是缺索引。6.2 测试事务的正确打开方式很多新手写完事务代码发现测试不出来回滚效果其实是没用对验证方法。你可以这样玩在事务代码执行到一半时手动把数据库服务停下来再重启看数据是否只有一部分被修改。如果事务回滚了说明原子性没毛病。但这种测试要非常谨慎别在线上做。更安全的验证方式是在代码里故意抛一个异常然后查数据库所有相关表都保持操作前的状态。我在本地环境经常用一段简单的 Python 配合 SQLAlchemy 来做模拟from sqlalchemy import create_engine, text from sqlalchemy.exc import SQLAlchemyError engine create_engine(mysqlpymysql://user:passlocalhost/testdb) with engine.begin() as conn: conn.execute(text(UPDATE accounts SET balance balance - 100 WHERE id 1)) # 故意执行一条错误语句触发回滚 conn.execute(text(UPDATE nonexistent SET x 1))看一下数据库里账户余额有没有变化就知道事务有没有生效了。注意engine.begin()会帮我们做好提交和回滚管理省去手动commit/rollback的繁琐和遗漏风险。7. 最后一页笔记事务哲学与个人体会数据库事务看起来只是一堆理论条目和几条 SQL但真正到了生产环境它是一套协作系统日志保障崩溃恢复锁控制并发交错隔离级别决定一致性视图代码里的边界决定故障影响半径。我见过太多只会在业务代码里写一个Transactional的人遇到“日志满”就只会重启删日志根本不理解后台发生了什么。我个人在实际操作中的体会是事务和日志是同一个硬币的两面。你理解了日志才能理解为什么事务不能随便开理解了锁才能理解为什么事务越小越好。每次写涉及多个表变更的代码时先问自己三个问题这条业务链路是不是必须原子并发量大概多少隔离级别够不够用三个问题想清楚了事务基本不会出大乱子。最后再分享一个小技巧我给自己维护的所有数据库脚本模板里都默认加了一段“事务健康度检查”注释内容包括预计执行耗时、涉及的表、隔离级别、是否包含远程调用。这个习惯帮我避免了好几次“小脚本搞垮大库”的事故。事务不是越复杂越好而是越清晰、越短小、越可控越好。希望这篇笔记也能帮你少踩几个坑。