MySQL中锁解决幻读问题的方法

52次阅读
没有评论

共计 8109 个字符,预计需要花费 21 分钟才能阅读完成。

自动写代码机器人,免费开通

这篇文章主要介绍 MySQL 中锁解决幻读问题的方法,文中介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们一定要看完!

什么是锁

锁是一种用于保证在并发场景下每个事务仍能以一致性的方式读取和修改数据的方式,当一个事务对某一条数据上锁之后,其他事务就不能修改或者只能阻塞等待锁的释放,所以锁的粒度大小一定程度上可以影响到访问数据库的性能。

从锁的粒度上来说,我们可以将锁分为表锁和行锁。

表锁

顾名思议,表锁就是直接锁表,在 MyISAM 引擎中就只有表锁。

表锁的加锁方式为:

LOCK TABLE 表名 READ;-- 锁定后表只读
UNLOCK TABLE; -- 解锁复制代码

行锁

行锁,从名字上来看,就是锁住一行数据,然而,行锁的实际实现算法会相对复杂,有时候并不仅仅只是锁住某一条数据,这个后面再展开。

正常的思路是:锁住一行数据之后,其他事务就不能来访问这条数据了,那么我们想象,假如事务 A 访问了一条数据,只是拿出来读一下,并不想去修改,正好事务 B 也来访问这条数据,也仅仅只是想拿出来读一下,并不想去修改,这时候如果因此阻塞了,就有点浪费性能了。所以为了优化这种读数据的场景,我们又把行锁分为了两大类型:共享锁和排他锁。

共享锁

共享锁,Shared Lock,又称之为读锁,S 锁,就是说一条数据被加了 S 锁之后,其他事务也能来读数据,可以共享一把锁。
我们可以通过如下语句加共享锁:

select * from test where id=1 LOCK IN SHARE MODE; 复制代码

加锁之后,直到加锁的事务结束 (提交或者回滚) 就会释放锁。

排他锁

排他锁,Exclusive Lock,又称之为写锁,X 锁。就是说一条数据被加了 X 锁之后,其他事务想来访问这条数据只能阻塞等待锁的释放,具有排他性。

当我们在修改数据,如:insert,update,delete 的时候 MySQL 就会自动加上排他锁,同样的,我们可以通过如下 sql 语句手动加上排他锁:

select * from test where id=1 for update; 复制代码

在 InnoDB 引擎中,是允许行锁和表锁共存的。

但是这样就会有一个问题,假如事务 A 给 t 表其中一行数据上锁了,这时候事务 B 想给 t 表上一个表锁,这时候怎么办呢?事务 B 怎么知道 t 表有没有行锁的存在,如果采用全表遍历的情况,当表中的数据很大的话,加锁都要加半天,所以 MySQL 中就又引入了意向锁。

意向锁

意向锁为表锁,分为两种类型,分为:意向共享锁 (Intention Shared Lock) 和意向排他锁(Intention Exclusive Lock),这两种锁又分别可以简称为 IS 锁和 IX 锁。

意向锁是 MySQL 自己维护的,用户无法手动加意向。

意向锁有两大加锁规则:

当需要给一行数据加上 S 锁的时候,MySQL 会先给这张表加上 IS 锁。当需要给一行数据加上 X 锁的时候,MySQL 会先给这张表加上 IX 锁。

这样的话上面的问题就迎刃而解了,当需要给一张表上表锁的时候,只需要看这张表是否有对应的意向锁就可以了,无需遍历整张表。

各种锁的兼容关系

下面这张图是各种锁的兼容关系,参考自官网:

XIXSISX

互斥

互斥

互斥

互斥

IX

互斥

共享

冲突

共享

S

互斥

互斥

共享

共享

IS

互斥

共享

共享

共享

锁到底锁的是什么

建立以下两张表,并初始化 5 条数据,注意 test 表有 2 个索引而 test2 没有索引:

CREATE TABLE `test` (`id` int(11) NOT NULL,
 `name` varchar(50) DEFAULT NULL,
 PRIMARY KEY (`id`),
 KEY `NAME_INDEX` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO test VALUE(1, 张 1 
INSERT INTO test VALUE(5, 张 5 
INSERT INTO test VALUE(8, 张 8 
INSERT INTO test VALUE(10, 张 10 
INSERT INTO test VALUE(20, 张 20 
CREATE TABLE `test2` (`id` varchar(32) NOT NULL,
 `name` varchar(32) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO test2 VALUE(1, 张 1 
INSERT INTO test2 VALUE(5, 张 5 
INSERT INTO test2 VALUE(8, 张 8 
INSERT INTO test2 VALUE(10, 张 10 
INSERT INTO test2 VALUE(20, 张 20 复制代码

举例猜测

在行锁中,假如我们对一行记录加锁,那么到底是把什么东西锁住了,我们来看下面两个例子:
举例 1(操作 test 表):

事务 A 事务 BBEGIN;
SELECT * FROM test WHERE id=1 FOR UPDATE;

SELECT * FROM test WHERE id=1 FOR UPDATE;

阻塞

SELECT * FROM test WHERE id=5 FOR UPDATE;

加锁成功

COMMIT;

(释放锁)

SELECT * FROM test WHERE id=1 FOR UPDATE;

加锁成功

举例 2(操作 test2 表):

事务 A 事务 BBEGIN;
SELECT * FROM test2 WHERE id=1 FOR UPDATE;

SELECT * FROM test2 WHERE id=1 FOR UPDATE;

阻塞

SELECT * FROM test2 WHERE id=5 FOR UPDATE;

阻塞

COMMIT;

(释放锁)

SELECT * FROM test2 WHERE id=1 FOR UPDATE;

加锁成功

从上面两个例子我们可以发现,test 表好像确实是锁住了 id= 1 这一行的记录,而 test2 表好像不仅仅是锁住了 id= 1 这一行记录,实际上经过尝试我们就知道,test2 表是被锁表了,所以其实 MySQL 中 InnoDB 锁住的是索引,当没有索引的时候就会锁表。

接下来再看一个场景:

事务 A 事务 BBEGIN;
SELECT * FROM test WHERE name=‘张 1’FOR UPDATE;

SELECT name FROM test WHERE name=‘张 1’FOR UPDATE;

阻塞

SELECT id FROM test WHERE id=1 FOR UPDATE;

阻塞

COMMIT;

(释放锁)

SELECT id FROM test WHERE id=1 FOR UPDATE;

加锁成功

这个例子中我们是把 name 索引锁住了,然后我们在事务 B 中通过主键索引只查 id,这样就用到 name 索引了,但是最后发现也被阻塞了。所以我们又可以得出下面的结论,MySQL 索引不但锁住了辅助索引,还会把辅助索引对应的主键索引一起锁住。

到这里,可能有人会有怀疑,那就是我把辅助索引锁住了,但是假如加锁的时候,只用到了覆盖索引,然后我再去查主键会怎么样呢?

接下来让我们再验证一下:

事务 A 事务 BBEGIN;
SELECT name FROM test WHERE name=‘张 1’FOR UPDATE;

SELECT name FROM test WHERE name=‘张 1’FOR UPDATE;

阻塞

SELECT * FROM test WHERE id=1 FOR UPDATE;

阻塞

SELECT id FROM test WHERE id=1 FOR UPDATE;

阻塞

COMMIT;

(释放锁)

SELECT id FROM test WHERE id=1 FOR UPDATE;

加锁成功

我们可以看到,就算只是用到了辅助索引加锁,MySQL 还是会把主键索引锁住,而主键索引的 B + 树叶子节点中,又存储了整条数据,所以查询任何字段都会被锁定。

到这里,我们可以明确的给锁到底锁住了什么下结论了:

结论

InnoDB 引擎中,锁锁的是索引:

假如一张表没有索引,MySQL 会进行锁表 (其实锁住的是隐藏列 ROWID 的主键索引) 假如我们对辅助索引加锁,那么辅助索引所对应的主键索引也会被锁住主键索引被锁住,实际上就等于是整条记录都被锁住了 (主键索引叶子节点存储了整条数据) 行锁的算法

上一篇介绍事务的时候我们提到了,MySQL 通过加锁来防止了幻读,但是如果行锁只是锁住一行记录,好像并不能防止幻读,所以行锁锁住一条记录的话只是其中一种情况,实际上行锁有三种算法:记录锁 (Record Lock),间隙锁(Gap Lock) 和临键锁(Next-Key Lock),而之所以能做到防止幻读,正是临键锁起的作用。

记录锁(Record Lock)

记录锁就是上面介绍的,当我们的查询能命中一条记录的时候,InnoDB 就会使用记录锁,锁住所命中的这一行记录。

间隙锁(Gap Lock)

当我们的查询没有命中记录的时候,这时候 InnoDB 就会加上一个间隙锁。

事务 A 事务 BBEGIN;
SELECT * FROM test WHERE id=1 FOR UPDATE;

INSERT INTO test VALUE (2,‘张 2’);

阻塞

INSERT INTO test VALUE (3,‘张 3’);

阻塞

SELECT * FROM test WHERE id=2 FOR UPDATE;

加锁成功

COMMIT;

(释放锁)

从上面的例子中,我们可以得出结论:

间隙锁与间隙锁之间不冲突,也就是事务 A 加了间隙锁,事务 B 可以在同一个间隙中加间隙锁。(之所以会用到间隙锁就是没有命中数据的时候,所以并没有必要去阻塞读,也没有必要阻塞其他事务对同一个间隙加锁)间隙锁主要是会阻塞插入操作间隙是如何确定的

test 表中有 5 条记录,主键值分别为:1,5,8,10,20。那么就会有如下六个间隙:
(-∞,1),(1,5),(5,8),(8,10),(10,20),(20,+∞)

而假如主键不是 int 类型,那么就会转化为 ASCII 码之后再确定间隙。

临键锁(Next-Key Lock)

临键锁就是记录锁和间隙锁的结合。当我们进行一个范围查询,不但命中了一条或者多条记录,且同时包括了间隙,这时候就会使用临键锁,临键锁是 InnoDB 中行锁的默认算法。

注意了,这里仅针对 RR 隔离级别,对于 RC 隔离级除了外键约束和唯一性约束会加间隙锁,没有间隙锁,自然也就没有了临键锁,所以 RC 级别下加的行锁都是记录锁,没有命中记录则不加锁,所以 RC 级别是没有解决幻读问题的。

临键锁在以下两个条件时会降级成为间隙锁或者记录锁:

当查询未命中任务记录时,会降级为间隙锁。当使用主键或者唯一索引命中了一条记录时,会降级为记录锁。事务 A 事务 BBEGIN;
SELECT * FROM test WHERE id =2 AND id =6 FOR UPDATE;

INSERT INTO test VALUE (2,‘张 2’);

阻塞

INSERT INTO test VALUE (6,‘张 6’);

阻塞

INSERT INTO test VALUE (8,‘张 8’);

阻塞

SELECT * FROM test WHERE id=8 FOR UPDATE;

阻塞

INSERT INTO test VALUE (9,‘张 9’);

插入成功

COMMIT;

(释放锁)

上面这个例子,事务 A 加的锁跨越了 (1,5) 和(5,8)两个间隙,且同时命中了 5,然后我们发现我们对 id= 8 这条数据进行操作也阻塞了,但是 9 这条记录插入成功了。

临键锁加锁规则

临键锁的划分是按照左开右闭的区间来划分的,也就是我们可以把 test 表中的记录划分出如下区间:(-∞,1],(1,5],(5,8],(8,10],(10,20],(20,+∞)。

那么临键锁到底锁住了哪些范围呢?

** 临键锁中锁住的是最后一个命中记录的 key 和其下一个左开右闭的区间 **

那么上面的例子中其实锁住了 (1,5] 和(5,8]这两个区间。

临键锁为何能解决幻读问题

临键锁为什么要锁住命中记录的下一个左开右闭的区间?答案就是为了解决幻读。

我们想一想上面的查询范围 id = 2 且 id =6,如果我们事务 A 只锁住了 (1,5] 这个区间,假如这时候事务 B 插入一条数据 id=6,那么事务 A 再去查询,就会多出来了一条记录 id=6,就会出现了幻读,所以我把你下一个区间 5,10]也给锁住,就可以避免了幻读。

当然,其实如果我们执行的查询刚好是 id = 2 且 id =5,那么就算只锁住了(1,5],同样能避免幻读问题,只是我们要考虑到查询范围的最大值没有命中记录的情况,而锁住了下一个区间,可以确保不论是哪种范围查询,都可以避免幻读的产生。

在我们使用锁的时候,有一个问题是需要注意和避免的,我们知道,排它锁有互斥的特性。一个事务持有锁的时候,会阻止其他的事务获取锁,这个时候会造成阻塞等待,那么假如事务一直等待下去,就会一直占用 CPU 资源,所以,锁等待会有一个超时时间,在 InnoDB 引擎中,可以通过参数:innodb_lock_wait_timeout 查询:

SHOW VARIABLES LIKE innodb_lock_wait_timeout 复制代码

默认超时时间是 50s,超时后会自动释放锁回滚事务。但是我们想一下,假如事务 A 在等待事务 B 释放锁,而事务 B 又在等待事务 A 释放锁,这时候就会产生一个等待环路了,而这种情况是无论等待多久都不可能会获取锁成功的,所以是没有必要去等 50s 的,这种形成等待环路的现象又叫做死锁。

死锁 (Dead Lock) 什么是死锁

死锁是指的两个或者两个以上的事务在执行过程中,因为争夺锁资源而造成的一种互相等待的现象。

事务 A 事务 BBEGIN;
SELECT * FROM test WHERE id=10 FOR UPDATE;

BEGIN;
SELECT * FROM test WHERE id=20 FOR UPDATE;SELECT * FROM test WHERE id=20 FOR UPDATE;

SELECT * FROM test WHERE id=10 FOR UPDATE;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 查询出结果

MySQL 中锁解决幻读问题的方法
我们可以看到,发生死锁之后就会立刻回滚,而不会漫无目的的去等待 50s 之后超时再回滚事务,那么 MySQL 是如何知道产生了死锁的,是如何检测死锁的发生呢?

死锁的检测

目前数据库大部分采用 wait-for graph(等待图)的方式来进行死锁检测,InnoDB 引擎也是采用这种方式来检测死锁。数据库中会记录两种信息:

锁的信息链表事务的等待链表
wait-for graph 算法会根据这两个信息构建一张图,当图中存在回路,则证明存在死锁:
如下图中,t1 和 t2 之间存在回路,这就证明 t1 和 t2 事务之间存在死锁
MySQL 中锁解决幻读问题的方法 死锁的避免尽量将长事务拆分成多个小事务查询时避免没有 where 条件语句查询,并尽可能使用索引查询可以的话尽量使用等值查询锁信息查询

InnoDB 在 information_schema 库下提供了 3 张表供我们查询并排查事务和锁相关问题。

INNODB_TRX

记录了当前在 InnoDB 中执行的每个事务的信息,包括事务是否在等待锁、事务何时启动以及事务正在执行的 SQL 语句(如果有的话)。

列名含义 trx_idInnoDD 引擎中的事务的唯一 IDtrx_state 事务状态:RUNNING, LOCK WAIT, ROLLING BACK,COMMITTINGtrx_started 事务的开始时间 trx_requested_lock_id 等待会务的锁 ID, 如果 trx_state 不为 LOCK WAIT 时,为 nulltrx_wait_started 事务等待开始的时间 trx_weight 事务的权重,反映了一个事务修改和锁住的行数,当发生死锁时候,InnoDB 会选择该值最小的事务进行回滚 trx_mysql_thread_idMySQL 中的线程 ID,可以通过 SHOW PROCESSLIST 查询 trx_query 事务运行的 sql 语句 trx_operation_state 事务的当前操作状态,如果没有则为 NULLtrx_tables_in_use 当前事务中执行的 sql 语句用到的表数量 trx_tables_locked 已经被锁定表的数量(因为用的是行锁,所以虽然显示一张表被锁了,但是可能只是锁定的其中一行或几行,所以其他行还是可以被其他事务访问)trx_lock_structs 当前事务保留的锁数量 trx_lock_memory_bytes 当前事务的索结构在内存中的大小 trx_rows_locked 当前事务中锁住的大致行数,包括已经被打上删除标记等物理存在的但是对当前事务不可见的数据 trx_rows_modified 当前事务修改或者插入的行数 trx_concurrency_tickets 并发数,指的是当前事务未结束前仍然可以执行的并发数, 可以通过系统变量 innodb_concurrency_tickets 设置 trx_isolation_level 当前事务隔离级别 trx_unique_checks 是否为当前事务打开或者关闭唯一约束:0- 否 1 - 是 trx_foreign_key_checks 是否为当前事务打开或者关闭外键约束:0- 否 1 - 是 trx_last_foreign_key_error 最后一个外键错误信息,没有则为空 trx_adaptive_hash_latched 自适应哈希索引是否被当前事务锁定。在分区自适应哈希索引搜索系统时,单个事务不会锁定整个自适应哈希索引。自适应哈希索引分区由 innodb_adaptive_hash_index_parts 控制,默认设置为 8。trx_adaptive_hash_timeout 是立即放弃自适应哈希索引的搜索 latch,还是在来自 MySQL 的调用中保留它。当没有自适应哈希索引争用时,这个值将保持为零,并且语句会保留 latch 直到它们完成。在争用期间,它的计数减少到零,并且语句在每一行查找之后立即释放锁存。当自适应哈希索引搜索系统被分区时(由 innodb_adaptive_hash_index_parts 控制),该值保持为 0。trx_is_read_only 当前事务是否只读:0- 否 1 - 是 trx_autocommit_non_locking 值为 1 表示这是一条不包含 for update 和 lock in share model 的语句,而且是在开启 autocommit 情况下执行的有且仅有这一条语句,当这列和 TRX_IS_READ_ONLY 都为 1 时,InnoDB 会优化事务以减少与更改表数据事务的相关开销。INNODB_LOCKS

记录了事务请求锁但未获得的每个锁的信息和一个事务持有锁但正在阻塞另一个事务的每个锁的信息。

列名含义 lock_id 锁的 id(虽然 LOCK_ID 当前包含 TRX_ID,但 LOCK_ID 中的数据格式随时可能更改,不要编写解析 LOCK_ID 值的应用程序)lock_trx_id 上一张表的事务 IDlock_mode 锁的模式: S, X, IS, IX, GAP, AUTO_INC,UNKNOWNlock_type 锁的类型是表锁还是行锁 lock_table 被锁住的表 lock_index 被锁住的索引,表锁则为 NULLlock_space 锁记录的空间 id,表锁则为 NULLlock_page 事务锁定页的数量,表锁则为 NULLlock_rec 事务锁定行的数量,表锁则为 NULLlock_data 事务锁定的主键值,表锁则为 NULLINNODB_LOCK_WAITS

记录了锁等待的信息。每个被阻塞的 InnoDB 事务包含一个或多个行,表示它所请求的锁以及正在阻塞该请求的任何锁。

列名含义 lock_id 锁的 id(虽然 LOCK_ID 当前包含 TRX_ID,但 LOCK_ID 中的数据格式随时可能更改,不要编写解析 LOCK_ID 值的应用程序)requesting_trx_id 申请锁资源的事务 IDrequested_lock_id 申请的锁的 IDblocking_trx_id 阻塞的事务 IDblocking_lock_id 阻塞的锁的 ID

以上是 MySQL 中锁解决幻读问题的方法的所有内容,感谢各位的阅读!希望分享的内容对大家有帮助,更多相关知识,欢迎关注丸趣 TV 行业资讯频道!

向 AI 问一下细节

丸趣 TV 网 – 提供最优质的资源集合!

正文完
 
丸趣
版权声明:本站原创文章,由 丸趣 2023-12-18发表,共计8109字。
转载说明:除特殊说明外本站除技术相关以外文章皆由网络搜集发布,转载请注明出处。
评论(没有评论)