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 事务日志为什么会满
日志文件满的原因可以归为三类:
- 日志无法自动增长:很多数据库实例配置了日志文件初始大小,但没有设置自动增长,或者增长步长太小,写满后无法扩展,直接报9002。
- 日志无法被截断(回收):最常见的原因,数据库处于“完整恢复模式”且长期没有进行日志备份,或者存在长时间运行、未提交的活跃事务。日志备份是唯一能真正截断日志的操作,我自己就吃过亏——某测试库用了完整恢复模式,但没人做过日志备份,跑了一个批量更新后日志爆掉,数据库直接只读。
- 手动增长被限制:磁盘空间不足、文件设置了最大大小(
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的人,遇到“日志满”就只会重启删日志,根本不理解后台发生了什么。
我个人在实际操作中的体会是:事务和日志是同一个硬币的两面。你理解了日志,才能理解为什么事务不能随便开;理解了锁,才能理解为什么事务越小越好。每次写涉及多个表变更的代码时,先问自己三个问题:这条业务链路是不是必须原子?并发量大概多少?隔离级别够不够用?三个问题想清楚了,事务基本不会出大乱子。
最后再分享一个小技巧:我给自己维护的所有数据库脚本模板里,都默认加了一段“事务健康度检查”注释,内容包括预计执行耗时、涉及的表、隔离级别、是否包含远程调用。这个习惯帮我避免了好几次“小脚本搞垮大库”的事故。事务不是越复杂越好,而是越清晰、越短小、越可控越好。希望这篇笔记也能帮你少踩几个坑。