MySQL是一种关系型数据库,有着开源免费,方便拓展的优势。本篇文章主要对MySQL的面试相关知识点做一个总结,主要包括以下内容:存储引擎、字符集和校对规则、索引、查询缓存的使用、事务、锁机制、大表优化、数据库连接池等。
MySQL目前的默认存储引擎是InnoDB,并且在5.7版本中只有InnoDB支持事务。
在5.5版本之前,MySQL的默认引擎是MyISAM。
InnoDB相对于MyISAM的主要优势在于:
而MyISAM的优势在于读操作的速度比较快。
字符集指的是多个字符的集合,通过一个或者多个字节表示字符集中的一个字符。
常见的字符集包括:GBK、UTF8、ASCII等。
MySQL有多个系统变量值与字符集相关:
建立库、表。列字符集的时候:
当涉及更新和查询的时候:
校对规则指的是在字符集内部比较和排序的一套规则,有的规则区分大小写,有的则无视。
校对规则有以下特征:
MySQL使用的索引主要有BTree索引和哈希索引。在绝大多数需求为单条记录查询的时候可以选择哈希索引,性能较高,但是哈希索引的缺点在于无法进行高效范围查找,所以其他时候建议采用BTree索引。
索引的本质实际上是一张表,该表中保存了主键和索引字段,并且指向实体表的记录。
当执行创建表和索引语句之后,不同的引擎会产生不同的文件。
InnoDB会产生一个 *.frm 文件和 *.idb 文件,其中frm文件保存了创建表的语句,idb文件中保存了表中的数据和索引。这说明了InnoDB采用的是聚集索引,即索引中保存了具体的数据。
而MyISAM引擎会生成除了frm文件之外还会生成MYD(MyISAM Data)和MYI(MyISAM Index)文件,这说明MyISAM采用的是非聚集索引,索引中保存的是数据的物理地址。
事实上,对于InnoDB来说,可以同时支持聚集索引和非聚集索引:
并非每次使用普通索引都会进行回表,如果查询的数据就是使用普通索引的数据而非全部数据,那么不需要进行回表能够返回数据。
那么当使用联合索引(使用多个列建立索引)的时候,如果需要查询的数据就在列中且满足最左前缀匹配,那么同样也不需要进行回表。
当通过索引就可以返回查询所需要的数据的时候,可以称之为实现了索引覆盖。
联合索引指的是多个键共同组成的索引,它遵循最左前缀匹配原则。
假设创建了一个联合索引(key1,key2,key3),那么MySQL会根据最左优先的原则从最左边开始匹配,相当于建立(key1)、(key1,key2)、(key1,key2,key3)三个索引,但是(key2,key3)不满足条件。
所以一般把识别度最高的一个键放在最左边。
但是,联合索引与where条件无关,如果是where key3='1',key2='2',key1='3'
,依旧能够使用(key1,key2,key3)的联合索引,这是由于Mysql会对sql语句进行自动优化。
除此之外,当联合索引的某一个键使用了范围查询的时候,后面的键就会失效。
比如select * from myTest where key1>1 and key2=2 and key3 = 3
。在这个查询语句中,由于key1使用了范围查询,所以后面的key2和key3会失效。
select * from myTest where key1=1 order by key2
,在这条sql语句中,还是能够使用到联合索引的,key1下面任意一段key2都是排序好的。
当MySQL优化器判断扫描全表要比通过索引查询快的时候,就不会通过索引进行查询。
除此之外还包括:
id = 10000
而非id = "10000"
也会导致索引失效。where table1.id = table2.t1_id
但是t1和t2的字符编码不相同,也会导致索引失效。在说唯一索引和普通索引之前,首先要说一下change buffer,change buffer的功能其实和redo log有一些相似。
Mysql和操作系统类似,读取也是把一页数据调入内存,但是为了提高性能,Mysql会采用change buffer的功能,先把修改记录在change buffer中,等到下一次要读取这一行数据的时候再读入对应页,这个时候把change buffer中的操作刷新到内存页上。
当然,不把页读入内存就记录修改的话存在一个很大问题就是无法校验修改的有效性。
采用唯一索引的话,需要保证数据的唯一性,还是需要把数据读入内存中查看是否有相同的值,如果有的话就抛出异常,所以唯一索引是无法使用change buffer的。
MySQL8.0之前可以设置开启缓存,开启之后每次查询的时候会先从缓存中进行查询,如果在缓存中则直接返回。
事务指的是逻辑上的一组操作,要么都执行,要么都不执行。
并发事务会带来如下问题:
SQL把隔离级别划分为4种:
InnoDB存储引擎默认支持的隔离级别是可重复读,但是InnoDB在事务隔离级别下上的是Next-Key Lock锁算法,因此可以避免幻读的发生。
MyISAM和InnoDB存储引擎使用的锁:
表级锁和行级锁的区别:
InnoDB的行级锁包括以下几种:
除此之外还有页级锁,页级锁介于两者之间,是引擎BDB所采用的锁。
InnoDB的行级锁是建立在索引之上的,所以只有通过了索引的查询才会加锁,否则使用的是表锁。
在对数据库进行逻辑备份的时候会加上全局锁,对表的结构进行修改的时候,会对表加上表锁。
所以如果想要在线给一个表增加一个字段,那么可以选择在alter table中设定等待语句。
表级锁和行级锁可以进一步分为共享锁(s)和排他锁(x)。
除此之外,表级锁还有意向排它锁和意向共享锁,事务在对一个数据行加共享锁之前必须取得该表的意向共享锁。
悲观锁和乐观锁是一种概念,并非是数据库中实际存在的锁。
首先使用SHOW ENGINE INNODB STATUS
查看数据库日志,然后查找最近一次死锁发生的位置搜索关键字LATEST DETECTED DEADLOCK
,然后在后面的字段中就可以看出哪两个事务发生了死锁,死锁的时候在等待什么(日志来源)?
170219 13:31:31
*** (1) TRANSACTION:
TRANSACTION 2A8BD, ACTIVE 11 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 376, 1 row lock(s)
MySQL thread id 448218, OS thread handle 0x2abe5fb5d700, query id 18923238 renjun.fangcloud.net 121.41.41.92 root updating
delete from test where a = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 923 n bits 80 index a of table oauthdemo.test trx id 2A8BD lock_mode X waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 32
0: len 4; hex 00000002; asc ;;
1: len 4; hex 00000002; asc ;;
第一个部分是事务1,线程号为448218,执行到delete from test where a = 2
的时候卡住了,这时候正在申请a的X锁,所以提示lock_mode X waiting。
再看第二部分。
*** (2) TRANSACTION:
TRANSACTION 2A8BC, ACTIVE 18 sec inserting
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1248, 3 row lock(s), undo log entries 2
MySQL thread id 448217, OS thread handle 0x2abe5fd65700, query id 18923239 renjun.fangcloud.net 121.41.41.92 root update
insert into test (id,a) values (10,2)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 0 page no 923 n bits 80 index a of table oauthdemo.test trx id 2A8BC lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 32
0: len 4; hex 00000002; asc ;;
1: len 4; hex 00000002; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 923 n bits 80 index a of table oauthdemo.test trx id 2A8BC lock mode S waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 32
0: len 4; hex 00000002; asc ;;
1: len 4; hex 00000002; asc ;;
线程48217在执行insert into test (id,a) values (10,2)
的事务的时候卡住了,这个时候正在申请S锁,这是因为a字段是一个唯一索引,所以insert语句会在插入前进行一次duplicate key的检查,为了使这次检查成功,需要申请S锁防止其他事务对a字段进行修改。
这个时候Mysql会自动让代价比较低的事务回滚,很明显事务2的操作代价更大,所以事务1被回滚了。
分布式事务常见的解决方案有如下几种:
和MySQL中二阶段提交的方案一样,首先协调者先确认所有参与事务的数据库是否已经准备好了,只要有一个没有准备好就回滚该事务,如果都准备好了就提交事务。
我们一般会假设准备提交阶段之后的提交都是可靠的,如果真的有一个参与者提交失败或者超时就需要不停地重试直到这个参与者成功。
TCC的意思是try、confirm、cancel。这个方案的也非常简单,就是尝试进行事务,然后确认每个参与者的事务是否成功,如果不成功就取消该事务,对消耗的资源进行补偿。
这个方式是弱一致性的,这个方案就是指不同参与者之间通过消息进行事务,每个参与者定期扫描自己收到的消息,然后执行消息的任务并且回复。如果一个参与者一直没有能够执行任务,则消息的生产者会一直发送该消息直至成功。
这个方案由于是弱一致性的,所以适合用于并发量不高并且对一致性要求不高或者有其他方案作为逃生门的场景。
当MySQL单表数据过大的时候,性能会明显下降,可以采用一些方法来进行优化。
一般整型类型的表在千万级以下,字符串类型的表在五百万以下都是没有太大问题。只有当超过这个级别的时候才会需要进行单表优化。
单表优化的方式包括以下内容:
采用从库读主库写的方式来进行优化。
采用缓存可以加快数据查询的速度,一般缓存发生在以下层次:
MySQL可以将表进行拆分,包括水平拆分和垂直拆分。
垂直拆分的优势在于使得列数据变小,在查询的时候减少读取的Block数,减少I/O次数等。
劣势在于主键会出现冗余,需要管理冗余列。
水平拆分的优势不存在单库大数据和高并发的性能瓶颈、提高系统稳定性和负载能力。
劣势在于分片事务一致性难以解决,跨节点Join性能差,逻辑复杂。
分片的原则在于尽量少拆分表、分片数量尽量少、数据分布尽量均匀等。
由于水平拆分的逻辑比较复杂,所以有了不少成熟的解决方案,这些方案分为两部分:客户端架构和代理架构。
order by是常用的排序语句,有时候我们需要先进行查询,然后对查询结果进行排序,最后取前面几行(TopK)问题。
例如,select city,name,age from table where city='杭州' order by name limit 1000
。
那么这条语句是如何执行的呢?
首先他会先根据建立的索引进行查询,比如我们在city字段上建立了索引,那么首先会根据索引进行查询,查询到主键id之后就先回表,把剩余的信息(name,age)都取出来放入到sort_buffer中,然后再取下一个id,再把信息放入到buffer中直到city的值不满足条件查询。
然后在buffer中对name字段进行快速排序(如果太大的话需要进行外部排序),最后返回排序结果的前1000行。
先把所有字段读入内存再进行排序的操作我们成为全字段排序。
但是当查询的字段太多,以至于超过了mysql设置的最大行长度的时候,sort_buffer中就只会保存需要排序的name字段以及主键id。
当排序完成之后,因为Buffer中只有name和id所以还需要再进行一次回表查询,速度就慢了下来。
在buffer中只有name和id字段的排序我们称为rowid排序。
从结果上来说全字段排序的速度肯定是比rowid排序的速度快上不少的。
那么能不能不进行排序呢?
上面这个操作之所以生成临时表的原因是需要对name字段进行排序,那么如果name字段在读取的时候已经有序了,是不是就不用排序了呢?
为了使得name有序,我们就可以使用联合索引(city,name)。使用联合索引之后,name就已经是有序的了,直接在结果集中返回就行。
那么对于select * from t where city in("杭州","苏州") order by name limit 100
呢?
显然in("杭州","苏州")
是一个范围查询,那么联合索引走到这里就中断了。
那么对于范围不太大的查询,我们可以在java层面实现分次查询,首先对select * from t where city = "杭州" order by name limit 100
和select * from t where city = "苏州" order by name limit 100
进行两次查询,然后对两个结果集进行归并排序,最后再取出前100。
但是如果是select * from t where city in("杭州","苏州") order by name limit 10000,10
呢?
这样返回给客户端的数据量就非常大,我们可以采用rowid排序的思路,同样的首先查询select id,name from t where city = "杭州" order by name limit 100010
和select id,name from t where city = "苏州" order by name limit 100010
,然后进行归并,最后再根据归并的前100个id到数据库中读取。
首先先要明白limit是如何操作的,limit [offset] row
在这个标准的limit语句中,offset指的是偏移量,row指的是偏移量后面的行数。对于limit 100000,20
来说就先读出前面的100020行,然后丢弃前面的100000行。
所以对于大表来说,直接使用limit语句执行效率会非常低下。
那么如何进行优化呢?
先考虑下我们使用limit的场景,在我们项目中使用比较多的地方是前端分页,那么如果是根据主键查询,完全可以先计算出这一页的偏移,然后直接使用where
语句来查询。
比如一页20行数据,那么第十页就是190~200的偏移。
where id >= 190 and id <= 200
但是有些时候偏移量的计算不是那么显著的。
那么我们可以先借助索引覆盖来进行优化。
select * from table where ID>=(select id from table limit 100000,1) limit 20
。
但是有时候查询比较复杂,比如使用了where进行范围查询,那么这个时候就不能直接计算主键的偏移了。
可以考虑存放到临时表中,每次访问临时表的记录。
对于一条join语句select * from t1 join t2 on t1.a = t2.a
。
在这个join语句中没有指定哪个表强制作为驱动表,所以mysql会自己进行优化,选择一张比较小的表作为驱动表。
我们假设t1是驱动表,t2是被驱动表,那么执行的过程就是先查询出t1表中的一行数据R,然后根据这行数据中的a字段在t2表中进行查询,取出t2表中满足条件的行,然后和t1表中的行组成一行。
在这个过程中,查询t2的时候是可以使用a上的索引的。
这种join方式被称为"index Nested-Loop Join",简称NLJ,它的时间复杂度为N+N2log2M,N为t1表的数量,M为t2表的数量。
显然N对时间复杂度的影响更大,所以选择一张小表作为驱动表的速度会快上不少,这里的小表指的是结果集比较小的表,而不是整张表的大小。
NLJ的前提是被驱动表可以用上索引,如果用不上索引,就只能采用Block Nested-Loop join了.
直接进行匹配的话时间复杂度为N*M,效率非常低,所以MySQL就提出了优化Block Nested-Loop join。
这个算法的意思就是首先将t1表全部读到内存里面,然后把t2中的每一行取出来,逐行匹配,如果有匹配上的就加入结果集,没有就丢弃。这种做法虽然结果上还是N*M的复杂度,但是只需要进行N+M次的全表查询就可以了,其余的判断都在内存中完成。
但是如果t1表太大,内存中放不下呢?
那就采用分段放的策略,每次取出一部分t1表的数据,匹配完这块数据之后清空buffer,再取出下一块数据。
总的来说,如果要使用join语句的话最好被驱动表的匹配字段上有索引,这样的查询速度会快很多。
select id%10 as m, count(*) as c from t1 group by m
group by的执行过程是这样的,首先建立一个临时表,表中有两个字段m和c,主键是m,然后扫描t1表的索引,如果临时表中没有主键为x的行就行插入,如果有的话就讲x这行的c值加1。最后再根据m进行排序,返回结果集。
如果业务需求中不需要对m进行排序,可以选择加上order by null
。
由于group by需要使用临时表保存结果,所以当内存不够的时候会转变为硬盘临时表,开销非常大。所以根据需要可以选择增加内存临时表的临界值或者使用SQL_BIG_RESULT
来告诉优化器直接使用磁盘临时表,MySQL会自动生成一个排好序的数组,对这个数据进行group by速度就快多了。
在没有数据库连接池的情况下,我们访问数据库的过程是:
但是建立数据库连接是一个非常费时的操作,如果频繁地进行数据库连接操作将占用大量的系统资源。并且每一次数据库连接使用完之后必须关闭,如果关闭异常还会出现内存泄漏。
为了解决上述问题,最后采用了资源池设计模式。
先建立一个数据库的连接池,当请求数据库连接的时候首先查看是否有空闲连接,如果存在则分配给请求,并将引用数+1,如果没有则查看是否到了最大连接数,没有到达最大连接数的话则先创建一个新的数据库连接加入连接池中,如果达到就按设定的最大等待时间进行等待。
释放连接的时候,首先看该连接的引用次数是否超过了规定值,如果超过了就关闭该连接,如果关闭之后超过了最小连接数,则将连接池填充满。
数据库的范式一共分为三级:
MySQL主要分为Sever层和存储引擎。
存储引擎上面已经介绍过了就不再赘述,这边主要介绍Sever层,Sever层主要包括:
查询语句首先判断该语句是否有权限,如果没有权限则直接返回错误信息,如果有权限的话则先查看缓存(如果启动了缓存的话)
然后通过分析器进行词法分析,判断语句是否出错。
然后由优化器选择执行方案,最后判断进行权限校验,如果通过则直接返回执行结果。
更新语句大致和查询语句相同,但是多出了记录日志的部分。
其实Mysql中最重要的就是日志系统,我之前居然直接跳过了,这次还是补充上。
Mysql更新数据并非是每次都直接到磁盘上,这样每次都要IO,效率太低。
在InnoDB引擎中首先会把更新记录到一个叫做redo log的日志中,然后等到数据库空闲的时候或者redo日志即将写满的时候就会把redo日志中的信息刷新到磁盘上。
所以有时候,sql变慢也有可能是正在把redo日志的信息刷新到磁盘上。
redo日志的大小是固定的,一个有4个redo log,每个1G,采用循环队列的方式,如果后面的日志写满了就会从第一个日志开始覆盖。所以和一般的循环队列类似,redo日志也存在两个指针,一个是当前写位置叫做write pos,一个是当前日志的尾部叫做check piont。
当write pos追上check piont的时候,InnoDB就需要把中间的一部分数据刷新到磁盘上,这个能力叫做crash-safe。
有了redo log,即使数据库发生异常重启,之前的操作记录也不会丢失。
bin log和redo log有些相似,但是是运行在Server层上的。之前的MyISAM引擎是没有redo日志的,所以依靠bin log来完成crash-safe。
这两个日志的区别如下:
既然存在两个记录日志,那么保证两个日志的一致性也是一个非常重要的事情。
在InnoDB中采用了一个叫做二阶段提交的功能。
对于一条update语句来说,执行步骤如下:
那么如果不采用二阶段提交会发生什么事情呢?
所以总的来说,如果不采用二阶段提交就会导致数据库的状态和回滚的状态不一致。
undo log是用于MVCC(多版本并发控制)的日志,事务的读取已提交和可重复读就依靠undo log来实现。
undo log中记录的是回滚操作,也就是如果要将当前版本的值回滚到上个版本需要进行什么操作。
以可重复读为例,当一个事务开始的时候会记录下当前数据的版本号,当需要进行第二次读操作的时候,如果这个数据的版本号已经和开始时候的版本不一致了,那么就会执行回滚操作,回滚到事务开始时候的版本号。
首先来说以下count(*)
是怎么实现的。
在MyISAM中把一个表的总行数存在了磁盘上,所以执行count(*)
的时候会直接返回这个数值,所以查询速度非常快,但是如果需要使用where语句,那么还是需要全表查询的。
如果是InnoDB的话,由于需要进行MVCC,查询出来的行数也会有多个版本号,所以无法像MyISAM那样可以把行数存放在磁盘上,只能进行一次全表查询。
但是其实MySQL也是做了优化的,因为在InnoDB中普通索引存放的是主键的地址,所以普通索引会比主键索引树小不少,那么在查询的是InnoDB就会选择比较小的普通索引而非主键索引。
MySQL中的show table status命令也可以查询出当前表有多少行,但是这个数据是不准确的。之前说到数据库选择索引的时候是使用采样的方式的,所以有时候会不太准确,这个命令也是这样,官方文档说误差可以达到40%~50%。
那么如何快速的查询出count呢?可以采用的方法之一就是增加一张表专门用于保存其他表的行数,这样在事务中更新的时候顺便把这张表也更新一下,可以使得速度快上不少的同时不会对MVCC造成影响。
首先我们需要明白count究竟是一个什么操作? 在MySQL中删除表和字段的操作和OS比较相似,都是直接把这个空间标记为可复用。 explain用于查看数据库中sql语句执行的情况,其中有多个字段,下面就逐一解释这些字段的作用和意义。 这道也是常见的面试题,我想对于一个应届生来说,如果能写出来的话应该能加分不少。 这篇文章主要对MySQL的相关知识做一个总结,主要包括了索引、事务、引擎等内容。 7.27更新数据库连接池的简单实现。
count()本身是一个聚合函数,会逐行判断里面的参数是否是NULL,但是根据主键还是普通字段会有所不同的操作。
由于主键一般是not null的,所以InnoDB会进行全表查询,然后把id返回给Server层,Server层拿到id后判断是不可能为空的,就按行累加。
但是对于普通字段来说,不一定是not null的,所以对于可以为null的行还需要把值取出来一下,不是null才累加。
count(1)
则是对每一行插入一个1,查看是否能加入1,能加入就累加。
而使用最多的count(*)
进行过优化,不需要把值取出来,直接按行进行累加,所以速度会比其他的方法快一些。
总的来说,count(字段)删除表和字段
但是这样的操作和OS也会有同样的问题,即会造成空间的碎片化,有时候会出现明明有800行的空间却无法插入的问题(因为这800行是离散的,而存储要求连续的)。
所以如果想要使得删除记录之后收缩空间,就需要新建一个表,然后把原来的数据复制到新的表上去,有点类似于GC的标记复制算法,所以他也和GC有着同样的问题——不能并行,必须暂停表的操作,即先锁住表。
当然就像GC出现了并行垃圾回收器一样,MySQL在5.6之后也采用了一个叫做Online DDL操作。简单来说就是并行的把数据复制到临时表上,然后在复制的同时记录下复制时候对表进行的操作,等到复制完了再回放记录下的操作,最后使用临时表替换原来的表。
可以看到MySQL和OS、GC有着相似的原理。Explain
sql语句和执行结果如下EXPLAIN SELECT * FROM tblnomio WHERE tblnomio.niogctid = 'A0232'
where id = niogctid
那么就是niogctid
where id = niogctid
,using temporary,表示使用了临时表保存中间结果,常见于order by和分组查询group by。自己动手实现一个数据库连接池
不过话说回来,感觉写一个连接池比写一个线程池简单一些。总结一下要点:
DriverManager.getConnection(url,user,password)
方法来新增一个数据库连接,然后代理其close()
方法。close()
方法中进行判断,如果连接池的连接数量小于核心数数量则加入队列,否则直接释放。getConnection()
方法中进行判断,如果当前队列为空,说明已经达到核心数,判断一下是否还能增加连接,如果可以则新建连接,否则抛出异常。invocationHandler
接口。public class MyDataConn {
private Deque<Connection> connectionQueue = new LinkedList<>();
private final int init = 10;
private final int max = 20;
//保证可见性
private volatile int size = 10;
//初始化
public MyDataConn(){
for(int i=0;i<10;i++){
try {
connectionQueue.add(createConnection());
}catch (Exception ex){
}
}
}
//从队列中返回一个连接,或者直接新建一个连接
public Connection getConnection(){
if(connectionQueue.size() > 0)
return connectionQueue.pollFirst();
if(size < max){
size++;
return createConnection();
}
throw new RuntimeException("超过最大连接数");
}
//新建一个连接,并且返回其代理
public Connection createConnection(){
try{
final Connection conn = DriverManager.getConnection("jdbc:mysql:///jdbc_demo","root","123456789");
Connection connProxy = (Connection) Proxy.newProxyInstance(conn.getClass().getClassLoader(), new Class[]{
Connection.class}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if(method.getName().equals("close")){
releaseConnection(conn);
return null;
}else{
return method.invoke(conn,args);
}
}
});
return connProxy;
}catch (Exception ex){
throw new RuntimeException(ex);
}
}
//释放连接
private void releaseConnection(Connection conn) {
if(connectionQueue.size()<init){
connectionQueue.addLast(conn);
}else{
try {
size--;
conn.close();
}catch (Exception ex){
throw new RuntimeException(ex);
}
}
System.out.println("当前数量"+connectionQueue.size());
}
}
后记
主要参考内容如下:
MySQL的字符集和校对规则
深入理解 Mysql 索引底层原理
mysql覆盖索引与回表
无语,我差点被面试官怼坏了,又给我问到MySQL索引
【Java面试题】数据库连接池Java代码实现
《MySQL45讲》
以及内推军和JavaGuide的参考资料。