一、事务
数据库事务(Database Transaction)是数据库管理系统(DBMS)中执行的一系列操作,这些操作被当作一个逻辑单元进行处理,以保证数据的一致性和完整性。
ACID,事务四个关键特性
1、原子性(Atomicity)
- 原子性意味着事务中的所有操作要么全部完成,要么全部不完成。如果事务中的某个操作失败,那么整个事务应该回滚(撤销所有已经执行的操作),使数据库返回到事务开始前的状态。
2、一致性(Consistency)
- 一致性确保事务执行前后,数据库从一个一致状态转换到另一个一致状态。事务执行过程中,必须遵守所有数据库的完整性约束(如外键约束、唯一性约束等),以确保数据的逻辑正确性。
3、隔离性(Isolation)
- 隔离性保证多个事务并发执行时,一个事务的中间状态对其他事务是不可见的。这样,即使多个事务并发执行,每个事务都好像是在没有其他事务并发执行的情况下单独执行的。常见的隔离级别包括未提交读(Read Uncommitted)、提交读(Read Committed)、可重复读(Repeatable Read)和可串行化(Serializable)。
4、持久性(Durability)
- 持久性意味着一旦事务提交成功,即使系统崩溃,事务对数据库的影响也是永久的。事务的修改会被持久地保存在数据库中,不会丢失。
事务隔离级别
1、读未提交(Read Uncommitted)
- 特点:允许一个事务读取另一个事务尚未提交的数据。
- 问题:可能导致脏读(Dirty Read),即读取到可能最终会被回滚的数据。
2、读已提交(Read Committed)
- 特点:一个事务只能读取另一个事务已经提交的数据。
- 问题:可能导致不可重复读(Non-repeatable Read),即同一事务中多次读取同一数据可能得到不同的结果,因为其他事务可能在此期间对数据进行了修改。
3、可重复读(Repeatable Read)
- 特点:确保同一事务中多次读取同一数据得到相同的结果,即使其他事务在此期间对数据进行了修改。
- 问题:可能导致幻读(Phantom Read),即一个事务在读取某些数据行后,另一个事务插入了新行,导致第一个事务在后续读取时看到了这些新行。
4、串行化(Serializable)
- 特点:确保事务完全隔离,就像它们按某种顺序串行执行一样。
- 问题:性能开销最大,因为事务之间不能并发执行。
数据一致性问题
脏读(Dirty Read)
- 定义:事务A对一个值做修改,事务B读取这个值,但由于某种原因事务A回滚撤销了对这个值的修改,导致事务B读取到的值是无效数据。
- 影响:脏读可能会导致数据不一致的问题,因为读取到的数据可能是临时性的,尚未得到持久化,也可能被后续事务回滚掉。如果其他事务依赖于这个脏读数据进行后续操作,就可能导致系统出现错误或不一致的状态。
不可重复读(Non-repeatable Read)
- 定义:当事务A按照查询条件得到了一个结果集,这时事务B对事务A查询的结果集数据做了修改操作。之后事务A为了数据校验继续按照之前的查询条件查询,得到的结果集与前一次查询不同,导致不可重复读取原始数据。
- 影响:不可重复读可能导致事务在多次读取数据时得到不一致的结果,从而影响事务的正确性和一致性。
幻读(Phantom Read)
- 定义:当事务A按照查询条件得到了一个结果集,这时事务B对事务A查询的结果集数据做新增操作,之后事务A继续按照之前的查询条件查询时,结果集平白无故多了几条数据,好像出现了幻觉一样。
- 影响:幻读可能导致事务在读取数据时得到意外的结果,因为新的数据行在事务的两次读取之间被插入到了查询范围内。
二、InnoDB
MySQL InnoDB对隔离级别的支持
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读(未提交读) | 可能 | 可能 | 可能 |
已提交读(已提交) | 不可能 | 可能 | 可能 |
可重复读(可重复读) | 不可能 | 不可能 | 对InnoDB 不可能 |
串行化(Serializable) | 不可能 | 不可能 | 不可能 |
MySQL InnoDB 解决不可重复读和幻读
1、LBCC (Lock-Based Concurrent Control)
读锁
select * from sys_user where id = 1 for share
Concurrency: 读锁允许多个会话并发读取,但写操作会被阻塞。写锁会阻塞其他读操作和写操作。
写锁
-- 获取行级写锁
SELECT * FROM sys_user WHERE id = 1 FOR UPDATE;
根据算法,写锁可以细分为
- 记录锁(Record Lock)
记录锁是锁定单个行记录的锁,防止其他事务对此行进行update和delete等修改操作。
SELECT * FROM sys_user WHERE id = 1 FOR UPDATE;
上述语句会在 sys_user 表中,锁定 id 为 1 的那一行。
- 间隙锁 (Gap Lock)
当进行范围查询时,没有命中记录的时候会使用间隙锁,只对添加有效,允许修改不存在的值,既只对insert有效,update无效
间隙锁只锁定记录之间的间隙,而不锁定任何实际的记录。间隙锁用于防止其他事务在锁定的间隙中插入新记录。相同间隙锁之间不冲突
假设一个表的数据为
1、设置隔离级别为 REPEATABLE READ:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2、开启事务并执行范围查询
间隙锁会锁住最后一个 key 的下一个左开右闭的区间,没有key就锁住无穷,防止幻读
START TRANSACTION;
-- 这条语句会锁定 id 在 10 和 20 之间的所有记录和间隙,锁住的是闭区间(10,20) 系统不存在数据
SELECT * FROM sys_menu_perm WHERE id <= 4 FOR UPDATE;
-- 提交第一个事务后,再次尝试插入,可以成功
COMMIT;
3、在另一个事务中尝试插入(应被阻塞):
-- 另一个会话/事务中
START TRANSACTION;
-- 试图插入一条记录到被锁定的间隙中
INSERT INTO sys_menu_perm (id, role_id) VALUES (5, 12); -- 被阻塞
update `saas`.sys_menu_perm set role_id = 5 where id = 8; -- 被阻塞
INSERT INTO sys_menu_perm (id, role_id) VALUES (7, 12); -- 不被阻塞
COMMIT;
- 临键锁(Next-Key Lock)
当我们使用了范围查询,不仅仅命中了Record 记录,还包含了 Gap间隙,在这种情况下我们使用的就是临键锁
临键锁是 MySQL 里面默认的行锁算法,相当于记录锁加上间隙锁。
临键锁不仅锁定了记录本身,还锁定了记录和前一条记录之间的间隙。这意味着它锁定了索引记录范围内的所有可能插入的位置。
假设有一个表的记录
1、设置隔离级别为 REPEATABLE READ:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
开启事务并执行范围查询(获取临键锁):
2、查询并锁定 id 在 2 和 5 之间的记录和间隙
临键锁,锁住最后一个 key 的下一个左开右闭的区间,没有key就锁住无穷,防止幻读
START TRANSACTION;
SELECT * FROM employees WHERE id >= 2 AND id <= 11 FOR UPDATE; -- 验证插入是否被阻塞(在另一个事务中尝试插入):
左开右闭的区间,会锁住 2,(2,5],(5,9], (9,11],(11,15] -- 临键锁,锁住最后一个 key 的下一个左开右闭的区间。防止幻读
-- 提交第一个事务后,再次尝试插入,可以成功
COMMIT;
3、验证插入是否被阻塞(在另一个事务中尝试插入)
-- 在另一个会话/事务中
START TRANSACTION;
-- 试图在已经锁定的间隙中插入新记录
INSERT INTO sys_menu_perm (id, role_id) VALUES (10, 12); -- 被阻塞
INSERT INTO sys_menu_perm (id, role_id) VALUES (16, 12); -- 不被阻塞
-- 试图在已经锁定的间隙中更新记录
update `saas`.sys_menu_perm set role_id = 15 where id = 15; -- 被阻塞
COMMIT;
注意:
- 1、在读已提交(READ COMMITTED)隔离级别下,InnoDB 只会使用记录锁,不会应用间隙锁和临键锁。
- 2、不使用索引的话会锁住全表
- 3、在非唯一索引情况下,使用等值查询的时候,也会锁住下一个间隙,所有索引情况下,范围查询都会锁住下一个左开右闭的间隙
例如表数据如下
唯一索引
事务一:
START TRANSACTION;
-- 这条语句会锁定 id 在 8 和 (8,10] 的记录和间隙
SELECT * FROM `saas`.sys_menu_perm WHERE id = 8 FOR UPDATE;
commit;
事务二:
START TRANSACTION;
INSERT INTO `saas`.sys_menu_perm (id, role_id) VALUES (9, 9); -- 不会被阻塞
commit;
非唯一索引
事务一:
START TRANSACTION;
-- 这条语句会锁定 id = 8 的记录
SELECT * FROM `saas`.sys_menu_perm WHERE role_id = 8 FOR UPDATE;
commit;
事务二:
START TRANSACTION;
INSERT INTO `saas`.sys_menu_perm (id, role_id) VALUES (9, 9); -- 会被阻塞
commit;
原理
2、MVCC(Multi-Version Concurrency Control)
核心组件
1、版本链(Version Chain)
InnoDB通过为每行记录保存多个版本的快照来实现MVCC。这些版本通过隐式生成的列来管理,包括:
-
m_ids:Read View 创建时未提交的活跃事务 ID 列表。m_ids 不包括当前事务自己和已提交的事务(正在内存中)。
-
m_creator_trx_id:创建该 Read View 的事务 ID。
-
m_low_limit_id(max_trx_id):目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。
-
m_up_limit_id(min_trx_id): m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_low_limit_id为m_up_limit_id
2、隐藏字段:
- DB_TRX_ID:6字节,记录最近修改(修改/插入)该记录的事务ID。
- DB_ROLL_PTR:7字节,回滚指针,指向该记录的上一个版本(存储于rollback segment里),用于配合undo日志
- DB_ROW_ID:6字节,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。
3、undo日志:
- insert undo log:事务在insert新记录时产生的undo日志,只在事务回滚时需要,并且在事务提交后可以被立即丢弃。
- update undo log:事务在进行update或delete时产生的undo日志,不仅在事务回滚时需要,在快照读时也需要,所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除。
4、Read View:
- 事务进行快照读操作时生成的读视图,在该事务执行快照读的那一刻,会生成数据库系统当前的一个版本视图,RR级别的Read View保持不变
Read View 可见性具体判断如下:
- DB_TRX_ID == m_creator_trx_id ,表示当前事务访问自己的记录。
- DB_TRX_ID < m_up_limit_id,说明生成该版本的事务在当前事务生成 Read View 之前已经提交,因此该版本可以被当前事务访问。
- DB_TRX_ID >= m_low_limit_id ,说明生成该版本的事务在当前事务生成 Read View 之后才提交,因此该版本不能被当前事务访问。
- m_low_limit_id > DB_TRX_ID >= m_up_limit_id ,检查 DB_TRX_ID 在m_ids 列表中,事务仍处于活跃状态,因此该版本不能被访问;如果不在列表中,说明在创建 Read View 时生成该版本的事务已经提交,因此该版本可以被访问。
快照读与当前读
1、快照读:
- 不加锁的select操作就是快照读,即不加锁的非阻塞读。
- 快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。
- 快照读的实现基于多版本并发控制,即MVCC,它避免了加锁操作,降低了开销。
- 快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
2、当前读:
- 像select lock in share mode(共享锁)、select for update、update、insert、delete(排他锁)这些操作都是一种当前读。
- 当前读读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
示例
表sys_menu_perm的数据如下
在各个时间段事务1、2 、3分别执行查询更新插入操作
分析:
T1 的 read view 为
字段 | 值 |
---|---|
m_ids | [T1] |
m_low_limit_id | T2 |
m_up_limit_id | T1 |
m_creator_trx_id | T1 |
T3 的 read view 为
字段 | 值 |
---|---|
m_ids | [T1,T3] |
m_low_limit_id | T4 |
m_up_limit_id | T1 |
m_creator_trx_id | T3 |
对于 Time 5 时刻,版本链数据为
对于事务T1,判断规则如下
-
ID = 6
链路第一条数据,DB_TRX_ID不存在,即为数据库的原始数据,可以访问的记录。 -
ID = 8
链路第一条数据,DB_TRX_ID = T2 ,DB_TRX_ID >= m_low_limit_id ,说明生成该版本的事务在当前事务生成 Read View 之后才提交,因此该版本不能被当前事务访问。
链路第二条数据,DB_TRX_ID不存在,即为数据库的原始数据,可以访问的记录。
对于事务T3,判断规则如下
- ID = 6
链路第一条数据,DB_TRX_ID不存在,即为数据库的原始数据,可以访问的记录。 - ID = 8
链路第一条数据,DB_TRX_ID = T2,m_low_limit_id > DB_TRX_ID >= m_up_limit_id ,检查 DB_TRX_ID 不在 m_ids 列表中,说明在创建 T3 的 Read View 时生成该版本的事务已经提交,因此该版本可以被访问。
对于 Time 9 时刻,版本链数据为
对于事务T1,判断规则如下
-
ID = 2
链路第一条数据,DB_TRX_ID = T3,DB_TRX_ID >= m_low_limit_id,说明生成该版本的事务在当前事务生成 Read View 之后才提交,因此该版本不能被当前事务访问。 -
ID = 6
链路第一条数据,DB_TRX_ID不存在,即为数据库的原始数据,可以访问的记录。 -
ID = 8
链路第一条数据,DB_TRX_ID = T1 ,DB_TRX_ID == m_creator_trx_id ,表示当前事务访问自己的记录。