博客园  :: 首页  :: 新随笔  :: 管理

4.1.2 MySQL事务原理分析

Posted on 2023-03-22 18:36  wsg_blog  阅读(23)  评论(0编辑  收藏  举报

Linux C/C++服务器

MySQL事务原理分析

事务

事务的本质是并发控制的单元,是用户定义的一个操作序列。这些操作要么都做,要么都不做,是一个不可分割的工作单位。类似于原子操作CAS
事务将数据库从一种一致性状态转换为另一种一致性状态;保证系统始终处于一个完整且正确的状态;
事务可由一条非常简单的 SQL 语句组成,也可以由一组复杂的 SQL 语句组成; MySQL innodb下,单条语句都具备事务

-- 显示开启事务
START TRANSACTION | BEGIN
-- 提交事务,并使得已对数据库做的所有修改持久化
COMMIT
-- 回滚事务,结束用户的事务,并撤销正在进行的所有未提交的修改
ROLLBACK
-- 创建一个保存点,一个事务可以有多个保存点
SAVEPOINT identifier
-- 删除一个保存点
RELEASE SAVEPOINT identifier
-- 事务回滚到保存点
ROLLBACK TO [SAVEPOINT] identifier

ACID特性

原子性(A)
事务操作要么都做(提交),要么都不做(回滚);
事务是访问并更新数据库各种数据项的一个程序执行单元,是不可分割的工作单位;通过 undolog 来实现回滚操作。undolog 记录的是事务每步具体操作,当回滚时,回放事务具体操作的逆运算;
一致性(C)
一致性指事务将数据库从一种一致性状态转变为下一种一致性的状态,在事务执行前后,数据库完整性约束没有被破坏;
一个事务单元需要提交之后才会被其他事务可见。例如:一个表的姓名是唯一键,如果一个事务对姓名进行修改,但是在事务提交或事务回滚后,表中的姓名变得不唯一了,这样就破坏了一致性;一致性由原子性、隔离性以及持久性共同来维护的。
隔离性(I)
事务的隔离性要求每个读写事务的对象对其他事务的操作对象能相互分离,并发事务之间不会相互影响,设定了不同程度的隔离级别,通过适度破环一致性,得以提高性能;
通过 MVCC和 锁来实现;
MVCC 时多版本并发控制,主要解决一致性非锁定读,通过记录和获取行版本,而不是使用锁来限制读操作,从而实现高效并发读性能。锁用来处理并发 DML 操作;数据库中提供粒度锁的策略,针对表(聚集索引 B+ 树)、页(聚集索引 B+ 树叶子节点)、行(叶子节点当中某一段记录行)三种粒度加锁;
持久性(D)
事务一旦完成,要将数据所做的变更记录下来,包括数据存储和多副本的网络备份;
事务提交后,事务 DML 操作将会持久化(写入 redolog 磁盘文件 哪一个页 页偏移值 具体数据);即使发生宕机等故障,数据库也能将数据恢复。redolog 记录的是物理日志;

隔离级别

ISO 和 ANIS SQL 标准制定了四种事务隔离级别的标准,各数据库厂商在正确性和性能之间做了妥协,并没有严格遵循这些标准;
MySQL innodb默认支持的隔离级别是 REPEATABLE READ;
目的:尽可能提升读的并发性能,不同隔离级别只是读有区别,写都是一样的(写直接加锁),通常工作中我只会使用一种隔离级别

组成

READ UNCOMMITTED(读未提交-RU)
该级别下读不加锁,写加排他锁,写锁在事务提交或回滚后释放锁;
READ COMMITTED(读已提交-RC)
从该级别后支持 MVCC (多版本并发控制),也就是提供一致性非锁定读;此时读取操作读取历史快照数据;该隔离级别下读取历史版本的最新数据,所以读取的是已提交的数据;
REPEATABLE READ(可重复读-RR)
该级别下也支持 MVCC,此时读取操作读取事务开始时的版本数据;
SERIALIZABLE(可串行化)
该级别下给读加了共享锁;所以事务都是串行化的执行;此时隔离级别最严苛;
命令

-- 设置隔离级别
SET [GLOBAL | SESSION] TRANSACTION ISOLATION
LEVEL REPEATABLE READ;
-- 或者采用下面的方式设置隔离级别
SET @@tx_isolation = 'REPEATABLE READ';
SET @@global.tx_isolation = 'REPEATABLE READ';
-- 查看全局隔离级别
SELECT @@global.tx_isolation;
-- 查看当前会话隔离级别
SELECT @@session.tx_isolation;
SELECT @@tx_isolation;
-- 手动给读加 S 锁
SELECT ... LOCK IN SHARE MODE;
-- 手动给读加 X 锁
SELECT ... FOR UPDATE;
-- 查看当前锁信息
SELECT * FROM information_schema.innodb_locks;

不同隔离级别并发异常

脏读:
一个事务读到另一个未提交事务修改的数据,只在READ UNCOMMITTED(读未提交-RU)隔离中出现
不可重复读:
一个事务内两次读取同一个数据不一样
幻读:
一个事务内两次读取同一范围内的记录得到的结果集不一样 for update加到前面可解决幻读问题

并发读异常SQL

DROP TABLE IF EXISTS `account_t`;
CREATE TABLE `account_t` (
	`id` INT(11) NOT NULL,
	`name` VARCHAR(255) DEFAULT NULL,
	`money` INT(11) DEFAULT 0,
	PRIMARY KEY (`id`),
	KEY `idx_name` (`name`)
)ENGINE = INNODB AUTO_INCREMENT=0 DEFAULT CHARSET = utf8;

SELECT * from account_t;

rollback;
INSERT INTO `account_t` VALUES (7,'M',1000), (1, 'C', 1000),(2, 'B', 1000),(3, 'A', 1000);

insert into 

-- 脏读读取了另一个事务未提交的修改 (其他事务的修改影响了本事务的读取)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN
-- 脏读事务1
UPDATE account_t SET money = money - 100 WHERE name = 'A';
-- 脏读事务2
-- SELECT money FROM account_t WHERE name = 'A';
-- SELECT money FROM account_t WHERE name = 'B';
-- 脏读事务1
UPDATE account_t SET money = money + 100 WHERE name = 'B';
-- 脏读事务1
COMMIT;
-- 脏读事务2
-- COMMIT

-- 不可重复读读取了另一个事务提交之后的修改(其他事务的修改影响了本事务的读取)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN
-- 不可重复读事务2
SELECT money FROM account_t WHERE name = 'A';
-- 不可重复读事务1
UPDATE account_t SET money = money - 100 WHERE name = 'A';
-- 不可重复读事务1
COMMIT;
-- 不可重复读事务2
-- SELECT money FROM account_t WHERE name = 'A';
-- COMMIT

-- 幻读两次读取得到的结果集不一样
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN
-- 幻读事务2
-- SELECT * FROM account_t WHERE id >= 2 for update;
-- 幻读事务1
INSERT INTO account_t(id,name,money) VALUES (4,'D',1000);
-- 幻读事务1
COMMIT
-- 幻读事务2
-- SELECT * FROM account_t WHERE id >= 2 for update;
-- COMMIT;


-- 丢失更新(提交覆盖)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN
-- 丢失更新事务1
SELECT money FROM account_t WHERE name = 'A';
-- 丢失更新事务2
-- SELECT money FROM account_t WHERE name = 'A';
-- UPDATE account_t SET money = 1100 WHERE name = 'A';
-- COMMIT;
-- 丢失更新事务1
UPDATE account_t SET money = 900 WHERE name = 'A';
COMMIT;

MVCC

多版本并发控制;用来实现一致性的非锁定读;非锁定读是指不需要等待访问的行上X锁的释放;
在 read committed 和 repeatable read 下,innodb 使用MVCC;然后对于快照数据的定义不同;在 read committed 隔离级别下,对于快照数据总是读取被锁定行的最新一份快照数据;而在 repeatable read 隔离级别下,对于快照数据总是读取事务开始时的行数据版本;
思考:为什么读取快照数据不需要上锁?
因为没有事务需要对历史的数据进行修改操作;

read view

在 read committed 和 read repeatable 隔离级别下,MVCC 采用read view 来实现的,它们的区别在于创建 read view 时机不同:
read committed 隔离级别会在事务中每个 select 都会生成一个新的 read view,也意味着在同一个事务多次读取同一条数据可能出现数据不一致;因为在多次读取期间可能有其他事务修改了该条记录,并提交了;
read repeatable 隔离级别是启动事务时生成一个 readview,在整个事务读取数据都才使用这个 read view,这样保证了在事务期间读到的数据都是事务启动前的记录;

构成

  • m_ids:创建 read view 时,当前数据库活跃事务(开启未提交的事务)的事务 id 列表;
  • min_trx_id:创建 read view 时, m_ids 中的最小事务 id;
  • max_trx_id:创建 read view 时,当前数据库将为下一个事务分配的事务 id; m_ids 中的最大事务 id + 1;
  • creator_trx_id:创建 read view 所在事务的 id;

聚集索引隐藏列

  • trx_id:当某个事务对某条聚集索引记录进行修改时,将会把当前事务的 id 赋值给 trx_id,同时只能被一个事务修改;
  • roll_pointer:当某个事务对某条聚集索引记录进行修改时,会将上一个版本的记录写到 undo log,然后通过roll_pointer 指向旧版本记录,通过它可以找到修改前的记录;

事务可见性问题

事务可以看到事务背身的修改
事务间的可见性:

  • trx_id < min_trx_id;说明该记录在创建 read_view 之前已经提交,所以对当前事务可见;
  • trx_id >= max_trx_id;说明该记录是在创建 read_view 之后启动事务生成的,所以对当前事务不可见;
  • min_trx_id <= trx_id < max_trx_id;此时需要判断是否在 m_ids 列表中;
    • 在列表中;生成该版本记录的事务仍处于活跃状态,该版本记录对当前事务不可见;
    • 不在列表中;生成该版本记录的事务已经提交,该版本记录对当前事务可见;

redo/undo

redo 日志用来实现事务的持久性;内存中包含 redo logbuffer,磁盘中包含 redo log file;顺序写磁盘的速度和随机写内存速度差不多
当事务提交时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的 commit 操作完成才完成了事务的提交;
redo log 顺序写,记录的是对每个页的修改(页、页偏移量、以及修改的内容);在数据库运行时不需要对 redo log 的文件进行读取操作;只有发生宕机的时候,才会拿redo log 进行恢复;
undo 日志用来帮助事务回滚以及 MVCC 的功能;存储在共享表空间中;
undo 是逻辑日志,回滚时将数据库逻辑地恢复到原来的样子,根据 undo log 的记录,做之前的逆运算;比如事务中有 insert 操作,那么执行 delete 操作;对于 update 操作执行相反的 update 操作;同时 undo 日志记录行的版本信息,用于处理 MVCC 功能;

锁类型

锁机制用于管理对共享资源的并发访问;用来实现事务的隔离级别 ;
共享锁和排他锁都是行级锁(锁一行记录);MySQL当中事务采用的是粒度锁;针对表(B+树)、页(B+树叶子节点)、行(B+树叶子节点当中某一段记录行)三种粒度加锁;
意向共享锁和意向排他锁都是表级别的锁;

共享锁(S)

事务读操作加的锁;对某一行加锁;
在 SERIALIZABLE 隔离级别下,默认帮读操作加共享锁;
在 REPEATABLE READ 隔离级别下,需手动加共享锁,可解决幻读问题;
在 READ COMMITTED 隔离级别下,没必要加共享锁,采用的是 MVCC;
在 READ UNCOMMITTED 隔离级别下,既没有加锁也没有使用MVCC;

排他锁(X)

事务删除或更新加的锁;对某一行加锁;
在4种隔离级别下,都添加了排他锁,事务提交或事务回滚后释放锁;

意向共享锁(IS)

对一张表中某几行加的共享锁;

意向排他锁(IX)

对一张表中某几行加的排他锁;
目的:为了告诉其他事务,此时这条表被一个事务在访问;作用:排除表级别读写锁 (全面扫描加锁)

锁的兼容性

由于 innodb 支持的是行级别的锁,意向锁并不会阻塞除了全表扫描以外的任何请求;
意向锁之间是互相兼容的;
IS 只对排他锁不兼容;
当想为某一行添加 S 锁,先自动为所在的页和表添加意向锁IS,再为该行添加 S 锁;
当想为某一行添加 X 锁,先自动为所在的页和表添加意向锁IX,再为该行添加 X 锁;
当事务试图读或写某一条记录时,会先在表上加上意向锁,然后才在要操作的记录上加上读锁或写锁。这样判断表中是否有记录加锁就很简单了,只要看下表上是否有意向锁就行了。意向锁之间是不会产生冲突的,也不和 AUTO_INC 表锁冲突,它只会阻塞表级读锁或表级写锁,另外,意向锁也不会和行锁冲突,行锁只会和行锁冲突。

锁算法

innodb使用行锁机制

记录锁(Record Lock)

包含 S锁(共享锁)、X锁(排他锁),单个行记录上的锁;

间隙锁(Gap Lock)

间隙锁,锁定一个范围,但不包含记录本身;全开区间;防止其他事务在记录间插入新的记录,从而避免幻读
REPEATABLE READ 级别及以上支持间隙锁;
如果 REPEATABLE READ 修改innodb_locks_unsafe_for_binlog = 0,那么隔离级别相当于退化为 READ COMMITTED;

-- 查看是否支持间隙锁,默认支持,也就是
innodb_locks_unsafe_for_binlog = 0;
SELECT @@innodb_locks_unsafe_for_binlog;

临键锁(Next-Key Lock)

记录锁+间隙锁,锁定一个范围,并且锁住记录本身;左开右闭区间;

Insert Intention Lock

插入意向锁, insert 操作的时候产生;在多事务同时写入不同数据至同一索引间隙的时候,并不需要等待其他事务完成,不会发生锁等待。
假设有一个记录索引包含键值 4 和 7,两个不同的事务分别插入5 和 6,每个事务都会产生一个加在 4-7 之间的插入意向锁,获取在插入行上的排它锁,但是不会被互相锁住,因为数据行并不冲突。

并发死锁

死锁:两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象;MySQL 中采用 wait-for graph(等待图-采用非递归深度优先的图算法实现)的方式来进行死锁检测;
异常报错: deadlock found when trying to get lock;

相反加锁顺序死锁

不同表的加锁顺序相反或者相同表不同行加锁顺序相反造成死锁;其中相同表不同行加锁顺序相反造成死锁有很多变种,其中容易忽略的是给辅助索引行加锁的时候,同时会给聚集索引行加锁;同时还可能出现在外键索引时,给父表加锁,同时隐含给子表加锁;触发器同样如此,这些都需要视情况分析;调整加锁顺序;

锁冲突死锁

innodb 在 RR 隔离级别下,最常见的是插入意向锁与 gap 锁冲突造成死锁;主要原理为:一个事务想要获取插入意向锁,如果有其他事务已经加了 gap lock 或 Next-key lock 则会阻塞;

死锁解决

对于顺序相反型,调整执行顺序;
对于锁冲突型,更换语句或者降低隔离级别;

如何避免死锁

  • 尽可能以相同顺序来访问索引记录和表;
  • 如果能确定幻读和不可重复读对应用影响不大,考虑将隔离级别降低为 RC;
  • 添加合理的索引,不走索引将会为每一行记录加锁,死锁概率非常大;
  • 尽量在一个事务中锁定所需要的所有资源,减小死锁概率;
  • 避免大事务,将大事务分拆成多个小事务;大事务占用资源多,耗时长,冲突概率变高;
  • 避免同一时间点运行多个对同一表进行读写的概率;