news 2026/10/2 14:33:30

数据库事务与事务日志:从ACID到9002报错排查实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数据库事务与事务日志:从ACID到9002报错排查实战

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 Logging(WAL)策略:在把数据页真正写入磁盘前,先把这个操作记录写到日志文件里。这样如果系统崩溃,数据库重启时可以通过日志进行“前滚”或“回滚”,保证已提交的不丢失、未提交的不生效。

你可以把事务日志想象成飞机的“黑匣子”:飞机坠毁时,客舱和引擎的具体状态可能已经无法恢复,但黑匣子记录了所有操作指令和飞行参数。数据库崩溃时也一样,可能内存里有些脏页还没刷到数据文件,但日志文件里有完整记录,重启后靠它就能重演。

事务日志还有一个关键动作叫检查点(Checkpoint)。当检查点发生时,数据库会把当前内存中已经提交的脏页批量刷到数据文件,同时把日志文件中检查点之前的部分标记为“不再需要”。检查点越频繁,日志回收越及时,日志文件增长越慢;检查点太少,日志就会越积越多。

2.2 事务日志为什么会满

日志文件满的原因可以归为三类:

  1. 日志无法自动增长:很多数据库实例配置了日志文件初始大小,但没有设置自动增长,或者增长步长太小,写满后无法扩展,直接报9002。
  2. 日志无法被截断(回收):最常见的原因,数据库处于“完整恢复模式”且长期没有进行日志备份,或者存在长时间运行、未提交的活跃事务。日志备份是唯一能真正截断日志的操作,我自己就吃过亏——某测试库用了完整恢复模式,但没人做过日志备份,跑了一个批量更新后日志爆掉,数据库直接只读。
  3. 手动增长被限制:磁盘空间不足、文件设置了最大大小(MAXSIZE),或者文件组设置为只读,都会导致日志无法增长。

我见过最惨的案例是同事跑一个数据订正脚本,循环里有个事务一直不提交,日志文件从 10GB 疯涨到 100GB,直接把 D 盘怼满,整个实例的服务都停了。后来排查才发现,他开启事务的方式是在循环外面写了一个BEGIN TRAN,循环里有一半数据有外键冲突,但他只做了异常输出,没有执行ROLLBACK,事务就一直在那挂着,日志自然不会截断。

2.3 日志文件和事务之间的关系

每一个事务开始后,日志会记录它的操作序列,事务提交前日志记录不能被覆盖。多个事务的日志是交错追加到同一个日志文件里的,但每个事务都有自己的LSN(Log 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 Read,SQL Server 默认是Read Committed,Oracle 也是。如果你的系统没有特殊需求,保持默认即可。
  • 报表类、统计类应用,如果对数据一致性要求很高,可以用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里可以直接指定isolation:

@Transactional(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 (N'ais_log', 100);

SHRINKFILE第二个参数是目标大小(MB),注意目标值不要设太小,否则会导致日志频繁增长,影响性能。

如果是 FULL 恢复模式:

先做一次事务日志备份,日志备份后会把不活跃部分截断:

BACKUP LOG [ais20221123194008] TO DISK = N'D:\backup\ais_trn.trn' WITH NOFORMAT, INIT, NAME = N'ais-事务日志备份';

备份成功后再收缩文件:

USE [ais20221123194008]; DBCC SHRINKFILE (N'ais_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("mysql+pymysql://user:pass@localhost/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的人,遇到“日志满”就只会重启删日志,根本不理解后台发生了什么。

我个人在实际操作中的体会是:事务和日志是同一个硬币的两面。你理解了日志,才能理解为什么事务不能随便开;理解了锁,才能理解为什么事务越小越好。每次写涉及多个表变更的代码时,先问自己三个问题:这条业务链路是不是必须原子?并发量大概多少?隔离级别够不够用?三个问题想清楚了,事务基本不会出大乱子。

最后再分享一个小技巧:我给自己维护的所有数据库脚本模板里,都默认加了一段“事务健康度检查”注释,内容包括预计执行耗时、涉及的表、隔离级别、是否包含远程调用。这个习惯帮我避免了好几次“小脚本搞垮大库”的事故。事务不是越复杂越好,而是越清晰、越短小、越可控越好。希望这篇笔记也能帮你少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 14:33:08

WiFi漫游与全屋覆盖:Mesh和AC+AP组网区别及优化实践

刚把家里一百四十平的老房子做完WiFi覆盖改造&#xff0c;正好又帮朋友把他的三室两厅也调了一遍&#xff0c;发现绝大多数人对“WiFi组网”和“WiFi漫游”的理解还停留在“路由器信号好就行”的阶段&#xff0c;甚至在用两台路由器当桥接器凑合着用&#xff0c;结果走到客厅和…

作者头像 李华
网站建设 2026/10/2 14:32:40

Excel均值曲线图表:重复数据平均、误差线与动态数据源

数据处理这活儿干久了&#xff0c;你会发现一个规律&#xff1a;单条曲线基本没法看。同一台设备连测五遍&#xff0c;五条线七拐八拐&#xff0c;你盯着屏幕半天也说不清到底哪个才是"真实趋势"。这时候大概率要请出均值曲线图表——把多组重复数据在每个采样点上取…

作者头像 李华
网站建设 2026/10/2 14:32:39

多尺度训练提升5类腹部脏器分割精度:基于Unet的完整实践

简介&#xff1a;面向需要从零落地Unet多尺度分割方案的开发者&#xff0c;实战项目包包含完成训练的完整代码与腹部多脏器5类别分割数据集&#xff0c;适合医学图像处理方向的算法练习与二次开发。压缩包共1020个文件&#xff0c;含990张png图像、8个py脚本、权重pth及配置说明…

作者头像 李华
网站建设 2026/10/2 14:32:39

VMware虚拟机ping不通主机:桥接NAT仅主机排查与ICMP放行

上周同事的实验机上出了一件特别典型的事&#xff1a;VMware Workstation 里跑着一台最小化安装的 CentOS&#xff0c;宿主机是 Windows 11。他在虚拟机终端里敲ping 192.168.109.1&#xff0c;四条记录全是 Request timeout&#xff1b;反过来在宿主机 cmd 里ping 192.168.109…

作者头像 李华
网站建设 2026/10/2 14:32:17

舌苔识别端到端系统:从标注到PyQt部署实战

简介&#xff1a;本资源是一套面向计算机专业本科生的高分毕业设计实战项目&#xff0c;聚焦中医舌诊数字化落地&#xff0c;为毕设选题、课程设计及深度学习项目实践提供完整闭环方案。压缩包含Python源码、PyQt5开发的图形化交互界面、预训练深度学习模型及配套毕业论文全文&…

作者头像 李华
网站建设 2026/10/2 14:32:07

MySQL表操作全解析:ALTER TABLE、索引与生产级大表变更

在实际项目里&#xff0c;建表真的只是开始。一张表从"能跑"到"好用"的差距&#xff0c;往往藏在后续一轮又一轮的结构调整里——加字段、调索引、清理数据、复制归档、甚至重命名换表。这篇就把MySQL表操作里那些高频、容易翻车的点一次讲透&#xff0c;重…

作者头像 李华