16 MySQL 事务

MySQL 事务

1. 什么是事务?

事务就是“要么全做,要么全不做”的一组数据库操作,可以类比以前学过的原子操作。 打个比方:银行转账。假设要给朋友转账 100 块钱,这在数据库里其实分两步走:

  1. 自己的账户余额减去 100 元。
  2. 朋友的账户余额加上 100 元。

如果没有事务会怎样? 万一第一步执行完了(自己扣了钱),突然停电了、断网了,第二步没执行。结果就是:自己的钱没了,朋友也没收到钱,这 100 块凭空消失了。这肯定不行!

有了事务之后: 我们把这两步打包成一个“事务”。

  • 如果两步都成功,我们就 提交,转账生效。
  • 如果中间出错了,我们就 回滚,数据库自动恢复到转账之前的样子,就像什么都没发生过一样。

事务有四大特性(ACID),我们只需要记住核心两点:

  1. 原子性: 像原子一样不可分割。要么全成功,要么全失败回滚。
  2. 一致性: 数据库总是从一个一致状态变到另一个一致状态。转账前后,总金额是不变的。

事务四大特性 ACID:

  • A(Atomic)原子性:要么全做,要么全不做。
  • C(Consistency)一致性:操作前后数据规则不破坏。
  • I(Isolation)隔离性:多个事务同时做,互不影响。
  • D(Durability)持久性:提交后的数据,不会丢。

2. 事务的版本支持

在 MySQL 中,并不是所有的存储引擎都支持事务。最常用的两个引擎是:

  • InnoDB: 支持事务(这也是 MySQL 8.0 的默认引擎,也是我们最常用的)。
  • MyISAM: 不支持事务(以前的老引擎,适合只读场景,现在用得少)。

如何查看当前数据库支持哪些事务引擎?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
mysql> show engines;
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| Engine | Support | Comment | Transactions | XA | Savepoints |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+
| ARCHIVE | YES | Archive storage engine | NO | NO | NO |
| BLACKHOLE | YES | /dev/null storage engine (anything you write to it disappears) | NO | NO | NO |
| MRG_MYISAM | YES | Collection of identical MyISAM tables | NO | NO | NO |
| FEDERATED | NO | Federated MySQL storage engine | NULL | NULL | NULL |
| MyISAM | YES | MyISAM storage engine | NO | NO | NO |
| PERFORMANCE_SCHEMA | YES | Performance Schema | NO | NO | NO |
| InnoDB | DEFAULT | Supports transactions, row-level locking, and foreign keys | YES | YES | YES |
| MEMORY | YES | Hash based, stored in memory, useful for temporary tables | NO | NO | NO |
| CSV | YES | CSV storage engine | NO | NO | NO |
+--------------------+---------+----------------------------------------------------------------+--------------+------+------------+

mysql> show engines\G
*************************** 1. row ***************************
Engine: ARCHIVE
Support: YES
Comment: Archive storage engine
Transactions: NO
XA: NO
Savepoints: NO
*************************** 2. row ***************************
Engine: BLACKHOLE
Support: YES
Comment: /dev/null storage engine (anything you write to it disappears)
Transactions: NO
XA: NO
Savepoints: NO
*************************** 3. row ***************************
Engine: MRG_MYISAM
Support: YES
Comment: Collection of identical MyISAM tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 4. row ***************************
Engine: FEDERATED
Support: NO
Comment: Federated MySQL storage engine
Transactions: NULL
XA: NULL
Savepoints: NULL
*************************** 5. row ***************************
Engine: MyISAM
Support: YES
Comment: MyISAM storage engine
Transactions: NO
XA: NO
Savepoints: NO
*************************** 6. row ***************************
Engine: PERFORMANCE_SCHEMA
Support: YES
Comment: Performance Schema
Transactions: NO
XA: NO
Savepoints: NO
*************************** 7. row ***************************
Engine: InnoDB
Support: DEFAULT
Comment: Supports transactions, row-level locking, and foreign keys
Transactions: YES
XA: YES
Savepoints: YES
*************************** 8. row ***************************
Engine: MEMORY
Support: YES
Comment: Hash based, stored in memory, useful for temporary tables
Transactions: NO
XA: NO
Savepoints: NO
*************************** 9. row ***************************
Engine: CSV
Support: YES
Comment: CSV storage engine
Transactions: NO
XA: NO
Savepoints: NO

真正能看事务支持的,是 Transactions 列,它显示 YES/NO/NULL。在上面输出里:

  • InnoDB → Transactions: YES → 支持事务
  • 其他大部分 → Transactions: NO → 不支持事务
  • FEDERATED → Transactions: NULL → 不可用

总之:Transactions 列 YES = 支持事务,NO/NULL = 不支持。

其他字段含义(了解):

  • Engine: 引擎名。
  • Support: 这引擎能不能用。YES 能用,DEFAULT 默认用它,NO 不能用。
  • Comment: 备注,简单说说这引擎干嘛的。
  • Transactions: 支不支持事务。YES 支持,NO 不支持。
  • XA: 支不支持分布式事务。一般开发不用管。
  • Savepoints: 支不支持保存点。事务里能回滚到中间某一步。

3. 事务的提交方式

事务的提交方式分为两种:

  1. 自动提交: 每写一条 SQL(比如 INSERT),数据库立马悄悄帮我们“保存”,这是默认模式。
  2. 手动提交: 每写一条 SQL,必须手动输入“保存”命令,数据才会真正落盘。

1. 查看事务的提交方式

输入以下命令查看当前的状态(一般默认开):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SHOW VARIABLES LIKE 'autocommit';	# 返回 ON 就是自动提交开,OFF 就是关
SELECT @@autocommit; # 返回 1 就是自动提交开,0 是关

mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+

mysql> select @@autocommit;
+--------------+
| @@autocommit |
+--------------+
| 1 |
+--------------+

结果解读:

  • Value 为 ON:表示开启了 自动提交。每一条 SQL 语句都被当成一个独立的事务,执行完立刻生效。
  • Value 为 OFF:表示关闭了自动提交。你必须显式地写 COMMIT; 才能保存数据。

2. 设置事务的提交方式

场景 A:想临时关闭自动提交(手动事务模式)

有时候我们需要一次性执行多条 SQL,必须成功了一起保存。这时就要关闭自动提交。

  • 设置命令:

    1
    2
    SET autocommit = 0;  # 或者写成 OFF
    # START TRANSACTION; # 开始事务

    执行完这条命令后,当前连接的窗口就进入了“手动模式”。

  • 操作流程(经典三部曲):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 1. 显式开启事务(虽然关闭了autocommit后默认就在事务中,但为了规范,建议写上)
    START TRANSACTION;

    # 2. 执行自己的 SQL 语句
    UPDATE account SET balance = balance - 100 WHERE name = '我';
    UPDATE account SET balance = balance + 100 WHERE name = '朋友';

    # 3. 确认没问题,手动提交(事务)
    COMMIT;

    # 如果中间发现问题,想反悔,可以用:
    # ROLLBACK; # 回滚事务

场景 B:想恢复自动提交

  • 设置命令:
    1
    SET autocommit = 1;  # 或者写成 ON

3. 注意事项

  • DDL 语句会隐式提交: 即便开了事务 (autocommit = 0),只要敲了 CREATEALTERDROPTRUNCATE,MySQL 会 强制 COMMIT 自动提交 当前的事务,之前的操作全被焊死了,回滚不了
  • 退出连接自动提交: 崩了/断网:必然回滚(ROLLBACK);正常退出(exit):不同工具表现不同,可能提交也可能回滚。永远记住:后端开发里,没写 COMMIT 的一律当没存成功!

4. 示例

用一个 银行账户转账 的场景来演示,先创建一张表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mysql> create database transactions;
mysql> use transactions;

mysql> create table test1 (
-> id int primary key,
-> name varchar(20),
-> balance decimal(10,2)
-> );

mysql> insert into test1 values
-> (1, '张三', 1000),
-> (2, '李四', 2000),
-> (3, '王五', 3000);
Query OK, 3 rows affected (0.03 sec)
Records: 3 Duplicates: 0 Warnings: 0

1. Savepoint 和 Rollback

场景:张三给李四转账 100 元,然后李四给王五转账 200 元。但后来发现李四给王五的转账有问题,要撤销这一步,保留第一步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 1. 开启事务
mysql> start transaction;

# 2. 张三给李四转账100
mysql> update test1 set balance = balance - 100 where id = 1;
mysql> update test1 set balance = balance + 100 where id = 2;

# 3. 打个保存点(存档)
mysql> savepoint step1;

# 4. 查看当前状态
mysql> select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 900.00 |
| 2 | 李四 | 2100.00 |
| 3 | 王五 | 3000.00 |
+----+--------+---------+
# 结果:张三900,李四2100,王五3000

# 5. 李四给王五转账200
mysql> update test1 set balance = balance - 200 where id = 2;
mysql> update test1 set balance = balance + 200 where id = 3;

# 6. 再次查看
mysql> select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 900.00 |
| 2 | 李四 | 1900.00 |
| 3 | 王五 | 3200.00 |
+----+--------+---------+
# 结果:张三900,李四1900,王五3200

# 7. 发现问题,回滚到保存点(撤销李四给王五的转账)
mysql> rollback to step1;

# 8. 查看回滚后的状态
select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 900.00 |
| 2 | 李四 | 2100.00 |
| 3 | 王五 | 3000.00 |
+----+--------+---------+
# 结果:张三900,李四2100,王五3000(李四给王五的转账被撤销了)

# 9. 最后提交事务
mysql> commit;

# 10. 最终确认
mysql> select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 900.00 |
| 2 | 李四 | 2100.00 |
| 3 | 王五 | 3000.00 |
+----+--------+---------+
# 结果:张三900,李四2100,王五3000

2. autocommit = 0 不提交就退出

继续上面的账户表,演示关闭自动提交后,不 commit 就退出的后果。第一个终端窗口(会话 1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 1. 关闭自动提交
mysql> set autocommit=0;

# 2. 确认已关闭
mysql> show variables like 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | OFF |
+---------------+-------+
# 结果:Value = OFF

# 3. 查看当前数据
mysql> select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 900.00 |
| 2 | 李四 | 2100.00 |
| 3 | 王五 | 3000.00 |
+----+--------+---------+
# 结果:张三900,李四2100,王五3000

# 4. 王五给张三转账500
mysql> update test1 set balance=balance-500 where id=3;
mysql> update test1 set balance=balance+500 where id=1;

# 5. 查看当前会话的结果
mysql> select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 1400.00 |
| 2 | 李四 | 2100.00 |
| 3 | 王五 | 2500.00 |
+----+--------+---------+
# 结果:张三1400,李四2100,王五2500
# 注意:这个变化只有当前会话能看到

# 6. 【关键】不执行 COMMIT,直接退出
mysql> quit
Bye

第二个终端窗口(会话 2):

1
2
3
4
5
6
7
8
9
10
# 在会话1退出的同时或之后,立即查询
mysql> select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 900.00 |
| 2 | 李四 | 2100.00 |
| 3 | 王五 | 3000.00 |
+----+--------+---------+
# 结果:张三900,李四2100,王五3000,王五给张三的转账根本没生效!

重新连接后的验证(会话 1 重新登录):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 重新登录 MySQL
mysql -u root -p
mysql> use transactions;

# 查询数据
mysql> select * from test1;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | 张三 | 900.00 |
| 2 | 李四 | 2100.00 |
| 3 | 王五 | 3000.00 |
+----+--------+---------+
# 结果:张三900,李四2100,王五3000,刚才的转账操作全部丢失!

正确的做法:

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 关闭自动提交
mysql> set autocommit=0;

# 2. 执行转账
mysql> update test1 set balance=balance-500 where id=3;
mysql> update test1 set balance=balance+500 where id=1;

# 3. 【关键】提交事务
mysql> commit;

# 4. 退出
mysql> quit

重新登录后查询,这次数据会永久保存。所以(保证数据的一致性和安全性):

  1. Savepoint:事务中的 “存档点”,可以部分回滚。

  2. autocommit = 0: 关闭自动提交后,必须手动 COMMIT,否则:退出 MySQL 时自动回滚、其他会话看不到之前的修改、数据不会持久化!

4. 事务隔离级别

1. 什么是事务隔离级别

隔离级别是 MySQL 控制并发事务同时操作同一数据时,是否会出现脏读、不可重复读、幻读等异常的规则,MySQL 有四种隔离级别(从宽松到严格):

隔离级别特点说明
READ UNCOMMITTED(读未提交)最宽松,事务可以看到别的事务未提交的数据脏读、不可重复读、幻读、并发性能最高、安全性最低,基本不用
READ COMMITTED(读提交)只能看到别的事务提交后的数据不可重复读、幻读、高并发性能,Oracle、SQL Server 默认,只能读别人已提交的数据
REPEATABLE READ(可重复读)同一事务里多次读取结果一样幻读,中并发性能、InnoDB 默认,确保同一事务内读取数据一致
SERIALIZABLE(串行化)最严格,事务一个个排队执行安全性最高,性能最差。相当于“排队”执行,并发极低
  1. 隔离级别越低:并发能力通常越强、出错概率越高。

  2. 隔离级别越高:数据通常越稳、等待和锁冲突通常越多。

简记:越严格越安全,越慢;越宽松越快,可能看到脏数据!

  • 脏读: 读到了别人“还没提交”的数据。如果别人回滚了,自己读到的就是脏数据。
  • 不可重复读: 在同一个事务里,两次读取同一条数据,结果不一样。因为中间被别人 修改并提交 了。
  • 幻读: 在同一个事务里,两次查询的数据 条数 不一样。因为中间被别人 插入或删除 了数据。

2. 查看当前隔离级别

查看全局隔离级别(影响新连接):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# MySQL 8.0+ 新写法
mysql> SELECT @@global.transaction_isolation;
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| REPEATABLE-READ |
+--------------------------------+

mysql> show variables like 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+

查看当前会话隔离级别(只影响当前连接):

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+

mysql> SELECT @@session.transaction_isolation;
+---------------------------------+
| @@session.transaction_isolation |
+---------------------------------+
| REPEATABLE-READ |
+---------------------------------+

3. 设置事务隔离级别

设置当前会话(临时):

1
2
3
4
5
6
7
8
9
# SET SESSION TRANSACTION ISOLATION LEVEL 隔离级别
mysql> set session transaction isolation level READ COMMITTED;

mysql> select @@global.transaction_isolation;
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| REPEATABLE-READ |
+--------------------------------+

设置全局(影响新连接,老连接不会变):

1
2
3
4
5
6
7
8
9
10
# SET GLOBAL TRANSACTION ISOLATION LEVEL 隔离级别;
mysql> set global transaction isolation level read committed;

# 验证:需要新开一个窗口查看
mysql> SELECT @@global.transaction_isolation;
+--------------------------------+
| @@global.transaction_isolation |
+--------------------------------+
| READ-COMMITTED |
+--------------------------------+

设置全局隔离级别会影响后续的新会话,当前会话的隔离级别不会发生变化,要让当前会话的隔离级别也改变,需要重启会话。

4. 展开讨论

1. 环境准备

先开 2 个终端,并初始化环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 建库
mysql> CREATE DATABASE IF NOT EXISTS tx_demo;
mysql> USE tx_demo;

# InnoDB 是 MySQL 8 默认事务引擎,这里写出来更直观
mysql> CREATE TABLE demo (
-> id BIGINT PRIMARY KEY,
-> owner_name VARCHAR(20) NOT NULL,
-> balance DECIMAL(10,2) NOT NULL,
-> KEY idx_balance (balance)
-> ) ENGINE=InnoDB;

# 初始数据
mysql> INSERT INTO demo (id, owner_name, balance) VALUES
-> (1, 'Alice', 1000.00),
-> (2, 'Bob', 1000.00);

# 可选:查看mysql版本
mysql> SELECT VERSION();
+-------------------------+
| VERSION() |
+-------------------------+
| 8.0.45-0ubuntu0.24.04.1 |
+-------------------------+

# 初始实验数据
mysql> UPDATE demo
-> SET balance = CASE id
-> WHEN 1 THEN 1000.00
-> WHEN 2 THEN 1000.00
-> END
-> WHERE id IN (1, 2);

mysql> COMMIT;

2. 读未提交

一个事务可以读取到其他事务 尚未提交 的数据,但是会 脏读,也就是说,即使是看到的数据,可能也不会真正落库。

理解: 好比自己写作业,刚写了个草稿还没定稿呢,就被同桌偷看到了,后来自己把草稿擦了,同桌抄的答案全是错的。同桌读到了自己最后回滚了(撤销了)的数据,这就是“脏数据”。

先在 2 个终端都执行:

1
2
3
4
USE tx_demo;

# 设置当前会话的事务隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

终端 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mysql> USE tx_demo;
mysql> START TRANSACTION;

# Alice 扣掉 200 元,但先不提交
mysql> UPDATE demo
-> SET balance = balance - 200.00
-> WHERE id = 1;

# A 自己能看到 800
mysql> SELECT id, owner_name, balance
-> FROM demo
-> WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 800.00 |
+----+------------+---------+

终端 2:

1
2
3
4
5
6
7
8
9
10
11
12
mysql> USE tx_demo;
mysql> USE tx_demo;
# 按理说 A 还没提交,B 不该看到 800
# 但在 READ UNCOMMITTED 下,B 就是能看到
mysql> SELECT id, owner_name, balance
-> FROM demo
-> WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 800.00 |
+----+------------+---------+

紧接着终端 1:

1
2
3
4
5
6
7
8
mysql> ROLLBACK;
# 此时终端2
mysql> SELECT id, owner_name, balance FROM demo WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 1000.00 |
+----+------------+---------+

3. 读已提交

一个事务 只能 读到别的事务 已经提交 的数据,这就解决了脏读。只有别人提交了的数据,你才能看见,它解决不了:不可重复读,也就是在同一个事务里,两次查同一行,结果可能不一样。

理解:只看正式版,不看草稿;但正式版可能在查询期间被别人更新。

2 个终端都执行:

1
2
USE tx_demo;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

终端 2 先执行:

1
2
3
4
5
6
7
8
9
10
11
12
mysql> USE tx_demo;
mysql> START TRANSACTION;

# 第一次查 Alice 的余额
mysql> SELECT id, owner_name, balance
-> FROM demo
-> WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 1000.00 |
+----+------------+---------+

终端 1 执行:

1
2
3
4
5
6
7
8
9
mysql> USE tx_demo;
mysql> START TRANSACTION;

# A 改余额并提交
mysql> UPDATE demo
-> SET balance = balance - 100.00
-> WHERE id = 1;

mysql> COMMIT;

回到终端 2,继续执行:

1
2
3
4
5
6
7
8
9
# B 还在自己的事务里,再查一次同一行
mysql> SELECT id, owner_name, balance
-> FROM demo
-> WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 900.00 |
+----+------------+---------+

问题来了:

  1. Session B 的事务还没结束。
  2. 但它前后两次读同一行,结果变了。

这就是 不可重复读

4. 可重复读

同一个事务里,多次读取同一行数据,结果要保持一致。事务一开始,数据库像拍了一张快照。只要当前事务没结束,就会一直看这张快照,不管别人怎么改、怎么提交,自己查到的数据永远是事务刚开始时的样子。开始 2 个终端都执行:

1
2
USE tx_demo;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

终端 2 先执行:

1
2
3
4
5
6
7
8
9
10
11
12
mysql> USE tx_demo;
mysql> START TRANSACTION;

# 第一次查
mysql> SELECT id, owner_name, balance
-> FROM demo
-> WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 1000.00 |
+----+------------+---------+

终端 1 执行:

1
2
3
4
5
6
7
8
9
mysql> USE tx_demo;
mysql> START TRANSACTION;

# A 把 Alice 余额改成 700 并提交
mysql> UPDATE demo
-> SET balance = balance - 300.00
-> WHERE id = 1;

mysql> COMMIT;

终端 2 执行:

1
2
3
4
5
6
7
8
mysql> SELECT id, owner_name, balance
-> FROM demo
-> WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 1000.00 |
+----+------------+---------+

虽然数据库里真实最新值已经变成 700.00,但终端 2 在自己这个事务没结束前,看到的还是事务开始时那份结果,这就是 可重复读终端 2:

1
2
3
4
5
6
7
8
9
COMMIT;

# 事务结束后再查,就能看到最新值
mysql> SELECT id, owner_name, balance FROM demo WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 700.00 |
+----+------------+---------+

理论上该级别会有“幻读”问题(即查询条数不一致),但 MySQL 的 InnoDB 有两套很关键的机制:

  1. MVCC,让普通查询常常读的是一致性快照。
  2. Next-Key Lock,在锁定读、更新范围时,会顺手把“间隙”也管起来。

已经很好地解决了 大部分 幻读问题,所以课本说 RR 可能幻读,自己用 SELECT 试了半天,也不一定能轻松复现。

5. 串行化

这是最高级别,它强制大家 排队,只要有一个人在用这张表(读或写),其他人就得等着。性能差,等待多,锁冲突明显。2 个终端都执行:

1
2
USE tx_demo;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

终端 1 执行:

1
2
3
4
5
6
7
8
9
10
11
12
mysql> USE tx_demo;
mysql> START TRANSACTION;

# A 先查一把
mysql> SELECT id, owner_name, balance
-> FROM demo
-> WHERE id = 1;
+----+------------+---------+
| id | owner_name | balance |
+----+------------+---------+
| 1 | Alice | 600.00 |
+----+------------+---------+

这时先别提交,然后终端 2 执行:

1
2
3
4
5
6
7
mysql> USE tx_demo;
mysql> START TRANSACTION;

# B 尝试修改同一行
mysql> UPDATE demo
-> SET balance = balance - 50.00
-> WHERE id = 1;

这时会发现:终端 2 卡住了,在等,只有当终端 1 执行 COMMIT;ROLLBACK;,终端 2 才会继续。

6. 小结

隔离级别英文名称脏读不可重复读幻读性能常用场景
读未提交Read Uncommitted极快几乎不用
读已提交Read CommittedOracle/SQL Server 默认
可重复读Repeatable Read❌ (基本解决)MySQL 默认,推荐
串行化Serializable极慢极度严格的数据一致性要求

5. 一致性

一致性就是:事务执行前后,数据库得是 “合法” 的,如何保证?

一致性是事务 ACID 特性中最综合的一个,它依赖于其他三个特性的协同工作,同时也依赖于业务逻辑:

  1. 原子性保证(要么全做,要么全不做): 如果事务执行中途出错,必须回滚到初始状态,不能留下一半的残缺数据。
  2. 持久性保证: 一旦提交,数据就 永久保存,系统崩溃也不能丢失结果。
  3. 隔离性保证: 多个事务并发执行时不乱套,不会互相干扰导致数据错乱(如脏写、脏读)。
  4. 业务逻辑保证: 数据库只能管技术层面的“对错”,管不了业务层面的“逻辑”。例如,转账逻辑写成了“扣款不收款”,虽然事务提交成功了,但数据在业务层面上是不一致的。

总之:一致性是原子性、持久性、隔离性加上正确业务逻辑共同作用的结果。


6. 多版本并发控制(MVCC)

MySQL 事务(重点) | CSDN

  • 读-读并发: 没问题,不需要管。
  • 写-写并发: 需要加锁(如行锁),保证数据安全。
  • 读-写并发: 这是最高频的场景。如果每次读都要加锁,性能会极差。MVCC 就是为了解决这个问题。

传统做法:读的时候加锁,写的时候也加锁。读写互斥,性能差。MVCC 做法:读旧版本,写新版本。读写不互斥,性能高。其 核心思想 是每条数据都存多个 “历史版本”,读的时候,根据事务开始时间,挑一个 “能看到” 的版本读,写的时候,创建新版本,不影响别人读旧版本。

实现依赖: 隐藏字段、Undo Log(回滚日志)、Read View(读视图)。

1. 三个隐藏字段(InnoDB 每条记录都有)

虽然用 DESC 看不到,但底层真有:

字段大小作用
DB_TRX_ID6 字节事务 ID。记录创建或最近一次修改该行数据的事务 ID,是判断数据可见性的关键
DB_ROW_ID6 字节隐含主键。如果表没有显式主键,InnoDB 会自动生成这个字段作为聚簇索引的依据
DB_ROLL_PTR7 字节回滚指针。指向该行记录的上一个版本(存储在 Undo Log 中),通过这个指针能把数据的不同版本串起来

2. undo log:回滚日志

MySQL 三大日志:

  • redo log:崩溃恢复用,保证持久性。
  • bin log:主从同步用,保证一致性。
  • undo log:回滚用,保证原子性 + 隔离性(MVCC)历史版本。

写操作前会把当前数据拷贝到 undo log,这个副本就构成快照,那么版本链啥时候清理?

  • 新插入的记录,提交后就能清(没人会读它的历史)
  • 修改/删除的记录,要等 所有可能读它历史版本的事务都结束 才能清
  • 所以热点数据的 undo log 可能留很久,正常现象

3. 快照

Undo Log 中串联起来的历史数据链表,就是 版本链。链表中的每一个节点,都是一个 快照。最新数据: 在缓冲池/磁盘的主索引上,历史快照: 在 Undo Log 链表中。

当前读 vs 快照读

类型触发场景加锁吗读哪个版本
当前读UPDATE/DELETE/SELECT ... FOR UPDATE加锁最新版本
快照读普通 SELECT不加锁历史版本(根据 Read View 判断)

4. Read View:读视图

Read View 是事务进行 快照读 时的“裁判”,它决定了当前事务能看到版本链中的哪一个版本。当一个事务执行快照读时,会生成一个 Read View,其中包含生成时刻系统的活跃事务信息。核心字段如下:

  1. m_ids 生成 Read View 时,当前系统中 活跃(未提交) 的事务 ID 列表。
  2. m_up_limit_id (低水位): 活跃事务列表中最小的事务 ID。
  3. m_low_limit_id (高水位): 生成 Read View 时,系统预分配给下一个事务的 ID(即当前最大事务 ID + 1)。
  4. m_creator_trx_id 创建该 Read View 的事务 ID。

可见性判断规则(版本匹配算法): 拿着数据行版本中的 DB_TRX_ID,与 Read View 进行比对:

  1. 如果是自己改的(DB_TRX_ID == m_creator_trx_id): 可见。
  2. 如果是很老的事务(DB_TRX_ID < m_up_limit_id): 说明该版本在 Read View 生成前就已经提交了,可见
  3. 如果是未来的事务(DB_TRX_ID >= m_low_limit_id): 说明该版本是在 Read View 生成后才开启的事务修改的,不可见
  4. 如果是在活跃列表范围内(m_up_limit_id <= DB_TRX_ID < m_low_limit_id):
    • 再次判断 DB_TRX_ID 是否在 m_ids 列表中:
      • 在列表中: 说明生成 Read View 时该事务还未提交(活跃),不可见
      • 不在列表中: 说明生成 Read View 时该事务已经提交了,可见

如果不满足可见性怎么办? 根据 DB_ROLL_PTR 回滚指针,去找 Undo Log 中的下一个旧版本,再次进行上述判断,直到找到可见版本或链表结束。

5. RR 和 RC 的本质区别

现象

  • RC(读提交):同一个事务里,两次 SELECT 可能读到不同结果(别人提交了就能看见)
  • RR(可重复读):同一个事务里,两次 SELECT 结果一样(不管别人提没提交)

本质原因:Read View 创建时机不同!

隔离级别Read View 创建时机结果
RC在每条 SELECT 语句开始时生成新的 Read View每次都能看见最新已提交的数据 → 不可重复读
RR仅第一次快照读创建 Read View,后面复用一直看见第一次读时的快照 → 可重复读

举个栗子(RR 级别):

1
2
3
T0: 事务 A 开始,第一次 SELECT → 创建 Read View(记录此时活跃事务)
T1: 事务 B 修改并提交数据
T2: 事务 A 第二次 SELECT → 复用 T0 的 Read View → 还是看到旧数据

举个栗子(RC 级别):

1
2
3
T0: 事务 A 开始,第一次 SELECT → 创建 Read View1
T1: 事务 B 修改并提交数据
T2: 事务 A 第二次 SELECT → 创建新的 Read View2 → 看到 B 提交的新数据