文章目录
- 一、MySQL锁机制
- 1.1 锁的分类
- 1.1.1 按操作分
- 1.1.2 按粒度分
- 1.1.3 按算法划分
- 1.2 MyIsam引擎锁
- 1.2.1 准备数据
- 1.2.2 MySIAM引擎写锁
- 1.2.3 MySIAM引擎读锁
- 1.2.4 小结
- 1.2.5 表锁应用场景
- 1.2.6 InnoDB的表锁
- 1.2.7 MyISAM 的并发插入
- 1.2.8 MyISAM的锁调度
- 1.3 InnoDB 引擎锁
- 1.3.1 InnoDB行锁实现方式
- 1.3.2 InnoDB读锁
- 1.3.3 InnoDB写锁
- 1.3.4 InnoDB索引与锁
- 1.3.5 InnoDB死锁
- 1.4 锁小结
- 1.4.1 MySIAM引擎
- 1.4.2 InnoDB 引擎
- 1.5 意向锁
- 1.5.1 意向锁是表锁还是行锁?
- 1.5.2 S锁、X锁、IS、IX锁之间的关系
- 1.5 自增锁
- 1.6 锁的算法
- 1.6.1 Record Lock
- 1.6.2 Gap Lock
- 1.6.5 Next-Key Lock
- 1.6.5.1 临键锁体验:
- 1.6.5.2 临键锁触发时机
- 1)普通列
- 2)二级索引列
- 3)主键和唯一索引
- 4)临键锁总结
- 1.8 悲观锁,乐观锁
- 1.8.1 悲观锁乐观锁概念
- 1.8.2 乐观锁的实现方式
- 1)版本号
- 2)时间戳
- 二、事务
- 2.1 事务隔离级别
- 2.1.1 隔离级别说明
- 2.1.2 脏读
- 2.1.3 不可重复读
- 2.1.4 幻读
- 2.1.5 SQL92标准
- 三、事务日志
- 3.1 Redo log
- 3.1.1 Redo工作原理:
- 3.1.2 Redo脏页的概念
- 3.1.3 Redo的刷新策略:
- 3.1.4 Redo相关系统参数
- 3.2 Undo log
- 3.2.1 Undo工作原理:
- 3.2.2 Purge线程
- 四、MVCC并发版本控制
- 4.1 MVCC与LBCC
- 4.2 MVCC实现原理
- 4.2.1 插入流程
- 4.2.2 删除流程
- 4.2.3 修改流程
- 4.2.4 查询流程
- 1)RR环境下MVCC查询流程
- 2)RC环境下MVCC查询流程
- 4.3 快照读和当前读
一、MySQL锁机制
锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一 个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要。
MySQL用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。这些锁统称为悲观锁(Pessimistic Lock)。
MySQL中的锁其最显著的特点是不同的存储引擎支持不同的锁机制。比如,MyISAM存储引擎采用的是表级锁(table-level locking);InnoDB存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用表级锁。
**表级锁:**开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
**行级锁:**开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
**页面锁:**开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般
1.1 锁的分类
1.1.1 按操作分
- 读锁(共享锁):Shared Locks(S)
- 写锁(排它锁):Exclusive Locks(X)
- 意向共享锁:Intention Shared Locks(IS)
- 意向排他锁:Intention Exclusive Locks(IX)
- 自增锁:(AUTO-INC Locks)
1.1.2 按粒度分
- 表锁:Table Locks
- 行锁:Row Locks
- 页锁:Page Locks
1.1.3 按算法划分
- 记录锁:Record Locks
- 间隙锁:Gap Locks
- 临键锁:Next-key Locks
1.2 MyIsam引擎锁
MyIsam引擎默认使用的是表锁,对MyISAM表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;对MyISAM表的写操作,则会阻塞其他用户对同一表的读和写操作;MyISAM表的读操作与写操作之间,以及写操作之间是串行的!
MySQL的表级锁有两种模式:
- 表共享读锁(读锁)(Table Read Lock):允许本线程/进程读、不允许本线程写,其他线程可读,不可写
- 表独占写锁(写锁)(Table Write Lock):允许本线程读、写操作,其他线程不可读、不可写
1.2.1 准备数据
建立一张使用MyIsam引擎的数据库表
CREATE TABLE `test1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) ,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM ;
INSERT INTO test1 VALUES(1,'aaa');
INSERT INTO test1 VALUES(2,'bbb');
查看表是否被锁过
show open tables;
对表加锁(读锁/写锁)
lock table test1 read;
lock table test1 write;
对表解锁
unlock tables;
1.2.2 MySIAM引擎写锁
在MySIAM引擎中,当某个线程获取到表的写锁时,只有持有该锁的线程才可以对表进行进行操作,其他线程对表进行读、写操作时都会被等待,直到锁被释放。
- 示例
给student表加写锁
lock table test1 write;
当前线程可以执行update、delete、insert、select等操作,其他线程以上操作均不能执行,需要等到线程释放锁
session-01 | session-02 |
---|---|
lock table test1 write; – 写锁 | |
select * from test1; – 本线程可读 | |
select * from test1; – 其他线程不可读 | |
update test1 set name=‘aaa’ where id=1; – 本线程可写 | |
update test1 set name=‘aaa’ where id=1; – 其他线程 不可写 |
Tips:本线程可读、可写,其他线程不可读不可写;
1.2.3 MySIAM引擎读锁
在MySIAM引擎中,当某个线程获取到表的读锁时,当前线程除了读取数据之外,不可进行其他操作,如(insert、update、delete)等操作,其他线程可以进行读的操作,不可进行写的操作。
- 示例
给test1表加上读锁
lock table test1 read;
当前线程只能执行query操作,但是只能查询本表,其他表不能查询,不能执行insert、update、delete操作
其他线程可以读取该表数据,但是insert、update、delete会出现阻塞状态。
session-01 | session-02 |
---|---|
lock table test1 read; | |
select * from test1; – 本线程可读 | |
select * from test1; – 其他本线程可读 | |
update test1 set name=‘aaa’ where id=1; – 本线程不可写 | |
update test1 set name=‘aaa’ where id=1; – 其他线程不可写 |
Tips:本线程可读不可写,其他线程也是可读不可写
1.2.4 小结
在MySIAM引擎中,对表进行select、insert、update、delete等操作都会自动加上表锁,其中select操作加上的是读锁,insert等操作加上的是写锁,整个过程不需要用户干预,因此,用户一般不需要直接用lock table命令给MyISAM表显式加锁。
当使用LOCK TABLE时,不仅需要一次锁定用到的所有表,而且,同一个表取过多少别名,也要对那些别名进行锁定,否则也会出错!
-- 给test1表加读锁
lock table test1 read;
-- Table 'a' was not locked with LOCK TABLES 提示表别名"a"没有被锁住
select * from test1 a where a.id=1;
-- 释放锁
unlock tables;
-- 取别名
lock table test1 as a read;
select * from test1 a where a.id=10; -- 一切正常
unlock tables;
-- 取多个别名
lock table test1 as a read,test1 b read;
select * from test1 a; -- 正常
select * from test1 b; -- 正常
select * from test1 c; -- Table 'c' was not locked with LOCK TABLES
1.2.5 表锁应用场景
给MyISAM表显示加锁,一般是为了在一定程度模拟事务操作,实现对某一时间点多个表的一致性读取。
- SQL语句:
CREATE TABLE `t_orders` (
`id` int(11) NOT NULL COMMENT '订单id',
`order_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '订单名称',
`total` double(255, 0) NULL DEFAULT NULL COMMENT '订单总金额',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
CREATE TABLE `t_order_detail` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '订单详情id',
`order_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '订单详情名称',
`subtotal` double(255, 0) NULL DEFAULT NULL COMMENT '订单详情金额',
`order_id` int(11) NULL DEFAULT NULL COMMENT '所属订单',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- 插入订单数据
insert into t_orders values(1,'双十一购物',0);
insert into t_orders values(2,'618购物',0);
-- 在订单详情插入一条数据
insert into t_order_detail values(1,'神舟笔记本',4999,1);
-- 同时更改订单表的订单金额
update t_orders set total=total+4999 where id=1;
-- 又插入一条记录
insert into t_order_detail values(2,'华为手机',2888,1);
-- 更改订单表中的订单金额
update t_orders set total=total+2888 where id=1;
有一个订单表orders,其中记录有各订单的总金额total,同时还有一个订单明细表order_detail,其中记录有各订单每一产品的金额小计 subtotal,假设我们需要检查这两个表的金额合计是否相符,可能就需要执行如下两条
- SQL语句:
-- 统计订单表金额
select sum(total) from t_orders;
-- 统计订单详情表金额
select sum(subtotal) from t_order_detail;
这时,如果不先给两个表加锁,就可能产生错误的结果,因为第一条语句执行过程中,order_detail表可能已经发生了改变。因此,正确的方法应该是:
-- 给两张表都加上读锁
Lock tables t_orders read, t_order_detail read;
select sum(total) from t_orders;
select sum(subtotal) from t_order_detail;
Unlock tables;
1.2.6 InnoDB的表锁
lock table等语句是MySQL服务器提供的API,任何存储引擎都可以使用这些API来锁表;Innodb也不例外;
测试:
drop table if exists test2;
CREATE TABLE `test2` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) ,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = innodb ; -- InnoDB存储引擎
INSERT INTO test2 VALUES(1,'aa');
INSERT INTO test2 VALUES(2,'bb');
session-01 | session-02 |
---|---|
lock table test1 read; – 读锁 | |
update test1 set name=‘aaa’ where id=1; – 阻塞 |
Tips:我们知道InnoDB是支持行锁与表锁的,使用
lock table
等语句给InnoDB表上锁,无疑是增大了锁的粒度(直接提升为表锁);因此InnoDB表很少使用lock table
等语句来给表上锁;
1.2.7 MyISAM 的并发插入
MyISAM表的读写操作之间是串行的,在一定的条件下,MyISAM表也支持查询和插入操作的并发进行,在MyISAM引擎中有一个系统变量concurrent_insert
,专门用于控制器并发插入的行为
- 查询当前
concurrent_insert
的值:
show variables like 'concurrent_insert';
- concurrent_insert为0时,为NEVER状态,不允许并发插入
- concurrent_insert为1时,为AUTO状态,如果MyISAM表中没有空洞(即表的中没有被标记删除的行),MyISAM允许在一个进程读表的同时,另一个进程从数据文件尾部插入。这也是MySQL的默认设置。
- concurrent_insert为2时,为ALWAYS状态,无论MyISAM表中有没有空洞,都允许在表尾并发插入记录。
Tips:在MyISAM并发插入时,推荐值为2(ALWAYS)
修改会话级别:
set global concurrent_insert=2; -- 重启服务后失效
修改配置文件:
[mysqld]
concurrent_insert=2
- 案例测试:
drop table if exists test3;
CREATE TABLE `test3` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) ,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = MyISAM ;
INSERT INTO test3 VALUES(10,'aa');
- 并发插入设置:
| session-01 | session-02 |
| — | — |
| lock table test3 read local; | |
| | insert into test3 values(5,‘bb’); |
| | insert into test3 values(15,‘bb’); |
| | update test3 set name=‘a’ where id=1; – 阻塞 |
| | |
Tips:
- 1)
read local
选项,其作用就是在满足MyISAM表并发插入条件的情况下,允许其他用户在表尾插入记录- 2)并发插入
AUTO
状态指的是允许在数据文件末尾进行插入,不是指表末尾;- 3)只有MyISAM表才有并发插入,InnoDB表不支持;
1.2.8 MyISAM的锁调度
MyISAM存储引擎的读锁和写锁是互斥的,读写操作是串行的。那么,一个进程请求某个 MyISAM表的读锁,同时另一个进程也请求同一表的写锁,MySQL如何处理呢?
答案是写进程先获得锁。不仅如此,即使读请求先到锁等待队列,写请求后到,写锁也会插到读锁请求之前!这是因为MySQL认为写请求一般比读请求要重要。这也正是MyISAM表不太适合于有大量更新操作和查询操作应用的原因,因为大量的更新操作会造成查询操作很难获得读锁,从而可能永远阻塞。
这种情况有时可能会变得非常糟糕!幸好我们可以通过一些设置来调节MyISAM 的调度行为。
- 通过指定INSERT、UPDATE、DELETE语句的
low_priority_updates
属性,降低该语句的优先级。
low_priority_updates
- 0:默认值,状态为OFF,代表写优先
- 1:状态为NO,代表读优先
查看MyISAM的读/写优先级:
show variables like 'low_priority_updates';
更改low_priority_updates
set low_priority_updates=1;
update low_priority userinfo set username='1' where id=1;
虽然上面的方法都是要么更新优先,要么查询优先的方法,但还是可以用其来解决查询相对重要的应用(如用户登录系统)中,读锁等待严重的问题。
另外,MySQL也提供了一种折中的办法来调节读写冲突,即给系统参数max_write_lock_count
设置一个合适的值,当一个表的写锁达到这个值后,MySQL就暂时将写请求的优先级降低,给读进程一定获得锁的机会。
show variables like 'max_write_lock_count';
看到上面的值就知道了,在读写竞争时,读锁毫无优势。
1.3 InnoDB 引擎锁
InnoDB与MyISAM的最大不同有两点:
一是支持事务、外键;
二是采用了行级锁。行级锁与表级锁本来就有许多不同之处,另外,事务的引入也带来了一些新问题。
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上 “串行化”进行,这显然与“并发”是矛盾的。同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读”和“幻读”并不敏感,可能更关心数据并发访问的能力。
- **共享锁(S):又称读锁。**允许一个事务去读一行,阻止其他事务获取该行的排它锁
- **排他锁(X):又称写锁。**允许获取排他锁的事务更新数据,阻止其他事务获取排它锁、共享锁
InnoDB引擎加锁:
- 共享锁(S):
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
- 排他锁(X):
SELECT * FROM table_name WHERE ... FOR UPDATE
1.3.1 InnoDB行锁实现方式
如何在操作时锁定一行操作:
- 在查询之后添加
for update
,其它操作会被阻塞,直到锁定的行提交commit;
查看行锁的使用信息:
show status like 'innodb_row_lock%';
**Innodb_row_lock_current_waits**
:当前有多少线程正在等待行锁**Innodb_row_lock_time**
:行锁等待时间**Innodb_row_lock_time_avg**
:行锁平均等待时间**Innodb_row_lock_time_max**
:行锁最大等待时间**Innodb_row_lock_waits**
:总的行锁等待数
1.3.2 InnoDB读锁
在InnoDB引擎中,某个事务获取共享锁之后,会阻止其他事务获取排它锁,但是其他事务可以获取共享锁,值得注意的是,在InnoDB引擎中,进行普通的查询操作是不会触发共享锁的,必须显示的加上**lock in share mode**
,才会加上共享锁。
- 测试表:
-- 创建数据表
CREATE TABLE account (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(10),
money DOUBLE
);
-- 添加数据
INSERT INTO account (name, money) VALUES ('a', 1000), ('b', 1000);
- 测试案例:
在InnoDB引擎中,获取到共享锁的事务将会阻止其他事务获取排它锁;但其他事务可以获取该行的共享锁
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from account where id=1 lock in share mode; | |
select * from account where id=1 for update; – 阻塞 | |
select * from account where id=1 lock in share mode; – 不阻塞 | |
rollback; | |
rollback; |
1.3.3 InnoDB写锁
在InnoDB中,排它锁允许当前排它锁事务更新数据,阻止其他事务获取排它锁、共享锁,获取排它锁可以在查询语句后面显示的加上for update
,来获取排它锁,触发任何修改(update/delete/insert)操作也会获取该行的排它锁
- 测试案例:
在InnoDB引擎中,获取到排它锁的事务将会阻止其他事务获取排它锁、共享锁;
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from account where id=1 for update; | |
update account set money=10 where id=1; – 阻塞 | |
select * from account where id=1 for update; – 阻塞 | |
select * from account where id=1 lock in share mode; – 阻塞 | |
rollback; | |
rollback; |
1.3.4 InnoDB索引与锁
InnoDB行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁! 在实际应用中,要特别注意InnoDB行锁的这一特性,不然的话,可能导致大量的锁冲突,从而影响并发性能。
Tips:在不通过索引条件查询的时候,InnoDB使用的是表锁,而不是行锁。
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from account where id=2 for update; | |
select * from account where id=2 for update; – 阻塞 | |
select * from account where id=2 lock in share mode; – 阻塞 | |
rollback; | |
rollback; |
在另一个事务中,查询某条数据,获取单条数据的排它锁,发现不能获取,InnoDB如果没有用到索引则默认使用表锁
1.3.5 InnoDB死锁
如果一个事务中请求了某表的读锁,另一个事务请求了某表的写锁,势必会被阻塞,于此同时在第一个事务中(请求读锁的事务)再次请求写锁,那么这样一来两个客户端都在等待对方的锁释放,造成死锁;
Client-01 | Client-02 |
---|---|
begin; – 开启事务 | |
begin – 开启事务 | |
select * from account where id=1 lock in share mode; | |
select * from account where id=1 lock in share mode; | |
select * from account where id=1 for update; – 阻塞 | |
select * from account where id=1 for update; --触发死锁 |
InnoDB死锁:死锁的一方将所有的锁都释放;
1.4 锁小结
1.4.1 MySIAM引擎
1)共享读锁(S)之间是兼容的,但共享读锁(S)与排他写锁(X)之间,以及排他写锁(X)之间是互斥的,也就是说读和写是串行的。
2)在一定条件下,MyISAM允许查询和插入并发执行,我们可以利用这一点来解决应用对同一表查询和插入的锁争用问题。
3)MyISAM默认的锁调度机制是写优先,这并不一定适合所有应用,用户可以通过设置LOW_PRIORITY_UPDATES
参数,或在INSERT、UPDATE、DELETE语句中指定LOW_PRIORITY
选项来调节读写锁的争用。
4)由于表锁的锁定粒度大,读写之间又是串行的,因此,如果更新操作较多,MyISAM表可能会出现严重的锁等待,可以考虑采用InnoDB表来减少锁冲突。
1.4.2 InnoDB 引擎
(1)InnoDB的行锁是基于索引实现的,如果不通过索引访问数据,InnoDB会默认使用表锁。
(2)在不同的隔离级别下,InnoDB的锁机制和一致性读策略不同。
在了解InnoDB锁特性后,用户可以通过设计和SQL调整等措施减少锁冲突和死锁,包括:
- 尽量使用较低的隔离级别; 精心设计索引,并尽量使用索引访问数据,使加锁更精确,从而减少锁冲突的机会;
- 给记录集显式加锁时,最好一次性请求足够级别的锁。比如要修改数据的话,最好直接申请排他锁,而不是先申请共享锁,修改时再请求排他锁,这样容易产生死锁;
1.5 意向锁
意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存(阻塞也是并存)。意向锁是由InnoDB在操作数据之前自动加的,不需要用户干预;
- 意向共享锁(IS锁):事务在请求S锁前,要先获得IS锁
- 意向排他锁(IX锁):事务在请求X锁前,要先获得IX锁
场景举例(假设此时没有意向锁):假设事务A锁住了表中的一行记录,之后,事务B申请整个表的写锁。数据库需要避免这种冲突,需要让B的申请被阻塞,直到A释放了行锁。数据库要怎么判断这个冲突呢?
- 方式1)判断表中的每一行是否已被行锁锁住(效率非常低)
- 方式2)直接判断整表是否已被其他事务用表锁锁表
意向锁就是在这个时候发挥作用的,有了意向锁。在意向锁存在的情况下,事务A必须先申请表的意向共享锁(表级锁),成功后再申请一行的行锁。下次事务B去申请表的排它锁时,发现有意向共享锁,说明表中肯定有某些行被锁住了,事务B将会阻塞;
1.5.1 意向锁是表锁还是行锁?
答:意向锁是表级别锁
当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定;
(1)如果意向锁是行锁,则需要遍历每一行数据去确认;
(2)如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能。
测试IS和IX之间是共享的,意向锁(共享和排他)和表级别的S/X锁是冲突的;
- 测试数据:
drop table if exists test4;
CREATE TABLE `test4` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) ,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = innodb ;
INSERT INTO test4 VALUES(1,'aa');
INSERT INTO test4 VALUES(2,'bb');
- 案例:
| session-01 | session-02 |
| — | — |
| begin; | |
| | begin; |
| select * from test4 where id=1 lock in share mode; – 申请这行的共享锁 | |
| | select * from test4 for update; – 申请整表的排它锁(阻塞) |
示意图:
Tips:有了意向锁,在事务B申请表的排它锁时,MySQL就可以很轻松判断这个表中是否记录被锁住了;
意向锁的主要功能就是:避免为了判断表是否存在行锁而去全表扫描。
1.5.2 S锁、X锁、IS、IX锁之间的关系
我们之前说过,事务A在锁定一行记录时,会先加上意向锁(表级别),之后事务B申请整个表的排它锁时,先加上意向排它锁,发现该表已经被加上意向锁了,申请锁失败,被阻塞;
按照这个逻辑来说,如果此时事务B申请的是行锁呢(而且并不是事务A锁定的那一条记录)?根据意向锁是表锁的原则,那么此时事务B也会申请意向排它锁(表级别),这样下来不是会造成事务B阻塞吗?但事实并不是这样;因为意向共享锁和意向排它锁之间是兼容的!
关系如下:
X | IX | S | IS | |
---|---|---|---|---|
X | ||||
(表级) | Conflict | Conflict | Conflict | Conflict |
IX | Conflict | Compatible | Conflict | Compatible |
S | ||||
(表级) | Conflict | Conflict | Compatible | Compatible |
IS | Conflict | Compatible | Compatible | Compatible |
注意:这里的排他 / 共享锁指的都是表锁!意向锁不会与行级的共享 / 排他锁互斥
上了行级X锁后,行级X锁不会因为有别的事务上了IX而堵塞,一个mysql是允许多个行级X锁同时存在的,只要他们不是针对相同的数据行。
测试意向锁和行级S/X锁是兼容的;
- 测试案例:
| session-01 | session-02 |
| — | — |
| begin; | |
| | begin; |
| select * from test3 where id=1 lock in share mode; – 申请这行的共享锁 | |
| | select * from test3 where id=2 for update; – 申请这行的排它锁 |
示意图:
Tips:意向锁与行级的S/X锁之间的兼容的
1.5 自增锁
MySQL的自增锁是针对于自增列增长的一个特殊的表级别锁
和自增锁相关的一个参数为(5.1.22版本之后加入)innodb_autoinc_lock_mode:可以设定3个值,0,1,2
show variables like 'innodb_autoinc_lock_mode';
- 0:traditional (每次都会产生表锁)
- 1:consecutive (会产生一个轻量锁,insert会获得批量的锁,保证连续插入),事务未提交ID永久丢失,默认值
- 2:interleaved (不会锁表,来一个处理一个,并发最高)对于同一个语句来说它所得到的auto_increment值可能不是连续的。
tips:参数只控制InnoDB引擎的设置,所有Myisam均为traditional ,每次均会进行表锁。只有Innodb会视参数不同而产生不通的锁。
创建测试表:
CREATE TABLE `t1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`age` int(11) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT=1;
一般我们在创建表的时候id起始值为1,通过AUTO_INCREMENT可以设置其值;
-- 在创建表后也可以通过SQL语句修改auto_increment
alter table t1 auto_increment=20;
自增幅度由以下两个参数进行控制:
-- 自增的步长
set auto_increment_increment=2; -- 默认1
可以通过函数获取最后一个插入的id:
select last_insert_id();
测试:
1.6 锁的算法
1.6.1 Record Lock
Record Lock:行锁,也叫记录锁,总是会去锁住索引记录,在使用主键或唯一索引精准匹配行时,临键锁将会退化成行锁;
1.6.2 Gap Lock
Gap Lock:间隙锁,当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁 (Next-Key锁)。
间隙锁(Gap Lock)是Innodb在可重复读提交下为了解决幻读问题时引入的锁机制
- 创建测试表,添加数据
CREATE TABLE `t2` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`age` int(11) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT=1;
insert into t2 values(1,10);
insert into t2 values(2,20);
insert into t2 values(3,30);
查询范围数据,触发间隙锁
select * from t2 where id>2 for update;
在另一事务中插入间隙数据触发锁,发现处于等待现象
insert into t2 values(4,40);
- 思考
1、执行如下sql语句(表中并没有id为100的数据)
select * from t2 where id=100 for update;
2、执行如下sql语句
insert into t2 values(100,100);
思考:会不会造成锁阻塞?
注意:间隙锁主要是阻塞插入 insert
- 案例:
| session-01 | session-02 |
| — | — |
| select * from t1 where id>2 for update; | |
| |select * from t1 where id=30 for update;
– 不阻塞 |
| |update t1 set age=30 where id=20;
– 不阻塞 |
| |delete from t1 where id=10;
– 不阻塞 |
| |insert into t1 values(5,20);
– 阻塞(间隙锁阻塞的就是insert语句) |
| |update t1 set age=30 where id=3;
– 阻塞(不是间隙,而是实体记录) |
| |delete from t1 where id=3;
– 阻塞(不是间隙,而是实体记录) |
| |select * from t1 where id=2 for update;
– 不阻塞 |
InnoDB使用间隙锁的目的,一方面是为了防止幻读,以满足相关隔离级别的要求,对于上面的例子,要是不使用间隙锁,如果其他事务插入了id大于2的任何记录,那么本事务如果再次执行上述语句,就可能会发生幻读(某些情况通过MVCC快照已经解决);
很显然,在使用范围条件检索并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待**。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。**
1.6.5 Next-Key Lock
Next-Key Lock:临键锁,即Gap Lock+Record Lock
,当SQL语句按照索引进行数据的检索时,锁定一个范围,并且锁定记录本身;其设计的目的是为了解决Phantom Problem(幻读),临键锁锁住的区间为:记录+区间(左开右闭)
左开右闭:不锁住左边,锁右边
测试表:
CREATE TABLE `t2` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`num` int(11) ,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB ;
INSERT INTO `t2`(`id`, `num`) VALUES (5, 5);
INSERT INTO `t2`(`id`, `num`) VALUES (10, 10);
INSERT INTO `t2`(`id`, `num`) VALUES (15, 15);
INSERT INTO `t2`(`id`, `num`) VALUES (20, 20);
-- 创建普通索引
create index idx_num on t2(num);
-- 创建唯一索引
create unique index idx_num on t2(num);
-- 删除索引
drop index idx_num on t2;
- 区间示意图:
1.6.5.1 临键锁体验:
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where id>11 and id<16 for update; | |
insert into t2 values(10,10); – 不阻塞 | |
insert into t2 values(11,11); – 阻塞 | |
insert into t2 values(15,15); – 阻塞 | |
insert into t2 values(16,16); – 阻塞 | |
insert into t2 values(18,18); – 阻塞 | |
insert into t2 values(20,20); – 阻塞 | |
insert into t2 values(21,21); – 不阻塞 | |
rollback; | |
rollback; |
临键锁的触发不仅把条件区间(11-16)的数据行锁住了,还把临键的数据行统统锁住了;锁住的区间为:(10,15]、(15,20]
锁住的id范围:10(不含)~20(含)
1.6.5.2 临键锁触发时机
我们刚刚测试的是以主键索引进行测试,如果采用不同的列(主键索引、普通索引、普通列等)锁住的区间大不相同;
- 临键锁触发时机:
1)如果是主键索引或者唯一索引,那么临键锁会降级为行级锁(前提是刚好在临键值,如果不在临建值则和二级索引一致)
2)如果是二级索引则触发普通临键锁
3)如果是普通列,则触发表级临键锁(即使触发表级锁也只是阻塞insert)
1)普通列
以num
列触发排他锁测试:
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where num=11 for update; | |
insert into t2 values(null,3); – 阻塞 | |
insert into t2 values(null,5); – 阻塞 | |
insert into t2 values(null,10); – 阻塞 | |
insert into t2 values(null,15); – 阻塞 | |
insert into t2 values(null,20); – 阻塞 | |
insert into t2 values(null,21); – 阻塞 | |
rollback; | |
rollback; |
Tips:innodb查询如果没有使用到索引默认触发表级间隙锁,把所有的间隙都锁住了,并且把表中的所有记录行也锁住了
2)二级索引列
二级索引触发临键锁
-- 创建普通索引
create index idx_num on t2(num);
测试临键锁:
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where num=17 for update; | |
insert into t2 values(null,15); – 阻塞 | |
insert into t2 values(null,18); – 阻塞 | |
insert into t2 values(null,20); – 不阻塞 | |
rollback; | |
rollback; |
num=17这条记录不是会锁定(15,20]
区间吗?为什么15被阻塞了,20反而没被阻塞呢?
这里需要牵扯到另一个问题了,在InnoDB中,相同的普通索引的叶子节点是以主键的顺序进行排列的,我们来模拟一下刚刚插入的数据在B+Tree上的变化:
只考虑叶子节点的变化,可以看到在上图在演变的过程中产生了分裂情况(假设每个叶子节点都只存储两个元素),如果普通索引的重复值太多势必会造成大量的分裂情况,减低插入效率,因此索引列不宜选择重复率太大的列;
再看下图数据库表中实际存储的列的样子我们就会明白为什么num=20不阻塞,num=15阻塞了
- num索引列排列情况:
查询示意图:
如果查询的二级索引列刚好处于临界值,那么会锁住相邻的两个区间!
下面案例将会锁住(10,15]
、(15,20]
两个区间
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where num=15 for update; | |
insert into t2 values(null,8); – 不阻塞 | |
insert into t2 values(null,10); – 阻塞 | |
insert into t2 values(null,11); – 阻塞 | |
insert into t2 values(null,15); – 阻塞 | |
insert into t2 values(null,18); – 阻塞 | |
insert into t2 values(null,20); – 不阻塞 | |
rollback; | |
rollback; |
- 索引底层构建过程:
- 临键锁区间:
15处于和两个临键区间,因此在两个区间内的数据行都被锁住了
**(10,15]**``**(15,20]**
3)主键和唯一索引
创建唯一索引:
-- 删除索引
drop index idx_num on t2;
-- 创建唯一索引
create unique index idx_num on t2(num);
- 测试唯一索引临键锁(在临界值上):
测试临键锁:
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where num=15 for update; | |
insert into t2 values(null,4); – 不阻塞 | |
insert into t2 values(null,8); – 不阻塞 | |
insert into t2 values(null,11); – 不阻塞 | |
insert into t2 values(null,15); – 阻塞 | |
insert into t2 values(null,28); – 不阻塞 | |
rollback; | insert into t2 values(null,20); – 不阻塞 |
rollback; |
分析:在session1中首先会num=15加X锁,由于num是唯一键,并且在临界点上,因此锁定的只有15这个值,而不是(10,15]、(15,20]这两个区间;在session2中插入值18,是可以成功插入的,即锁定由Next-Key Lock算法降级为了Record Lock,从而提高应用的并发性。
- 测试唯一索引临键锁(非临界值上):锁住的
**(15,20]**
区间
| session-01 | session-02 |
| — | — |
| begin; | |
| | begin; |
| select * from t2 where num=17 for update; | |
| | insert into t2 values(null,11); – 不阻塞 |
| | insert into t2 values(null,15); – 不阻塞 |
| | insert into t2 values(null,16); – 阻塞 |
| | insert into t2 values(null,18); – 阻塞 |
| | insert into t2 values(null,20); – 不阻塞 |
| | insert into t2 values(null,21); – 不阻塞 |
num=17锁住的区间应该是(15,20]
,num=16、18这两条数据都正常,但是num=20这条记录不应该是被阻塞吗?为什么又没有被阻塞呢?
- num列索引的B+Tree底层构建情况:
Tips:唯一索引冲突时MySQL会立即响应,不会触发临键锁
4)临键锁总结
临键锁,是记录锁(行锁)与间隙锁的组合,他跟间隙锁一样,主要是阻塞插入 insert;
Tips:临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。
1.8 悲观锁,乐观锁
1.8.1 悲观锁乐观锁概念
- 悲观锁
- 就是很悲观,它对于数据被外界修改持保守态度,认为数据随时会修改。
- 整个数据处理中需要将数据加锁。悲观锁一般都是依靠关系数据库提供的锁机制
- 事实上关系数据库中的行锁,表锁不论是读写锁都是悲观锁
- 乐观锁
- 顾名思义,就是很乐观,每次自己操作数据的时候认为没有人会来修改它,所以不去加锁。
- 但是在更新的时候会去判断在此期间数据有没有被修改
- 需要用户自己去实现,不会发生并发抢占资源,只有在提交操作的时候检查是否违反数据完整性
- 悲观锁,乐观锁使用前提
- 对于读操作远多于写操作的时候,这时候一个更新操作加锁会阻塞所有读取,降低了吞吐量。最后还要释放锁,锁是需要一些开销的,这时候可以选择乐观锁
- 如果是读写比例差距不是非常大或者你的系统没有响应不及时,吞吐量瓶颈问题,那就不要去使用乐观锁,它增加了复杂度,也带来了业务额外的风险。
1.8.2 乐观锁的实现方式
1)版本号
就是给数据增加一个版本标识,在数据库上就是表中增加一个version字段,每次更新把这个字段加1,读取数据的时候把version读出来,更新的时候比较version如果还是开始读取的version就可以更新了如果现在的version比老的version大,说明有其他事务更新了该数据,并增加了版本号,这时候得到一个无法更新的通知,用户自行根据这个通知来决定怎么处理,比如重新开始一遍。
-- 要修改数据之前,先查该数据上一次修改的时间戳
select version from t_goods where id=1;
-- 修改数据时,更新时间戳
update t_goods set goods_name='小苹果', version=version+1 where version=${version};
2)时间戳
和版本号基本一样,只是通过时间戳来判断而已,注意时间戳要使用数据库服务器的时间戳不能是业务系统的时间同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间(timestamp)和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比如果一致则OK,否则就是版本冲突。
-- 要修改数据之前,先查该数据上一次修改的时间戳
select lock_time from t_goods where id=1;
-- 修改数据时,更新时间戳
update t_goods set goods_name='小苹果', lock_time=unix_timestamp(CURRENT_TIMESTAMP) where lock_time=${lock_time};
二、事务
2.1 事务隔离级别
事务的四大特性:
事务特性 | 含义 |
---|---|
原子性(Atomicity) | 事务是工作的最小单元,整个工作单元要么全部执行成功,要么全部执行失败 |
一致性(Consistency) | 事务执行前与执行后,数据库中数据应该保持相同的状态。如:转账前总金额与转账后总金额相同。 |
隔离性(Isolation) | 事务与事务之间不能互相影响,必须保持隔离性。 |
持久性(Durability) | 如果事务执行成功,对数据库的操作是持久的。 |
2.1.1 隔离级别说明
MySQL中可以有两种方式进行事务的操作:
- 1)手动提交事务
- 2)自动提交事务(MySQL默认)
查看当前MySQL是否是自动提交事务:
show variables like 'autocommit';
No(1):开启自动提交事务(默认值)
OFF(0):关闭自动提交事务
- 设置手动提交事务(关闭自动提交事务):
set autocommit=0; -- 本次会话有效
set global autocommit=0; -- 服务器只要不关闭一直有效(需要重启会话)
并发访问下事务产生的问题:
当同时有多个用户在访问同一张表中的记录,每个用户在访问的时候都是一个单独的事务。
事务在操作时的理想状态是:事务之间不应该相互影响,实际应用的时候会引发下面三种问题。应该尽量避免这些问题的发生。通过数据库本身的功能去避免,设置不同的隔离级别。
- 脏读: 一个事务(用户)读取到了另一个事务没有提交的数据
- 不可重复读:一个事务多次读取同一条记录,出现读取数据不一致的情况。一般因为另一个事务更新了这条记录而引发的。
- 幻读:在一次事务中,多次读取到的条数不一致
四种隔离级别:
级别 | 名字 | 隔离级别 | 脏读 | 不可重复读 | 幻读 | 数据库默认隔离级别 |
---|---|---|---|---|---|---|
1 | 读未提交 | read uncommitted | 是 | 是 | 是 | |
2 | 读已提交 | read committed | 否 | 是 | 是 | Oracle和SQL Server |
3 | 可重复读 | repeatable read | 否 | 否 | 是 | MySQL |
4 | 串行化 | serializable | 否 | 否 | 否 |
四种隔离级别起的作用:
Serializable
(串行化): 可以避免所有事务产生的并发访问的问题 效率及其低下Repeatable read
(可重复读):简称RR,会引发幻读的问题(InnoDB某些情况下已经解决)Read committed
(读已提交):简称RC,会引发不可重复读和幻读的问题Read uncommitted
(读未提交):简称RU,所有事务中的并发访问问题都会发生
- 查询全局事务隔离级别
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
mysql>
修改隔离级别:
set transaction isolation level read uncommitted; -- 本次会话有效
set global transaction isolation level read uncommitted; -- 服务器只要不关闭一直有效
set global transaction isolation level Repeatable read;
修改隔离级别后需要重启会话
2.1.2 脏读
在并发情况下,一个事务读取到另一个事务没有提交的数据,这个数据称之为脏数据,此次读取也称为脏读。
我们知道,只有read uncommitted
(读未提交)的隔离级别才会引发脏读。
- 将MySQL的事务隔离级别设置为
read committed
(读已提交):
mysql> set transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)
测试表:
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB ;
INSERT INTO `user`(`id`, `name`, `age`) VALUES (1, 'zs', 20);
解决脏读的方法就是将隔离级别设置高级一点(
read committed
)
2.1.3 不可重复读
在一次事务中,多次读取到数据信息不一致;
在上面案例中,session-01窗口两次查询id=1的数据都不一致
解决不可重复读的方法就是将隔离级别再设置高级一点(
repeatable read
)
2.1.4 幻读
在一次事务中,多次读取到的条数不一致,但是在InnoDB中,幻读问题已经被解决了
我们来看看幻读的现象:
在InnoDB中,RR隔离级别可以解决幻读的问题:
session-01:
begin; -- 1
select * from user where age>15; -- 3
select * from user where age>15; -- 6
----------------------------------
session-02:
begin; -- 2
insert into user values(2,'李四',18); -- 4
commit; -- 5
2.1.5 SQL92标准
数据并发情况下存在很多的问题,为了解决这些问题,数据库专家联合制定了一个标准,也就是说建议数据库厂商都按照这个标准,提供一定的事务隔离级别,来解决事务并发的问题,这个就是 SQL92 标准。
SQL92标准官网:http://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt
关于隔离级别处理的问题:
在SQL92标准中,RR隔离级别是会引发幻读问题的;
搜索isolation
关键字,找到关于幻读的定义
幻读: 事务T1读取满足搜索条件的N行数据,事务T2执行SQL语句生成一条或多条SQL语句满足事务T1的搜索条件,如果事务T1使用相同搜索条件的SQL语句读取,那么他会返回一个不同行的集合
注意:SQL92只是一个标准,他提出了事务并发引起的问题有哪几种方案(隔离级别)可以解决,不同的事务隔离级别应该处理哪些问题,但是具体实现落实在了不同的数据库厂商,比如在Oracle中的事务默认隔离级别就是RC(Read Committed),并且Oracle只支持三种事务隔离级别(读已提交、串行化、只读),但MySQL支持四种隔离级别,而且MySQL默认的存储引擎(InnoDB)在RR隔离级别下不会引发幻读;(部分情况)
三、事务日志
事务的隔离性是通过锁实现,而事务的原子性、和持久性则是通过事务日志实现,在MySQL中,事务日志分为两类,一个是Redo log
,也叫重做日志,另一个是Undo log
,也叫回滚日志;其中Redo Log保证事务的持久性,Undo Log保证的是事务的原子性;
3.1 Redo log
Redo log也叫重做日志;事务开启时,事务中的操作都会先写入存储引擎的日志缓冲(Buffer Pool)中,默认情况下事务每次提交的时候都会将事务日志刷到磁盘中(多种策略),这就是经常说的"日志先行"(Write-Ahead Logging)。
注意:日志永远比实际数据先到磁盘;换句话说日志没有刷新成功数据不可能提交到表中;
如果在事务提交时,此时数据库崩溃或者宕机,那么当系统重启进行恢复时,就可以根据Redo log中记录的日志,把数据库恢复到崩溃前的一个状态。
3.1.1 Redo工作原理:
首先在操作表时,会将表数据从磁盘(.idb)加载到内存中(Buffer),对表所有的操作都会记录一份到Redo日志中,在事务最终要提交时,如果数据库突然宕机,那么当数据库重启时,就可以根据Redo日志中的记录进行数据的恢复;
Tips:Redo log主要保障的是事务的持久性;
3.1.2 Redo脏页的概念
内存中(buffer pool)未刷到磁盘的数据称为脏数据(dirty data)。由于数据和日志都以页的形式存在,所以脏页表示脏数据和脏日志。
3.1.3 Redo的刷新策略:
1、事务提交时默认将Buffer内容刷新到Disk中;
2、每秒将Buffer内容刷新到Disk中(和条件1并存)
3、Buffer中已经使用的内存超过一半以上时;
4、Checkpoint策略刷盘(当数据库宕机时,数据库不需要重做所有的日志,只需要执行上次刷入点之后的日志。这个点就叫做Checkpoint)
MySQL官方对于Checkpoint的介绍:https://dev.mysql.com/doc/refman/5.7/en/innodb-checkpoints.html
3.1.4 Redo相关系统参数
innodb_flush_log_at_trx_commit
:Redo日志的持久化策略,取值有0、1、2;默认为1- 0:代表每秒将
Redo Buffer
中的数据刷到OS buffer
然后立即从OS buffer
刷到磁盘中。 - 1:代表每次提交事务都将
Redo Buffer
同步到OS Buffer
然后立即从OS Buffer
刷到磁盘**(默认值)**。 - 2:代表每次提交都将
Redo Buffer
中的数据刷到OS Buffer
,然后再隔一秒从OS Buffer
刷到磁盘。
- 0:代表每秒将
innodb_log_buffer_size
:Redo Buffer
的大小,默认16Minnodb_log_file_size
:单个Redo log
事务日志文件的最大大小,默认48Minnodb_log_files_group
:Redo log
日志文件的个数,默认2个innodb_log_group_home_dir
:Redo log
的存放路径;默认值为./
代表当前MySQL的数据目录(Linux默认是/var/lib/mysql
innodb_log_checksums
:启用或禁用Relo log
数据页的校验,默认开启。innodb_log_compressed_pages
:指定是否将重新压缩的页写入Redo log
,默认是开启状态
show variables like '%innodb_log%';
- 测试不同的redo刷盘策略对性能的影响:
mysql> truncate userinfo;
Query OK, 0 rows affected (0.01 sec)
mysql> set global innodb_flush_log_at_trx_commit=0;
Query OK, 0 rows affected (0.00 sec)
mysql> call test_insert(10000);
Query OK, 1 row affected (0.42 sec)
mysql> truncate userinfo;
Query OK, 0 rows affected (0.01 sec)
mysql> set global innodb_flush_log_at_trx_commit=1;
Query OK, 0 rows affected (0.00 sec)
mysql> call test_insert(10000);
Query OK, 1 row affected (17.36 sec)
mysql> truncate userinfo;
Query OK, 0 rows affected (0.01 sec)
mysql> set global innodb_flush_log_at_trx_commit=2;
Query OK, 0 rows affected (0.00 sec)
mysql> call test_insert(10000);
Query OK, 1 row affected (0.46 sec)
mysql>
3.2 Undo log
Undo log也叫回滚日志;Undo log记录了数据在每个操作前的状态,如果事务执行过程中需要回滚,就可以根据Undo log进行回滚操作。单个事务的回滚,只会回滚当前事务做的操作,并不会影响到其他的事务做的操作。
Tips:Redo log主要保证事务的持久性,Undo log主要保证事务的原子性,提供回滚功能;其次Undo log用于提供MVCC的快照读
3.2.1 Undo工作原理:
首先在操作表时,会将表数据从磁盘(.idb)加载到内存中(Buffer),对表的update/delete等操作InnoDB都会事先将修改前的数据备份到Undo Buffer中,这样当事务进行回滚时可以根据Undo Buffer中的内容进行事务的回滚操作,除此之外,Undo Buffer提供了数据的快照读取,在事务未提交之前,Undo 日志可以作为并发读写时的读快照,来保证事务的可重复读;
事务做到一半了,失败了,那就要将数据还原到未提交之前的状态,undo 就是记录这些事务步骤的,当然了redo 也记录了,但是redo 里面东西太繁杂,不可能什么事都找它,于是就将事务步骤写入另外一个地方例如undo,以后遇到回滚了就去查找因此在每一步操作时都会写入磁盘中的Undo log;
在一次事务中的delete、update等操作会造成大量的废弃数据,在事务提交时,会将该事务对应的undo log放入到删除列表中,通过purge线程来删除。
3.2.2 Purge线程
在事务开启后,不管是update还是delete都只是将记录标记为删除,并不是真正的将记录清除,当被标记为删除的记录被提交到磁盘中后,磁盘中就存在了很多被标记为删除的记录。那些被标记为删除的行是由后台的Purge线程来进行删除,最终数据的清除是由purge线程来决定的什么时候来真正删除文件的;
有关于Purge线程的参数:
mysql> show variables like '%purge%';
+--------------------------------------+-------+
| Variable_name | Value |
+--------------------------------------+-------+
| gtid_purged | |
| innodb_max_purge_lag | 0 |
| innodb_max_purge_lag_delay | 0 |
| innodb_purge_batch_size | 300 |
| innodb_purge_rseg_truncate_frequency | 128 |
| innodb_purge_threads | 4 |
| relay_log_purge | ON |
+--------------------------------------+-------+
7 rows in set (0.01 sec)
innodb_max_purge_lag
:当InnoDB存储引擎压力非常大时,Purge线程可能并不会工作,此时是否要延缓DML的操作,innodb_max_purge_lag控制undo log的数量,如果数量大于该值,就延缓DML的操作,默认为0,代表不延缓;innodb_max_purge_lag_delay
:表示当上面innodb_max_purge_lag的delay超时时间太大,超过这个参数时,将delay设置为该参数值,防止purge线程操作缓慢导致其他SQL线程长期处于等待状态。默认为0,一般不用修改。innodb_purge_batch_size
:用来设置每次purge操作需要清理的undo log page的数量。innodb_purge_threads
:Purge线程的数量(默认为4,最大为32)
Tips:Purge线程不仅会清理磁盘中被标记为删除的行,还会清除Undo 日志中被标记为删除的行;
四、MVCC并发版本控制
4.1 MVCC与LBCC
上面我们说到了InnoDB在RR隔离级别下解决了幻读问题,又保证了高并发的读取(避免了读写串行化),那他到底是如何做的呢?
我们需要解决幻读,即保证前后两次读取的数据条数一致,那么我们就在我们读取的数据的时候加锁,锁定我们需要的数据,不允许其他事务对其修改;这种方案我们叫做基于锁的并发控制 Lock Based Concurrency Control(LBCC)。但很显然,InnoDB没有采用这种方案,我们在查询数据的时候并没有锁定行(没有加锁);
从我们的直观理解上来看,要实现数据库的并发访问控制,最简单的做法就是LBCC,即读的时候不能写(允许多个线程同时读,即共享锁,S锁),写的时候不能读(一次最多只能有一个线程对同一份数据进行写操作,即排它锁,X锁)。这样的加锁访问,其实并不算是真正的并发,或者说它只能实现并发的读,因为它最终实现的是读写串行化,这样就大大降低了数据库的读写性能。是四种隔离级别中级别最高的Serialize隔离级别。为了提出比LBCC更优越的并发性能方法,MVCC便应运而生。
MVCC(Multi-Version Concurrency Control):多版本并发控制。并发访问(读或写)数据库时,对正在事务内处理的数据==做多版本的管理==。以达到用来避免写操作的堵塞,从而引发读操作的并发问题。
MVCC实现了对数据库的读写并发访问,MVCC主要是为了提高数据库读写并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读;提高了数据库并发读写能力;
4.2 MVCC实现原理
在InnoDB中,所有表中都会有三个隐藏的列,分别为:DB_ROW_ID
、DB_TRX_ID
、DB_ROLL_PTR
DB_TRX_ID
:数据行版本号;当有新的数据修改或插入时的事务ID号,用于记录修改这条记录的事务ID和创建这条记录的事务ID;(记录这条数据是哪个事务修改的,哪个事务创建的)
DB_ROLL_PTR
:回滚指针,也叫删除行版本号;指向undo log
中这条记录的上一个版本,删除记录,记录当前事务ID;(记录这条数据是哪个事务删除的)
DB_ROW_ID
:聚集索引;如果数据表没有主键,InnoDB会创建一个DB_ROW_ID
作为聚集索引
MVCC的目的就是实现数据库的并发读取,为了解决读写冲突,它的实现原理主要是依赖记录中的**3个隐式字段,undo日志 ,Read View(快照)**来实现的。
4.2.1 插入流程
InnoDB在每次开启事务时都会为此次事务分配一个事务ID号,用于标识此次事务
本次事务插入的所有的数据行的版本号字段都为当前事务的ID;
4.2.2 删除流程
- 步骤:
- ①:将原来的数据copy一份,将新行的trx_id设置为此次事务id
- ②:将新的行的头信息中的delete_flag标记为true,并将新行的roll_id设置为旧行的trx_id(数据行版本号=被删除记录的删除行版本号)
4.2.3 修改流程
- 步骤:
- ①:将原数据copy一份,并将当前行的trx_id设置为此次事务id
- ②:将新行的roll_id设置为旧行的trx_id
4.2.4 查询流程
在MVCC中,查询时会拍下一个一致性快照(Read-View),该一致性快照具备如下属性:
trx_ids
:当前活跃的事务id集合,本次事务开启(拍快照)后才开启的其他事务;min_id
:为当前查询事务开启(拍快照)后的第一个事务id;max_id
:为此次查询事务提交前的最后一个事务id;
Tips:在InnoDB中,MVCC只在RR和RC两个隔离级别下工作,因为RU隔离级别总是会读取最新的行,而不是符合当前事务版本的数据行。而Serializable则会对所有读取的行都加锁;
MVCC快照生成流程RC和RR隔离级别几乎一模一样,唯一不同的是生成 ReadView 的时机,RR 级别只在事务开始之后第一次查询生成一次,之后一直使用该 ReadView。而 RC 级别则在每次 select 时,都会生成一个 ReadView;
1)RR环境下MVCC查询流程
- 1)被查询的行记录中的
trx_id
为当前查询的trx_id
,能够被当前事务访问(说明自己创建的); - 2)被查询的行记录中的
trx_id
比min_id还小,表明该记录为之前提交的,能够被当前事务访问(图中绿色部分); - 3)被查询的行记录的
trx_id
大于等于ReadView
中的min_id
值,表明生成该版本的事务在当前事务生成ReadView
后才开启,所以该版本不可以被当前事务访问(黄色部分)。 - 4)被查询的行记录的
trx_id
大于max_id
,说明查询的数据行时本次事务开启之后才插入的数据行,不能被当前事务访问(蓝色部分)
测试表:
create table user(
id int primary key auto_increment,
name varchar(30),
age int
);
insert into user values(1,'zhangsan',18);
- 案例1:
| session-01 | session-02 |
| — | — |
| begin; version:10 | |
| | begin; version:11 |
| select * from user; | |
| insert into user values(2,“lisi”,20); | |
| select * from user; – 能否查询到lisi? | |
| update user set age=100 where id=2; | |
| select * from user; – 能否查询到age=100的修改? | |
| rollback; | |
答案
Tips:lisi记录的
DB_TRX_ID
为10,修改过后的id=1的记录DB_TRX_ID
也为10,因此能够查询到;
- 案例2:
| session-01 | session-02 |
| — | — |
| begin; version:10 | |
| | begin; version:11 |
| insert into user values(2,“lisi”,20); | |
| commit; | |
| | select * from user; – 能否查询到lisi记录? |
| | rollback; |
答案
Tips:lisi这条记录的
DB_TRX_ID
为10,可以查询到;
- 案例3:
| session-01 | session-02 |
| — | — |
| begin; version:10 | |
| | begin; version:11 |
| select * from user; – 拍下ReadView快照 | |
| | insert into user values(2,“lisi”,20); |
| | commit; |
| select * from user; – 能否查询到lisi? | |
| rollback; | |
答案
Tips:lisi这条记录的
DB_TRX_ID
为11,因此查询不到;
- 案例4:
| session-01 | session-02 |
| — | — |
| begin; version:12 | |
| | begin; version:11 |
| | insert into user values(2,“lisi”,20); |
| | commit; |
| select * from user; --能否查询到lisi? | |
| rollback; | |
答案
Tips:InnoDB的快照是在执行查询语句(select)时才会拍下;因此session-01的全局事务ID肯定要比session-02大,可以查询到lisi记录;
2)RC环境下MVCC查询流程
我们刚刚了解到,RC的查询流程和RR的是一样的;RC与RR唯一的不同点在于ReadView
生成的次数,RR只在事务开始时生成一次,RC则是在每次select语句时都生成一次;也就是说每次select的时候trx_ids都是在变化的(前提是有新的事务开启了)
4.3 快照读和当前读
我们根据Undo日志的工作原理可以分析,当一个事务对表的任何的更新操作都会事先记录到Undo日志,当另一个事务查询的上一个事务的操作的那条数据时,返回的是当前事务的快照,也就是Undo日志中的记录;我们把这种读取也称之为快照读取;
当前读:即读的必须是当前最新的数据,当前读在每次读取都加上了锁,例如S锁(lock in share mode)、X锁(for update)等,当前读用于读取的是数据最新的版本,但当前读会对记录加锁,在事务并发访问情况下,如果其他事务对该记录加上了排它锁,那么当前读进入阻塞状态;同样的如果使用当前读读取数据,该数据也不能被其他事务加上排它锁;
快照读:在InnoDB事务中默认的读取方式就是快照读,即:select * from user [where xxx];
这些操作默认都不会加锁的,这些操作读的都是数据的快照;快照读的出现极大的提升了InnoDB在并发读写能力上的提升;但由于快照读所读取的数据都是快照(旧版本数据),所以说快照读取并不一定是最新版本的数据;
我们来看一个案例:
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from user where id=1; – age=18 (快照读) | |
update user set age=20 where id=1; | |
select * from user where id=1; – age=18(快照读,保证读已提交) | |
commit; | |
select * from user where id=1; – age=18(快照读,保证可重复读) | |
select * from user where id=1 lock in share mode; – age=20(当前读,读的是最新的版本) | |
commit/rollback; |
需要注意的是,当前读读的是最新的数据,但与此同时,id=1的这行记录已经被加上S锁了,其他事务要对其update(加X锁)就会被其阻塞,并发能力差;
Tips:快照读的前提是隔离级别不是串行化级别,串行化级别下的快照读会进化成当前读;
快照读(Snapshot Read),这种一致性不加锁的读(Consistent Nonlocking Read),就是 InnoDB 并发如此之高的核心原因。
Tips:另外,读未提交和串行化的隔离级别是没有MVCC快照的;