锁是计算机协调多个进程或线程并发访问某一资源的机制。在数据库中,除传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一 个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要。
MySQL用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。这些锁统称为悲观锁(Pessimistic Lock)。
MySQL中的锁其最显著的特点是不同的存储引擎支持不同的锁机制。比如,MyISAM存储引擎采用的是表级锁(table-level locking);InnoDB存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用表级锁。
**表级锁:**开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
**行级锁:**开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
**页面锁:**开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般
MyIsam引擎默认使用的是表锁,对MyISAM表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;对MyISAM表的写操作,则会阻塞其他用户对同一表的读和写操作;MyISAM表的读操作与写操作之间,以及写操作之间是串行的!
MySQL的表级锁有两种模式:
建立一张使用MyISAM引擎的数据库表:
drop table if exists test1;
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;
在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:本线程可读、可写,其他线程不可读不可写;
在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:本线程可读不可写,其他线程也是可读不可写
在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
unlock tables;
给MyISAM表显示加锁,一般是为了在一定程度模拟事务操作,实现对某一时间点多个表的一致性读取。
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,假设我们需要检查这两个表的金额合计是否相符,可能就需要执行如下两条
-- 统计订单表金额
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;
MyISAM表的读写操作之间是串行的,在一定的条件下,MyISAM表也支持查询和插入操作的并发进行,在MyISAM引擎中有一个系统变量concurrent_insert,专门用于控制器并发插入的行为
show variables like 'concurrent_insert';
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:
MyISAM存储引擎的读锁和写锁是互斥的,读写操作是串行的。那么,一个进程请求某个 MyISAM表的读锁,同时另一个进程也请求同一表的写锁,MySQL如何处理呢?
答案是写进程先获得锁。不仅如此,即使读请求先到锁等待队列,写请求后到,写锁也会插到读锁请求之前!这是因为MySQL认为写请求一般比读请求要重要。这也正是MyISAM表不太适合于有大量更新操作和查询操作应用的原因,因为大量的更新操作会造成查询操作很难获得读锁,从而可能永远阻塞。
这种情况有时可能会变得非常糟糕!幸好我们可以通过一些设置来调节MyISAM 的调度行为。
low_priority_updates
查看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';
lock table等语句是MySQL服务器提供的API,任何存储引擎都可以使用这些API来锁表;Innodb也不例外;
测试:
drop table if exists test1;
CREATE TABLE `test2` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) ,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = innodb ; -- InnoDB存储引擎
INSERT INTO test1 VALUES(1,'aa');
INSERT INTO test1 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等语句来给表上锁;
InnoDB与MyISAM的最大不同有两点:
一是支持事务、外键;
二是采用了行级锁。行级锁与表级锁本来就有许多不同之处,另外,事务的引入也带来了一些新问题。
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上 “串行化”进行,这显然与“并发”是矛盾的。同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读”和“幻读”并不敏感,可能更关心数据并发访问的能力。
查看InnoDB行锁的使用信息:
show status like 'innodb_row_lock%';
【创建测试表】
-- 创建数据表
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引擎中,某个事务获取共享锁之后,会阻止其他事务获取排它锁,但是其他事务可以获取共享锁,值得注意的是,在InnoDB引擎中,进行普通的查询操作是不会触发共享锁的,必须显示的加上lock in share mode,才会加上共享锁。
【测试案例-1】
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from account where id=1; – 不会加上共享锁 | |
update account set money=10 where id=1; – 不阻塞 | |
rollback; | |
rollback; |
【测试案例-2】
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from account where id=1 lock in share mode; | |
update account set money=10 where id=1; – 阻塞 | |
select * from account where id=1 lock in share mode; – 不阻塞 | |
rollback; | |
rollback; |
在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; |
InnoDB行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁! 在实际应用中,要特别注意InnoDB行锁的这一特性,不然的话,可能导致大量的锁冲突,从而影响并发性能。
Tips:在不通过索引条件查询的时候,InnoDB使用的是表锁,而不是行锁。
【测试案例-01】
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from account where name=‘a’ lock in share mode; – 表级共享锁 | |
select * from account where name=‘b’ for update; – 表级排它锁,阻塞 | |
rollback; | |
rollback; |
在另一个事务中,查询某条数据,获取单条数据的排它锁,发现不能获取,InnoDB如果没有用到索引则默认使用表锁
给name列创建索引:
-- 添加索引
create index idx_name on account(name);
【测试案例-02】
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from account where name=‘a’ lock in share mode; – 行级共享锁 | |
select * from account where name=‘b’ for update; – 行级排它锁,非阻塞 | |
rollback; | |
rollback; |
如果一个事务中请求了某表的读锁,另一个事务请求了某表的写锁,势必会被阻塞,于此同时在第一个事务中(请求读锁的事务)再次请求写锁,那么这样一来两个客户端都在等待对方的锁释放,造成死锁;
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; --触发死锁 |
1)共享读锁(S)之间是兼容的,但共享读锁(S)与排他写锁(X)之间,以及排他写锁(X)之间是互斥的,也就是说读和写是串行的。
2)在一定条件下,MyISAM允许查询和插入并发执行,我们可以利用这一点来解决应用对同一表查询和插入的锁争用问题。
3)MyISAM默认的锁调度机制是写优先,这并不一定适合所有应用,用户可以通过设置LOW_PRIORITY_UPDATES参数,或在INSERT、UPDATE、DELETE语句中指定LOW_PRIORITY选项来调节读写锁的争用。
4)由于表锁的锁定粒度大,读写之间又是串行的,因此,如果更新操作较多,MyISAM表可能会出现严重的锁等待,可以考虑采用InnoDB表来减少锁冲突。
(1)InnoDB的行锁是基于索引实现的,如果不通过索引访问数据,InnoDB会默认使用表锁。
(2)在不同的隔离级别下,InnoDB的锁机制和一致性读策略不同。
在了解InnoDB锁特性后,用户可以通过设计和SQL调整等措施减少锁冲突和死锁,包括:
意向锁的存在是为了协调行锁和表锁的关系,用于优化InnoDB加锁的策略。意向锁的主要功能就是:避免为了判断表是否存在行锁而去全表扫描。
意向锁是由InnoDB在操作数据之前自动加的,不需要用户干预;
场景举例(假设此时没有意向锁):假设事务A锁住了表中的一行记录,之后,事务B申请整个表的写锁。数据库需要避免这种冲突,需要让B的申请被阻塞,直到A释放了行锁。数据库要怎么判断这个冲突呢?
意向锁就是在这个时候发挥作用的,有了意向锁。在意向锁存在的情况下,事务A必须先申请表的意向共享锁(表级锁),成功后再申请一行的行锁。下次事务B去申请表的排它锁时,发现有意向共享锁,说明表中肯定有某些行被锁住了,事务B将会阻塞;
当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定;
(1)如果意向锁是行锁,则需要遍历每一行数据去确认;
(2)如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能。
因此,意向锁是表级别的;
测试IS和IX之间是共享的,意向锁(共享和排他)和表级别的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就可以很轻松判断这个表中是否记录被锁住了;
我们之前说过,事务A在锁定一行记录时,会先加上意向锁(表级别),之后事务B申请整个表的排它锁时,先加上意向排它锁,发现该表已经被加上意向锁了,但是意向锁之间是兼容的,可以申请成功,之后事务B尝试申请表级别排它锁,申请锁失败,被阻塞;因为表级别的排它锁和意向锁是冲突的;
按照这个逻辑来说,如果此时事务B申请的是行锁呢(而且并不是事务A锁定的那一条记录)?根据意向锁是表锁的原则,那么此时事务B也会申请意向排它锁(表级别),这样下来不是会造成事务B阻塞吗?但事实并不是这样;因为所有的意向锁之间都是兼容的!
测试意向锁和行级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锁之间的兼容的
关系如下:
X | IX | S | IS | |
---|---|---|---|---|
X(表级) | Conflict | Conflict | Conflict | Conflict |
S(表级) | Conflict | Conflict | Compatible | Compatible |
X(行级) | Conflict/Compatible | Compatible | Conflict/Compatible | Compatible |
S(行级) | Conflict/Compatible | Compatible | Compatible | Compatible |
注意:这里的排他 / 共享锁指的都是表锁!意向锁不会与行级的共享 / 排他锁互斥
上了行级X锁后,行级X锁不会因为有别的事务上了IX而堵塞,一个mysql是允许多个行级X锁同时存在的,只要他们不是针对相同的数据行。
Record Lock:记录锁,在使用主键或唯一索引精确匹配行时触发的行级锁;
需要注意的是:记录锁锁的触发查询条件必须为==精确匹配且命中数据==,不能为 > 、 <、 like 、 between…end 等,否则将会触发间隙锁或临键锁;
记录锁与后面学习的间隙锁和临键锁不同,记录数会与所有符合条件的锁互斥;而间隙锁和临键锁只会与符合条件的insert互斥;
行锁的条件:
【创建测试表】
drop table if exists t1;
CREATE TABLE `t1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`num` int(11) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT=1;
INSERT INTO `t1`(`id`, `num`) VALUES (5, 5);
INSERT INTO `t1`(`id`, `num`) VALUES (10, 10);
INSERT INTO `t1`(`id`, `num`) VALUES (15, 15);
INSERT INTO `t1`(`id`, `num`) VALUES (20, 20);
【测试案例-01】
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from t1 where id=5 for update; – 触发记录锁 | |
select * from t1 where id=2 for update; – 不阻塞 | |
select * from t1 where id=7 for update; – 不阻塞 | |
update t1 set num=1 where id=7; – 不阻塞 | |
delete from t1 where id=7; – 不阻塞 | |
select * from t1 where id=5 for update; – 阻塞 | |
update t1 set num=1 where id=5; – 阻塞 | |
delete from t1 where id=5; – 阻塞 | |
rollback; | |
rollback; |
上述案例中触发的是记录锁,锁住的记录只有id=5的这一条记录;
Gap Lock:间隙锁,当我们用范围条件而不是等值条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁 (Next-Key锁)。
间隙锁(Gap Lock)是Innodb在可重复读提交下为了解决幻读问题时引入的锁机制,因此,间隙锁只会阻塞insert类型的排它锁;
drop table if exists t1;
CREATE TABLE `t1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`num` int(11) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT=1;
INSERT INTO `t1`(`id`, `num`) VALUES (5, 5);
INSERT INTO `t1`(`id`, `num`) VALUES (10, 10);
INSERT INTO `t1`(`id`, `num`) VALUES (15, 15);
INSERT INTO `t1`(`id`, `num`) VALUES (20, 20);
【测试案例-01】
当发生范围加锁时,InnoDB将符合范围的间隙全部加上间隙锁,这些被间隙锁锁住的间隙将不能被insert(被阻塞)
但可以执行update/delete/for update/lock in share mode等操作;
Tips:间隙锁的主要目的就是为了防止幻读,因此只会阻塞insert语句;
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from t1 where id>10 for update; | |
insert into t1 values(11,0); – 处于间隙中,阻塞 | |
insert into t1 values(18,0); – 处于间隙中,阻塞 | |
insert into t1 values(8,0); – 不处于间隙,不阻塞 | |
select * from t1 where id=11 for update; – 不是insert语句,不阻塞 | |
update t1 set num=1 where id=11; – 不是insert语句,不阻塞 | |
delete from t1 where id=11; – 不是insert语句,不阻塞 | |
rollback; | |
rollback; |
【测试案例-02】
间隙锁不一定要使用范围加锁,有时候等值查询一样可以触发间隙锁;
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from t1 where id=100 for update; – 触发间隙锁 | |
insert into t1 values(100,0); – 处于间隙中,阻塞 | |
select * from t1 where id=100 for update; – 不是insert语句,不阻塞 | |
update t1 set num=1 where id=100; – 不是insert语句,不阻塞 | |
delete from t1 where id=100; – 不是insert语句,不阻塞 | |
rollback; | |
rollback; |
当发生范围加锁时,如果范围内有符合数据的记录,那么这些记录加的不是间隙锁,而是记录锁;
只有那些间隙才会被加上间隙锁,间隙锁只会阻塞insert,但记录锁不是;
【测试案例】
session-01 | session-02 |
---|---|
begin; | |
begin; | |
select * from t1 where id>10 for update; | |
insert into t1 values(11,0); – 处于间隙中,阻塞 | |
insert into t1 values(18,0); – 处于间隙中,阻塞 | |
insert into t1 values(8,0); – 不处于间隙,不阻塞 | |
select * from t1 where id=11 for update; – 不是insert,不阻塞 | |
select * from t1 where id=15 for update; – 不是间隙锁,而是行锁,阻塞 | |
rollback; | |
rollback; |
InnoDB使用间隙锁的目的,目的是为了防止幻读,以满足相关隔离级别的要求,对于上面的例子,要是不使用间隙锁,如果其他事务插入了id大于10的任何记录,那么本事务如果再次执行上述语句,就可能会发生幻读(某些情况通过MVCC快照已经解决);
很显然,在使用范围条件检索并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待**。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。**
临键锁(Next-Key Lock):临键锁是查询时InnoDB根据查询的条件而锁定的一个范围,这个范围中包含有间隙锁和记录数;临键锁=间隙锁+记录锁。
其设计的目的是为了解决Phantom Problem(幻读);主要是阻塞insert,但由于临键锁中包含有记录锁,因此临键锁所锁定的范围内如果包含有记录,那么也会给这些记录添加记录锁,从而造成阻塞除insert之外的操作;
Tips:临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。
临键锁锁住的区间为:记录+区间(左开右闭)
左开右闭:不锁住左边,锁右边
测试表:
drop table if exists t2;
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;
Tips:间隙锁只会阻塞insert,记录锁会阻塞任意的锁(单要注意排他锁和共享锁的关系);
【测试案例-01-间隙锁】
临键锁的触发不仅把条件区间(11-16)的数据行锁住了,还把临键的数据行统统锁住了;锁住的区间为:(10,15]、(15,20]
锁住的id范围:10(不含)~20(含)
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where id>11 and id<16 for update; | |
insert into t2 values(10,0); – 不阻塞 | |
insert into t2 values(11,0); – 阻塞 | |
insert into t2 values(15,0); – 阻塞 | |
insert into t2 values(16,0); – 阻塞 | |
insert into t2 values(18,10); – 阻塞 | |
insert into t2 values(20,0); – 阻塞 | |
insert into t2 values(21,0); – 不阻塞 | |
rollback; | |
rollback; |
【案例测试-02-记录锁】
临键锁是间隙锁+记录数的;上述案例中测试了临键锁中的间隙锁,这次我们来测试一下临键锁中的记录锁;
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where id>11 and id<16 for update; | |
select * from t2 where id=12 for update; – 间隙锁,不阻塞 | |
select * from t2 where id=15 for update; – 记录数,阻塞 | |
select * from t2 where id=17 for update; – 间隙锁,不阻塞 | |
select * from t2 where id=20 for update; – 记录数,不阻塞 | |
rollback; | |
rollback; |
我们刚刚测试的是以主键索引进行测试,如果采用不同的列(普通列、普通索引、唯一索引/主键索引等),则临键锁中的间隙锁和记录锁住的内容大不相同;
如果查询的是普通列,那么触发的临键锁为:表级别的间隙锁+表级别的记录锁
drop table if exists t2;
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);
【案例测试-01-表级别间隙锁】
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,8); – 阻塞 | |
insert into t2 values(null,10); – 阻塞 | |
insert into t2 values(null,18); – 阻塞 | |
insert into t2 values(null,21); – 阻塞 | |
rollback; | |
rollback; |
Tips:innoDB查询如果没有使用到索引默认触发表级临键锁,把所有的间隙都锁住了
以普通列查询除了会触发表级别的临键锁外,同时还会触发表级别的记录锁;
【案例测试-02-表级别记录锁】
session1 | session2 |
---|---|
begin; | |
begin; | |
select * from t2 where num=11 for update; | |
select * from t2 where id=3 for update; – 间隙锁,不阻塞 | |
select * from t2 where id=5 for update; – 记录数,阻塞 | |
select * from t2 where id=8 for update; – 间隙锁,不阻塞 | |
select * from t2 where id=15 for update; – 记录数,阻塞 | |
select * from t2 where id=18 for update; – 间隙锁,不阻塞 | |
select * from t2 where id=20 for update; – 记录数,阻塞 | |
rollback; | |
rollback; |
如果查询的列为普通索引列,要看被查询的记录是否在临界值,以及是否是范围查询,才能判断临建锁的范围;
drop table if exists t2;
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);
当使用普通索引列查询,查询的记录不处于临界值时,那么间隙锁为被查询记录所在的区间,记录锁则不会生效;
【测试案例-01-间隙锁】
session1 | session2 |
---|---|
begin; | |
begin; |
| – 触发间隙锁,锁住(15,20]区间
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阻塞了
查询示意图:
【测试案例-02-间隙锁】
当我们把id列的影响也计算进来时,数据就符合我们正常分析的情况了:
session1 | session2 |
---|---|
begin; | |
begin; |
| – 触发间隙锁,锁住(15,20]区间
select * from t2 where num=17 for update; | |
| | insert into t2 values(14,15); – 不阻塞 |
| | insert into t2 values(18,18); – 阻塞 |
| | insert into t2 values(19,20); – 阻塞 |
| rollback; | |
| | rollback; |
【测试案例-03-记录锁】
当使用普通索引列查询,查询的记录不处于临界值时,那么间隙锁为被查询记录所在的区间,记录锁则不会生效
session1 | session2 |
---|---|
begin; | |
begin; |
| – 没有num=17的这条记录,记录锁不会存在
select * from t2 where num=17 for update; | |
| | select * from t2 where num=15 for update; – 不阻塞 |
| | select * from t2 where num=16 for update; – 不阻塞 |
| | select * from t2 where num=17 for update; – 不阻塞 |
| | select * from t2 where num=20 for update; – 不阻塞 |
| rollback; | |
| | rollback; |
【测试案例-01-间隙锁】
当使用普通索引列来查询,并且查询的记录处于临界值时,那么间隙锁为相邻的两个区间,记录锁退化成行锁;
下面案例将会锁住(10,15]、(15,20]两个区间
session1 | session2 |
---|---|
begin; | |
begin; |
| – 触发的间隙锁的区间为(10,15]、(15,20]
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; |
发现实际插入的数据跟我们分析的情况不一致,这个时候我们依然也要观察B+Tree的实现:
15处于(10,15]和(15,20]两个临键区间,因此在两个区间内的数据行都被锁住了
【测试案例-02-记录锁】
当使用普通索引列来查询,并且查询的记录处于临界值时,那么间隙锁为相邻的两个区间,记录锁退化成行锁;
session1 | session2 |
---|---|
begin; | |
begin; |
| – 记录锁只锁住num=15这行记录
select * from t2 where num=15 for update; | |
| | select * from t2 where num=10 for update; – 不阻塞 |
| | select * from t2 where num=12 for update; – 不阻塞 |
| | select * from t2 where num=15 for update; – 阻塞 |
| | select * from t2 where num=18 for update; – 不阻塞 |
| | select * from t2 where num=20 for update; – 不阻塞 |
| | select * from t2 where num=22 for update; – 不阻塞 |
| rollback; | |
| | rollback; |
【测试案例-01-间隙锁】
当使用普通索引进行条件范围查询时,那么间隙锁查询范围所涉及到的区间,记录锁也会升级为查询范围涉及到的区间;
session1 | session2 |
---|---|
begin; | |
begin; |
| – 间隙锁为(10,20]区间
select * from t2 where num>11 and num <16 for update; | |
| | insert into t2 values(9,10); – 不阻塞 |
| | insert into t2 values(11,10); – 阻塞(参考B+Tree的构建) |
| | insert into t2 values(11,11); – 阻塞 |
| | insert into t2 values(12,12); – 阻塞 |
| | insert into t2 values(15,15); – 阻塞(被记录锁阻塞) |
| | insert into t2 values(18,18); – 阻塞 |
| | insert into t2 values(19,20); – 阻塞 |
| | insert into t2 values(21,20); – 不阻塞(参考B+Tree的构建) |
| rollback; | |
| | rollback; |
【测试案例-02-记录锁】
当使用普通索引进行条件范围查询时,那么间隙锁查询范围所涉及到的区间,记录锁也会升级为查询范围涉及到的区间;
session1 | session2 |
---|---|
begin; | |
begin; |
| – 记录锁的区间为(10,20]区间
select * from t2 where num>11 and num <16 for update; | |
| | select * from t2 where num=10 for update; – 不阻塞(左开右闭) |
| | select * from t2 where num=12 for update; – 不阻塞(属于间隙) |
| | select * from t2 where num=15 for update; – 阻塞(触发记录锁) |
| | select * from t2 where num=16 for update; – 不阻塞(属于间隙) |
| | select * from t2 where num=18 for update; – 不阻塞(属于间隙) |
| | select * from t2 where num=20 for update; – 阻塞(左开右闭,触发记录锁) |
| | select * from t2 where num=21 for update; – 不阻塞(即是间隙,也不在区间) |
| rollback; | |
| | rollback; |
如果查询的是唯一索引或主键索引,也要看被查询的记录是否在临界值;是否是范围查询等
创建唯一索引:
-- 删除索引
drop index idx_num on t2;
-- 创建唯一索引
create unique index idx_num on t2(num);
唯一索引在查询非临界值的记录时和普通索引的特点一样,即==间隙锁为当前记录所在的区间,记录锁不生效;==
【测试案例-01-间隙锁】
session-01 | session-02 |
---|---|
begin; | |
begin; |
| – 间隙锁锁住的区间为(15,20]
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); – 不阻塞 |
Tips:唯一索引冲突时MySQL会立即响应,不会触发临键锁
【测试案例-02-记录锁】
唯一索引在查询非临界值的记录时,记录锁不生效;
session1 | session2 |
---|---|
begin; | |
begin; |
| – 没有num=17的这条记录,记录锁不会存在
select * from t2 where num=17 for update; | |
| | select * from t2 where num=15 for update; – 不阻塞 |
| | select * from t2 where num=16 for update; – 不阻塞 |
| | select * from t2 where num=17 for update; – 不阻塞 |
| | select * from t2 where num=20 for update; – 不阻塞 |
| rollback; | |
| | rollback; |
在使用唯一索引查询临界值时,间隙锁会消失,记录锁会退化成行锁;
【测试案例-01-间隙锁】
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; |
【测试案例-02-记录锁】
session1 | session2 |
---|---|
begin; | |
begin; |
| – 记录锁只锁住num=15这行记录
select * from t2 where num=15 for update; | |
| | select * from t2 where num=10 for update; – 不阻塞 |
| | select * from t2 where num=12 for update; – 不阻塞 |
| | select * from t2 where num=15 for update; – 阻塞 |
| | select * from t2 where num=18 for update; – 不阻塞 |
| | select * from t2 where num=20 for update; – 不阻塞 |
| | select * from t2 where num=22 for update; – 不阻塞 |
| rollback; | |
| | rollback; |
【测试案例-01-间隙锁】
当使用普通索引进行条件范围查询时,那么间隙锁查询范围所涉及到的区间,记录锁也会升级为查询范围涉及到的区间;
session1 | session2 |
---|---|
begin; | |
begin; |
| – 间隙锁为(10,20]区间
select * from t2 where num>11 and num <16 for update; | |
| | insert into t2 values(9,10); – 不阻塞 |
| | insert into t2 values(11,10); – 阻塞(参考B+Tree的构建) |
| | insert into t2 values(11,11); – 阻塞 |
| | insert into t2 values(12,12); – 阻塞 |
| | insert into t2 values(15,15); – 阻塞(被记录锁阻塞) |
| | insert into t2 values(18,18); – 阻塞 |
| | insert into t2 values(19,20); – 阻塞 |
| | insert into t2 values(21,20); – 不阻塞(参考B+Tree的构建) |
| rollback; | |
| | rollback; |
【测试案例-02-记录锁】
当使用普通索引进行条件范围查询时,那么间隙锁查询范围所涉及到的区间,记录锁也会升级为查询范围涉及到的区间;
session1 | session2 |
---|---|
begin; | |
begin; |
| – 记录锁的区间为(10,20]区间
select * from t2 where num>11 and num <16 for update; | |
| | select * from t2 where num=10 for update; – 不阻塞(左开右闭) |
| | select * from t2 where num=12 for update; – 不阻塞(属于间隙) |
| | select * from t2 where num=15 for update; – 阻塞(触发记录锁) |
| | select * from t2 where num=16 for update; – 不阻塞(属于间隙) |
| | select * from t2 where num=18 for update; – 不阻塞(属于间隙) |
| | select * from t2 where num=20 for update; – 阻塞(左开右闭,触发记录锁) |
| | select * from t2 where num=21 for update; – 不阻塞(即是间隙,也不在区间) |
| rollback; | |
| | rollback; |
临键锁是InnoDB在查询数据时锁定的一个范围,这个范围包含有间隙锁和记录锁;根据查询的条件不同、列的类型不同(是否是索引等)触发的临键锁范围也不同;
Tips:临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。
要在InnoDB表中使用AUTO_INCREMENT机制,必须将AUTO_INCREMENT列定义为索引(index)或唯一列(unique);InnoDB提供了一种可配置的锁机制,可以显著提高SQL语句在AUTO_INCREMENT列的表中添加行的可伸缩性和性能。这种特殊的锁就是自增锁;
MySQL的自增锁是针对于自增列增长的一个特殊的表级别锁
drop table if exists t3;
CREATE TABLE `t3` (
`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 t3 values(1,20);
insert into t3 values(2,25);
我们之前在表中插入数据都是用最基本的insert,但insert语句的用法用很多,另外MySQL还提供replace语句,运行对表中的数据进行替换;
drop table if exists t4;
CREATE TABLE `t4` (
`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 t4 values(10,20);
insert into t4 values(11,20),(12,21),(13,22);
insert into t4 set id=14,age=25;
insert into t4 select * from t3;
delete from t4;
-- 如果没有这条记录就新增,有这条记录就修改
replace into t4 values(1,20);
replace into t4 set id=10,age=100 ;
replace into t4 select * from t3;
简单插入模式
insert into table_name values(xxx);
批量插入模式,包含insert…select、replace select、load data等语句;
insert into t4 select * from t3;
replace into t4 select * from t3;
Tips:load data属于海量数据插入,暂时不演示
混合插入模式
insert into table_name values(xxxx),(xxxx),(xxxx);
和自增锁相关的一个参数为(5.1.22版本之后加入)innodb_autoinc_lock_mode:可以设定3个值,0,1,2
show variables like 'innodb_autoinc_lock_mode';
Tips:参数只控制InnoDB引擎的设置,所有MyISAM均为traditional ,每次均会进行表锁。只有Innodb会视参数不同而产生不通的锁。
【测试】
session1 | session2 |
---|---|
begin; | |
begin; | |
insert into t3 values(3,1); | |
insert into t3 values(4,1); | |
rollback; | |
rollback; |
一般我们在创建表的时候id起始值为1,通过AUTO_INCREMENT可以设置其值;
-- 在创建表后也可以通过SQL语句修改auto_increment
alter table t1 auto_increment=20;
自增幅度由以下两个参数进行控制:
-- 自增的步长
set auto_increment_increment=2; -- 默认1
可以通过函数获取最后一个插入的id:
select last_insert_id();
就是给数据增加一个版本标识,在数据库上就是表中增加一个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};
和版本号基本一样,只是通过时间戳来判断而已,注意时间戳要使用数据库服务器的时间戳不能是业务系统的时间同样是在需要乐观锁控制的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};
事务的四大特性:
事务特性 | 含义 |
---|---|
原子性(Atomicity) | 事务是工作的最小单元,整个工作单元要么全部执行成功,要么全部执行失败 |
一致性(Consistency) | 事务执行前与执行后,数据库中数据应该保持相同的状态。如:转账前总金额与转账后总金额相同。 |
隔离性(Isolation) | 事务与事务之间不能互相影响,必须保持隔离性。 |
持久性(Durability) | 如果事务执行成功,对数据库的操作是持久的。 |
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 | 否 | 否 | 否 |
四种隔离级别起的作用:
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;
修改隔离级别后需要重启会话
在并发情况下,一个事务读取到另一个事务没有提交的数据,这个数据称之为脏数据,此次读取也称为脏读。
我们知道,只有read uncommitted(读未提交)的隔离级别才会引发脏读。
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)
在一次事务中,多次读取到数据信息不一致;
在上面案例中,session-01窗口两次查询id=1的数据都不一致
解决不可重复读的方法就是将隔离级别再设置高级一点(repeatable read)
在一次事务中,多次读取到的条数不一致,但是在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
数据并发情况下存在很多的问题,为了解决这些问题,数据库专家联合制定了一个标准,也就是说建议数据库厂商都按照这个标准,提供一定的事务隔离级别,来解决事务并发的问题,这个就是 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保证的是事务的原子性;
Redo log也叫重做日志;事务开启时,事务中的操作都会先写入存储引擎的日志缓冲(Buffer Pool)中,默认情况下事务每次提交的时候都会将事务日志刷到磁盘中(多种策略),这就是经常说的"日志先行"(Write-Ahead Logging)。
注意:日志永远比实际数据先到磁盘;换句话说日志没有刷新成功数据不可能提交到表中;
如果在事务提交时,此时数据库崩溃或者宕机,那么当系统重启进行恢复时,就可以根据Redo log中记录的日志,把数据库恢复到崩溃前的一个状态。
首先在操作表时,会将表数据从磁盘(.idb)加载到内存中(Buffer),对表所有的操作都会记录一份到Redo日志中,在事务最终要提交时,如果数据库突然宕机,那么当数据库重启时,就可以根据Redo日志中的记录进行数据的恢复;
Tips:Redo log主要保障的是事务的持久性;
内存中(buffer pool)未刷到磁盘的数据称为脏数据(dirty data)。由于数据和日志都以页的形式存在,所以脏页表示脏数据和脏日志。
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
show variables like '%innodb_log%';
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>
Undo log也叫回滚日志;Undo log记录了数据在每个操作前的状态,如果事务执行过程中需要回滚,就可以根据Undo log进行回滚操作。单个事务的回滚,只会回滚当前事务做的操作,并不会影响到其他的事务做的操作。
Tips:Redo log主要保证事务的持久性,Undo log主要保证事务的原子性,提供回滚功能;其次Undo log用于提供MVCC的快照读
首先在操作表时,会将表数据从磁盘(.idb)加载到内存中(Buffer),对表的update/delete等操作InnoDB都会事先将修改前的数据备份到Undo Buffer中,这样当事务进行回滚时可以根据Undo Buffer中的内容进行事务的回滚操作,除此之外,Undo Buffer提供了数据的快照读取,在事务未提交之前,Undo 日志可以作为并发读写时的读快照,来保证事务的可重复读;
事务做到一半了,失败了,那就要将数据还原到未提交之前的状态,undo 就是记录这些事务步骤的,当然了redo 也记录了,但是redo 里面东西太繁杂,不可能什么事都找它,于是就将事务步骤写入另外一个地方例如undo,以后遇到回滚了就去查找因此在每一步操作时都会写入磁盘中的Undo log;
在一次事务中的delete、update等操作会造成大量的废弃数据,在事务提交时,会将该事务对应的undo log放入到删除列表中,通过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)
Tips:Purge线程不仅会清理磁盘中被标记为删除的行,还会清除Undo 日志中被标记为删除的行;
上面我们说到了InnoDB在RR隔离级别下解决了幻读问题,又保证了高并发的读取(避免了读写串行化),那他到底是如何做的呢?
我们需要解决幻读,即保证前后两次读取的数据条数一致,那么我们就在我们读取的数据的时候加锁,锁定我们需要的数据,不允许其他事务对其修改;这种方案我们叫做基于锁的并发控制 Lock Based Concurrency Control(LBCC)。但很显然,InnoDB没有采用这种方案,我们在查询数据的时候并没有锁定行(没有加锁);
从我们的直观理解上来看,要实现数据库的并发访问控制,最简单的做法就是LBCC,即读的时候不能写(允许多个线程同时读,即共享锁,S锁),写的时候不能读(一次最多只能有一个线程对同一份数据进行写操作,即排它锁,X锁)。这样的加锁访问,其实并不算是真正的并发,或者说它只能实现并发的读,因为它最终实现的是读写串行化,这样就大大降低了数据库的读写性能。是四种隔离级别中级别最高的Serialize隔离级别。为了提出比LBCC更优越的并发性能方法,MVCC便应运而生。
MVCC(Multi-Version Concurrency Control):多版本并发控制。并发访问(读或写)数据库时,对正在事务内处理的数据==做多版本的管理==。以达到用来避免写操作的堵塞,从而引发读操作的并发问题。
MVCC实现了对数据库的读写并发访问,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(快照)**来实现的。
InnoDB在每次开启事务时都会为此次事务分配一个事务ID号,用于标识此次事务
本次事务插入的所有的数据行的版本号字段都为当前事务的ID;
在MVCC中,查询时会拍下一个一致性快照(Read-View),该一致性快照具备如下属性:
Tips:在InnoDB中,MVCC只在RR和RC两个隔离级别下工作,因为RU隔离级别总是会读取最新的行,而不是符合当前事务版本的数据行。而Serializable则会对所有读取的行都加锁;
MVCC快照生成流程RC和RR隔离级别几乎一模一样,唯一不同的是生成 ReadView 的时机,RR 级别只在事务开始之后第一次查询生成一次,之后一直使用该 ReadView。而 RC 级别则在每次 select 时,都会生成一个 ReadView;
测试表:
create table user(
id int primary key auto_increment,
name varchar(30),
age int
);
insert into user values(1,'zhangsan',18);
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,因此能够查询到;
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,可以查询到;
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,因此查询不到;
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记录;
我们刚刚了解到,RC的查询流程和RR的是一样的;RC与RR唯一的不同点在于ReadView生成的次数,RR只在事务开始时生成一次,RC则是在每次select语句时都生成一次;也就是说每次select的时候trx_ids都是在变化的(前提是有新的事务开启了)
我们根据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快照的;