@ -608,13 +608,13 @@ WHERE A.EMP_SUPV_ID = B.EMP_ID;
## 行级锁
在 `InnoDB` 事务中,行锁通过给索引上的索引项加锁来实现。即只有通过索引条件检索数据,`InnoDB` 才使用行级锁,否则将使用表锁。行级锁定同样分为两种类型:`共享锁` 和`排他锁`,以及加锁前需要先获得的 `意向共享锁` 和 `意向排他锁` 。行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
## 行锁(Record Locks)
在 `InnoDB` 事务中,行锁通过给索引上的索引项加锁来实现。即只有通过索引条件检索数据,`InnoDB` 才使用行级锁,否则将使用表锁。行级锁定同样分为两种类型:`共享锁` 和`排他锁`,以及加锁前需要先获得的 `意向共享锁` 和 `意向排他锁` 。
行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议 。
在 InnoDB 存储引擎中, SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决, INSERT 的不可重复读问题是通过 Next-Key Lock( Record Lock + Gap Lock) 解决的 。
@ -636,14 +636,6 @@ WHERE A.EMP_SUPV_ID = B.EMP_ID;
**行锁实现算法**
- Record Lock( 记录锁)
- Gap Lock( 间隙锁)
- Next-Key Lock( 间隙锁)
**MVCC**
**MVCC**主要是通过**版本链**和**ReadView**来实现的。在Mysql的InnoDB引擎中, 只有**已提交读(READ COMMITTD)**和**可重复读(REPEATABLE READ)**这两种隔离级别下的事务采用了MVCC机制。
@ -675,33 +667,91 @@ WHERE A.EMP_SUPV_ID = B.EMP_ID;
**行锁实现算法**
- Record Lock( 记录锁)
- Gap Lock( 间隙锁)
- Next-Key Lock( 间隙锁)
### Record Lock( 记录锁)
单个行记录上的锁,总是会去锁住索引记录。
记录锁就是为**某行**记录加锁,它封锁该行的索引记录:
行锁,顾名思义,是加在`索引行`(是索引行,不是数据行)上的锁。比如:
```sql
-- id 列为主键列或唯一索引列
SELECT * FROM t_user WHERE id = 1 FOR UPDATE;
```
`select * from user where id=1 and id=10 for update` ,就会在`id=1`和`id=10`的索引行上加Record Lock。
id 为 1 的记录行会被锁住。需要注意:
- `id` 列必须为`唯一索引列`或`主键列`,否则上述语句加的锁就会变成`临键锁`
- 同时查询语句必须为`精准匹配`( `=`),不能为 `>` 、`< `、`like`等,否则也会退化成`临键锁`
也可以在通过 `主键索引` 与 `唯一索引` 对数据行进行 UPDATE 操作时,也会对该行数据加`记录锁`:
```sql
-- id 列为主键列或唯一索引列
UPDATE t_user SET age = 50 WHERE id = 1;
```
### Gap Lock( 间隙锁)
间隙锁,它会锁住两个索引之间的区域。比如:
**间隙锁**基于`非唯一索引`,它`锁定一段范围内的索引记录`。**间隙锁**基于下面将会提到的`Next-Key Locking` 算法,请务必牢记:**使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据**。
```sql
SELECT * FROM t_user WHERE id BETWEN 1 AND 10 FOR UPDATE;
-- 或
SELECT * FROM t_user WHERE id > 1 AND id < 10 FOR UPDATE ;
```
即所有在`( 1, 10) `区间内的记录行都会被锁住, 所有id 为 2、3、4、5、6、7、8、9 的数据行的插入会被阻塞,但是 1 和 10 两条记录行并不会被锁住。除了手动加锁外,在执行完某些 `SQL` 后,`InnoDB`也会自动加**间隙锁**。
**幻读原因**:因为行锁只能锁住行,但新插入记录这个动作,要更新的是记录之间的“`间隙`”。所以加入间隙锁来解决幻读。
### Next-Key Lock( 临键锁)
临键锁是一种特殊的**间隙锁**,也可以理解为一种特殊的**算法**。通过**临建锁**可以解决`幻读`的问题。每个数据行上的`非唯一索引列`上都会存在一把**临键锁**,当某个事务持有该数据行的**临键锁**时,会锁住一段**左开右闭区间**的数据。需要强调的一点是,`InnoDB` 中`行级锁`是基于索引实现的,**临键锁**只与`非唯一索引列`有关,在`唯一索引列`(包括`主键列`)上不存在**临键锁**。
比如:表信息 `t_user(id PK, age KEY, name)`
![Next-Key-Locks ](images/Database/Next-Key-Locks.jpg )
该表中 `age` 列潜在的`临键锁`有:
![Next-Key-Locks-临键锁 ](images/Database/Next-Key-Locks-临键锁.jpg )
在`事务 A` 中执行如下命令:
`select * from user where id>1 and id<10 for update` , 就会在id为(1,10)的索引区间上加Gap Lock。
```sql
-- 根据非唯一索引列 UPDATE 某条记录
UPDATE t_user SET name = Vladimir WHERE age = 24;
-- 或根据非唯一索引列 锁住某条记录
SELECT * FROM t_user WHERE age = 24 FOR UPDATE;
```
想一下幻读的原因,其实就是行锁只能锁住行,但新插入记录这个动作,要更新的是记录之间的“`间隙`”。所以加入间隙锁来解决幻读。
不管执行了上述 `SQL` 中的哪一句,之后如果在`事务 B` 中执行以下命令,则该命令会被阻塞:
```sql
INSERT INTO t_user VALUES(100, 26, 'tian');
```
很明显,`事务 A` 在对 `age` 为 24 的列进行 UPDATE 操作的同时,也获取了 `(24, 32]` 这个区间内的临键锁。
### Next-Key Lock( 间隙锁)
不仅如此,在执行以下 SQL 时,也会陷入阻塞等待:
也叫间隙锁,它是 Record Lock + Gap Lock 形成的一个闭区间锁。比如:
```sql
INSERT INTO table VALUES(100, 30, 'zhang');
```
`select * from user where id>=1 and id<=10 for update` , 就会在id为[1,10]的索引闭区间上加 Next-Key Lock。
那最终我们就可以得知,在根据`非唯一索引` 对记录行进行 `UPDATE \ FOR UPDATE \ LOCK IN SHARE MODE` 操作时, InnoDB 会获取该记录行的 `临键锁` ,并同时获取该记录行下一个区间的`间隙锁` 。
这样组合起来就有:行级共享锁,表级共享锁;行级排它锁,表级排它锁。
即`事务 A`在执行了上述的 SQL 后,最终被锁住的记录区间为 `(10, 32)` 。
@ -716,23 +766,175 @@ MySQL 里面表级别的锁有这几种:
### 表锁
- 表锁会限制别的线程的读写外
- 表锁也会限制本线程接下来的读写操作
如果我们想对学生表( t_student) 加表锁, 可以使用下面的命令:
```sql
-- 表级别的共享锁,也就是读锁
lock tables t_student read;
-- 表级别的独占锁,也就是写锁
lock tables t_stuent wirte;
```
不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能,**InnoDB 的优势在于实现了颗粒度更细的行级锁**。要释放表锁,可以使用下面这条命令,会释放当前会话的所有表锁:
```sql
unlock tables
```
### 元数据锁( MDL)
MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。不需要显示的使用 MDL, 因为当我们对数据库表进行操作时, 会自动给这个表加上 MDL:
- 对一张表进行 CRUD 操作时,加的是 **MDL 读锁**
当有线程在执行 select 语句( 加 MDL 读锁)的期间,如果有其他线程要更改该表的结构( 申请 MDL 写锁),那么将会被阻塞,直到执行完 select 语句( 释放 MDL 读锁)。
- 对一张表做结构变更操作的时候,加的是 **MDL 写锁**
当有线程对表结构进行变更( 加 MDL 写锁)的期间,如果有其他线程执行了 CRUD 操作( 申请 MDL 读锁),那么就会被阻塞,直到表结构变更完成( 释放 MDL 写锁)。
MDL 是在事务提交后才会释放,这意味着**事务执行期间, MDL 是一直持有的**。申请 MDL 锁的操作会形成一个队列,队列中**写锁获取优先级高于读锁**,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。
### 意向锁
- 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
- 在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;
也就是,当执行插入、更新、删除操作,需要先对表加上「意向共享锁」,然后对该记录加独占锁。而普通的 select 是不会加行级锁的,普通的 select 语句是利用 MVCC 实现一致性读, 是无锁的。不过, select 也是可以对记录加共享锁和独占锁的,具体方式如下:
```sql
-- 先在表上加上意向共享锁,然后对读取的记录加独占锁
select ... lock in share mode;
-- 先表上加上意向独占锁,然后对读取的记录加独占锁
select ... for update;
```
**意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(\*lock tables … read\*)和独占表锁(\*lock tables … write\*)发生冲突。**
表锁和行锁是满足读读共享、读写互斥、写写互斥的。如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。所以,**意向锁的目的是为了快速判断表里是否有记录被加锁**。
### AUTO-INC 锁
在为某个字段声明 `AUTO_INCREMENT` 属性时,之后可以在插入数据时,可以不指定该字段的值,数据库会自动给该字段赋值递增的值,这主要是通过 AUTO-INC 锁实现的。
AUTO-INC 锁是特殊的表锁机制,锁**不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放**。**在插入数据时,会加一个表级别的 AUTO-INC 锁**,然后为被 `AUTO_INCREMENT` 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。
AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。因此, 在 MySQL 5.1.22 版本开始, InnoDB 存储引擎提供了一种**轻量级的锁**来实现自增。一样也是在插入数据的时候,会为被 `AUTO_INCREMENT` 修饰的字段加上轻量级锁,**然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁**。
InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择用 AUTO-INC 锁,还是轻量级的锁。
- 当 innodb_autoinc_lock_mode = 0, 就采用 AUTO-INC 锁
- 当 innodb_autoinc_lock_mode = 2, 就采用轻量级锁
- 当 innodb_autoinc_lock_mode = 1, 这个是默认值, 两种锁混着用, 如果能够确定插入记录的数量就采用轻量级锁, 不确定时就采用 AUTO-INC 锁
不过,当 innodb_autoinc_lock_mode = 2 是性能最高的方式,但是会带来一定的问题。因为并发插入的存在,在每次插入时,自增长的值可能不是连续的,**这在有主从复制的场景中是不安全的**。
## 死锁
当两个及以上的事务,双方都在等待对方释放已经持有的锁或因为加锁顺序不一致造成循环等待锁资源,就会出现“死锁”。常见的报错信息为 ” `Deadlock found when trying to get lock...` ”。`MySQL` 出现死锁的几个要素为:
- 两个或者两个以上事务
- 每个事务都已经持有锁并且申请新的锁
- 锁资源同时只能被同一个事务持有或者不兼容
- 事务之间因为持有锁和申请锁导致彼此循环等待
# 事务
### 预防死锁
- `innodb_lock_wait_timeout` ** 等待锁超时回滚事务**
直观方法是在两个事务相互等待时,当一个等待时间超过设置的某一阀值时,对其中一个事务进行回滚,另一个事务就能继续执行。
- `wait-for graph` **算法来主动进行死锁检测**
每当加锁请求无法立即满足需要并进入等待时, wait-for graph算法都会被触发。wait-for graph要求数据库保存以下两种信息:
- 锁的信息链表
- 事务等待链表
### 解决死锁
- 等待事务超时,主动回滚
- 进行死锁检查,主动回滚某条事务,让别的事务能继续走下去
下面提供一种方法,解决死锁的状态:
```sql
-- 查看正在被锁的事务
SELECT * FROM INFORMATION_SCHEMA.INNODB_TRX;
```
![解决死锁 ](images/Database/解决死锁.jpg )
```sql
--上图trx_mysql_thread_id列的值
kill trx_mysql_thread_id;
```
## 锁问题
### 脏读
脏读指的是不同事务下,当前事务可以读取到另外事务未提交的数据。
例如: T1 修改一个数据, T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。
![img ](images/Database/007S8ZIlly1gjjfxu6baej30j30kijsr.jpg )
### 不可重复读
不可重复读指的是同一事务内多次读取同一数据集合,读取到的数据是不一样的情况。
例如: T2 读取一个数据, T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。
![img ](images/Database/007S8ZIlly1gjjfxx1pw3j30i90j0myc.jpg )
在 InnoDB 存储引擎中:
- `SELECT` :操作的不可重复读问题通过 MVCC 得到了解决的
- `UPDATE/DELETE` :操作的不可重复读问题是通过 Record Lock 解决的
- `INSERT` :操作的不可重复读问题是通过 Next-Key Lock( Record Lock + Gap Lock) 解决的
### 幻读
幻读是指在同一事务下,连续执行两次同样的 sql 语句可能返回不同的结果,第二次的 sql 语句可能会返回之前不存在的行。幻影读是一种特殊的不可重复读问题。
### 丢失更新
一个事务的更新操作会被另一个事务的更新操作所覆盖。
例如: T1 和 T2 两个事务都对一个数据进行修改, T1 先修改, T2 随后修改, T2 的修改覆盖了 T1 的修改。
![img ](images/Database/007S8ZIlly1gjjfxzqa84j30h30eowfd.jpg )
这类型问题可以通过给 SELECT 操作加上排他锁来解决,不过这可能会引入性能问题,具体使用要视业务场景而定。
# 数据库事务
**什么叫事务?**
@ -1102,178 +1304,391 @@ SELECT * FROM products WHERE id LIKE '3' FOR UPDATE;
# 数据库
# InnoDB
## 锁类型
## 数据页
锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享资源的并发访问 。
数据页主要是用来存储表中记录的,它在磁盘中是用双向链表相连的,方便查找,能够非常快速得从一个数据页,定位到另一个数据页 。
**共享锁( S Lock) **:允许事务读一行数据
- 写操作时,先将数据写到内存的某个批次中,然后再将该批次的数据一次性刷到磁盘上。如下图所示:
**排他锁( X Lock) **:允许事务删除或者更新一行数据
![InnoDB-数据页-写操作 ](images/Database/InnoDB-数据页-写操作.jpg )
**意向共享锁( IS Lock) **:事务想要获得一张表中某几行的共享锁
- 读操作时,从磁盘上一次读一批数据,然后加载到内存当中,以后就在内存中操作。如下图所示:
**意向排他锁**:事务想要获得一张表中某几行的排他锁
![InnoDB-数据页-读操作 ](images/Database/InnoDB-数据页-读操作.jpg )
磁盘中各数据页的整体结构如下图所示:
![InnoDB-数据页 ](images/Database/InnoDB-数据页.jpg )
## 锁算法
通常情况下,单个数据页默认的大小是`16kb`。当然,我们也可以通过参数:`innodb_page_size`,来重新设置大小。不过,一般情况下,用它的默认值就够了。单个数据页包含内容如下:
### Record Lock
![InnoDB-单个数据页内容 ](images/Database/InnoDB-单个数据页内容.jpg )
锁定一个记录上的索引,而不是记录本身。
### 文件头部
如果表没有设置索引, InnoDB 会自动在主键上创建隐藏的聚簇索引,因此 Record Locks 依然可以使用 。
通过前面介绍的行记录中`下一条记录的位置`和`页目录`, innodb能非常快速的定位某一条记录。但有个前提条件, 就是用户记录必须在同一个数据页当中 。
如果用户记录非常多,在第一个数据页找不到我们想要的数据,需要到另外一页找该怎么办呢?这时就需要使用`文件头部`了。它里面包含了多个信息, 但我只列出了其中4个最关键的信息:
- 页号
- 上一页页号
- 下一页页号
- 页类型
### Gap Lock
顾名思义, innodb是通过页号、上一页页号和下一页页号来串联不同数据页的。如下图所示:
锁定索引之间的间隙,但是不包含索引本身。例如当一个事务执行以下语句,其它事务就不能在 t.c 中插入 15。
![InnoDB-文件头部 ](images/Database/InnoDB-文件头部.jpg )
```sql
SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;
```
不同的数据页之间, 通过上一页页号和下一页页号构成了双向链表。这样就能从前向后, 一页页查找所有的数据了。此外, 页类型也是一个非常重要的字段, 它包含了多种类型, 其中比较出名的有: 数据页、索引页( 目录项页) 、溢出页、undo日志页等。
### Next-Key Lock
### 页头部
它是 Record Locks 和 Gap Locks 的结合, 不仅锁定一个记录上的索引, 也锁定索引之间的间隙。例如一个索引包含以下值: 10, 11, 13, and 20, 那么就需要锁定以下区间 :
比如一页数据到底保存了多条记录,或者页目录到底使用了多个槽等。这些信息是实时统计,还是事先统计好了,保存到某个地方?为了性能考虑,上面的这些统计数据,当然是先统计好,保存到一个地方。后面需要用到该数据时,再读取出来会更好。这个保存统计数据的地方,就是`页头部`。当然页头部不仅仅只保存:槽的数量、记录条数等信息。它还记录了 :
```
(-∞, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞)
```
- 已删除记录所占的字节数
- 最后插入记录的位置
- 最大事务id
- 索引id
- 索引层级
在 InnoDB 存储引擎中, SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决, INSERT 的不可重复读问题是通过 Next-Key Lock( Record Lock + Gap Lock) 解决的。
### 最大和最小记录
## 锁问题
在一个数据页当中,如果存在多条用户记录,它们是通过`下一条记录的位置`相连的。不过有个问题: 如果才能快速找到最大的记录和最小的记录呢? 这就需要在保存用户记录的同时, 也保存最大和最小记录了。最大记录保存到Supremum记录中。最小记录保存在Infimum记录中。
### 脏读
在保存用户记录时, 数据库会自动创建两条额外的记录: Supremum 和 Infimum。它们之间的关系, 如下图所示:
脏读指的是不同事务下,当前事务可以读取到另外事务未提交的数据。
![InnoDB-最大和最小记录 ](images/Database/InnoDB-最大和最小记录.jpg )
例如: T1 修改一个数据, T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据 。
从图中可以看出用户数据是从最小记录开始,通过下一条记录的位置,从小到大,一步步查找,最后找到最大记录为止 。
![img ](images/Database/007S8ZIlly1gjjfxu6baej30j30kijsr.jpg )
### 用户记录
### 不可重复读
对于新申请的数据页, 用户记录是空的。当插入数据时, innodb会将一部分`空闲空间`分配给用户记录。用户记录是innodb的重中之重, 我们平时保存到数据库中的数据, 就存储在它里面。其实在innodb支持的数据行格式有四种:
不可重复读指的是同一事务内多次读取同一数据集合,读取到的数据是不一样的情况。
- compact行格式
- redundant行格式
- dynamic行格式
- compressed行格式
例如: T2 读取一个数据, T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。
![img ](images/Database/007S8ZIlly1gjjfxx1pw3j30i90j0myc.jpg )
在 InnoDB 存储引擎中 :
以compact行格式为例 :
- `SELECT` :操作的不可重复读问题通过 MVCC 得到了解决的
- `UPDATE/DELETE` :操作的不可重复读问题是通过 Record Lock 解决的
- `INSERT` :操作的不可重复读问题是通过 Next-Key Lock( Record Lock + Gap Lock) 解决的
![InnoDB-compact行格式 ](images/Database/InnoDB-compact行格式.jpg )
一条用户记录主要包含三部分内容:
- 记录额外信息: 它包含了变长字段、null值列表和记录头信息
- 隐藏列: 它包含了行id、事务id和回滚点
- 真正的数据列:包含真正的用户数据,可以有很多列
### 幻读
幻读是指在同一事务下,连续执行两次同样的 sql 语句可能返回不同的结果,第二次的 sql 语句可能会返回之前不存在的行。幻影读是一种特殊的不可重复读问题。
#### 额外信息
额外信息并非真正的用户数据,它是为了辅助存数据用的。
### 丢失更新
- ** 变长字段列表**
一个事务的更新操作会被另一个事务的更新操作所覆盖 。
有些数据如果直接存会有问题, 比如: 如果某个字段是varchar或text类型, 它的长度不固定, 可以根据存入数据的长度不同, 而随之变化。如果不在一个地方记录数据真正的长度, innodb很可能不知道要分配多少空间。假如都按某个固定长度分配空间, 但实际数据又没占多少空间, 岂不是会浪费? 所以, 需要在变长字段中记录某个变长字段占用的字节数, 方便按需分配空间 。
例如: T1 和 T2 两个事务都对一个数据进行修改, T1 先修改, T2 随后修改, T2 的修改覆盖了 T1 的修改。
- **null值列表**
![img ](images/Database/007S8ZIlly1gjjfxzqa84j30h30eowfd.jpg )
数据库中有些字段的值允许为null, 如果把每个字段的null值, 都保存到用户记录中, 显然有些浪费存储空间。有没有办法只简单的标记一下, 不存储实际的null值呢? 答案: 将为null的字段保存到null值列表。在列表中用二进制的值1, 表示该字段允许为null, 用0表示不允许为null。它只占用了1位, 就能表示某个字符是否为null, 确实可以节省很多存储空间。
这类型问题可以通过给 SELECT 操作加上排他锁来解决,不过这可能会引入性能问题,具体使用要视业务场景而定。
- ** 记录头信息**
记录头信息用于描述一些特殊的属性。它主要包含:
- deleted_flag: 即删除标记, 用于标记该记录是否被删除了
- min_rec_flag: 即最小目录标记, 它是非叶子节点中的最小目录标记
- n_owned: 即拥有的记录数, 记录该组索引记录的条数
- heap_no: 即堆上的位置, 它表示当前记录在堆上的位置
- record_type: 即记录类型, 其中0表示普通记录, 1表示非叶子节点, 2表示Infrimum记录, 3表示Supremum记录
- next_record: 即下一条记录的位置
## 数据切分
### 水平切分
水平切分又称为 Sharding, 它是将同一个表中的记录拆分到多个结构相同的表中。当一个表的数据不断增多时, Sharding 是必然的选择,它可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。
#### 隐藏列
![img ](images/Database/007S8ZIlly1gjjfy33yx2j30fm05zwg9.jpg )
数据库在保存一条用户记录时,会自动创建一些隐藏列。如下图所示:
![InnoDB-隐藏列 ](images/Database/InnoDB-隐藏列.jpg )
目前innodb自动创建的隐藏列有三种:
### 垂直切分
- db_row_id, 即行id, 它是一条记录的唯一标识。
- db_trx_id, 即事务id, 它是事务的唯一标识。
- db_roll_ptr, 即回滚点, 它用于事务回滚。
垂直切分是将一张表按列分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直气氛将经常被使用的列喝不经常被使用的列切分到不同的表中。在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不通的库中,例如将原来电商数据部署库垂直切分称商品数据库、用户数据库等。
如果表中有主键, 则用主键做行id, 无需额外创建。如果表中没有主键, 假如有不为null的unique唯一键, 则用它做为行id, 同样无需额外创建。如果表中既没有主键, 又没有唯一键, 则数据库会自动创建行id。也就是说在innodb中, 隐藏列中`事务id`和`回滚点`是一定会被创建的, 但行id要根据实际情况决定 。
![img ](images/Database/007S8ZIlly1gjjfy5yoatj30cy09l776.jpg )
#### 真正数据列
### Sharding策略
真正的数据列中存储了用户的真实数据,它可以包含很多列的数据。
- 哈希取模: hash(key)%N
- 范围:可以是 ID 范围也可以是时间范围
- 映射表:使用单独的一个数据库来存储映射关系
### 页目录
### Sharding存在的问题
从上面可以看出,如果我们要查询某条记录的话,数据库会从最小记录开始,一条条查找所有记录。如果中途找到了,则直接返回该记录。如果一直找到最大记录,还没有找到想要的记录,则返回空。
- ** 事务问题**:使用分布式事务来解决,比如 XA 接口
但效率会不会有点低?这不是要对整页用户数据进行扫描吗?
- ** 连接**:可以将原来的连接分解成多个单表查询,然后在用户程序中进行连接。
这就需要使用`页目录`了。说白了,就是把一页用户记录分为若干组,每一组的最大记录都保存到一个地方,这个地方就是`页目录`。每一组的最大记录叫做`槽`。由此可见,页目录是有多个槽组成的。所下图所示:
- ** 唯一性**
- 使用全局唯一 ID ( GUID)
- 为每个分片指定一个 ID 范围
- 分布式 ID 生成器(如 Twitter 的 Snowflake 算法)
![InnoDB-页目录 ](images/Database/InnoDB-页目录.jpg )
假设一页的数据分为4组, 这样在页目录中, 就对应了4个槽, 每个槽中都保存了该组数据的最大值。这样就能通过二分查找, 比较槽中的记录跟需要找到的记录的大小。如果用户需要查找的记录, 小于当前槽中的记录, 则向上查找上一个槽。如果用户需要查找的记录, 大于当前槽中的记录, 则向下查找下一个槽。如此一来, 就能通过二分查找, 快速的定位需要查找的记录了。
## 复制
### 主从复制
### 文件尾部
主要涉及三个线程:
数据库的数据是以数据页为单位, 加载到内存中, 如果数据有更新的话, 需要刷新到磁盘上。但如果某一天比较倒霉, 程序在刷新到磁盘的过程中, 出现了异常, 比如: 进程被kill掉了, 或者服务器被重启了。这时候数据可能只刷新了一部分, 如何判断上次刷盘的数据是完整的呢? 这就需要用到`文件尾部`。它里面记录了页面的`校验和`。
- **binlog 线程** : 负责将主服务器上的数据更改写入二进制日志( Binary log) 中
- **I/O 线程** :负责从主服务器上读取- 二进制日志, 并写入从服务器的中继日志( Relay log)
- **SQL 线程** : 负责读取中继日志, 解析出主服务器已经执行的数据更改并在从服务器中重放( Replay)
在数据刷新到磁盘之前,会先计算一个页面的校验和。后面如果数据有更新的话,会计算一个新值。文件头部中也会记录这个校验和,由于文件头部在前面,会先被刷新到磁盘上。
![img ](images/Database/007S8ZIlly1gjjfy97e83j30jk09ltav.jpg )
接下来,刷新用户记录到磁盘的时候,假设刷新了一部分,恰好程序出现异常了。这时,文件尾部的校验和,还是一个旧值。数据库会去校验,文件尾部的校验和,不等于文件头部的新值,说明该数据页的数据是不完整的。
### 读写分离
## Buffer Pool
主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。读写分离能提高性能的原因在于:
`InnoDB` 为了解决磁盘`IO`问题,`MySQL`需要申请一块内存空间,这块内存空间称为`Buffer Pool`。
- 主从服务器负责各自的读和写,极大程度缓解了锁的争用
- 从服务器可以使用 MyISAM, 提升查询性能以及节约系统开销
- 增加冗余,提高可用性
![Buffer-Pool ](images/Database/Buffer-Pool.png )
读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。
### 缓存页
![img ](images/Database/007S8ZIlly1gjjfycefayj313k0s20wl.jpg )
`Buffer Pool` 申请下来后,`Buffer Pool`里面放什么,要怎么规划?
`MySQL` 数据是以页为单位,每页默认`16KB`,称为数据页,在`Buffer Pool`里面会划分出若干**个缓存页**与数据页对应。
![Buffer-Pool-缓存页 ](images/Database/Buffer-Pool-缓存页.png )
### 描述数据
如何知道缓存页对应那个数据页呢?
所以还需要缓存页的元数据信息,可以称为**描述数据**,它与缓存页一一对应,包含一些所属表空间、数据页的编号、`Buffer Pool`中的地址等等。
![Buffer-Pool-描述数据 ](images/Database/Buffer-Pool-描述数据.png )
## 索引
后续对数据的增删改查都是在`Buffer Pool`里操作
### 索引的优点
- 查询:从磁盘加载到缓存,后续直接查缓存
- 插入:直接写入缓存
- 更新删除:缓存中存在直接更新,不存在加载数据页到缓存更新
- ** 大大减少了服务器需要扫描的数据行数**
- ** 帮助服务器避免进行排序和分组,以及避免创建临时表**( B+Tree 索引是有序的,可以用于 ORDER BY 和 GROUP BY 操作。临时表主要是在排序和分组过程中创建,不需要排序和分组,也就不需要创建临时表)。
- ** 将随机 I/O 变为顺序 I/O**( B+Tree 索引是有序的,会将相邻的数据都存储在一起)。
`MySQL` 宕机数据不就全丢了吗?
`InnoDB` 提供了`WAL`技术( Write-Ahead Logging) , 通过`redo log`让`MySQL`拥有了崩溃恢复能力。再配合空闲时,会有异步线程做缓存页刷盘,保证数据的持久性与完整性。
![Buffer-Pool-Write-Ahead-Logging ](images/Database/Buffer-Pool-Write-Ahead-Logging.png )
直接更新数据的缓存页称为**脏页**,缓存页刷盘后称为**干净页**。
### Free链表
`MySQL` 数据库启动时,按照设置的`Buffer Pool`大小,去找操作系统申请一块内存区域,作为`Buffer Pool`( **假设申请了512MB**)。申请完毕后,会按照默认缓存页的`16KB`以及对应的`800Byte`的描述数据,在`Buffer Pool`中划分出来一个一个的缓存页和它们对应的描述数据。
![Buffer-Pool-Free链表 ](images/Database/Buffer-Pool-Free链表.png )
`MySQL` 运行起来后,会不停的执行增删改查,需要从磁盘读取一个一个的数据页放入`Buffer Pool`对应的缓存页里,把数据缓存起来,以后就可以在内存里执行增删改查。
![Buffer-Pool-Free链表-增删改查 ](images/Database/Buffer-Pool-Free链表-增删改查.png )
但是这个过程必然涉及一个问题,**哪些缓存页是空闲的**?
为了解决这个问题,我们使用链表结构,把空闲缓存页的**描述数据**放入链表中,这个链表称为`free`链表。针对`free`链表我们要做如下设计:
![Buffer-Pool-Free链表设计 ](images/Database/Buffer-Pool-Free链表设计.png )
- 新增`free`基础节点
- 描述数据添加`free`节点指针
最终呈现出来的,是由空闲缓存页的**描述数据**组成的`free`链表。
![Buffer-Pool-Free链表组成 ](images/Database/Buffer-Pool-Free链表组成.png )
有了`free`链表之后,我们只需要从`free`链表获取一个**描述数据**,就可以获取到对应的缓存页。
![Buffer-Pool-Free链表-获取描述数据 ](images/Database/Buffer-Pool-Free链表-获取描述数据.png )
往**描述数据**与**缓存页**写入数据后,就将该**描述数据**移出`free`链表。
### 缓存页哈希表
查询数据时,如何在`Buffer Pool`里快速定位到对应的缓存页呢?难道需要一个**非空闲的描述数据**链表,再通过**表空间号+数据页编号**遍历查找吗?这样做也可以实现,但是效率不太高,时间复杂度是`O(N)`。
![Buffer-Pool-缓存页哈希表 ](images/Database/Buffer-Pool-缓存页哈希表.png )
所以我们可以换一个结构,使用哈希表来缓存它们间的映射关系,时间复杂度是`O(1)`。
![Buffer-Pool-缓存页哈希表-复杂度 ](images/Database/Buffer-Pool-缓存页哈希表-复杂度.png )
**表空间号+数据页号**,作为一个`key`,然后缓存页的地址作为`value`。每次加载数据页到空闲缓存页时,就写入一条映射关系到**缓存页哈希表**中。
![Buffer-Pool-缓存页哈希表-映射关系 ](images/Database/Buffer-Pool-缓存页哈希表-映射关系.png )
后续的查询,就可以通过**缓存页哈希表**路由定位了。
### Flush链表
还记得之前有说过「**空闲时会有异步线程做缓存页刷盘,保证数据的持久性与完整性**」吗?
新问题来了,难道每次把`Buffer Pool`里所有的缓存页都刷入磁盘吗?当然不能这样做,磁盘`IO`开销太大了,应该把**脏页**刷入磁盘才对(更新过的缓存页)。可是我们怎么知道,那些缓存页是**脏页**?很简单,参照`free`链表,弄个`flush`链表出来就好了,只要缓存页被更新,就将它的**描述数据**加入`flush`链表。针对`flush`链表我们要做如下设计:
- 新增`flush`基础节点
- 描述数据添加`flush`节点指针
![Buffer-Pool-Flush链表 ](images/Database/Buffer-Pool-Flush链表.png )
最终呈现出来的,是由更新过数据的缓存页**描述数据**组成的`flush`链表。
![Buffer-Pool-Flush链表-缓存页 ](images/Database/Buffer-Pool-Flush链表-缓存页.png )
后续异步线程都从`flush`链表刷缓存页,当`Buffer Pool`内存不足时,也会优先刷`flush`链表里的缓存页。
### LRU链表
目前看来`Buffer Pool`的功能已经比较完善了。
![Buffer-Pool-LRU链表 ](images/Database/Buffer-Pool-LRU链表.png )
但是仔细思考下,发现还有一个问题没处理。`MySQL`数据库随着系统的运行会不停的把磁盘上的数据页加载到空闲的缓存页里去,因此`free`链表中的空闲缓存页会越来越少,直到没有,最后磁盘的数据页无法加载。
![Buffer-Pool-LRU链表-无法加载 ](images/Database/Buffer-Pool-LRU链表-无法加载.png )
为了解决这个问题,我们需要淘汰缓存页,腾出空闲缓存页。可是我们要优先淘汰哪些缓存页?总不能一股脑直接全部淘汰吧?这里就要借鉴`LRU`算法思想,把最近最少使用的缓存页淘汰(命中率低),提供`LRU`链表出来。针对`LRU`链表我们要做如下设计:
- 新增`LRU`基础节点
- 描述数据添加`LRU`节点指针
![Buffer-Pool-LRU链表-结构 ](images/Database/Buffer-Pool-LRU链表-结构.png )
实现思路也很简单,只要是查询或修改过缓存页,就把该缓存页的描述数据放入链表头部,也就说近期访问的数据一定在链表头部。
![Buffer-Pool-LRU链表-节点 ](images/Database/Buffer-Pool-LRU链表-节点.png )
当`free`链表为空的时候,直接淘汰`LRU`链表尾部缓存页即可。
### LRU链表优化
麻雀虽小五脏俱全,基本`Buffer Pool`里与缓存页相关的组件齐全了。
![Buffer-Pool-LRU链表优化 ](images/Database/Buffer-Pool-LRU链表优化.png )
但是缓存页淘汰这里还有点问题,如果仅仅只是使用`LRU`链表的机制,有两个场景会让**热点数据**被淘汰。
- ** 预读机制**
InnoDB使用两种预读算法来提高I/O性能: 线性预读( linear read-ahead) 和随机预读( randomread-ahead) 。
- ** 全表扫描**
预读机制是指`MySQL`加载数据页时,可能会把它相邻的数据页一并加载进来(局部性原理)。这样会带来一个问题,预读进来的数据页,其实我们没有访问,但是它却排在前面。
![Buffer-Pool-LRU链表优化-全表扫描 ](images/Database/Buffer-Pool-LRU链表优化-全表扫描.png )
正常来说,淘汰缓存页时,应该把这个预读的淘汰,结果却把尾部的淘汰了,这是不合理的。我们接着来看第二个场景全表扫描,如果**表数据量大**,大量的数据页会把空闲缓存页用完。最终`LRU`链表前面都是全表扫描的数据,之前频繁访问的热点数据全部到队尾了,淘汰缓存页时就把**热点数据页**给淘汰了。
![图片 ](images/Database/819dbbcd31605b3a692576932f25d325.png )
为了解决上述的问题,我们需要给`LRU`链表做冷热数据分离设计,把`LRU`链表按一定比例,分为冷热区域,热区域称为`young`区域,冷区域称为`old`区域。
**以7:3为例, young区域70%, old区域30%**
![图片 ](images/Database/0f2c2610773fb9fe304c374fc37af4ac.png )
如上图所示, 数据页第一次加载进缓存页的时候, 是先放入冷数据区域的头部, 如果1秒后再次访问缓存页, 则会移动到热区域的头部。这样就保证了**预读机制**与**全表扫描**加载的数据都在链表队尾。
- `young` 区域其实还可以做一个小优化,为了防止`young`区域节点频繁移动到表头
- `young` 区域前面`1/4`被访问不会移动到链表头部,只有后面的`3/4`被访问了才会
记住是按照某个比例将`LRU`链表分成两部分,不是某些节点固定是`young`区域的,某些节点固定是`old`区域的,随着程序的运行,某个节点所属的区域也可能发生变化。
- InnoDB在LRU列表中引入了midpoint参数。新读取的页并不会直接放在LRU列表的首部, 而是放在LRU列表的midpoint位置, 即 innodb_old_blocks_pct这个点的设置。默认是37%, 最小是5, 最大是95; 如果内存比较大的话, 可以将这个数值调低, 通常会调成20, 也就是说20%的是冷数据块。目的是为了保护热区数据不被刷出内存。
- InnoDB还引入了innodb_old_blocks_time参数, 控制成为热数据的所需时间, 默认是1000ms, 也就是1s, 也就是数据在1s内没有被刷走, 就调入热区。
## InnoDB日志
### Redo Log(重做日志)
### Undo Log
# 数据切分
## 水平切分
水平切分又称为 Sharding, 它是将同一个表中的记录拆分到多个结构相同的表中。当一个表的数据不断增多时, Sharding 是必然的选择,它可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。
![img ](images/Database/007S8ZIlly1gjjfy33yx2j30fm05zwg9.jpg )
## 垂直切分
垂直切分是将一张表按列分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直气氛将经常被使用的列喝不经常被使用的列切分到不同的表中。在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不通的库中,例如将原来电商数据部署库垂直切分称商品数据库、用户数据库等。
![img ](images/Database/007S8ZIlly1gjjfy5yoatj30cy09l776.jpg )
## Sharding策略
- 哈希取模: hash(key)%N
- 范围:可以是 ID 范围也可以是时间范围
- 映射表:使用单独的一个数据库来存储映射关系
## Sharding存在的问题
- ** 事务问题**:使用分布式事务来解决,比如 XA 接口
- ** 连接**:可以将原来的连接分解成多个单表查询,然后在用户程序中进行连接。
- ** 唯一性**
- 使用全局唯一 ID ( GUID)
- 为每个分片指定一个 ID 范围
- 分布式 ID 生成器(如 Twitter 的 Snowflake 算法)
@ -2252,6 +2667,34 @@ MyISAM既不支持事务、也不支持外键、其优势是访问速度快,
## 复制
### 主从复制
主要涉及三个线程:
- **binlog 线程** : 负责将主服务器上的数据更改写入二进制日志( Binary log) 中
- **I/O 线程** :负责从主服务器上读取- 二进制日志, 并写入从服务器的中继日志( Relay log)
- **SQL 线程** : 负责读取中继日志, 解析出主服务器已经执行的数据更改并在从服务器中重放( Replay)
![img ](images/Database/007S8ZIlly1gjjfy97e83j30jk09ltav.jpg )
### 读写分离
主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。读写分离能提高性能的原因在于:
- 主从服务器负责各自的读和写,极大程度缓解了锁的争用
- 从服务器可以使用 MyISAM, 提升查询性能以及节约系统开销
- 增加冗余,提高可用性
读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。
![img ](images/Database/007S8ZIlly1gjjfycefayj313k0s20wl.jpg )
## 日志系统
**生产优化**
@ -2362,71 +2805,6 @@ redo log 实际的触发 fsync 操作写盘包含以下几个场景:
## 全局锁表锁& 行锁
### 全局锁
**FTWRL**
全局锁就是对整个数据库实例加锁, MySQL 提供了 flush tables with read lock (FTWRL) 的方式去加全局锁。当你需要让整个库处于只读状态的时候,就可以使用这个命令了,之后所有线程的更改操作都会被阻塞。
**mysqldump**
mysqldump 是官方提供的备份工具,可以通过 --single-transaction 参数来启用可重复读隔离级别,从而可以拿到一个一致性视图。
**set global readonly = true**
通过上述命令可以让全库进入只读状态,但是在开发当中,事务框架往往会利用这个参数来处理读写分离。所以通常情况下,还是不建议使用这种方式。
### 表级锁
MySQL 的表级锁有 2 种:表锁和元数据锁。
**表锁**
表锁可以使用 lock tables T read/write , 可以使用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放锁。
**MDL (metadata lock)**
MDL 没有显示的命令, 当执行改表语句时, MDL 会保证读写的正确性。MySQL 在 5.5 版本以后引入了 MDL 锁,当对一个表做增删改查的时候,加 MDL 读锁;当要多表结构做变更的时候,加 MDL 写锁。
- MDL 读锁之间不互斥,因此可以有多个线程同时对一张表 增删改查
- MDL 读-写、写-写之间是互斥的,因此如果同时有 2 个线程给表加字段,则需要顺序执行
### 行锁
**两阶段锁**
当使用update 更新数据时,会对 where 条件扫描到的行加行锁。在 InnoDB 事务中,行锁是在需要的时候才加上的,并且在事务提交后释放的,这就是两阶段锁协议。所以我们在更新数据时,应尽量把容易产生并发更新的行放在事务末端执行。
**死锁**
在事务对不同行加锁的时候,就很有可能出现死锁的情况。
MySQL 有 2 中策略去解决死锁:
- ** 超时等待**:可以通过 innodb_lock_wait_timeout 来设置,默认 50S。( 一般不采用, 因为 50S 对应用来说是不可接受的,并且这个值的设置也没有合适的估算值)
- ** 死锁检测**:发现死锁后,主动回滚其中一个事务。可以通过 innodb_deadlock_detect 设置为 on
上面 2 种死锁的解决方法, 都是MySQL 本身提供的。我们实际开发的过程当中,往往是需要自己从业务的角度去考虑,如何规避死锁和解决死锁的问题:
- ** 按规则加锁**:如 A 转账给 B, 同时 B 也转账给 A, 此时就很可能出现死锁。但是如果我们根据 userId 的升序规则去加锁,就不会产生死锁的问题了
- ** 控制并发度**:如支付系统中的账户系统,可以将总账户拆分成子账户,然后每个子账户是一个独立的锁实体
## mysql复制原理
### 基于语句的复制
@ -2457,26 +2835,6 @@ MySQL5.1开始支持基于行的复制,这种方式会将实际数据记录在
# InnoDB原理
## Buffer Pool
`InnoDB` 为了解决磁盘`IO`问题,`MySQL`需要申请一块内存空间,这块内存空间称为`Buffer Pool`。
![img ](images/Database/modb_20210816_ed8bed5e-fe80-11eb-8072-00163e068ecd.png )
### 缓存页
`Buffer Pool` 申请下来后,`Buffer Pool`里面放什么,要怎么规划?
`MySQL` 数据是以页为单位,每页默认`16KB`,称为数据页,在`Buffer Pool`里面会划分出若干**个缓存页**与数据页对应。
![img ](images/Database/modb_20210816_edb843d6-fe80-11eb-8072-00163e068ecd.png )
### 描述数据
@ -2889,6 +3247,10 @@ mysql> show variables like 'slave_parallel%';
# ClickHouse
# 常见问题
## MySQL事务