一、什么是数据库事务
事务是一组被当作单个逻辑工作单元执行的数据库操作序列------这组操作要么全部成功,要么全部失败回滚,不存在"执行了一半"的中间状态。
最经典的例子是银行转账:A 给 B 转 100 元,包含两步:① A 扣 100,② B 加 100。如果第 1 步成功后系统崩溃、第 2 步没执行,A 的钱就凭空消失了。事务的作用就是保证这两步要么都成功,要么都不发生。
sql
BEGIN; -- 开启事务
UPDATE account SET balance = balance - 100 WHERE id = 'A';
UPDATE account SET balance = balance + 100 WHERE id = 'B';
COMMIT; -- 提交(全部生效)
-- 出错则 ROLLBACK(全部撤销)
二、ACID 四大特性
| 特性 | 名称 | 含义 | 底层机制 |
|---|---|---|---|
| A | 原子性 | 操作不可分割,全成功或全失败 | undo log 回滚日志 |
| C | 一致性 | 事务前后满足业务规则/约束(最终目的) | 其他三性 + 约束共同保证 |
| I | 隔离性 | 并发事务互不干扰 | 锁 + MVCC |
| D | 持久性 | 提交后数据永久保存,断电不丢 | redo log 重做日志 |
三、为什么需要事务
- 故障恢复:异常/断电/崩溃时,保证数据不停在错误的中间状态。
- 并发控制:多用户同时读写同一数据时避免相互干扰(否则会超卖、重复扣款、账目不平)。
四、常见使用方式
原生 SQL:
sql
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 'A';
UPDATE account SET balance = balance + 100 WHERE id = 'B';
COMMIT; -- 或 ROLLBACK
Spring 声明式事务(Java 最常用):
java
@Transactional(rollbackFor = Exception.class)
public void transfer(String from, String to, int amount) {
accountMapper.decrease(from, amount);
accountMapper.increase(to, amount); // 抛异常时上面也会回滚
}
Node.js(Sequelize 托管事务):
javascript
await sequelize.transaction(async (t) => {
await Account.decrement('balance', { by: amount, where: { id: fromId }, transaction: t });
await Account.increment('balance', { by: amount, where: { id: toId }, transaction: t });
// 正常返回自动 commit,抛异常自动 rollback
});
(Python SQLAlchemy 用 with session.begin():,JDBC 用 conn.setAutoCommit(false) + commit/rollback,原理一致。)
五、隔离级别与并发问题
三类并发读问题:
- 脏读:读到其他事务未提交的数据(对方回滚后就是脏数据)
- 不可重复读:同一事务两次读同一行,值不同(针对 UPDATE)
- 幻读:同一事务两次范围查询,行数不同(针对 INSERT/DELETE)
四种隔离级别(越高越安全、越慢):
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 Read Uncommitted | 可能 | 可能 | 可能 |
| 读已提交 Read Committed(Oracle 默认) | 避免 | 可能 | 可能 |
| 可重复读 Repeatable Read(MySQL 默认) | 避免 | 避免 | 可能* |
| 串行化 Serializable | 避免 | 避免 | 避免 |
MySQL InnoDB 靠 MVCC + 间隙锁,可重复读级别下大多能避免幻读。
六、实践避坑
- 事务要短小:内部别放 RPC、发消息等耗时操作,会长期占用连接和锁。
- Spring
@Transactional失效场景:非 public 方法、同类内部自调用、异常被 try-catch 吞掉、默认只回滚 RuntimeException(需加rollbackFor)。 - 高并发扣库存:优先用乐观锁替代长事务:
sql
UPDATE account SET balance = balance - 100
WHERE id = 'A' AND balance >= 100; -- 靠 WHERE 防超卖,判断影响行数
七、回滚是自动行为吗
回滚不是数据库"自动"帮你兜底的行为,而是需要有人(数据库自己或应用程序)显式或隐式地触发它。
数据库会自动回滚的情况:
- 语句执行出错 / 违反约束(主键冲突、外键约束、字段超长等)。注意:MySQL 默认只回滚出错的那一条语句,而不是整个事务,事务仍处于打开状态。
- 死锁:数据库检测到死锁时,自动选一个事务作为牺牲者强制回滚。
- 连接中断 / 会话超时 / 崩溃恢复:未提交的事务会被自动回滚(靠 undo log 恢复)。
- 锁等待超时。
需要程序主动回滚的情况:
- 业务规则失败(如"余额不足"):SQL 本身没报错,数据库不知道这是"失败",得靠代码判断并调用 ROLLBACK。
- 框架帮你做的"自动"回滚(Spring @Transactional、Sequelize 托管事务):本质是框架在捕获到异常时替你调用了 rollback,仍是"程序触发"。
| 触发方 | 场景 |
|---|---|
| 数据库自动回滚 | 死锁牺牲者、崩溃/断连恢复未提交事务、(部分)约束错误 |
| 程序/框架触发回滚 | 业务校验失败、捕获到异常、主动调用 ROLLBACK |
八、rollback 的逻辑定义在哪
应用层调用的 rollback() 只是一个"发起指令",真正的回滚逻辑不在应用代码里,而在数据库引擎内部。
调用链:
你的代码 conn.rollback()
│ (JDBC/驱动层:只是发一条命令)
▼
数据库客户端驱动 → 把 "ROLLBACK" 命令通过网络协议发给数据库服务器
│
▼
数据库服务器(真正的回滚逻辑在这里!)
│ 读取 undo log,逆向撤销本事务所有已改动的数据
▼
数据恢复到事务开始前的状态
真正的回滚逻辑靠 undo log(撤销日志)实现(以 MySQL InnoDB 为例):
- 事务每做一次修改,引擎会先记录一条"反向操作"到 undo log:
- INSERT 一行 → undo log 记录一条对应的 DELETE
- UPDATE 把 A 改成 B → undo log 记录"把 B 改回 A"
- DELETE 一行 → undo log 记录一条对应的 INSERT
- 收到 ROLLBACK 命令时,引擎从后往前依次执行 undo log 里的反向操作,把数据还原到事务开始前。
- 这段逻辑由数据库引擎内核源码实现(如 InnoDB 的 trx_rollback 相关函数),应用层看不到也改不了。
各层"rollback 定义"对照表:
| 层次 | "rollback" 是什么 | 定义/实现在哪 |
|---|---|---|
| 业务代码 | try { ... } catch { conn.rollback(); } | 你自己写,决定"何时"回滚 |
| 框架层 | Spring @Transactional 捕获异常后调用回滚 | 框架源码帮你调 rollback() |
| JDBC/ORM 接口 | Connection.rollback() / session.rollback() | JDBC 规范定义接口,数据库驱动实现:发送 ROLLBACK 命令 |
| 数据库协议 | 一条 ROLLBACK 命令 | 驱动按数据库通信协议发送 |
| 数据库引擎 | 真正撤销数据的逻辑 | 数据库内核源码,基于 undo log 逆向回放 |
一句话概括:你(或框架)负责"喊一声回滚",数据库引擎负责"真正把数据倒回去",而它能倒回去是因为提前把每一步的反悔操作都记在了 undo log 里。