数据库事务管理

基于 MySQL 示例,整理数据库事务、ACID、事务隔离级别以及账户余额与交易明细场景中的事务管理要点。

  • 数据库
  • 数据库事务
  • MySQL
·7 min

1. 文章简介

在日常开发中,数据库事务管理是否正确使用,会直接影响到业务数据的准确性。

本文结合数据库事务原理,以及项目中的常见使用场景,整理数据库事务管理相关内容,主要包括:

  • 示例数据
  • 数据库事务
  • 事务隔离级别
  • 事务处理要点

2. 示例数据

后续 SQL 均基于账户余额与交易明细场景展开。

schema.sql
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;
data.sql
sql
-- 截断表
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,为了突出事务问题,示例直接写出明细中的变动前后余额。

缺少事务保护
sql
-- 请先执行 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 带事务转账流程

将转账操作放入同一个事务后,可以由应用决定在失败时回滚本次变更。

ROLLBACK 事务
sql
-- 请先执行 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 连接并结束事务

正常转账流程应在所有步骤成功后提交:

正常事务提交
sql
-- 请先执行 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:

sql
-- 请先执行 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:

sql
-- 设置事务等级为 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:

sql
-- 请先执行 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:

sql
-- 设置事务等级为 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:

sql
-- 请先执行 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:

sql
-- 设置事务等级为 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:

sql
-- 请先执行 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:

sql
-- 开启事务
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 生效UPDATEDELETESELECT ... FOR UPDATE 等当前读会读取最新已提交数据,并加锁处理。
  • 幻读主要靠间隙锁防范:在 REPEATABLE READ 下,InnoDB 会对范围当前读加间隙锁,避免其他事务插入“幻影行”,但也会增加锁冲突和死锁风险。

4.4 SERIALIZABLE

SERIALIZABLE 是 MySQL 中最严格的隔离级别。

InnoDB 的实现方式是:把普通 SELECT 隐式转换为加共享锁的读取(相当于 SELECT ... LOCK IN SHARE MODE),这会让读和写互相阻塞,从而保证串行效果。

Session A:

sql
-- 请先执行 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:

sql
-- 开启事务
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 TRANSACTIONCOMMITROLLBACK 明确事务边界。
  • 显式事务中普通 SQL 报错后,事务本身仍然有效,应用捕获异常后应停止后续业务 SQL,并主动执行 ROLLBACK
  • InnoDB 默认隔离级别为 REPEATABLE READ,普通查询在事务内基于一致性快照读取数据。
  • READ UNCOMMITTED 会产生脏读,READ COMMITTED 可能出现不可重复读,SERIALIZABLE 会带来更强的阻塞。
  • 隔离级别不是越高越好,应结合业务一致性要求、并发量、锁等待和重试成本一起选择。

事务管理的核心不在于写出复杂 SQL,而在于把同一个业务事实放进清晰的事务边界中,并在异常发生时明确结束事务。