Mysql事务和锁的深入研究(亲测权威版,看完后彻底搞懂)

本博文以思想指导实践来验证论点并加以总结归纳,切忌死记硬背。本博文的所有demo都很详细,各位可以自行在自己的数据库中做测试验证。

1、入门准备工作

1.1、#建表语句

CREATE TABLE `student` (
    `id` int(16) NOT NULL AUTO_INCREMENT COMMENT '自增ID',
    `sno` VARCHAR(16) DEFAULT NULL COMMENT '学号',
    `sname` VARCHAR(64) DEFAULT NULL COMMENT '姓名',
    `company` VARCHAR(128) DEFAULT NULL COMMENT '公司',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='学生表';

1.2、查看数据库的相关配置

#数据库版本
SELECT VERSION();
#使用的存储引擎:InnoDB
SHOW VARIABLES LIKE '%engine';
#数据库隔离级别:可重复读
SHOW GLOBAL VARIABLES LIKE '%tx_isolation';

1.3、常规事务的基础知识:

#有事务?
update student set name='沐风111' where id=1;
#YES:若未手工开启,则会话层面会自动的开启事务 
SHOW VARIABLES LIKE 'Autocommit';
SET SESSION autocommit = ON;    #off 建议不要随便修改设置


BEGIN; #START TRANSACTION 等价的手工开启事务
UPDATE student SET sname='沐风222' where id=1;
ROLLBACK; 

BEGIN; #START TRANSACTION 等价的手工开启事务
UPDATE student SET sname='沐风333' where id=1;
COMMIT; 

#结束事务 两种方式 ROLLBACK/COMMIT

2、事务并发三大问题(脏读、不可重复读、幻读)

其实都是数据库读一致性的问题,需要依靠数据库提供一定的事务隔离机制解决。

3、脏读、不可重复读(虚读)、幻读的解释:(其他很多博文都表述有误

  • 脏读:A事务可以读取了B事务未提交的数据,一旦B事务做了回滚,那么A事务拿到的就是错误的脏数据去执行接下来的业务,后果比较严重。
  • 不可重复读:A事务可以读取B事务update/delete操作已经提交的数据,即A事务多次重复读取的可能会是不同的数据。
  • 幻读:A多次事务读取中,穿插了B事务执行的insert操作,导致多次查询得到的数据行数变化了,像幻觉一样。

注:不可重复读(虚读)和幻读的差别: 
从总的结果来看, 似乎两者都表现为两次读取的结果不一致。但如果你从控制的角度来看, 两者的区别就比较大: 
对于前者, 只需要锁住满足条件的记录 
对于后者, 要锁住满足条件及其相近的记录

4、Mysql隔离机制

参考SQL92的AMSI/ISO标准(很多数据库厂商并未严格按照这些规范全盘实现):http://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt,截图如下:

  • Read Uncommitted(读取未提交内容)  未解决任何并发问题

在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。读取未提交的数据,也被称之为脏读(Dirty Read)。

  • Read Committed(读取提交内容)  解决脏读问题

这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别仍然会出现不可重复读(虚读)。

  • Repeatable Read(可重读) 解决不可重复读的问题 + (依靠MVCC)幻读的问题

MySQL的默认事务隔离级别!它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。不过理论上,这会导致另一个棘手的问题:幻读 (Phantom Read)。InnoDB和Falcon存储引擎通过多版本并发控制(MVCC)机制解决了该问题。

  • Serializable(可串行化)  解决所有问题

这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争,低效。

5、事务隔离级别的实现(LBCC和MVCC)

  • 基于锁的并发控制LBCC(Lock Based Concurrency Control):

在读取数据前,对其加锁,阻止其他事务对数据进行修改。

  • 多版本并发控制MVCC(Muilti Version Concurrency Control):

生成一个数据请求时间点的一致性数据快照,并用这个快照来提供一定级别(语句级和事务级)的一致性读取。

5.1、基于锁的并发控制LBCC的详解

锁:用于管理不通事务对共享资源(行数据,表数据,页数据使用的引擎太少不讨论)的并发访问

表锁VS行锁:
    锁定粒度:表锁>行锁
    加锁效率:表锁>行锁
    冲突概率:表锁>行锁
    并发性能:表锁<行锁

MyISAM:只支持表锁
InnoDB:支持表锁和行锁(更强大通用)

1、锁类型

  • 共享锁(行锁):Shared Locks

又称为读锁,简称S锁(英文首字母),共享锁就是多个事务对于同一数据可以共享一把锁,都能访问数据,但是只能读不能修改。

加锁释锁方式:

select * from xx_db where id=1 Lock IN SHARE MODE;
commit/rollback

验证:

#Session 1
BEGIN;
SELECT * FROM student WHERE id=1 LOCK IN SHARE MODE;
ROLLBACK;


#Session 2
BEGIN;
SELECT * FROM student WHERE id=1 LOCK IN SHARE MODE;
DELETE from student WHERE id=1;
ROLLBACK;
  • 排它锁(行锁):Exclusive Locks

又称为写锁,简称X锁(英文第二个字母),排它锁不能和其他锁共存。如一个事务获取了一个数据行的排它锁,其他事务就不能再获取该行的锁(共享锁和排它锁),只有获取了排它锁的事务才可以对数据行进行读取和修改。

加锁释锁方式:

#自动:delete/update/insert的DML操作会默认加上X锁
#手动:select * from xx_db where id=1 FOR UPDATE;

commit/rollback

验证:

#Session 1
BEGIN;
update student SET sname='沐风雨林555' WHERE id=1;
ROLLBACK;
COMMIT;


#Session 2
BEGIN;
SELECT * FROM student WHERE id=1 LOCK IN SHARE MODE;
SELECT * FROM student WHERE id=1 FOR UPDATE;
DELETE from student WHERE id=1;
ROLLBACK;
  • 意向共享锁(表锁):Intension Shared Locks

简称IS锁,表示事务准备给数据行加入共享锁,也就是说:一个数据行加共享锁之前必须先取得该表的IS锁。

  • 意向排它锁(表锁):Intension  Exclusive Locks

简称IX锁,表示事务准备给数据行加入排他锁,也就是说:一个数据行加排他锁之前必须先取得该表的IX锁。

IS和IX锁的归纳:

(1)意向锁是由数据库引擎自己维护的,用户无法手动操作意向锁。

(2)既然都有行级别的锁,为何仍需要表级别的意向锁?因为表锁加锁效率高,行锁并发度高,可以取得一个较好的组合效果。

(3)一个事务能给一张表加上锁表的前提:没有其他任何一个事务锁定这张表中的任意一行。

(4)验证

#Session 1
BEGIN;
#加上了排它锁,说明一定有意向排它锁了
SELECT * FROM student WHERE id=1 FOR UPDATE;
ROLLBACK;
COMMIT;


#Session 2
BEGIN;
#检测到已有了被上了意向锁的标识符,则加锁失败
LOCK TABLES student WRITE;
UNLOCK TABLES;
  • 其他锁本文暂不讨论

2、锁住了什么?

#t1的建表语句
#不使用索引
CREATE TABLE `t1` (
	`id` int(11) DEFAULT NULL,
	`name` VARCHAR(255) DEFAULT NULL COMMENT '姓名'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='t1';



#Session 1
BEGIN;
#表中无显示的主键索引,mysql就会帮忙新建一个隐藏的主键索引。where条件未命中索引导致整个表都被锁定了
SELECT * FROM t1 WHERE id=1 FOR UPDATE;


#Session 2
BEGIN;
#会阻塞,因为where条件未命中索引导致整张表都被锁定了
SELECT * FROM t1 WHERE id=3 FOR UPDATE;
INSERT INTO `t1` (`id`, `name`) values(5, '5');
#t2的建表语句
#主键索引
CREATE TABLE `t2` (
	`id` int(11) NOT NULL,
	`name` VARCHAR(255) DEFAULT NULL COMMENT '姓名',
	PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='t2';



#Session 1
BEGIN;
SELECT * FROM t2 WHERE id=1 FOR UPDATE;
COMMIT;


#Session 2
#BEGIN;不影响,因为会自动获取事务
#失败 同一行数据加排它锁导致冲突了
SELECT * FROM t2 WHERE id=1 FOR UPDATE;
#成功 两个事务的排它锁,各自占据的是不同的行的数据,所以不冲突
SELECT * FROM t2 WHERE id=4 FOR UPDATE;
#t3的建表语句
#唯一索引
CREATE TABLE `t3` (
	`id` int(11) NOT NULL,
	`name` VARCHAR(255) DEFAULT NULL COMMENT '姓名',
	PRIMARY KEY (`id`),
	UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='t3';



#Session 1
BEGIN;
SELECT * FROM t3 WHERE `name`='4' FOR UPDATE;
COMMIT;


#Session 2
#BEGIN;不影响,因为会自动获取事务
#失败 同一行数据加排它锁导致冲突了
SELECT * FROM t3 WHERE `name`='4' FOR UPDATE;
#失败 不仅锁住了name='4'对应的索引还锁定了唯一索引对应的主键索引(可以参考B+Tree的辅助索引最终依赖于主键索引的关联方式)
SELECT * FROM t3 WHERE id=4 FOR UPDATE;
#成功 两个事务的排它锁,各自占据的是不同的行的数据,所以不冲突
SELECT * FROM t3 WHERE id=1 FOR UPDATE;

3、行锁算法 

#初始化一下
DROP TABLE IF EXISTS `t2`;
CREATE TABLE `t2` (
  `id` int(11) NOT NULL,
  `name` varchar(255) DEFAULT NULL COMMENT '姓名',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='t2';


INSERT INTO `t2` (`id`, `name`) values(1, '1');
INSERT INTO `t2` (`id`, `name`) values(4, '4');
INSERT INTO `t2` (`id`, `name`) values(7, '7');
INSERT INTO `t2` (`id`, `name`) values(10, '10');

  • 记录锁 Record Lock

记录锁是锁定记录。唯一索引=精准匹配,退化成Record锁。Record Lock:唯一性索引(唯一索引/主键索引)等值查询,精准匹配。例如:select * from t2 where id = 4 for update;锁住的就是:id=4。

  • 间隙锁 Gap Lock

查询记录不存在的时候,临键锁就会退化成间隙锁。

例如:select * from t2 where id >20 for update; 因为没有查到数据,所以锁住的是(10,+\infty)。间隙锁之间是不冲突的,也意味着两个事务之间可以获取一个完全相同的间隙锁。

  • 临键锁 Next-key Lock 

InnoDB默认的行锁算法,对索引进行范围查询时会触发锁定范围加记录,即:Next-key Locks  = Gap Lock + Record Lock。例如:select * from t2 where id > 3 and id < 9 for update;锁住的就是(1,4]和(4,7]和(7,10)。解释:锁住的是当前条件命中的区间+下一个区间,锁住下一个区间的好处在于塞满了所有的where条件对应的左右空隙(即where条件能够涉及到的所有的可能的数据区间)可以避免发生幻读的发生。有Record Lock的另一个事务不能查询也不能插入,Gap Lock区间另一个事务不能插入但是可以查询(不阻塞,查出来空的结果,原因是上面提到的:两个事务之间可以获取一个完全相同的间隙锁)。

此处提一下:找到满足条件的记录,但是记录无效(InnoDB上删除一条记录,并不是真正意义上的物理删除,而是将记录标识为删除状态。后续会由后台的Purge操作进行回收,物理删除。但是,删除状态的记录会在索引中存放一段时间。),也会对记录加next key锁(同时锁住记录本身,以及记录前后的Gap);

总结【必读必品】:以上三大行锁算法的本质在于,尽可能的避免同一个A事务的多次同样的查询条件出现不一致的查询行数(即避免幻读),也就意味着要尽可能的锁住可能存在其他事务在A事务的临界的空隙中插入数据的情况,不让其他事务钻空子。所以才能费尽心思的去考虑各种情况去锁住对应的范围区间。把握了本质也就对LBCC设计和原理了如指掌了,切忌死记硬背的那些算法规则,规则是人定的,出发点都是为了解决问题,当你抓住了问题本质也就能够想到该怎么设计了。

  • 关于Mutex(保护内部的共享变量操作)和RWLock(又称之为Latch,保护内部的页面读取与修改)锁,此文不做介绍。

利用锁如何解决三大问题:

  1. 脏读:因为B事务的update操作会自动的有一个排他锁,A事务只能拿到B事务已提交的结果,从而避免脏读。
  2. 不可重复读:A事务上第一次读取操作会加上共享锁,B事务就不能再去操作该数据。如果B事务的update条件坐落在间隙中虽然不阻塞但是没有该数据行相当于没执行。如果B事务的update条件坐落在A事务的共享锁的数据上,则排他锁的排他性生效直接阻塞。
  3. 幻读:临键锁的作用,A事务的查询会触发临键锁,B事务就不能操作相邻的间隙,保证了A事务的两次查询结果一致。
  4. 上述锁住的区间都是where条件对应的索引的范围,例如,where条件的是name,name主键id只会锁住命中的记录行,并不会锁住主键id周围的间隙。参考下图的例子:
#初始化一下
DROP TABLE IF EXISTS `t5`;
CREATE TABLE `t5` (
  `id` int(11) NOT NULL,
	`age` int(5) DEFAULT NULL COMMENT '年龄',
  `name` varchar(255) DEFAULT NULL COMMENT '姓名',
  PRIMARY KEY (`id`),
	UNIQUE KEY `uk_age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='t5';

INSERT INTO `t5` (`id`, `age`, `name`) values(1, 1, '1');
INSERT INTO `t5` (`id`, `age`, `name`) values(4, 4, '4');
INSERT INTO `t5` (`id`, `age`, `name`) values(7, 5, '7');
INSERT INTO `t5` (`id`, `age`, `name`) values(10, 9, '10');
INSERT INTO `t5` (`id`, `age`, `name`) values(18, 33, '10');

#==============================================
#Session 1
BEGIN;
UPDATE t5 SET name = 'xxxxx' WHERE age=9;

select * from t5 where age > 6 and age < 32 for update;
#(5,9],(9,33),命中9,对应的id=1
COMMIT;
ROLLBACK;


#Session 2
BIGINT;
#失败,是因为age=8被锁住了
INSERT INTO `t5` (`id`, `age`, `name`) VALUES(8, 8, '8');
#成功,是因为id=8虽然在id=9周围,但是并没有被锁住id=9的间隙
INSERT INTO `t5` (`id`, `age`, `name`) VALUES(8, 99, '8');
ROLLBACK;

设计上的启发:尽量避免使用范围查询,因为范围查询越大,锁住的区间越大,导致并发度越低。

5.2、多版本并发控制MVCC

先来一个demo暖暖场:

#Session 1
BEGIN;
SELECT * from t2 WHERE id=4 for UPDATE;


#Session 2
BEGIN;
#成功 读取的是一个快照,MVCC机制
SELECT * from t2 WHERE id=4 ;
#失败 排它锁冲突
SELECT * from t2 WHERE id=4 FOR UPDATE;
COMMIT;

1、MVCC是为了解决什么问题?

MVCC是为了实现数据库的并发控制而设计的一种协议。从我们的直观理解上来看,要实现数据库的并发访问控制,最简单的做法就是加锁访问,即读的时候不能写(允许多个线程同时读,即共享锁,S锁),写的时候不能读(一次最多只能有一个线程对同一份数据进行写操作,即排它锁,X锁)。这样的加锁访问,其实并不算是真正的并发,或者说它只能实现并发的读,因为它最终实现的是读写串行化,这样就大大降低了数据库的读写性能。为了提出比LBCC更优越的并发性能方法,MVCC便应运而生。

几乎所有的RDBMS都支持MVCC。它的最大好处便是,读不加锁,读写不冲突。在MVCC中,读操作可以分成两类,快照读(Snapshot read)和当前读(current read)。

  • 快照读

读取的是记录的可见版本(可能是历史版本,即最新的数据可能正在被当前执行的事务并发修改),不用对该返回的记录加锁;在MySQL InnoDB中,简单的select操作,如 select * from table where ? 都属于快照读;

  • 当前读

读取的是记录的最新版本,并且对该返回的纪录加锁,保证其他事务不会并发修改这条记录。属于当前读的包含以下操作:

#要加S锁的场景
select * from table where ? lock in share mode; 
#要加X锁的场景
select * from table where ? for update; 
insert, update, delete操作

   针对一条当前读的SQL语句,InnoDB与MySQL Server的交互,是一条一条进行的,因此,加锁也是一条一条进行的。先对一条满足条件的记录加锁,返回给MySQL Server,做一些DML操作;然后再读取下一条加锁,直至读取完毕。需要注意的是,以上需要加X锁的都是当前读,而普通的select(除了for update)都是快照读,每次insert、update、delete之前都是会进行一次当前读的,这个时候会上锁,防止其他事务对某些行数据的修改,从而造成数据的不一致性。我们广义上说的幻读现象是通过MVCC解决的,意思是通过MVCC的快照读可以使得事务返回相同的数据集。如下图所示:

2、实现原理(以InnoDB的REPEATABLE READ隔离级别下为例)

InnoDB的MVCC,是通过在每行记录后面保存两个隐藏的列来实现的,这两个列分别保存了这个行的创建时间和删除时间。这里存储的并不是实际的时间值,而是系统版本号(可以理解为事务的ID),每开始一个新的事务,系统版本号就会自动递增,事务开始时刻的系统版本号会作为事务的ID.

核心在于:

InnoDB会根据以下a,b两个条件检查每行记录,只有a,b同时满足的记录,才能返回作为查询结果。

  • a.InnoDB只会查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
  • b.行的删除版本要么未定义,要么大于当前事务版本号,这可以确保事务读取到的行,在事务开始之前未被删除。

具体的实现细节可以参考:https://blog.csdn.net/whoamiyang/article/details/51901888 写的比较通熟易懂,我就不再班门弄斧了。但是补充一点如下图所示:

#Session 1 
BEGIN;
SELECT * from t2 WHERE id=4; #step1先执行完这句,先瞅瞅数据
SELECT * from t2 WHERE id=4; #step3执行完这句,效果仍然是老数据,因为还未commit。
SELECT * from t2 WHERE id=4; #step5执行完这句,效果仍然是老数据,MVCC
SELECT * from t2 WHERE id=4 for UPDATE; #step6执行完这句,生效读取到name=new4的新数据,排它锁打破了原有的无锁,使得mvcc降级成LBCC
COMMIT;
ROLLBACK;

#Session 2
BEGIN;
update `t2` SET `name`='new4' where id=4; #step2执行完这句
COMMIT; #step4执行完这句

如有疑问和批评欢迎随时交流,纯手码辛苦不易,请支持原创勿转载。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章