1. 文章简介
在日常开发中,数据库事务管理是否正确使用,会直接影响到业务数据的准确性。
本文结合数据库事务原理,以及项目中的常见使用场景,整理数据库事务管理相关内容,主要包括:
- 示例数据
- 数据库事务
- 事务隔离级别
- 事务处理要点
2. 示例数据
后续 SQL 均基于账户余额与交易明细场景展开。
-- 账户表
DROP TABLE IF EXISTS account;
CREATE TABLE account (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL COMMENT '用户名',
balance DECIMAL(16, 2) NOT NULL COMMENT '余额'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
-- 账户明细表
DROP TABLE IF EXISTS account_detail;
CREATE TABLE account_detail (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
account_id BIGINT NOT NULL COMMENT '账户 ID',
target_account_id BIGINT COMMENT '对方账户 ID',
type VARCHAR(32) NOT NULL COMMENT '交易类型:充值、转账、退款',
in_out TINYINT NOT NULL COMMENT '收支方向:1 入账,2 出账',
amount DECIMAL(16, 2) NOT NULL COMMENT '金额,正数',
balance_before DECIMAL(16, 2) NOT NULL COMMENT '变动前账户余额',
balance_after DECIMAL(16, 2) NOT NULL COMMENT '变动后账户余额',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
CONSTRAINT chk_in_out CHECK (in_out IN (1, 2)),
CONSTRAINT chk_amount CHECK (amount > 0)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;-- 截断表
TRUNCATE TABLE account_detail;
TRUNCATE TABLE account;
-- 初始化账户
INSERT INTO account(id, name, balance)
VALUES
(1, 'Albert', 1000.00),
(2, 'Macy', 1000.00);
-- 初始化账户明细
INSERT INTO account_detail(account_id, target_account_id, type, in_out, amount, balance_before, balance_after, created_at)
VALUES
(1, NULL, '充值', 1, 1000.00, 0.00, 1000.00, NOW()),
(2, NULL, '充值', 1, 1000.00, 0.00, 1000.00, NOW());3. 数据库事务
数据库事务用于保证一组数据库操作在逻辑上作为一个整体执行,要么全成功并提交,要么失败并全部回滚。
数据库事务有以下特性:
| 特性 | 含义 | 账户场景 |
|---|---|---|
| 原子性(Atomicity) | 事务内操作 整体成功 或 整体失败 | 扣款、入账、明细写入一致提交或一致回滚 |
| 一致性(Consistency) | 事务前后数据 满足约束 和 业务规则 | 余额不能为负,明细金额与余额变化一致 |
| 隔离性(Isolation) | 并发事务之间的 数据可见性受规则约束 | 未提交余额变化不应被其他事务直接读取 |
| 持久性(Durability) | 事务提交后 结果可靠保存 | COMMIT 成功后余额与明细应持久保存 |
本文事务示例均基于 InnoDB。MySQL 的事务能力依赖存储引擎,建表时应确认使用 ENGINE = InnoDB。
3.1 无事务转账流程
如果业务操作依赖多条 SQL,但没有统一事务边界,前面已经执行成功的 SQL 可能无法随失败步骤一起回滚。
MySQL 默认开启自动提交(autocommit = 1),每条语句执行成功后会立即独立提交。
下面的 SQL 基于初始化数据:Albert 和 Macy 的账户余额都是 1000.00,为了突出事务问题,示例直接写出明细中的变动前后余额。
-- 请先执行 data.sql 初始化数据
-- 先检查数据是否准确
SELECT * FROM account;
SELECT * FROM account_detail;
-- 1. Albert 出账并生成明细
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
INSERT INTO account_detail(account_id, target_account_id, type, in_out, amount, balance_before, balance_after)
VALUES (1, 2, '转账', 2, 100.00, 1000.00, 900.00);
-- 模拟错误:查询一张不存在的表会报错
-- SELECT * FROM not_exist_table;
-- 2. Macy 入账并生成明细
UPDATE account SET balance = balance + 100.00 WHERE id = 2;
INSERT INTO account_detail(account_id, target_account_id, type, in_out, amount, balance_before, balance_after)
VALUES (2, 1, '转账', 1, 100.00, 1000.00, 1100.00);
-- 再次核对数据
SELECT * FROM account;
SELECT * FROM account_detail;该流程的问题在于事务边界被拆散。
- 【出账并生成明细】,已经入库了。
- 释放
SELECT * FROM not_exist_table;注释后,这一行执行会报错,可能会导致 【入账并生成明细】 没有执行。
3.2 带事务转账流程
将转账操作放入同一个事务后,可以由应用决定在失败时回滚本次变更。
-- 请先执行 data.sql 初始化数据
-- 先检查数据是否准确
SELECT * FROM account;
SELECT * FROM account_detail;
-- 开启事务
START TRANSACTION;
-- Albert 出账并生成明细
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
INSERT INTO account_detail(account_id, target_account_id, type, in_out, amount, balance_before, balance_after)
VALUES (1, 2, '转账', 2, 100.00, 1000.00, 900.00);
-- 模拟错误
SELECT * FROM not_exist_table;
-- ERROR 1146 (42S02): Table 'xxx.not_exist_table' doesn't exist
-- 再次查询,是可以看到 balance = 900.00,以及一条转账记录
SELECT * FROM account;
SELECT * FROM account_detail;
-- 此时事务仍然有效,只需要执行 ROLLBACK 即可回滚
ROLLBACK;
-- 回滚后数据恢复,UPDATE 与 INSERT 操作均被撤销,balance = 1000.00
SELECT * FROM account;
SELECT * FROM account_detail;在 MySQL 中,单条语句报错时,默认只对 该语句 做语句级回滚(statement rollback),事务本身 仍然有效,可以继续执行其他语句,甚至可以 COMMIT 把前面的修改提交掉。
数据库事务回滚 依赖应用程序的判断,数据库不会替业务做类似 try...catch...rollback 的决定,而是由业务自行决定。
3.3 MySQL 事务异常场景与处理方式
| 场景 | 数据库行为 | 处理方式 |
|---|---|---|
| 自动提交模式 | 每条 SQL 的事务都是独立的,执行成功后自动提交,后续有失败的 SQL 也不影响前面的执行结果 | 多 SQL 组成一个业务时需要 显式开启事务 |
| 显式事务中 SQL 报错 | MySQL 默认只回滚出错语句,事务本身仍然有效,可以继续执行或提交 | 应用捕获异常后停止执行后续语句,并执行 ROLLBACK 回滚事务 |
| 死锁 / 锁等待超时 | 死锁通常回滚整个事务;锁等待超时默认只回滚当前语句 | 识别错误类型,并按完整业务事务重新执行 |
| SQL 等待或执行过久 | SQL 卡住只是等待,不等于报错,数据库不会自动回滚事务 | 先排查阻塞来源,必要时 KILL 连接并结束事务 |
正常转账流程应在所有步骤成功后提交:
-- 请先执行 data.sql 初始化数据
-- 先检查数据是否准确
SELECT * FROM account;
SELECT * FROM account_detail;
-- 开启事务
START TRANSACTION;
-- 1. Albert 出账并生成明细
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
INSERT INTO account_detail(account_id, target_account_id, type, in_out, amount, balance_before, balance_after)
VALUES (1, 2, '转账', 2, 100.00, 1000.00, 900.00);
-- 2. Macy 入账并生成明细
UPDATE account SET balance = balance + 100.00 WHERE id = 2;
INSERT INTO account_detail(account_id, target_account_id, type, in_out, amount, balance_before, balance_after)
VALUES (2, 1, '转账', 1, 100.00, 1000.00, 1100.00);
-- 提交事务
COMMIT;
-- 再次核对数据
SELECT * FROM account;
SELECT * FROM account_detail;4. 事务隔离级别
事务隔离级别(Transaction Isolation Level)用于控制并发事务之间的数据可见性。隔离级别越高,事务之间的数据可见性规则越严格,但加锁和阻塞的成本也可能越高。
MySQL(InnoDB)的隔离级别表现如下:
| 隔离级别 | InnoDB 行为 | 常见现象 |
|---|---|---|
| READ UNCOMMITTED | 可读取其他事务未提交的数据 | 脏读 |
| READ COMMITTED | 每条语句读取已提交的最新数据 | 不可重复读 |
| REPEATABLE READ(默认) | 事务内基于首次读取建立一致性快照 | 普通读结果稳定,靠间隙锁防范幻读 |
| SERIALIZABLE | 普通 SELECT 隐式加共享锁 |
读写互相阻塞,可能锁等待超时 |
设置隔离级别可使用 SET TRANSACTION ISOLATION LEVEL ...(仅对下一个事务生效),下文示例均采用这种写法。
4.1 READ UNCOMMITTED 读未提交
READ UNCOMMITTED 允许读取其他事务尚未提交的数据,会出现脏读。
Session A:
-- 请先执行 data.sql 初始化数据
-- 查询余额 balance = 1000.00
SELECT balance FROM account WHERE id = 1;
-- 开启事务
START TRANSACTION;
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
-- 此时尚未 COMMIT
-- COMMIT;
-- 同一个 Session 中查询,balance = 900.00
SELECT balance FROM account WHERE id = 1;Session B:
-- 设置事务等级为 READ UNCOMMITTED
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 开启事务
START TRANSACTION;
-- 不同 Session 中查询,balance = 900.00
SELECT balance FROM account WHERE id = 1;
-- 提交
COMMIT;假设后续 Session A 后面执行了 ROLLBACK 语句,那么 Session B 读到的 900.00 这个余额就是脏数据,脏读会让业务基于错误数据进行判断,进而导致判断错误,因此实际项目几乎不会使用这个隔离级别。
4.2 READ COMMITTED 读已提交
READ COMMITTED 下,每条 SQL 语句只能读取该语句执行时已经提交的数据,不会脏读。
Session A:
-- 请先执行 data.sql 初始化数据
-- 查询余额,balance = 1000.00
SELECT balance FROM account WHERE id = 1;
-- 开启事务
START TRANSACTION;
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
-- 此时尚未 COMMIT
-- COMMIT;
-- 同一个 Session 中查询,balance = 900.00
SELECT balance FROM account WHERE id = 1;Session B:
-- 设置事务等级为 READ COMMITTED
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 开启事务
START TRANSACTION;
-- 不同 Session 中查询,balance = 1000
SELECT balance FROM account WHERE id = 1;
-- 提交
COMMIT;开启 READ COMMITTED 之后,能查到的数据都是提交后的数据,不会读取到没有 COMMIT 的数据。
开启 READ COMMITTED 之后,会面临一个新的问题,就是同一个事务内两次读取同一行数据,结果发生变化,这种现象称为不可重复读。
Session A:
-- 请先执行 data.sql 初始化数据
-- 设置事务等级为 READ COMMITTED
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 开启事务
START TRANSACTION;
-- 查询余额,balance = 1000.00
SELECT balance FROM account WHERE id = 1;
-- 此时先去执行 Session B 的 SQL,然后再次查询
-- 再次查询,balance = 900.00,两次查询结果不一样
SELECT balance FROM account WHERE id = 1;
-- 提交事务
COMMIT;Session B:
-- 设置事务等级为 READ COMMITTED
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 开启事务
START TRANSACTION;
-- 扣减余额 100.00
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
-- 提交事务
COMMIT;
-- 再次查询,balance = 900.00
SELECT balance FROM account WHERE id = 1;4.3 REPEATABLE READ 可重复读
REPEATABLE READ 是 MySQL InnoDB 的默认事务隔离级别。该级别下,事务的普通查询会在首次读取时建立快照,之后同一个事务内的普通查询都基于这个快照,即使其他事务已经提交了,也读取不到。
Session A:
-- 请先执行 data.sql 初始化数据
-- InnoDB 默认是 REPEATABLE READ,因此不需要额外设置,直接开启事务即可
START TRANSACTION;
-- 先查询余额,balance = 1000.00
SELECT balance FROM account WHERE id = 1;
-- 此时先去执行 Session B 的 SQL,然后再次查询
-- 再次查询余额,balance = 1000.00
SELECT balance FROM account WHERE id = 1;
-- 提交
COMMIT;Session B:
-- 开启事务
START TRANSACTION;
-- 扣减余额 100.00
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
-- 提交事务
COMMIT;
-- 查询余额,balance = 900.00
SELECT balance FROM account WHERE id = 1;REPEATABLE READ 适合事务内需要稳定读取视图的场景,但要注意两点:
- 快照只对普通
SELECT生效:UPDATE、DELETE、SELECT ... FOR UPDATE等当前读会读取最新已提交数据,并加锁处理。 - 幻读主要靠间隙锁防范:在
REPEATABLE READ下,InnoDB 会对范围当前读加间隙锁,避免其他事务插入“幻影行”,但也会增加锁冲突和死锁风险。
4.4 SERIALIZABLE
SERIALIZABLE 是 MySQL 中最严格的隔离级别。
InnoDB 的实现方式是:把普通 SELECT 隐式转换为加共享锁的读取(相当于 SELECT ... LOCK IN SHARE MODE),这会让读和写互相阻塞,从而保证串行效果。
Session A:
-- 请先执行 data.sql 初始化数据
-- 设置事务等级为 SERIALIZABLE
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 开启事务
START TRANSACTION;
-- 查询
SELECT balance FROM account WHERE id = 1;
-- 先不执行后续 SQL,先去执行 Session B 的 SQL,等 Session B 超时后,再执行后续 SQL
-- 提交事务
COMMIT;Session B:
-- 开启事务
START TRANSACTION;
-- 被阻塞:需要排他锁,但该行已被 Session A 的共享锁占用
UPDATE account SET balance = balance - 100.00 WHERE id = 1;
-- 若 Session A 一直不提交则会:[1205] Lock wait timeout exceeded; try restarting transaction
-- 提交事务
COMMIT;SERIALIZABLE 的冲突通常表现为 阻塞、锁等待超时甚至死锁。该级别并发能力较低,实际项目中很少使用,通常通过显式加锁(SELECT ... FOR UPDATE)在默认级别下解决具体的并发问题。
5. 总结
数据库事务解决的是多条 SQL 的一致提交问题。以账户转账为例,账户余额和账户明细属于同一个业务事实,不能让扣款、入账、明细写入分散在不同的事务边界中执行。
结合 MySQL InnoDB 的事务行为,可以重点关注以下几点:
- MySQL 默认开启自动提交,未显式开启事务时,每条 SQL 都会独立提交。
- 多条 SQL 组成一个业务操作时,应使用
START TRANSACTION、COMMIT、ROLLBACK明确事务边界。 - 显式事务中普通 SQL 报错后,事务本身仍然有效,应用捕获异常后应停止后续业务 SQL,并主动执行
ROLLBACK。 - InnoDB 默认隔离级别为
REPEATABLE READ,普通查询在事务内基于一致性快照读取数据。 READ UNCOMMITTED会产生脏读,READ COMMITTED可能出现不可重复读,SERIALIZABLE会带来更强的阻塞。- 隔离级别不是越高越好,应结合业务一致性要求、并发量、锁等待和重试成本一起选择。
事务管理的核心不在于写出复杂 SQL,而在于把同一个业务事实放进清晰的事务边界中,并在异常发生时明确结束事务。