其实关于mysql部分,小林coding讲得很不错,但是我觉得不够系统化,为了拆分多章,作者把很多内容打散了书写的。
为了整理让整个知识连贯,本文决定从底层到上层开始梳理mysql的实现。
作为数据库,数据存下来当然是最重要的。那么如何存是一个很有意思的话题。
mysql对于每一张表有一个单独的文件夹,文件夹内分别有三个文件:
db.opt:存放表的默认字符集和字符校验规则
t_order.frm:存放表的结构信息 t_order.ibd:存放着表的数据
所以我们只需要关注t_order这个文件如何存放我们的数据即可
这个文件会被分为许多段,比如:索引段、数据段、回滚段等。
每个段的数据保存在一起,这样做也是为了符合计算机的局部性原理。
区这个概念是相比于其他存储概念来说显得不那么重要,为什么这么说呢?
一个区=64个连续页。为什么有了页还要有区呢?页是磁盘操作(分配/读取/写入)的最小单位,但是页又太小了,每次只给一个索引结构分配一个页,会导致索引连续的数据实际上可能相隔很远,所以此时,就可以直接给这个索引空间分配一个区。这就是区存在的意义。
页是核心概念。B+数的一个节点就是一个页,无论是索引,还是数据。
页由数据行组成,数据行是数据库里的一条记录,当然除此之外还有些冗余信息。
页相当于一个行的list,为了快速检索到一个页中的一行,页引入了槽的概念。一个页有k个槽,可以通过二分法定位出数据在哪个槽里。
一个页的第一槽只会有一行,就是初始行。每个槽的最后一条记录的len位置会记录槽里有多少条数据。
这样,在槽的帮助下,页能够快速二分定位到数据。
数据行,也就是一条记录。 行的构成非常简单,但是有趣的是,行的头节点并不在最开头。
头节点的左边,分别是倒序的null list和变长字段长度list
头节点的右边,有6字节的row_id隐藏字段,当然,如果头节点具有unique_key的话就不需要这个隐藏字段。
6字节的trx_id,记录的是创建这条记录的事务id,7字节的roll_pointer,记录的这个记录修改前的记录,这两个字段用于MVCC,很疑惑,没关系,两分钟后就开始讲这个部分。
之后的部分就全部都是一条记录的各个字段的值。
所以mysql中为null的字段,会在null list对应的位记为1
MVCC,全称为Multi-Version Concurrency Control,多版本并发控制。
是数据库中实现事务隔离性的重要手段,不同事物的读操作不会被另一个事务的写操作阻塞的关键技术。
当一个事务开始的时候,会为这个事务建立一个快照,但是很显然,这个快照不可能去拷贝数据库的数据,所以只能记录少量数据获得数据库的快照。
我们还知道的是,数据库的更新时候,旧版本会留有一条记录,也就是undo log,而每条记录都有一个指针指向它的前一条记录,也就是undo log。
如果,我们能够在记录住当前事务的id,那么就可以控制当前事务能够看到的记录。
而记录这些信息的,就是Read View。
它一共记录四个信息:
这样,在利用MVCC机制进行快照读的时候,读取一条记录的时候就用creator_trx_id和记录中的trx_id进行比较。
有五种情况:
在可重复读的情况下:事务开启才创建一条Read View
在读已提交的情况下,每次读取数据时候生成一条新的Read View
是不是很疑惑,为什么写了这么多还不到索引,不着急的,索引是更加上层的东西。在有索引前,我们得先有数据。在数据读取和写入的时候,需要考虑冲突这件事情。
这里按锁的粒度来进行第一步划分:
fulsh tables with read lock
一般只在数据库做全局备份的时候使用。
用于对整张表加锁,可以是S锁,也可以是X锁lock tables table_name read
lock tables table_name write
值得注意的是,如果在当前线程中加了表锁,在释放表锁前不得访问其他的锁。
意向锁分为意向独占锁和意向共享锁,插入意向锁并不是意向锁。
意向锁存在的目的是为了快速确认能不能对整张表加锁。
举个例子:
在事务A中向table1加上了行锁S,而此时,事务B需要对整张表加锁,则事务B需要遍历全部的行确认行是否有锁吗?
不需要的,如果事务A向table加上了行锁S,那么同时也会向table1加上一把意向锁S。此时如果事务B想对表table1加表锁,会发现有一把意向锁S,此时锁冲突。
意向锁和意向锁并不冲突,意向锁只会和表锁冲突。
元数据锁锁的不是表的数据,锁的是表的结构,当修改表的结构的时候,mysql会自动给表加上元数据锁。修改表事务结束之后,也会自动释放元数据锁。
这个锁的主要作用是用来帮助自增主键能够按顺序自增,不会出现冲突。
当一个数据要插入的时候,获取Auto_Inc锁,当插入数据执行完成之后,该锁自动释放,不需要等到事务执行完毕。
同时后续mysql还引入了轻量Auto_Inc锁,只有在需要获取自增数据的时候才加锁,获取自增数据完成后立刻释放锁。
自增锁的应用在主从复制上会存在问题:
如果binlog的存储方式是row的话不会出现问题,毕竟是存储是行的变化。
但是如果存储方式是statement的方式,则会出现问题。在事务执行完成之后,才会写入binlog。
假设自增主键的初始值为0,A事务在time1插入数据1,B事务在time2插入数据2,A事务在time3插入数据3。
此时数据1、2、3在主库的id应该分别为:1,2,3
但是如果从库根据statement的binlog进行同样的插入操作执行,会发现:要先执行A事务,插入数据1、数据3,再执行B事务,插入数据2
此时数据1、2、3在主库的id应该分别为:1,3,2
会出现主从不一致的情况。
如下图所示:
行级锁的类型也比较多,包括:记录锁、间隙锁、临键锁、插入意向锁,这些锁一般都是由引擎管理的。
select * from table_name where id = 1 lock in share mode;
select * from table_name where id = 1 for update
还是上面的sql select * from table_name where id = 1 for update
,如果此时id = 1是没有记录的,则会加上间隙锁和。间隙锁和间隙锁是不冲突的,只和插入意向锁冲突。
临键锁:Next-Key Lock,是记录锁和间隙锁的组合
插入意向锁:用来和间隙锁作为冲突的锁,在一个位置插入数据时,需要在这个间隙加插入意向锁,如果这个位置存在间隙锁,则会冲突。
值得一题的是:mysql加锁的时候,会先生成一个锁,然后让锁处于等待状态。锁处于等待状态的时候并不是成功获取到锁了。
一般来说,mysql对单条记录是不会加读锁的。因为MVCC机制的存在,读操作大多数时候是快照读,所以可以不阻塞写操作。
但是当然,开发者也可以主动给读操作加锁,但是需要考虑性能问题。
通常我们的加锁时机包括:更新、删除、添加
为什么有MVCC还需要临键锁和间隙锁:主要是为了防止幻读操作。
等值查询: 当查询的记录「存在」时,由于不是唯一索引,所以可能存在索引值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的的二级索引记录就停止扫描,然后在扫描的过程中,对扫描到的二级索引记录加的是next-key锁,而对于第一个不符合条件的二级索引记录,该二级索引的next-key锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁。
当查询的记录「不存在」时,扫描到第一条不符合条件的二级索引记录,该二级索引的next-key锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会会对主键索引加锁。 范围查询: 非唯一索引和主键索引的范围查询的加锁也有所不同,不同之处上在于非唯一索引范围查询,索引的next key lock不会有退化为间隙锁和记录锁的情况,也就是非唯一索索引进行范围查询时,对二级索引记录加锁都是加next-key锁。
这里几乎全部引用自小林coding。
但是需要注意的是,小林coding说查询会加锁,这里是错误的。因为MVCC机制的存在,普通的查询是不会加锁的,一般加锁的情况都是用于更新。
我换了一种表述方式,特别强调了这个部分:操作范围查询
这章是后来才决定加到这里,在梳理日志之前需要梳理一下Buffer Pool
之前讲有存储的最小单位是页,每次读取一条记录,如果记录不在Buffer Pool,需要将记录所在的这个页读取到缓存中,这些缓存页,就会被存储在Buffer Pool。
数据页、索引页、插入缓存页、undo页、自适应哈希索引、锁信息。
如果需要将一块磁盘页读取到缓存中,但是free链表又不存在空闲页,此时就要对LRU链表进行淘汰。
Mysql的LRU链表设计了young区域和old区域,用以应对预读失效和缓存污染的问题。
根据程序的局部性原理,如果读取一个页,那么它的相邻页面被使用到的概率很高,所以也会读取这些页面加入缓存。但是实际上可能这些页根本不会被访问到,这就是预读失效问题。
为了解决预读失效,mysql将lru链表划分为young区域和old区域,young区域是头部,old区域在链表的末端。
如果一个缓存页被预读进来,会被默认放到old区域,直到他被真正使用才会放到young区域,当然,也可以提高进入young区域的门槛,这部分放在缓存污染里介绍。
如果一个sql遍历了数据库的整张表,但是只会便利一次,但是整张表的内容又十分大,缓存会进行频繁的淘汰。同时,热点数据可能被缓存淘汰。
此时为了应对这个问题,mysql提高了old区域进入young区域的门槛,当一个数据被访问两次的时候才会被加入young区域,同时,还要求两次访问的时间间隔大于一定的长度。只有同时满足,被访问,和在old区域停留一秒的条件才会被转移到young区域。
当然,如果我们将这个流程放到事务中来看
mysql采用了一种叫做WAL的策略,也就是Write Ahead Log,先写日志,再写磁盘的策略。通过写入redo log来让mysql拥有掉电恢复的能力。
在下面几种情况,Buffer Pool才会将数据写入磁盘:
如下图所示(Buffer Pool 与 redo 写入流程示意):
redo log日志的加入是追加写入的,是连续IO,磁盘执行较快,但是数据的写入并不是,随机IO小于数据 redo log只记录对数据页所做的物理操作(例如某个字节从A改成了B),而不是整个数据页的内容,因此redo log通常比实际的数据页要小得多。
在我们之前介绍数据库记录的存储时介绍过,一条记录的组成为:
【变长字段的长度】【null list】【记录头】【row_id】【trx_id】【roll_pointer】【字段内容】【字段内容】
如下图所示(InnoDB 记录结构示意):
其中,roll_pointer就是指向的就是这条记录的undo log, undo log本质上也是一条结构相同的记录。
当在一个事务中执行某项修改时,如果需要取消修改,执行流程图下:
为了保证数据的持久性,当一条记录需要更新时,在写完undo log,将数据的更新写入buffer pool之后,需要记录redo log,并且要在redo log落盘之后,才能完成事务的commit。
同时,我们写undo log的时候,也是写入buffer pool之中,没有落盘,redo log也需要记录这条undo log的修改,并且落盘。
当然,redo log也不是每产生一条log就落盘一次,也是会先写入redo log buffer,等待落盘时机
redo log落盘时机:
redo log是一个file,显然是不可能无限大的,而且存储那么多log的意义不大,一般来说只关注最近的log。 redo log是存在两个文件的,他们是循环写的流程,log1写满之后,往log2内写入内容,log2写满之后,再切回log1写。
同时,还存在一个已经写入到的指针checkpoint,当前写入的位置如果套圈了checkpoint,mysql就会阻塞并将脏页刷新到内存,然后擦除redo log中可擦除的内容。
上面的undo log和redo log都是InnoDB引擎生成的log,在mysql完成一条更新之后,server层还会生成一条bin log。
这个部分其实很多时候是难以理解的,为什么要写两份log,为什么log还可能出现差异。
mysql使用内部XA事务来协调提交。
事务提交触发后会进入下面两个流程:
关键点: 事务没提交的时候,redo log可以落盘,但是bin log一定要事务commit才落盘。