# 三、锁模块

# MyISAM 和 InnoDB 关于锁方面的区别是什么?

# 数据库事务的四大特性

# 事务隔离级别以及各级别下的并发访问问题

# InnoDB 可重复读隔离级别下如何避免幻读

# RC、RR 级别下的 InnoDB 的非阻塞读如何实现

# 1. 锁的类型

  • InnoDB 下的

参考:https://www.jianshu.com/p/b4731a7d255a

# 1.1 实现思想

  • 乐观锁

    乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。

MyBatisPlus 中使用乐观锁

原理:

  1. 取出记录时,获取当前 version
  2. 更新时,带上这个 version
  3. 执行更新时, set version = newVersion where version = oldVersion
  4. 如果 version 不对,就更新失败

使用方法: 字段上加上 @Version 注解 如:

		@Version
		private Integer version;

说明:

  1. 支持的数据类型只有:int, Integerlong, Long, Date, Timestamp, LocalDateTime
  2. 整数类型下 newVersion = oldVersion + 1
  3. newVersion 会回写到 entity 中
  4. 仅支持 updateById(id)update(entity, wrapper) 方法
  5. update(entity, wrapper) 方法下,wrapper 不能复用!!!
  • 悲观锁

    这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。

使用场景

  1. 乐观锁

    高并发、多读少写且如果出现提交失败,用户是可以接受的场景。

  2. 悲观锁

    在并发量不是很大,并且出现并发情况导致的异常用户和系统都很难以接受的情况下,会选择悲观锁。

# 1.2 锁粒度

  • 表级锁(table lock)
  • 行级锁(row lock)

# 1.3 意向锁 [表级锁(table lock)]

意向锁(Intention Locks)分为意向共享锁(IS)和意向排他锁(IX),依次表示接下来的一个事务将会获得共享锁还是排他锁。

意向锁不需要显示的获取,在获取共享锁或者排他锁的时候会自动的获取,也就是说,如果要获取共享锁或者排他锁,则一定是先获取到了意向共享锁或者意向排他锁。 意向锁不会锁住任何东西,除非有进行全表请求的操作,否则不会锁住任何数据。存在的意义只是用来表示有事务正在锁某一行的数据,或者将要锁某一行的数据。

IS 和 IX 是表级锁,不会和行级的 X,S 锁发生冲突。只会和表级的 X,S 发生冲突。

横向是已经持有的锁,纵向是正在请求的锁:

img

# 1.4 读写锁 [行级锁(row lock)]

读写锁(ReadWriteLock)即共享锁排他锁

InnoDB 通过共享锁和排他锁两种方式实现了标准的行锁。

共享锁(S 锁):允许事务获得锁后去读数据。

排他锁(X 锁):允许事务获得锁后去更新或删除数据。

一个事务获取的共享锁(S)后,允许其他事务获取 S 锁,此时两个事务都持有共享锁(S),但是不允许其他事务获取 X 锁。如果一个事务获取的排他锁(X),则不允许其他事务获取 S 或者 X 锁,必须等到该事务释放锁后才可以获取到。

e.g.: ①LOCK TABLE mchopin READ;用读锁锁表,会阻塞其他事务修改表数据,但不会阻塞其他事务读该表。 ②LOCK TABLE mchopin WRITE;用写锁锁表,会阻塞其他事务读和写。 ③select * from mchopin where id = 3 lock in share mode;读行锁,仅对一行数据加了读锁。 ④select * from mchopin where id = 3 for update;写行锁,仅对一行数据加了写锁。

img

# 1.5 记录锁(record locks)

锁住某一行,如果表存在索引,那么记录锁是锁在索引上的,如果表没有索引,那么 InnoDB 会创建一个隐藏的聚簇索引加锁

所以在进行查询的时候尽量采用索引进行查询,这样可以降低锁的冲突。

# 1.6 间隙锁(gap locks)

间隙锁是一种记录行与记录行之间存在空隙或在第一行记录之前或最后一行记录之后产生的锁。

间隙锁可能占据的单行,多行或者是空记录。

通常的情况是采用范围查找的时候,比如在学生成绩管理系统中,如果此时有学生成绩 60,72,80,95,一个老师要查下成绩大于 72 的所有同学的信息,采用的语句是 select * from student where grade > 72 for update

这个时候 InnoDB 锁住的不仅是 80,95,而是所有在 72-80,80-95,以及 95 以上的所有记录。

为什么会这样呢?因为不锁住这些行,另一个事务在此时插入了一条分数大于 72 的记录,会导致第一次的事务两次查询的结果不一样,出现了幻读。所以为了在满足事务隔离级别的情况下需要锁住所有满足条件的行。

  1. 加锁点:不是加在记录上的,而是加在两条记录之间的位置。
  2. 作用:两次当前读返回的是完全相同的记录。

幻读和不可重复读的关键点在于,幻读是数据增加了,而不可重复读是数据修改或删除了。从锁上来分析,幻读的关键是 GAP 锁,而不可重复读的关键是行锁。

# 1.7 Next-Key Locks

NK 是一种记录锁和间隙锁的组合锁。既锁住行也锁住间隙。并且采用的 左开右闭 的原则。InnoDB 对于查询都是采用这种锁的。

举个例子:

CREATE TABLE `xxp` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `uid` int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

INSERT INTO `xxp`(uid) VALUES (1);
INSERT INTO `xxp`(uid) VALUES (2);
INSERT INTO `xxp`(uid) VALUES (3);
INSERT INTO `xxp`(uid) VALUES (6);
INSERT INTO `xxp`(uid) VALUES (10);

# T1
START TRANSACTION WITH CONSISTENT SNAPSHOT; //1
SELECT * FROM xxp WHERE uid = 6 for UPDATE; //2
COMMIT;  //5

# T2
START TRANSACTION WITH CONSISTENT SNAPSHOT;  //3
INSERT INTO xxp(uid) VALUES(11);
INSERT INTO xxp(uid) VALUES(5);  //4
INSERT INTO xxp(uid) VALUES(7);
INSERT INTO xxp(uid) VALUES(8);
INSERT INTO xxp(uid) VALUES(9);
SELECT * FROM xxp WHERE uid = 6 for UPDATE;
COMMIT;

ROLLBACK;

按照上面 1,2,3,4 的顺序执行会发现第 4 步被阻塞了,必须执行完第 5 步后才能插入成功。这里会很奇怪明明锁住的是 uid=6 的这一行,为什么不能插入 5 呢?原因就是这里采用了 next-key 的算法,锁住的是(3,10]整个区间。

# 2. MyISAM 和 InnoDB 关于锁方面的区别

  • MyISAM 默认用的是表级锁,不支持行级锁;
  • InnoDB 默认用的是行级锁,也支持表级锁。

# 3. 数据库事务的四大特性 —— ACID

  • 原子性 Atomicity

    事务包含的所有操作要么全部成功,要么全部失败回滚;成功必须要完全应用到数据库,败则不能对数据库产生影响。

  • 一致性 Consistency

    事务执行前和执行后必须处于一致性状态。例如:转账事务执行前后,两账户余额的总和不变。

  • 隔离性 Isolation

    多个并发的事务之间要相互隔离。

  • 持久性 Durability

    事务一旦提交,对数据库的改变是永久性的.

# 4. 数据库事务的隔离级别

# 4.1 事务的隔离性

多事务操作之间不会产生影响。

# 4.2 三个读问题

  • 脏读

    一个未提交的事务读取到另一个未提交的事务的数据。

    image-20200916161119710

    如上:东方不败想从 5000 改到 100,而岳不群想从 5000 改到 60000。这个时候岳不群先改了,然后东方不败读取到数据已经改成 60000 了,所以东方不败就会继续在 60000 的基础上进行修改。但是这个时候,岳不群的事务并没有进行提交,而且进行了事务回滚,所以真实的数据现在还是 5000,而东方不败操作的数据是 60000。这就叫脏读。

  • 不可重复读

    一个未提交事务读取到另一提交事务修改数据。

    image-20200916161326921

    如上:东方不败先读取到数据是 5000,想对数据进行操作,但是这个时候岳不群已经将数据改成 900 了。而东方不败又检测到了数据已经改成 900 了,读两次,数据不一致,这就是不可重复读(因为不知道再读的话是不是又会不一样了)。

  • 虚(幻)读

    一个未提交事务读取到另一提交事务增加的数据。

    如:本来该事务只读取到 3 条数据,这个时候另外一个事务 insert 了一条新的数据,就变成了读取到 4 条数据了,同一事务里面读取到不同条数的数据。

# 4.3 解决读问题 —— 设置事务的隔离性

isolation 属性值 意思 脏读 不可重复读 幻读 作用
READ UNCOMMITTED 读未提交 效率高,但是啥也避免不了
READ COMMITTED 读已提交 常用,可避免脏读
REPEATABLE READ 可重复读 可以用在非 insert 方法上
SERIALIZABLE 串行化 三个问题都解决了,但效率低
DEFAULT 使用数据库默认 MySQL 的话就是 REPEATABLE READ
Oracle 的话就是 READ COMMITTED

# 4.4 当前读/快照读

# 当前读

当前读:读取的是最新版本,并且对读取的记录加锁,阻塞其他事务同时改动相同记录,避免出现安全问题。

select ... lock in share mode (共享读锁)

select ... for update

update, delete, insert

例如,假设要 update 一条记录,但是另一个事务已经 delete 这条数据并且 commit 了,如果不加锁就会产生冲突。所以 update 的时候肯定要是当前读,得到最新的信息并且锁定相应的记录。

关于 for update

使用 select ... for update 可以锁表也可以锁行。锁表的压力自然是比锁行的压力要大的,所以应尽量采用锁行。

for update 仅适用于 InnoDB(因为 MyISAM 不支持行锁),且必须在事务处理模块(BEGIN/COMMIT)中才能生效。

  • 例1: (明确指定主键,并且有此数据,row lock)

    SELECT * FROM wallet WHERE id='3' FOR UPDATE;

  • 例2: (明确指定主键,若查无此数据,无 lock)

    SELECT * FROM wallet WHERE id='-1' FOR UPDATE;

  • 例3: (无主键,table lock)

    SELECT * FROM wallet WHERE name='Mouse' FOR UPDATE;

  • 例4: (主键不明确,table lock)

    SELECT * FROM wallet WHERE id<>'3' FOR UPDATE;

  • 例5: (主键不明确,table lock)

    SELECT * FROM wallet WHERE id LIKE '3' FOR UPDATE;

实现方式: 当前读是通过 Next-Key Lock 来实现的。

下面通过一个例子来说明当前读的实现方式,例如下面这条 SQL:

delete from T where age = 7;

进行下面的实验:

img

测试可知 delete from T where age = 7; 语句在 age 上的加锁区间为 (4,10),图解如下:

img

# 快照读

快照读:读取的是记录数据的可见版本(可能是过期的数据),不用加锁。

单纯的 select 操作,不包括上述

select ... lock in share mode;

select ... for update。    

READ COMMITTED 隔离级别:每次 select 都生成一个快照读

REPEATABLE READ 隔离级别:开启事务后第一个 select 语句才是快照读的地方,而不是一开启事务就快照读

实现方式: undo log + MVCC

下图右侧黄色部分是数据:一行数据记录,主键 ID 是 10,object = 'Goland' ,被 update 更新为 object = 'Python' 。

img

  1. 事务会先使用“排他锁”锁定该行,将该行当前的值复制到 undo log 中;
  2. 然后再真正地修改当前行的值;
  3. 最后填写事务的 DB_TRX_ID ,使用回滚指针 DB_ROLL_PTR 指向 undo log 中修改前的行。
  • DB_TRX_ID : 6 字节 DB_TRX_ID 字段,表示最后更新的事务 id ( update , delete , insert ) 。此外,删除在内部被视为更新,其中行中的特殊位被设置为将其标记为已软删除。
  • DB_ROLL_PTR : 7 字节回滚指针,指向前一个版本的 undo log 记录,组成 undo 链表。如果更新了行,则撤消日志记录包含在更新行之前重建行内容所需的信息。
  • DB_ROW_ID:行标识(隐藏单调自增 ID),大小为 6 字节,如果表没有主键,InnoDB 会自动生成一个隐藏主键,因此会出现这个列。另外,每条记录的头信息(record header)里都有一个专门的 bit(deleted_flag)来表示当前记录是否已经被删除。

补充

insert undo log 只在事务回滚时需要,事务提交就可以删掉了。

update undo log 包括 update 和 delete,回滚和快照读都需要。

MVCC 解决幻读的原理:

  1. 其实它是用来替代行锁的,进一步提升并发能力
  2. InnoDB 的 MVCC,是通过在每行记录后面保存两个隐藏的列 DB_TRX_IDDB_ROLL_PTR 来实现的,它们是自动加上的,程序无法控制;
  3. MVCC 把一个个事务都隔离开来,自己玩自己的 CURD,在最后提交到数据库时再比较版本号;
  4. 不会读取事务版本号大于当前事务 ID 的数据,顺便解决了幻读问题;
  5. 主要解决的是写时加锁不能读的问题,但并没有解决写并发的问题。

# 4.5 InnoDB 可重复读隔离级别下如何避免幻读

  • 表现:快照读(非阻塞读) —— MVCC
  • 内在:next-key 锁(行锁 + gap 锁)
上次更新: 8/4/2021, 7:49:01 PM