一文搞定MySQL常见面试题

目录

数据库引擎

数据库查询过程

查询过程

查询缓存

语法解析

查询优化

使用索引进行排序

数据库事务

数据库隔离级别

数据库锁

锁机制

MyISAM 和 MEMORY 存储引擎采用的是表级锁(table-level locking)

不同粒度锁的比较:

行锁的算法

MVCC多版本并发控制

innodb存储引擎

数据操作

一致性读

用MVCC这一种手段可以同时实现RR与RC隔离级别

MVCC机制到底是控制记录可见性的补充知识

Innodb 存储 ---page结构

插入记录

删除记录

查找记录/定位位置

数据库缓存

mysql数据库缓存

MYSQL优化

遇到的问题

使用缓存

分表分库

读写分离

语句优化

参考链接


数据库引擎

mysql数据库主要有俩种存储引擎,innodb,myisam。

数据库查询过程

查询过程

mysql的查询过程首先就是先判断是否击中查询缓存(只有开启了查询缓存才需要判断),是的话直接返回,否则进入解析器进行解析,然后进入到查询优化器进行优化,最后就执行该sql语句。

一文搞定MySQL常见面试题_第1张图片

查询缓存

MySQL将缓存存放在一个引用表(不要理解成table,可以认为是类似于HashMap的数据结构),通过一个哈希值索引,这个哈希值通过查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息计算得来。所以两个查询在任何字符上的不同(例如:空格、注释),都会导致缓存不会命中。

如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、mysql库中的系统表,其查询结果都不会被缓存。比如函数NOW()或者CURRENT_DATE()会因为不同的查询时间,返回不同的查询结果,再比如包含CURRENT_USER或者CONNECION_ID()的查询语句会因为不同的用户而返回不同的结果,将这样的查询结果缓存起来没有任何的意义。

既然是缓存,就会失效,那查询缓存何时失效呢?MySQL的查询缓存系统会跟踪查询中涉及的每个表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。正因为如此,在任何的写操作时,MySQL必须将对应表的所有缓存都设置为失效。如果查询缓存非常大或者碎片很多,这个操作就可能带来很大的系统消耗,甚至导致系统僵死一会儿。而且查询缓存对系统的额外消耗也不仅仅在写操作,读操作也不例外:

  • 任何的查询语句在开始之前都必须经过检查,即使这条SQL语句永远不会命中缓存

  • 如果查询结果可以被缓存,那么执行完成后,会将结果存入缓存,也会带来额外的系统消耗

基于此,我们要知道并不是什么情况下查询缓存都会提高系统性能,缓存和失效都会带来额外消耗,只有当缓存带来的资源节约大于其本身消耗的资源时,才会给系统带来性能提升。但要如何评估打开缓存是否能够带来性能提升是一件非常困难的事情,也不在本文讨论的范畴内。如果系统确实存在一些性能问题,可以尝试打开查询缓存,并在数据库设计上做一些优化,比如:

  • 用多个小表代替一个大表,注意不要过度设计

  • 批量插入代替循环单条插入

  • 合理控制缓存空间大小,一般来说其大小设置为几十兆比较合适

  • 可以通过SQL_CACHESQL_NO_CACHE来控制某个查询语句是否需要进行缓存

最后的忠告是不要轻易打开查询缓存,特别是写密集型应用。如果你实在是忍不住,可以将query_cache_type设置为DEMAND,这时只有加入SQL_CACHE的查询才会走缓存,其他查询则不会,这样可以非常自由地控制哪些查询需要被缓存,生成cache之后,只要该select中涉及到的table有任何的数据变动(insert,update,delete操作等),相 关的所有cache都会被删除。因此只有数据很少变动的table,引入mysql 的cache才较有意义

缓存是如何使用内存的?

mysql查询缓存采用内存池的方式来使用内存,内存池使用的基本单位是变长的block, 用来存储类型、大小、数据等信息;一个result set的cache通过链表把这些block串起来。block最短长度为query_cache_min_res_unit,每次都从空闲快将query 的结果保存下来,如果一块不够就申请多块,在释放时直接释放内存块,所以会造成内存的锁片化.

如何控制内存的碎片化?

  1. 选择合适的block大小

  2. 使用 FLUSH QUERY CACHE 命令整理碎片.这个命令在整理缓存期间,会导致其他连接无法使用查询缓存 PS: 清空缓存的命令式 RESET QUERY CACHE

事务对查询缓存有何影响

语法解析

MySQL通过关键字将SQL语句进行解析,并生成一颗对应的解析树。这个过程解析器主要通过语法规则来验证和解析。比如SQL中是否使用了错误的关键字或者关键字的顺序是否正确等等。预处理则会根据MySQL规则进一步检查解析树是否合法。比如检查要查询的数据表和数据列是否存在等等。

查询优化

MySQL使用基于成本的优化器,它尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。在MySQL可以通过查询当前会话的last_query_cost的值来得到其计算当前查询的成本。

有非常多的原因会导致MySQL选择错误的执行计划,比如统计信息不准确、不会考虑不受其控制的操作成本(用户自定义函数、存储过程)、MySQL认为的最优跟我们想的不一样(我们希望执行时间尽可能短,但MySQL值选择它认为成本小的,但成本小并不意味着执行时间短)等等。

MySQL的查询优化器是一个非常复杂的部件,它使用了非常多的优化策略来生成一个最优的执行计划:

  • 重新定义表的关联顺序(多张表关联查询时,并不一定按照SQL中指定的顺序进行,但有一些技巧可以指定关联顺序)

  • 优化MIN()MAX()函数(找某列的最小值,如果该列有索引,只需要查找B+Tree索引最左端,反之则可以找到最大值,具体原理见下文)

  • 提前终止查询(比如:使用Limit时,查找到满足数量的结果集后会立即终止查询)

  • 优化排序(在老版本MySQL会使用两次传输排序,即先读取行指针和需要排序的字段在内存中对其排序,然后再根据排序结果去读取数据行,而新版本采用的是单次传输排序,也就是一次读取所有的数据行,然后根据给定的列排序。对于I/O密集型应用,效率会高很多)

使用索引进行排序

MySQL中,有两种方式生成有序结果集:一是使用filesort,二是按索引顺序扫描

利用索引进行排序操作是非常快的,而且可以利用同一索引同时进 行查找和排序操作。当索引的顺序与ORDER BY中的列顺序相同且所有的列是同一方向(全部升序或者全部降序)时,可以使用索引来排序,如果查询是连接多个表,仅当ORDER BY中的所有列都是第一个表的列时才会使用索引,其它情况都会使用filesort

数据库事务

数据库事务(简称:事务)是数据库管理系统执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。事务的使用是数据库管理系统区别文件系统的重要特征之一。

事务拥有四个重要的特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),人们习惯称之为 ACID 特性。下面我逐一对其进行解释。

  • 原子性

    (Atomicity)

    事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。例如,如果一个事务需要新增 100 条记录,但是在新增了 10 条记录之后就失败了,那么数据库将回滚对这 10 条新增的记录。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。

  • 一致性

    (Consistency)

    指事务将数据库从一种状态转变为另一种一致的的状态。事务开始前和结束后,数据库的完整性约束没有被破坏。例如工号带有唯一属性,如果经过一个修改工号的事务后,工号变的非唯一了,则表明一致性遭到了破坏。

  • 隔离性

    (Isolation)

    要求每个读写事务的对象对其他事务的操作对象能互相分离,即该事务提交前对其他事务不可见。 也可以理解为多个事务并发访问时,事务之间是隔离的,一个事务不应该影响其它事务运行效果。这指的是在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。例如一个用户在更新自己的个人信息的同时,是不能看到系统管理员也在更新该用户的个人信息(此时更新事务还未提交)。

    注:MySQL 通过锁机制来保证事务的隔离性。

  • 持久性

    (Durability)

    事务一旦提交,则其结果就是永久性的。即使发生宕机的故障,数据库也能将数据恢复,也就是说事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。这只是从事务本身的角度来保证,排除 RDBMS(关系型数据库管理系统,例如 Oracle、MySQL 等)本身发生的故障。

    注:MySQL 使用 redo log 来保证事务的持久性。

    数据库隔离级别

    了解了数据的锁机制,数据库的隔离级别也就好理解多了。每一种隔离级别满足不同的数据要求,使用不同程度的锁。

    Read Uncommitted,读写均不使用锁,数据的一致性最差,也会出现许多逻辑错误。

    Read Committed,使用写锁,但是读会出现不一致,不可重复读。

    Repeatable Read, 使用读锁和写锁,解决不可重复读的问题,但会有幻读。

    Serializable, 使用事务串形化调度,避免出现因为插入数据没法加锁导致的不一致的情况。

    读不提交,造成脏读(Read Uncommitted) 一个事务中的读操作可能读到另一个事务中未提交修改的数据,如果事务发生回滚就可能造成错误。

    例子:A打100块给B,B看账户,这是两个操作,针对同一个数据库,两个事物,如果B读到了A事务中的100块,认为钱打过来了,但是A的事务最后回滚了,造成损失。

    避免这些事情的发生就需要我们在写操作的时候加锁,使读写分离,保证读数据的时候,数据不被修改,写数据的时候,数据不被读取。从而保证写的同时不能被另个事务写和读。

    读提交(Read Committed) 我们加了写锁,就可以保证不出现脏读,也就是保证读的都是提交之后的数据,但是会造成不可重读,即读的时候不加锁,一个读的事务过程中,如果读取数据两次,在两次之间有写事务修改了数据,将会导致两次读取的结果不一致,从而导致逻辑错误。

    可重读(Repeatable Read) 解决不可重复读问题,一个事务中如果有多次读取操作,读取结果需要一致(指的是固定一条数据的一致,幻读指的是查询出的数量不一致)。 这就牵涉到事务中是否加读锁,并且读操作加锁后是否在事务commit之前持有锁的问题,如果不加读锁,必然出现不可重复读,如果加锁读完立即释放,不持有,那么就可能在其他事务中被修改,若其他事务已经执行完成,此时该事务中再次读取就会出现不可重复读,

    所以读锁在事务中持有可以保证不出现不可重复读,写的时候必须加锁且持有,这是必须的了,不然就会出现脏读。Repeatable Read(可重读)也是MySql的默认事务隔离级别,上面的意思是读的时候需要加锁并且保持

    可串行化(Serializable) 解决幻读问题,在同一个事务中,同一个查询多次返回的结果不一致。事务A新增了一条记录,事务B在事务A提交前后各执行了一次查询操作,发现后一次比前一次多了一条记录。幻读是由于并发事务增加记录导致的,这个不能像不可重复读通过记录加锁解决,因为对于新增的记录根本无法加锁。需要将事务串行化,才能避免幻读。 这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争

数据库锁

锁机制

共享锁与排他锁

  • 共享锁(读锁):其他事务可以读,但不能写。

  • 排他锁(写锁) :其他事务不能读取,也不能写。

粒度锁

MySQL 不同的存储引擎支持不同的锁机制,所有的存储引擎都以自己的方式显现了锁机制,服务器层完全不了解存储引擎中的锁实现:

  • MyISAM 和 MEMORY 存储引擎采用的是表级锁(table-level locking)

  • BDB 存储引擎采用的是页面锁(page-level locking),但也支持表级锁

  • InnoDB 存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用行级锁。

默认情况下,表锁和行锁都是自动获得的, 不需要额外的命令。

但是在有的情况下, 用户需要明确地进行锁表或者进行事务的控制, 以便确保整个事务的完整性,这样就需要使用事务控制和锁定语句来完成。

不同粒度锁的比较:

  • 表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。

    • 这些存储引擎通过总是一次性同时获取所有需要的锁以及总是按相同的顺序获取表锁来避免死锁。

    • 表级锁更适合于以查询为主,并发用户少,只有少量按索引条件更新数据的应用,如Web 应用

  • 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

    • 最大程度的支持并发,同时也带来了最大的锁开销。

    • 在 InnoDB 中,除单个 SQL 组成的事务外, 锁是逐步获得的,这就决定了在 InnoDB 中发生死锁是可能的。

    • 行级锁只在存储引擎层实现,而Mysql服务器层没有实现。 行级锁更适合于有大量按索引条件并发更新少量不同数据,同时又有并发查询的应用,如一些在线事务处理(OLTP)系统

  • 页面锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。

数据库锁一般可以分为两类,一个是悲观锁,一个是乐观锁。

乐观锁一般是指用户自己实现的一种锁机制,假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。乐观锁的实现方式一般包括使用版本号和时间戳。

悲观锁一般就是我们通常说的数据库锁机制,以下讨论都是基于悲观锁。

悲观锁主要表锁、行锁、页锁。在MyISAM中只用到表锁,不会有死锁的问题,锁的开销也很小,但是相应的并发能力很差。innodb实现了行级锁和表锁,锁的粒度变小了,并发能力变强,但是相应的锁的开销变大,很有可能出现死锁。同时inodb需要协调这两种锁,算法也变得复杂。InnoDB行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。

表锁和行锁都分为共享锁和排他锁(独占锁),而更新锁是为了解决行锁升级(共享锁升级为独占锁)的死锁问题。

innodb中表锁和行锁一起用,所以为了提高效率才会有意向锁(意向共享锁和意向排他锁)

考虑这个例子:

事务A锁住了表中的一行,让这一行只能读,不能写。

之后,事务B申请整个表的写锁。

如果事务B申请成功,那么理论上它就能修改表中的任意一行,这与A持有的行锁是冲突的。

数据库需要避免这种冲突,就是说要让B的申请被阻塞,直到A释放了行锁。

数据库要怎么判断这个冲突呢?

step1:判断表是否已被其他事务用表锁锁表 step2:判断表中的每一行是否已被行锁锁住。

注意step2,这样的判断方法效率实在不高,因为需要遍历整个表。 于是就有了意向锁。

在意向锁存在的情况下,事务A必须先申请表的意向共享锁,成功后再申请一行的行锁。

在意向锁存在的情况下,上面的判断可以改成

step1:不变 step2:发现表上有意向共享锁,说明表中有些行被共享行锁锁住了,因此,事务B申请表的写锁会被阻塞。

注意:申请意向锁的动作是数据库完成的,就是说,事务A申请一行的行锁的时候,数据库会自动先开始申请表的意向锁,不需要我们程序员使用代码来申请

行锁的算法

InnoDB 存储引擎使用三种行锁的算法用来满足相关事务隔离级别的要求。

  • Record Locks

    该锁为索引记录上的锁,如果表中没有定义索引,InnoDB 会默认为该表创建一个隐藏的聚簇索引,并使用该索引锁定记录。当索引为唯一索引,隔离机制为RR时,使用Record Locks

  • Gap Locks

    该锁会锁定一个范围,但是不括记录本身。可以通过修改隔离级别为 READ COMMITTED 或者配置 innodb_locks_unsafe_for_binlog 参数为 ON。当索引为辅助索引,且隔离机制为RR时,使用GAP锁定.

  • Next-key Locks

    该锁就是 Record Locks 和 Gap Locks 的组合,即锁定一个范围并且锁定该记录本身。InnoDB 使用 Next-key Locks 解决幻读问题。需要注意的是,如果索引有唯一属性,则 InnnoDB 会自动将 Next-key Locks 降级为 Record Locks。举个例子,如果一个索引有 1, 3, 5 三个值,则该索引锁定的区间为 (-∞,1], (1,3], (3,5], (5,+ ∞)

MVCC多版本并发控制


Mysql的大多数事务型存储引擎实现都不是简单的行级锁,基于并发性能考虑,一般都实现了MVCC多版本并发控制。MVCC是通过保存数据在某个时间点的快照来实现的。不管事务执行多长时间,事务看到的数据都是一致的。

MVCC只在READ COMMITED 和 REPEATABLE READ 两个隔离级别下工作。READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行。而SERIALIZABLE 则会对所有读取的行都加锁。

innodb存储引擎

​ innodb存储引擎中,每行数据都包含了一些隐藏字段:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR和DELETE_BIT。

一文搞定MySQL常见面试题_第2张图片

  • DB_TRX_ID:用来标识最近一次对本行记录做修改的事务的标识符,即最后一次修改本行记录的事务id。delete操作在内部来看是一次update操作,更新行中的删除标识位DELELE_BIT。

  • DB_ROLL_PTR:指向当前数据的undo log记录,回滚数据通过这个指针来寻找记录被更新之前的内容信息。

  • DB_ROW_ID:包含一个随着新行插入而单调递增的行ID, 当由innodb自动产生聚集索引时,聚集索引会包括这个行ID的值,否则这个行ID不会出现在任何索引中。

  • DELELE_BIT:用于标识该记录是否被删除。

数据操作

  • insert

    创建一条记录,DB_TRX_ID为当前事务ID,DB_ROLL_PTR为NULL。

  • delete

    将当前行的DB_TRX_ID设置为当前事务ID,DELELE_BIT设置为1。

  • update 复制一行,新行的DB_TRX_ID为当前事务ID,DB_ROLL_PTR指向上个版本的记录,事务提交后DB_ROLL_PTR设置为NULL。

  • select

    1、只查找创建早于当前事务ID的记录,确保当前事务读取到的行都是事务之前就已经存在的,或者是由当前事务创建或修改的;

    2、行的DELETE BIT为1时,查找删除晚于当前事务ID的记录,确保当前事务开始之前,行没有被删除。

 

一致性读

Mysql的一致性读是通过read view结构来实现。 read view主要是用来做可见性判断的,它维护的是本事务不可见的当前其他活跃事务。其中最早的事务ID为up_limit_id,最迟的事务ID为low_limit_id。low_limit_id应该是当前系统尚未分配的下一个事务ID(从这个语义来更容易理解),也就是目前已经出现过的事务ID的最大值+1

    trx_id_t    low_limit_id;
                /*!< The read should not see any transaction
                with trx id >= this value. In other words,
                this is the "high water mark". */
    trx_id_t    up_limit_id;
                /*!< The read should see all trx ids which
                are strictly smaller (<) than this value.
                In other words,
                this is the "low water mark". */

​ SELECT操作返回结果的可见性是由以下规则决定的:

DB_TRX_ID < up_limit_id -> 此记录的最后一次修改在read_view创建之前,可见

DB_TRX_ID > low_limit_id -> 此记录的最后一次修改在read_view创建之后,不可见 -> 需要用DB_ROLL_PTR查找undo log(此记录的上一次修改),然后根据undo log的DB_TRX_ID再计算一次可见性

up_limit_id <= DB_TRX_ID <= low_limit_id -> 需要进一步检查read_view中是否含有DB_TRX_ID

    DB_TRX_ID ∉ read_view -> 此记录的最后一次修改在read_view创建之前,可见

    DB_TRX_ID ∈ read_view -> 此记录的最后一次修改在read_view创建时尚未保存,不可见 -> 需要用DB_ROLL_PTR查找undo log(此记录的上一次修改),然后根据undo log的DB_TRX_ID再从头计算一次可见性

经过上述规则的决议,我们得到了这条记录相对read_view来说,可见的结果。

此时,如果这条记录的delete_flag为true,说明这条记录已被删除,不返回。

   如果delete_flag为false,说明此记录可以安全返回给客户端

用MVCC这一种手段可以同时实现RR与RC隔离级别

它们的不同之处在于:

RR:read view是在first touch read时创建的,也就是执行事务中的第一条SELECT语句的瞬间,后续所有的SELECT都是复用这个read view,所以能保证每次读取的一致性(可重复读的语义)

RC:每次读取,都会创建一个新的read view。这样就能读取到其他事务已经COMMIT的内容。

所以对于InnoDB来说,RR虽然比RC隔离级别高,但是开销反而相对少。

补充:RU的实现就简单多了,不使用read view,也不需要管什么DB_TRX_ID和DB_ROLL_PTR,直接读取最新的record即可。

MVCC机制到底是控制记录可见性的补充知识

InnoDB RR隔离界别下,MVCC对记录可见性控制,还有如下关键判定逻辑:
  1. 事务ID并非在事务begin时就分配,而是在事务首次执行非快照读操作(SELECT ... FOR UPDATE/IN SHARE MODE、UPDATE、DELETE)时分配。

  注:
  如果事务中只有快照读,InnoDB对只有快照读事务有特殊优化,这类事务不会拥有事务ID,因为它们不会在系统中留下任何修改(甚至连锁都不会建),所以也没有留下事务ID的机会。
  虽然使用SELECT TRX_ID FROM INFORMATION_SCHEMA.INNODB_TRX WHERE TRX_MYSQL_THREAD_ID = CONNECTION_ID();
  查询此类事务ID时,会输出一个很大的事务ID(比如328855902652352),不过这只是MySQL在输出时临时随机分配的一个用于显示的ID而已。
  1. 每个事务首次执行快照读操作时,会创建一个read_view对象(可以理解为在当前事务中,为数据表建立了一个逻辑快照,read_view对象就是用来控制此逻辑快照的可见范围的)。事务提交后,其创建的read_view对象将被销毁。

  read_view对象中有三个关键字段用于判断记录的可见范围。它们分别是trx_ids、low_limit_id、up_limit_id。
  1. read_view->trx_ids:创建该read_view时,记录正活跃的其他事务的ID集合。事务ID在集合中降序排列,便于二分查找。
  2. read_view->low_limit_id:当前活跃事务中的最大事务ID+1(即系统中最近一个尚未分配出去的事务号)。
  3. read_view->up_limit_id:当前活跃事务中的最小事务ID。
  1. 如果记录的版本号比自己事务的read_view->up_limit_id小,则该记录的当前版本一定可见。因为这些版本的内容形成于快照创建之前,且它们的事务也肯定已经commit了。或者如果记录的版本号等于自己事务的事务ID,则该记录的当前版本也一定可见,因为该记录版本就是本事务产生的。

  2. 如果记录的版本号与自己事务的read_view->low_limit_id一样或比它更大,则该版本的记录的当前版本一定不可见。因为这些版本的内容形成于快照创建之后。

  不可见有如下两层含义:
  1. 如果该记录是新增或修改后形成的新版本记录,则对新增和修改行为不可见,即看不到最新的内容;
  2. 如果该记录是标记为已删除形成的新版本记录,则对该删除行为不可见,即可以看到删除前的内容。
  1. 当无法通过4和5快速判断出记录的可见性时,则查找该记录的版本号是否在自己事务的read_view->trx_ids列表中,如果在则该记录的当前版本不可见,否则该记录的当前版本可见。

  2. 当一条记录判断出其当前版本不可见时,通过记录的DB_ROLL_PTR(undo段指针),尝试去当前记录的undo段中提取记录的上一个版本进行4~6中同样的可见性判断,如果可以则该记录的上一个版本可见。

Innodb 存储 ---page结构

InnoDB`采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16KB。InnnoDB page的数据结构 如下表:

名称 中文名 占用空间大小 简单描述
File Header 文件头 38字节 描述页的信息(存在俩个首尾指针指向其他页)
Page Header 页头 56字节 页的状态信息
Infimum + SupreMum 最小记录和最大记录 26字节 两个虚拟的行记录(后面会说明)
User Records 用户记录 不确定 实际存储的行记录内容
Free Space 空闲空间 不确定 页中尚未使用的空间
Page Directory 页目录 不确定 页中的记录相对位置
File Trailer 文件结尾 8字节 结尾信息

File Header:

一张表中可以有成千上万条记录,一个页只有16KB,所以可能需要好多页来存放数据,FIL_PAGE_PREVFIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号(双向链表)。

Page Header:

记录头部的一些状态信息

Uuser record:

人们在存储数据的时候,记录会存储到User Records部分 。但是在一个页新形成的时候是不存在User Records 这个部分的,每当我们在插入一条记录的时候,都会从Free Space中去申请一块大小符合该记录大小的空间并划分到User Records,当Free Space的部分空间全部被User Records部分替换掉之后,就意味着当前页使用完毕,如果还有新的记录插入,需要再去申请新的页

页中记录是一个按照大小从下到大连续的单向链表,现在来想想,当我们根据主键查询一条记录的时候是怎样进行的,我们来看看;

SELECT * FROM page_demo WHERE c1 = 3;
复制代码

上面是一条查询语句,我们想想它的执行方式可能是:

从最小记录开始,沿着链表一直往后找,总有一天会找到(或者找不到),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于您想要查找的主键值时,如果这个时候还没找到数据的话您就可以停止查找了(代表找不到),因为该节点后边的节点的主键值都是依次递增。

上面的方式存在的问题就是,当页中的存储的记录数量比较少的情况用起来也没啥问题,但是如果一个页中存储了非常多的记录,这么查找对性能来说还是有损耗的,所以这个方式很笨啊。

我们来看看InnoDB 的处理方式:InnoDB 的处理方式相当于我们平时看书的时候,想看那一章的时候不会傻到去一页一页的找,而是通过目录去找到对应的页数,直接就定位过去了。说说InnoDB 这样处理的步骤吧:

1. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。

2. 每个组的最后一条记录的头信息中的n_owned属性表示该组内共有几条记录。

3. 将每个组的最后一条记录的地址偏移量按顺序存储起来,每个地址偏移量也被称为一个(英文名:Slot)。这些地址偏移量都会被存储到靠近的尾部的地方,页中存储地址偏移量的部分也被称为Page Directory

InnoDB 对每个分组中的记录条数是有规定的,对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。所以分组是按照下边的步骤进行的:

  • 初始情况下一个数据页里面只有最小记录和最大记录(伪记录),它们属于不同的分组,也就是两个;

  • 之后插入的每一条记录都会放到最大记录所在的组,直到最大记录所在组的记录数等于8条;

  • 当最大记录所在组中的记录数等于8条的时候,如果还有记录插入的话,就会将最大记录所在组平均分裂成2个组,这个时候最大记录所在组就只剩下4条记录,这里再把这条记录再放入最大记录所在组;

我们一口气又往表中添加了12条记录,现在就一共有16条正常的记录了(包括最小和最大记录),这些记录被分成了5个组,如图所示:

 

一文搞定MySQL常见面试题_第3张图片

因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用二分法来进行快速查找。4个槽的编号分别是:01234,所以初始情况下最低的槽就是low=0,最高的槽就是high=4。比方说我们想找主键值为5的记录,现在我们再来看看查找一条记录的步骤:

1. 首先得到中间槽的位置:(0 + 4)/2 = 2 ,所以得到槽2,根据槽2的地址偏移量知道它的主键值是8,因为8>5,设置high=2low不变;

2. 再次计算中间槽的位置:(0 + 2)/2 = 1 ,所以得到槽1,根据槽1的地址偏移量知道它的主键值是4, 因为4<5,设置low=1high不变;

3. 因为high - low的值为1,所以确定主键值为5的记录在槽1和槽2之间,接下来就是遍历链表的查找了;

1.每个记录的头信息中都有一个next_record属性,从而使页中的所有记录串联成一个单向链表

2.InnoDB会为把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个槽,存放在Page Directory

中,所以在一个页中根据主键查找记录是非常快的,分为两步:

  • 通过二分法确定该记录所在的槽。

  • 通过记录的next_record属性组成的链表遍历查找该槽中的各个记录。

3.每个数据页的File Header部分都有上一个和下一个页的编号,所以所有的数据页会组成一个双链表

4.为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和LSN值,如果首部和尾部的校验和和LSN值校验不成功的话,就说明同步过程出现了问题

插入记录

核心入口函数在page_cur_insert_rec_low。核心步骤如下:

  1. 获取记录的长度。函数传入参数就有已经组合好的完整记录,所以只需要从记录的元数据中获取即可。

  2. 首先从PAGE_FREE链表中尝试获取足够的空间。仅仅比较链表头的一个记录,如果这个记录的空间大于需要插入的记录的空间,则复用这块空间(包括heap_no),否则就从PAGE_HEAP_TOP分配空间。如果这两个地方都没有,则返回空。这里注意一下,由于只判断Free链表的第一个头元素,所以算法对空间的利用率不是很高,估计也是为了操作方便。假设,某个数据页首先删除了几条大的记录,但是最后一条删除的是比较小的记录A,那么后续插入的记录大小只有比记录A还小,才能把Free链表利用起来。举个例子,假设先后删除记录的大小为4K, 3K, 5K, 2K,那么只有当插入的记录小于2K时候,这些被删除的空间才会被利用起来,假设新插入的记录是0.5K,那么Free链表头的2K,可以被重用,但是只是用了前面的0.5K,剩下的1.5K依然会被浪费,下次插入只能利用5K记录所占的空间,并不会把剩下的1.5K也利用起来。这些特性,从底层解释了,为什么InnoDB那么容易产生碎片,经常需要进行空间整理。

  3. 如果Free链表不够,就从PAGE_HEAP_TOP分配,如果分配成功,需要递增PAGE_N_HEAP

  4. 如果这个数据页有足够的空间,则拷贝记录到指定的空间。

  5. 修改新插入记录前驱上的next指针,同时修改这条新插入记录的指针next指针。这两步主要是保证记录上链表的连续性。

  6. 递增PAGE_N_RECS。设置heap_no。设置owned值为0。

  7. 更新PAGE_LAST_INSERTPAGE_DIRECTIONPAGE_N_DIRECTION,设置这些参数后,可以一定程度上提高连续插入的性能,因为插入前需要先定位插入的位置,有了这些信息可以加快查找。详见查找记录代码分析。

  8. 修改数据目录。因为增加了一条新的记录,可能有些目录own的记录数量超过了最大值(目前是8条),需要重新整理一下这个数据页的目录(page_dir_split_slot)。算法比较简单,就是找到中间节点,然后用这个中间节点重新构建一个新的目录,为了给这个新的目录腾空间,需要把后续的所有目录都平移,这个涉及一次momove操作(page_dir_split_slotpage_dir_add_slot)。

  9. 写redolog日志,持久化操作。

  10. 如果有blob字段,则处理独立的off-page。

删除记录

注意这里的删除操作是指真正的删除物理记录,而不是标记记录为delete-mark。核心函数入口函数在page_cur_delete_rec。步骤如下:

  1. 如果需要删除的记录是这个数据页的最后一个记录,那么直接把这个数据页重新初始化成空页(page_create_empty)即可。

  2. 如果不是最后一条,就走正常路径。首先记录redolog日志。

  3. 重置PAGE_LAST_INSERT和递增block的modify clock。后者主要是为了让乐观的查询失效。

  4. 找到需要删除记录的前驱和后继记录,然后修改指针,使前驱直接指向后继。这样记录的链表上就没有这条记录了。

  5. 如果一个目录指向这条被删除的记录,那么让这个目录指向删除记录的前驱,同时减少这个目录own的记录数。

  6. 如果这个记录有blob的off-page,则删除。

  7. 把记录放到PAGE_FREE链表头部,然后递增PAGE_GARBAGE的大小,减小PAGE_N_RECS用户记录的值。

  8. 由于第五步中递减了own值,可能导致own的记录数小于最小值(目前是4条)。所以需要重新均衡目录,可能需要删除某些目录(page_dir_balance_slot)。具体算法也比较简单,首先判断是否可以从周围的目录中挪一条记录过来,如果可以直接调整一下前后目录的指针即可。这种简单的调整要求被挪出记录的目录own的记录数量足够多,如果也没有足够的记录,就需要删除其中一个目录,然后把后面的目录都向前平移(page_dir_delete_slot)。

查找记录/定位位置

在InnoDB中,需要查找某条件记录,需要调用函数page_cur_search_with_match,但如果需要定位某个位置,例如大于某条记录的第一条记录,也需要使用同一个函数。定位的位置有PAGE_CUR_G,PAGE_CUR_GE,PAGE_CUR_L,PAGE_CUR_LE四种,分别表示大于,大于等于,小于,小于等于四种位置。 由于数据页目录的存在,查找和定位就相对简单,先用二分查找,定位周边的两个目录,然后再用线性查找的方式定位最终的记录或者位置。 此外,由于每次插入前,都需要调用这个函数确定插入位置,为了提高效率,InnoDB针对按照主键顺序插入的场景做了一个小小的优化。因为如果按照主键顺序插入的话,能保证每次都插入在这个数据页的最后,所以只需要直接把位置直接定位在数据页的最后(PAGE_LAST_INSERT)就可以了。至于怎么判断当前是否按照主键顺序插入,就依赖PAGE_N_DIRECTIONPAGE_LAST_INSERTPAGE_DIRECTION这几个信息了,目前的代码中要求满足5个条件:

  1. 当前的数据页是叶子节点

  2. 位置查询模式为PAGE_CUR_LE

  3. 相同方向的插入已经大于3了(page_header_get_field(page, PAGE_N_DIRECTION) > 3)

  4. 最后插入的记录的偏移量为空(page_header_get_ptr(page, PAGE_LAST_INSERT) != 0)

  5. 从右边插入的(page_header_get_field(page, PAGE_DIRECTION) == PAGE_RIGHT)

数据库缓存

mysql数据库缓存

MySQL 提供了一系列的 Global Status 来记录 Query Cache 的当前状态,具体如下:

Qcache_free_blocks:目前还处于空闲状态的 Query Cache 中内存 Block 数目

Qcache_free_memory:目前还处于空闲状态的 Query Cache 内存总量

Qcache_hits:Query Cache 命中次数

Qcache_inserts:向 Query Cache 中插入新的 Query Cache 的次数,也就是没有命中的次数

Qcache_lowmem_prunes:当 Query Cache 内存容量不够,需要从中删除老的 Query Cache 以给新的 Cache 对象使用的次数

Qcache_not_cached:没有被 Cache 的 SQL 数,包括无法被 Cache 的 SQL 以及由于 query_cache_type 设置的不会被 Cache 的 SQL

Qcache_queries_in_cache:目前在 Query Cache 中的 SQL 数量

Qcache_total_blocks:Query Cache 中总的 Block 数量

查询是否开启

mysql> ``select @@query_cache_type;

query_cache_type有3个值 0代表关闭查询缓存OFF,1代表开启ON,2(DEMAND)代表当sql语句中有SQL_CACHE关键词时才缓存,如:select SQL_CACHE user_name from users where user_id = ‘100’; 这样 当我们执行 select id,name from tableName; 这样就会用到查询缓存。

mysql缓存的优缺点

优点Query Cache的查询,发生在MySQL接收到客户端的查询请求、查询权限验证之后和查询SQL解析之前。也就是说,当MySQL接收到客户端的查询SQL之后,仅仅只需要对其进行相应的权限验证之后,就会通过Query Cache来查找结果,甚至都不需要经过Optimizer模块进行执行计划的分析优化,更不需要发生任何存储引擎的交互。由于Query Cache是基于内存的,直接从内存中返回相应的查询结果,因此减少了大量的磁盘I/O和CPU计算,导致效率非常高。

缺点Query Cache的优点很明显,但是也不能忽略它所带来的一些缺点:

  • 查询语句的hash计算和hash查找带来的资源消耗。如果将query_cache_type设置为1(也就是ON),那么MySQL会对每条接收到的SELECT类型的查询进行hash计算,然后查找这个查询的缓存结果是否存在。虽然hash计算和查找的效率已经足够高了,一条查询语句所带来的开销可以忽略,但一旦涉及到高并发,有成千上万条查询语句时,hash计算和查找所带来的开销就必须重视了。

  • Query Cache的失效问题。如果表的变更比较频繁,则会造成Query Cache的失效率非常高。表的变更不仅仅指表中的数据发生变化,还包括表结构或者索引的任何变化。

  • 查询语句不同,但查询结果相同的查询都会被缓存,这样便会造成内存资源的过度消耗。查询语句的字符大小写、空格或者注释的不同,Query Cache都会认为是不同的查询(因为他们的hash值会不同)。

  • 相关系统变量设置不合理会造成大量的内存碎片,这样便会导致Query Cache频繁清理内存。

mysql缓存的的评价

检查是否从查询缓存中受益的最简单的办法就是检查缓存命中率,当服务器收到SELECT 语句的时候,Qcache_hits 和Com_select 这两个变量会根据查询缓存的情况进行递增。

查询缓存碎片率 = Qcache_free_blocks / Qcache_total_blocks /* 100%

如果查询缓存碎片率超过20%,可以用FLUSH QUERY CACHE整理缓存碎片,或者试试减小query_cache_min_res_unit,如果你的查询都是小数据量的话。

查询缓存利用率 = (query_cache_size – Qcache_free_memory) / query_cache_size /* 100%

查询缓存利用率在25%以下的话说明query_cache_size设置的过大,可适当减小;查询缓存利用率在80%以上而且 Qcache_lowmem_prunes > 50的话说明query_cache_size可能有点小,要不就是碎片太多。

查询缓存命中率 = (Qcache_hits – Qcache_inserts) / Qcache_hits /* 100%

示例服务器 查询缓存碎片率 = 20.46%,查询缓存利用率 = 62.26%,查询缓存命中率 = 1.94%,命中率很差,可能写操作比较频繁吧,而且可能有些碎片

MYSQL优化

遇到的问题

1.用户请求量太大

因为单服务器TPS,内存,IO都是有限的。 解决方法:分散请求到多个服务器上; 其实用户请求和执行一个sql查询是本质是一样的,都是请求一个资源,只是用户请求还会经过网关,路由,http服务器等。

2.单库太大

单个数据库处理能力有限;单库所在服务器上磁盘空间不足;单库上操作的IO瓶颈 解决方法:切分成更多更小的库

3.单表太大

CRUD都成问题;索引膨胀,查询超时 解决方法:切分成多个数据集更小的表。

使用缓存

分表分库

水平分表:mysql的数据文件跟索引文件是放在同一个文件(.idb)的,因此当数据文件太大时,整棵索引树很大,造成查询性能的下降(一般得是亿万级的数据才会有比较大的影响),可以采用水平分表,将一个表例如 studen表格,可以加上一个数字后缀,例如student1,student2,student3等表格,然后通过哈希算法去选择哪一个数据库表格进行数据读写。

垂直分表:表的记录并不多,但是字段却很长,表占用空间很大,一般是表中的字段较多,将不常用的, 数据较大,长度较长(比如text类型字段)的拆分到“扩展表,检索表的时候需要执行大量的IO,严重降低了性能。这时需要把大的字段拆分到另一个表,并且该表与原表是一对一的关系。对于一个列数比较多的表格,可以将该表格按照业务拆分成多个小表,例如 student表存在id,name,date,school,grade等数据,可以拆分成 (id,name ,date),(id,school,grade)俩个表格,避免查询时,数据量太大造成的“跨页”问题,但是它无法解决单表数据过大的问题。垂直分表的另一个好处就是说可以优化缓存,例如说学生表的grade经常更新,但是school确实不经常更新的,所以会造成数据缓存的经常失效,如果分开表格,就不会影响到另一部分的缓存。

水平分库可以解决单个服务器中CPU、IO瓶颈问题。

读写分离

对于读场景多,写场景少的业务场景,可以采用读写分离的方式,因为写业务是比较费时的,而读业务的时间比较短暂,通过读写分离可以这种集群方式的本质就是把访问的压力从主库转移到从库,也就是在单机数据库无法支撑并发读写的时候,并且读的请求很多的情况下适合这种读写分离的数据库集群。如果写的操作很多的话不适合这种集群方式,因为你的数据库压力还是在写操作上,即使主从了之后压力还是在主库上和单机区别就不大了。

读写分离相对来说比较简单,当访问压力大的时候可以采用服务器集群,读写分离的方式来抗住访问压力,但是它无法缓解存储压力,对于单表数据过大造成索引膨胀以及当行数据量太大造成跨页访问等存储问题只能用分表分库来做。

但是有两点要注意:主从同步延迟、分配机制的考虑

1、二次读取

二次读取的意思就是读从库没读到之后再去主库读一下,只要通过对数据库访问的API进行封装就能实现这个功能。很简单,并且和业务之间没有耦合。但是有个问题,如果有很多二次读取相当于压力还是回到了主库身上,等于读写分离白分了。而且如有人恶意攻击,就一直访问没有的数据,那主库就可能爆了。

2、写之后的马上的读操作访问主库

也就是写操作之后,立马的读操作指定访问主库,之后的读操作采取访问从库。这就等于写死了,和业务强耦合了。

3、关键业务读写都由主库承担,非关键业务读写分离

类似付钱的这种业务,读写都到主库,避免延迟的问题,但是例如改个头像啊,个人签名这种比较不重要的就读写分离,查询都去从库查,毕竟延迟一下影响也不大,不会立马打客服电话哈哈。

语句优化

  1. 索引不会包含有NULL值的列

只要列中包含有NULL值都将不会被包含在索引中,复合索引中只要有一列含有NULL值,那么这一列对于此复合索引就是无效的。所以我们在数据库设计时不要让字段的默认值为NULL。

  1. 使用短索引

对串列进行索引,如果可能应该指定一个前缀长度。例如,如果有一个CHAR(255)的列,如果在前10个或20个字符内,多数值是惟一的,那么就不要对整个列进行索引。短索引不仅可以提高查询速度而且可以节省磁盘空间和I/O操作。

  1. 索引列排序

MySQL查询只使用一个索引,因此如果where子句中已经使用了索引的话,那么order by中的列是不会使用索引的。因此数据库默认排序可以符合要求的情况下不要使用排序操作;尽量不要包含多个列的排序,如果需要最好给这些列创建复合索引。

  1. like语句操作

一般情况下不鼓励使用like操作,如果非使用不可,如何使用也是一个问题。like “%aaa%” 不会使用索引而like “aaa%”可以使用索引。

  1. 不要在列上进行运算

例如:select * from users where YEAR(adddate)<2007,将在每个行上进行运算,这将导致索引失效而进行全表扫描,因此我们可以改成:select * from users where adddate<’2007-01-01′,因为在进行类型转换时,mysql可能不会触发索引直接进行全表搜索

更多面试资料可参考 https://github.com/46zhang/Note

参考链接

 

作者:小小少年Boy

链接:https://www.jianshu.com/p/7a0c215edb1d

作者:yes的练级攻略

链接:https://juejin.im/post/5cbdaf80f265da038d0b444e

作者:你的益达_

链接:https://juejin.im/post/5cb3e3dfe51d456e3428c0db

作者:梦之痕

链接:https://juejin.im/post/5c519bb8f265da617831cfff

https://www.cnblogs.com/zhoujinyi/p/3435982.html

作者:发条地精

链接:https://www.zhihu.com/question/51513268/answer/127777478

https://blog.csdn.net/C_J33/article/details/79487941

https://blog.csdn.net/C_J33/article/details/79487941

你可能感兴趣的:(系统)