Mysql
储存结构
InnoDB如何存储数据
数据库进行IO操作的最小单位为页,InnoDB中数据页默认大小为16KB,也就是说如果数据库使用InnoDB存储引擎,那么每次读写都是以16KB
作为单位,一次最少读16KB
,一次最少写16KB
。
File Header
中存有两个指针,一个指向上一个数据页,一个指向下一个数据页。这样一来数据页之间就可以不用是物理上的连续,而是逻辑上的连续。
User Records
则是存储用户数据,以行为单位,且使用单向链表连接。单向链表的优点就是插入、删除数据效率非常高,但是遍历的效率很低。为了解决遍历效率低的问题而引入了页目录的概念,具体组织结构如下:
由图可知,页目录由不同的槽构成,每个槽指向一个分组最后一条记录(也就是主键值最大的记录)。由于记录在数据页中是按照主键值从小到大串联起来的,既然有序,就可以通过槽进行二分找到目标记录所在的分组。找到分组之后通过线性遍历的方式找到对应的记录。
为了防止线性遍历的时间复杂度高,所以每个分组内的记录很少,具体如下:
第一个分组中只有一条记录
最后一个分组的记录条数在1~8条之间
中间分组的记录条数在4~8条之间
组内遍历的时间复杂度就可以近似看成O(1)
,组间遍历的时间复杂度为O(logN)
, 那么整体的时间复杂度就为O(logN)
现在已经解决了数据页内数据查询的效率问题,再来看看数据页之间如何组织的?
通过B+树来组织每张数据页
如上图总结B+树特点:
只有在叶子节点才保存用户数据,路上节点只保存保存其他数据页的地址。
叶子节点之间以双向链表的形式连接,便于范围查询。
索引
聚簇、非聚簇、回表和索引覆盖
聚簇索引与非聚簇索引的区别就是在于叶子节点存放的是什么数据
聚簇索引的叶子节点存放的是实际数据,用户的完整记录都存放在叶子节点中。
非聚簇索引的叶子节点存放的是主键值,并不是实际数据。如下图所示:
创建聚簇索引的规则
由于实际的数据在物理上只会保存一份,所以一个表中聚簇索引是唯一的,非聚簇索引可以不唯一。
如果有主键,则InnoDB创建索引的使用就会使用主键作为聚簇索引的索引键;
如果没有主键,则选择第一列不包含NULL值,且数据不重复的列作为聚簇索引的索引键;
如果上面两种情况都不满则,则InnoDB将自动生成一个隐式自增id作为聚簇索引的索引键。
回表与索引覆盖
如果在查询语句中使用到了二级索引,且查询的数据仅包含主键值,那么仅需二级索引的B+树就能完成任务,所以可以直接返回结果,这就叫做”索引覆盖“。
-- 例如: 这里id为主键,name为二级索引
select id from t_user where name = 'a';
如果在查询语句中使用到了二级索引,且查询的数据不是主键值(或不仅包含主键值),那么二级索引的B+树中存储的数据不足以满足任务,那么就需要重新回到主键索引的B+树上查找数据,这就叫做”回表“。
select id, phone from t_user where name = 'a';
索引什么时候失效
索引失效可以分为以下六种情况:
对索引使用左或者左右模糊匹配
like "%xx" 或 like "%xx%" -- 这两种匹配模式都无法走索引,走的是全表扫描
因为%
并不确定是什么,所以如果以"%"
开头只能走全表扫描;如果是like "x%"
,这个x
在开头可以确定,那么是可以走索引的(如果有)。
对索引使用函数
有时候会使用Mysql自带的函数来得到想要的结果,但是如果查询时对索引字段使用函数则会造成索引失效
-- 例如
select * from t_user where length(name) = 6;
因为索引保存的是索引字段的原始值,而不是经过函数计算后的值,所以走不了索引。
Mysql8.0
之后提供了函数索引,可以根据函数的运算结果来构造索引。
alter table t_user add key idx_name_length (length(name));
如上操作就会构建一个名为idx_name_length
的索引,只要使用length(name)
就会走该索引。
对索引进行表达式计算
select * from t_user where id + 1 = 10;
如上情况,即便是id是索引列,但是id + 1是表达式运算后的值,所以无法走索引,只能把索引中的字段全部取出然后执行该表达式进行逐一比对。故而是全表扫描。
隐式类型转换
Mysql在遇到字符串与数字进行比较的时候,会把字符串转化为数字之后再进行比较
select * from t_user where phone = 10086
--等价于
select * from t_user where CAST(phone AS signed int) = 10086;
-- 所以对phone使用了函数之后就无法走索引了
--类似地
select * from t_user where id = "1";
--等价于
select * from t_user where id = CAST("1" AS signed int);
--这里id并没有使用函数,所以可以使用索引
联合索引非最左匹配
将多个字段组合在一起创建的索引就叫做联合索引
联合索引要能正确使用需要满足最左匹配原则。
假如说(a, b, c)
三个列构成联合索引
where a = 1;
where a = 1, b = 2;
where a = 1, b = 2, c = 3;
--以上三种都会走联合索引
where b = 1, c = 2;
where c = 3;
where b = 1;
-- 这三种都不会走联合索引,因为没有从a开始
联合索引的查询规则是按顺序往后查询的,先比较a,在满足a条件的页中继续寻找满足b的…依次往后推。所以如果跳过a而直接开始b是不可以的。
where子句中的or
select * from t_user where id = 1 or age = 3; -- 这里无法走索引
以上场景,因为or条件是二选一,其中id是索引列而age不是索引列,只有其中一列是索引列则没有意义,会走全表扫描。
适合创建索引的时机
索引最大的好处就是提高查询速度,但是也是有缺点的:
需要占用物理空间,数量越大占用的空间越多
创建索引和维护索引都要耗费时间,且数据量越大耗费时间越多
会降低表的增删改效率,因为每次增删改索引,B+树为了维护索引有序性都需要进行动态维护
从缺点出发就能发现索引不适用的场景:
经常更新的字段不要创建索引,因为B+树为了维护有序性会频繁的重建索引,这个过程影响数据库性能。
字段中如果存在大量重复的数据也不适合创建索引,不论搜索哪个值可能结果都有大量结果(例如性别),还不如不创建索引。
不会在
where
,group by
,order by
子句中出现的字段也不必创建索引,因为并不需要根据这些字段进行搜索,创建了索引反而浪费物理空间。
总结适合创建索引的时机为:
字段唯一(主键索引,唯一索引),字段绝大部分不相同(普通索引)。
会出现在
where
,group by
,order by
子句中的字段。
有什么优化索引的方法
减少回表的频率,也就是多使用覆盖索引
我们知道如果在二级索引中,如果B+树上有数据则可以不用回到主键索引的B+树上查找。而且还知道二级索引不保存用户数据,仅保存关联的主键。如果给常用于搜寻的列创建联合索引,那么这个二级索引能够保存的数据就更多了,如果查询目标在联合索引中,那么就可以回表使用主键索引,这是一种优化思路。
主键索引最好使用auto_increment
如果使用自增主键,那么每次添加数据都是追加操作,不需要移动现有的数据,这种插入数据的方法效率非常高。
如果使用非自增主键,由于每次插入主键的索引值都是随机的,所以每次插入新数据的时候可能会插入到现有数据页中间某个位置,这就不得不移动移动其他记录来给该数据的插入创造条件。甚至需要将一个页面的数据复制到另一个页面。这种情况被叫做“页分裂”,会导致内存碎片,引发索引结构不紧凑等问题。
防止索引失效
索引失效的场景在之前已经提到过了,只要注意不要让上面的场景发生即可。
索引最好设置为NOT NULL
第一个原因:NULL值列表会占据至少一个字节的空间
第二个原因:索引列存在NULL值的时候,会使优化器在做索引选择的时候更加困难,因为NULL值的存在会使值比较、索引统计等都更加困难。
COMPACT行格式的页面布局
一条完整记录 = 记录的额外信息 + 记录的真实数据
变长字段长度列表
-- 先构建一张表
CREATE TABLE `t_user` (
`id` int(11) NOT NULL,
`name` VARCHAR(20) DEFAULT NULL,
`phone` VARCHAR(20) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT;
id name phone age
1 a 123 18
2 bb 1234 (NULL)
3 ccc (NULL) (NULL)
看第一条记录id = 1, name = a, phone = 123, age = 18
:
name = a
占据一个字节;phone = 123
占据3个字节,age,id
不是varchar类型不用关心
以16进制按顺序写:0x01 0x03
,然后按照逆序存放写入变长字段列表
同理,第二条记录与第三条记录:
当数据表中没有变长字段的时候,也就不会有变长字段列表
逆序存放的理由
NULL值列表
每个列对应一个比特位来标识自己是否为NULL(1标识为NULL,0表示不为NULL),例如第三列的值是NULL(倒序),则00000100
(1个字节起步,高位多的使用0补齐)。
以上面的第三条记录为例:
id = 3, name = ccc, phone = NULL, age = NULL
,age和phone都为NULL
,id为NOT NULL
直接被忽略
注意:如果某列属性是NOT NULL
,那么就不会在NULL
值列表中体现,所以如果表中所有列的属性都为NOT NULL
,那么就没有NULL
值列表的空间开销。
记录头信息
记录头信息中存储的内容很多,举例几个常用的:
delete_mask
: 标记本条记录是否被删除,从这里也能看出,一条记录被删除只是被标记为了1并非真的将数据删除了。next_record
: 下一条记录的位置,从这里也能看出,每条记录之间是通过链表串联起来的,指向的位置是下一条记录的【记录头信息】与【真实数据】之间的位置。record_type
: 用来表示记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录。
row_id
: 如果在建表的时候指定了主键或者唯一约束列,那么就没有该字段了。如果既没有指定主键也没有指定唯一列,那么InooDB就会给记录添加上row_id隐藏字段。row_id
不是必须的。
trx_id
: 事务id,表示这个修改这条记录的事务ID。 trx_id
是必需的,占用 6 个字节。使用这个来实现多版本并发控制(MVCC)。
roll_pointer
: 回滚指针,roll_pointer
是必需的,占用 7 个字节。它指向这条记录的undo日志。如果需要回滚或在MVCC中读取旧的数据版本,就会用到这个指针。
varchar(n)
的取值范围
Mysql规定除了TEXT,BLOBs这种大对象类型之外,其他所有列(不包括隐藏列和记录头信息)占用的字节长度不能超过65535字节
varchar(n)
中的n
代表的是字符数目,不是字节大小。
所以要确定n
的大小,得需要在确定字符集的条件下(也就是一个字符占据多少字节的规则)才能计算。
前面说到不包括隐藏列和记录头信息,那么:
一行数据占据的字节数目 = 变长字段长度列表占据的字节数 + NULL值列表占据的字节数 + 真实数据占用字节数
变长字段长度列表占据的字节数
我们知道这个长度列表是按照16进制逆序存放的,那至于每个单位需要用多大字节来表示,规则如下:
在列的定义里,看
varchar(n)
的n
,通过n
计算出所需要的字节数如果小于等于255,那么就用1个字节来表示该varchar
列。如果大于255则用2个字节来表示该
varchar
列。
所以我们能够成功创建varchar(65535)
吗?
显然不行,假设一个表中有且只有一个变长字段,那么长度列表的表示就花去了2
字节,如果允许为空,那么NULL
值列表花去了1
字节,最后剩余65532
字节可以用来存储真实数据。
而且这是在ascii
字符集的情况下仅有一个字段,那么可以成功创建varchar(65532)
。如果为utf8
,那么1
个字符需要3
个字节表示则65532/3 = 21844
个字符。
接下来换另一种多字段场景
create table test (
id varchar(255) not null,
name varchar(65277) not null
)ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT
这是否能够创建成功呢?
首先id
能够存储的最大的字节数为255
,所以可以用1
个字节表示长度;然后name
存储的最大字节数为65277
,需要用2个字节表示长度;并且由于全是NOT NULL
,那么省去了NULL
值列表占据的1
字节空间。总和:255 + 1 + 65277 + 2 = 65535 <= 65535
,所以表可以创建成功。
行溢出后如何处理
Mysql中磁盘与内存交互的基本单位为页,一个页的大小一般为16KB,也就是16384字节,而varchar类型的数据最多可以存储65532个字节。这时候一页可能存不了一条记录,就会发生行溢出,多的数据就会存到另外的溢出页中。
真实数据处用20字节存储指向溢出页的地址。
单表建议值
一页为16KB,File Header
38字节,Page Header
56字节,Infinum + Supermum
26字节,File Trailer
8字节,再加上页目录的大小约等于1K
所以还剩下约15K
的大小给记录用
路上节点
在索引页中主要存放的是主键与页号,这里假设主键是Bigint(8字节)
, 页号(4字节)
,一共12字节
所以15 * 1024 / 12 = 1280
行
叶子节点
假设一条记录的占据1K
大小,那么一个叶子节点(一页)就能存在15K / 1K = 15
条记录
统计总行数
total_row = 路上节点行数 ^ (高度 - 1)* 叶子节点行数
这里假设树的高度为3,那么total_row = 1280 ^ 2 * 15
约等于2400
w
这也就意味着一行记录如果大小在1K
左右,那么存储到2400w
行之后树的高度就会从3
层到4
层,这样就增加了IO次数,降低了效率。
事务
事务的特性
一提到Mysql的事务,就能想到其经典的四个特性:ACID
原子性(Atomicity
),一致性(Consistency
), 隔离性(Isolation
), 持久性(Durability
)
隔离性通过MVCC
和锁机制来保证,原子性通过undo log
日志来保证,持久性通过redo log
日志来保证,一致性是由隔离性、原子性和持久性共同保证的。
事务的隔离级别
读未提交(
read uncommitted
):指一个事务还没有提交,它所做的变更就能被其他事务看到。读已提交(
read committed
):指一个事务提交之后,它所做的变更才能被其他事务看到。可重复读(
repeatable read
):【InnoDB默认的隔离级别】指一个事务在执行过程中看到的数据,与这个事务在启动时看到的数据是保持一致,即使别的事务已经提交完成了,也不看到。串行化(
serializable
):会对记录加上读写锁,就像单线程一样,后来的事务一定要等到前面的事务执行完毕后才能执行。
不同的隔离级别带来的问题也是不同的
由图可知,【串行化】是最安全的,但也是效率最低的;而【可重复读】是默认隔离级别,虽然在一些特殊场景可能会出现幻读现象,但是已经能够保证绝大部分场景并发的安全性。
可重复读的两种实现策略
针对【快照读】(普通的
select
语句),RR(可重复读)级别采用了MVCC多版本控制的方式。针对【当前读】(
select ... for update
/select ... lock in share mode
语句),RR(可重复读)级别采用了加锁的方式。
MySQL的快照读(Snapshot Read)指的是在InnoDB存储引擎中,读取数据时能够看到某一时刻数据的状态,而不受其他并发事务修改的影响。这是多版本并发控制(MVCC)的一个特性,它允许用户执行一致性的非锁定读操作。在不同的事务隔离级别下,快照读的行为也会有所不同。
读视图(Read View)是MVCC中用来实现快照读的一种机制。当一个事务需要执行快照读时,它会创建一个读视图。这个读视图是当前数据库的一个逻辑拷贝,它根据事务的隔离级别和当前的数据库状态,确定哪些数据版本是对当前事务可见的。
确保了查询的一致性而不需要加锁。这样做既保证了数据的正确性,又提高了并发操作的性能。
RC和RR的本质区别
在RR级别下,某个事务使用快照读之后有且仅会生成一份Read View
,此后都是用同一份读视图;而RC级别下每次快照读都会生成一份新的读视图。
所以在RR级别下,事务中快照读的结果是非常依赖该事务中首次出现快照读的地方,这决定了该事务后续快照读的能力。
RR隔离级别下什么时候会出现幻读
场景一
事务A本来无论如何都看不到事务B的更新结果的,但是由于事务A进行了update,然后将新纪录的DB_TRX_ID
填写为自己,这样一来事务A就能看到自己的历史版本,也就造成了幻读。
这个场景合理吗?不合理,因为事务A在没有查询到id = 5
的记录的前提下仍然去更新结果,这个行为本身就不符合直觉,所以这个场景也是极其罕见的,并不影响RR级别防止绝大部分的幻读场景的功能。
场景二
该场景仍然发生了幻读,因为事务A采用了快照读之后又采用了当前读,这就造成了前后记录数目不一致的问题。
解决这种问题的建议就是:在事务一开始的时候就先执行select ... for update
这样当前读的命令,这样就会阻塞其他事务的插入数据操作,也就没有幻读问题。
互斥锁
行级锁
如何对记录加行锁?有如下两种方式:
-- 对读取的记录加共享锁(S锁)
select ... lock in share mode;
-- 对读取的记录加独占锁(X锁)
select ... for update;
注意点:这种加行锁的方式只能在事务中使用,事务提交了则锁就被释放了。
加锁的规则
只有S锁和S锁才相互兼容,其他任何组合方式都互斥。
行级锁的种类
我们在上层表面是使用统一的方式加上行级锁,但是底层可能根据不同的需求场景加上了不同类型的行级锁。
具体地分,分为记录锁(record lock
)、间隙锁(gap lock
)、临键锁(next-key lock
)。
记录锁(
record lock
)
顾名思义就是锁住某条记录,记录锁具有S和X的类别之分,遵守加锁的规则。
begin;
select * from t_user where id = 1 for update; -- 给id = 1的记录加上X锁
# 其他事务想要修改id = 1的记录都被阻塞,直到事务commit之后
间隙锁(
gap lock
)
只存在于RR隔离级别,目的就是为了解决幻读现象
如果有一个范围(3, 5)的间隙锁,那么4这条记录就无法插入。
间隙锁虽然具有X和S之分,但是没有区别,完全兼容,其存在的意义只是为了解决幻影问题。
临键锁(
next-key lock
)
是记录锁 + 间隙锁的组合形式
例如一个范围(3, 5]的临键锁,那么5这条数据不能被修改,且范围内不允许插入新的数据。
临键锁继承了记录锁的X和S型,完全遵守加锁的规则。
临键锁的退化规则
首先创建一张表
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL,
`age` int NOT NULL,
PRIMARY KEY (`id`),
KEY `index_age` (`age`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
表中记录如下:
接下来分情况讨论:
唯一索引的等值查询
若查询的记录【存在】,则在索引树上定位到记录之后,该记录的
next-key lock
退化成为record lock
。若查询的记录【不存在】,则在索引树上定位到记录之后,该记录的
next-key lock
退化成为gap lock
。
记录存在的情况
begin;
select * from user where id = 1 for update;
+----+--------+-----+
| id | name | age |
+----+--------+-----+
| 1 | 路飞 | 19 |
+----+--------+-----+
1 row in set (0.02 sec)
为什么这里仅需记录锁就可以?
因为主键的存在,导致其他事务无法成功插入id = 1
的数据,所以select
的结果有且只有一条,这就可以省去间隙锁且记录锁能保证id = 1
的记录不被删除。综合而言,记录锁就能满足不会造成幻读的所有要求,故而退化成记录锁。
记录不存在的情况
begin;
select * from user where id = 2 for update;
Empty set (0.03 sec)
试想一下,这种情况下如何会出现幻读现象?
无非就是其他事务插入了id = 2
的数据,导致当前事务再次进行当前读的时候出现id = 2
的记录。所以这时候只需对id = 5
的记录加一个(1, 5)
间隙锁不让id = 2
的数据插入即可。
接下来如果有其他事务插入id = 2,3,4
的记录,这些插入语句都会发生阻塞。插入id = 1和5的记录不会发生阻塞,而是直接报主键冲突的错误。
这个间隙锁的范围如何确定?
【右边界】的确定:只需要找到第一个id > 2
的记录即可,例子中就是id = 5;
【左边界】的确定:id = 5
的记录的上一条记录(因为不存在id = 2
的记录,所以上一条记录一定比2
小)就是左边界,例子中就是id = 1
;
综上就确定了(1, 5)
的范围。
唯一索引的范围查询
当唯一索引进行范围查询的时候,会对每一个扫描到的索引加next-key
锁,接下来进行分类讨论:
针对【大于】的范围查询
select * from user where id > 15 for update;
+----+-----------+-----+
| id | name | age |
+----+-----------+-----+
| 20 | 香克斯 | 39 |
+----+-----------+-----+
1 row in set (0.01 sec)
当看到大于条件的时候,记住后面全是next-key锁即可,没有退化的情况。
要注意,最后一条记录后面还有一个supremum pseudo-record
特殊记录用来标记真正的结尾(相当于+∞)
针对【大于等于】的范围查询
关键点就在于等于的那个值是否存在有对应记录
如果有对应记录,即有id = 15的记录,那么该处的
next-key
退化为record key
,后面的全是next-key
如果没有id = 15这条记录,那么就找到第一个大于15的记录,图中是20,这就是右边界。该记录的上一条记录id = 10就是左边界,然后对id = 20位置加上(10, 20]
next-key
。
针对【小于】的范围查询
对于查询出来的记录结果,每条记录对应的索引树上都挂上next-key
,最后一条是gap key
针对【小于等于】的范围查询
如果小于等于的边界值存在对应记录,则对于查询出来的记录结果对应的索引树上都挂上next-key
如果不存在对应的记录,对于查询出来的记录结果,每条记录对应的索引树上都挂上
next-key
,最后一条是gap key
非唯一索引等值查询
记录不存在的情况
begin;
select * from user where age = 25 for update;
Empty set (0.00 sec)
由于没有age = 25
的记录,所以退而求其次,找到第一个大于25的记录,然后在其二级索引树上挂上间隙锁就可以防止幻读
有一些情况下即便age = 22或age = 39记录也是能插入成功的:
gap key
锁住的是age在(22, 39)
这个区间,但是不能忽略主键索引,与之对应的范围是(10, 20)
。如果age = 22
, 但是id < 10
那么就不在间隙锁的范围之内就可以插入;同理age = 39, id > 20
也同样能成功插入。
记录存在的情况
begin;
select * from user where age = 22 for update;
+----+--------+-----+
| id | name | age |
+----+--------+-----+
| 10 | 山治 | 22 |
+----+--------+-----+
1 row in set (0.00 sec)
在二级索引中由于存在值可以重复的情况,所以既要保证已经存在的记录不被删除,且要保证值相同的记录不被插入,这样才能不会出现幻读现象。
age = 22, id = 10
处肯定至少需要一个记录锁能保证本条记录不会被移除,然后我们也要保证age = 22, id < 10
的记录不被插入,那么就需要在该记录前面加上一个(21, 22间隙锁。综合而言就需要一个(21, 22]next-key
。
但是仅有一个next-key
粒度是不够的,因为还有age = 22, id > 10的记录没有被限制,这时候就还需要一个(22, 39)gap key
需要注意:主键索引树上还要挂一个记录锁
非唯一索引范围查询
只要记住所有情况加的都是next-key
,不存在锁退化的场景。
begin;
select * from user where age >= 22 for update;
+----+-----------+-----+
| id | name | age |
+----+-----------+-----+
| 10 | 山治 | 22 |
| 20 | 香克斯 | 39 |
+----+-----------+-----+
2 rows in set (0.01 sec)
记录锁+间隙锁可以防止删除操作而导致的幻读吗
首先结论是不会。
RR隔离级别下出现的两种幻读情况在之前已经说过了,除此之外的其他情况不会导致幻读。
创建出如下表进行实验验证(其中仅有id
为主键索引):
分析以上场景会出现幻读吗?
分析这里的select ... for update
加了什么锁?
由于age
列不是索引,所以进行全表扫描,对每一个记录加上了next-key
。这就相当于整表被锁,其他事务任意的增删改操作都会被阻塞,所以也就不会有幻读现象产生。
死锁的发生与解决方案
-- 构造一张表
create table `t_order` if not exists (
`id` int NOT NULL auto_increment,
`order_no` int default null,
`create_date` datetime default null,
primary KEY (`id`),
KEY `index_order` (`order_no`) using BTREE
) ENGINE=InnoDB ;
-- 表中有如下记录
id order_no create_date
1 1001 2023-7-28 13:59:07
2 1002 2023-7-28 13:59:14
3 1003 2023-7-28 13:59:24
4 1004 2023-7-28 13:59:31
5 1005 2023-7-28 13:59:42
6 1006 2023-7-28 13:59:59
场景:
select id from t_order where order_no = 1007 for update;
加了(1006, +∞]next-key
X型锁
select id from t_order where order_no = 1008 for update
也想要获取(1006, +∞]next-key
X型锁会被阻塞吗?
不会,因为+∞并不是一个真正存在的值,并不会在正无穷处的记录锁发生冲突,而且间隙锁不会冲突,两个事务可以同时获得(1006, +∞]next-key
锁。
知道了这个还不够,还需要知道insert语句是如何加锁的?
insert
语句在正常执行的时候是不会生成锁结构的,而是靠聚簇索引记录自带的trx_id
隐藏列来作为隐式锁从而保护记录。
具体地,当事务需要加锁的时候,如果这个锁不可能发生冲突,InnoDB
会跳过加锁环节,这就叫做隐式锁。隐式锁是InnoDB
实现的一种延迟加锁机制,只有当可能发生冲突的时候隐式锁才转化为显示锁。这样可以减少锁的数量,提高整体性能。
以下场景,insert
的隐式锁会转化为显示锁:
记录之间有间隙锁
插入一条新记录之前,都需要看以下待插入记录的下一条记录上是否有间隙锁,如果有间隙锁则会生成一个插入意向锁,然后将锁的状态设置为等待(等待其他事务释放那个间隙锁,然后才会从等待变为正常状态,代表获取锁成功获取到了锁),这里等待的表现形式就是insert
语句发生阻塞。
插入记录时发生唯一键冲突
如果是主键冲突
如图所示,该事务插入记录的时候发生主键冲突,记录插入不成功。但是不仅仅是报错,这里还进行了一个更重要的操作,给该条记录加上了一个S型记录锁。
为什么要加这个S型记录锁?
因为该事务(记为A)当前是无法插入数据成功的,也就是证明了该数据已经存在,这里如果有其他事务B将该记录删除,那么事务A如果再进行插入数据就是成功的!这就算是幻读,不符合RR级别的隔离条件。所以加上S型记录锁之后,其他事务要修改该记录得先等待事务A提交之后释放S锁然后才能获取X型锁进行修改。
如果是唯一二级索引冲突
如图所示,基本原理与主键冲突相同,即使是唯一键,也全部按照二级索引(可以重复)的情况来处理,使用next-key
锁,在这里的例子中,由于1001记录存在所以1001就是右边界,左边界就是上一条记录,因此加上(-∞,1001]next-key
回到之前的场景:事务A和事务B都加上了(1006, +∞]next-key,然后事务A插入数据被事务B加的锁阻塞,事务B插入数据被事务A加的锁阻塞,两个事务都在等对方的锁释放,但是一直都等不到,所以造成了死锁。
如何避免死锁?
死锁产生的必要四个条件:互斥,占有且等待,不可强占用,循环等待。只需要破坏任意一个条件可以打破死锁。
在数据库层面提供了两种方式来打破【循环等待】条件:
设置事务等待锁的超时时间,超过该时间之后事务就发生回滚,然后锁就被释放,另一个事务就可以继续执行了。
innodb_lock_wait_timeout --这个参数就是用来控制超时时间的,默认是50s
开启主动死锁检测,主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。
innodb_deadlock_detect --将这个参数设置为on表示开启
Buffer Pool
Buffer Pool的工作原理
Buffer Pool
的作用:
当读取数据时,如果数据存在于Buffer Pool中,客户端直接读取其中数据,如果没有则再去磁盘读取数据。
当修改数据的时候,首先修改Buffer Pool中数据所在的页,将其设置为脏页,最后由后台线程将脏页写入磁盘。
Buffer Pool
有多大?
Mysql启动的时候会向操作系统申请一片连续的内存空间,默认配置下Buffer Pool
是128M
。
innodb_buffer_pool_size --通过配置该项来改变大小,推荐是物理内存的60%~80%
如何管理?
每张缓存页都有一个控制块来维护,记录了必要的信息,相当于句柄一样。
又由于缓存页有不同的类别,free page
, dirty page
, clean page
,所以底层又使用了三个链表来管理不同类别的page
。
Free
链表管理free page
Flush
链表管理dirty page
后台线程只需遍历Flush链表就可以将脏页写入到磁盘。
如何提高缓存命中率
我们希望频繁访问的数据能够一直在Buffer Pool
中,而一些较少访问的数据可以在适当的时候淘汰掉,这就要使用LRU
算法。
预读失效问题
Mysql在加载数据页的时候,会提前把相邻的数据页一起加入进来,目的是为了减少磁盘IO次数。因为局部性原理表明,程序访问某个位置数据的时候,大概率还会访问到周边的数据。但是也有可能提前加载进来的数据页在未来根本没有被访问到,造成的结果就是:不会被访问的预读页占据了LRU前排的位置,而末尾淘汰的页可能却是频繁访问的页,这样就大大降低了缓存命中率。
为了解决这个问题,Mysql的做法是:
划分出young
和old
区域之后,预读的页会加入的old
区域的首部,当页真正被访问到的时候才会进入到young
区域的首部,如果预读的页一直没有被访问就会在old区域中被移除。
Buffer Pool污染
通过划分young
和old
区域的方法避免了预读失效的问题,但是Buffer Pool
污染也是个问题。
当有一条语句扫描大量数据的时候,在Buffer Pool空间比较有限的情况下,可能会将Buffer Pool中的页全部替换出去,导致大量的热点数据被淘汰了。等到这些热点数据再次被访问的时候,由于缓存未命中,就会产生大量磁盘IO,Mysql的性能急剧下降,这就是Buffer Pool
污染。
解决方案:
像这种全表扫描的场景,其实很多记录只会被访问到一次,之后不会再访问到。但是为了让这些记录不会进入的young
区域,只需要提高进入young
区域的门槛即可。
Mysql是这样做的:当old区域的数据页第一次被访问的时候,会记录访问时间到该页的控制块中。如果后续的访问时间与第一次的访问时间不在某个定制的时间间隔内才将该页加入到young
区域,反之则继续停留在old
区域
innodb_old_blocks_time --这个配置项就是用来控制那个定制的时间间隔,默认是1000ms
脏页什么时候被刷入磁盘
下面几种情况会触发脏页的刷新:
当
redo log
满的时候,会主动触发脏页刷新到磁盘Buffer Pool空间不足的时候需要将一部分数据页淘汰掉,如果是脏页,则先刷新到磁盘再淘汰
Mysql认为空闲的时候,后台线程会定期将适量的脏页刷新到磁盘
Mysql正常关闭前会将所有脏页刷新到磁盘。
脏页来不及刷新,Mysql宕机了怎么办?
由于redo log日志先行(先写日志,再写磁盘),所以不用担心。通过redo log日志就能恢复数据。
慢监控SQL下间断出现用时稍长的SQL是什么情况?
可能是脏页刷新到磁盘带来的性能开销,导致数据操作抖动,如果间断出现,可能需要调大Buffer Pool
或者redo log
。