忘掉我所知的MYSQL,重新学习
基础架构
基本架构和主要任务(并非全部)
缓存在更新一个表中的一条语句之后,会将所有的缓存都清除
其中db代表了使用的存储索引和磁盘数据
数据库连接协议
为此,我专门跑到问答栏里去问了一遍,大哥们推荐了一篇文章,写的很好,概括一下
mysql数据库使用的通讯协议很多,但是最广泛的还是tcp。当客户端和服务端进行连接的时,首先客户端会先进行三次握手来连接服务端进行权限校验,连接成功之后,进行查询,查询过后,客户端拿到了服务端的结果值,然后发送一个完结通知,此时仅仅代表客户端和服务端的一次交互,但是连接本身不会断开,直到二者之间的连接超过wait_timeout
引擎
索引
如果说,数据库只涉及到了简单的查询的话,那么有索引就足够了
索引的本质是为了查找数据时更加快捷,但索引并不是mysql中server所拥有的,他是引擎所拥有的,主要是为了适应不同的业务场景中所使用的查询方式不同
常用的几种索引方式
常见的索引有hash表,有序数组,以及B+树,以下是关于这三种索引的描述。
哈希表
men引擎就是使用的哈希表作为索引的,通过哈希表形成的索引,可以将查询值进行快速查出,若出现哈希冲突,只需要在value上追加一个值就可以了
其中结果值2和结果值3就是哈希中途之后产生的链表
优点:等值查找时很快,查询效率接近O1
缺点:在进行范围性查找时,没法使用
有序链表
在范围查找时,不妨使用有序数组式的索引
将数据按照有序数组式排列(这个数组不是我们数据结构中理解的数组,就是数的集合),这样查找一个内容使用二分法进行查找,效率也比较高(O log n),而且可以向前向后遍历进行范围式搜索
B+树
在mysql中,MyISAM和InnoDB均使用B+树作为索引,但是二者之间的区别是,MyISAM使用的索引是非聚簇的(记录了数据地址),也就是叶子节点并没有实体数据,而InnoDB中的主键索引是聚簇的(记录了数据),非主键索引也使用了非聚簇索引(记录了数据主键)。
B+树结构基础组成为数据页(page),默认一页数据大小为16k。
字段 | 释义 |
---|---|
FileHeader | 主要是用来查找上一页和下一页的数据的,保证这些页面可以通过这个标签变成一个双向链表。 |
Page Header | 字段用于记录 Page 的状态信息。 |
Infimum(下确界) | 伪行记录,记录比该页中任何主键值都要小的值 |
Supremum (上确界) | 伪行记录,记录比该页中任何主键值都要大的值,这个伪记录分别构成了页中记录的边界。我把他们两个认作是记录行中的哨兵角色。 |
User Records | 中存放的是实际的数据行记录。 |
Free Space | 中存放的是空闲空间,被删除的行记录会被记录成空闲空间。 |
Page Directory | 记录着与二叉查找相关的信息。 |
File Trailer | 存储用于检测数据完整性的校验和等数据。 |
以InnoDB的一个整数字段索引为例,二级索引一页数据大概可以容纳1200个数据。因为一个数据页在索引叶子结点下是用来保存记录,在非叶子结点下用来保存索引(整数bigint长度8)加指针(指针长度通常为6)的,粗略计算为: 16*1024/(8+6),约等于1170,差不多就是1200的大小。
在FileHeader定义了上一页和下一页的指针,也就是说,在DML语言中,存在对于数据页的合并和分裂。
知道了数据页,就可以看得懂索引的整体结构(下面索引图中,每一个框都代指为一个数据页)
以主键索引为例:
非主键索引如下
其查询逻辑指的是从顶点开始,逐位查找。旨在牺牲空间,结合哈希表与B树的功能。
联合索引
在表中的多个字段上建立的索引成为联合索引
联合索引的目的是为了防止回表,因为在二级索引建立之后,索引数据中只有索引值和主键,找到索引值之后,还需要利用主键索引树去寻找其他的数据,会将查询效率翻倍,所以,联合索引应运而生,将一些经常使用的字段来包装进一个索引中来防止回表。
假设有如下表
id | name | age |
---|---|---|
1 | cc | 20 |
并且有如下索引
idx_name_age
那么我们在数据中通过name查询就会直接通过idx_name_age索引将id,name,age全部返回。但是通过age查询还是全表遍历查询,这是因为联合索引的最左前缀原则导致的。但是通过name和age联合查询是可以走这个索引的。
这就是为什么建立了以上索引就不需要建立idx_name了,但是如果age查询也是一个高频字段,还需要单独建立idx_age索引了
字符串索引
使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本
日志
MySql的日志主要分为undolog,redolog,和binlog,这句话其实是不准确的,属于Mysql的日志其实只有binlog,而redolog和undolog其实都是InnoDB下的。日志的主要目的是用来记录数据更新的。具体的日志在更新过程中分别拥有不同的意义。先来看一看简单的更新过程:
别多想,先记住它
以上流程是这样的,这个流程很重要,每一步发生了什么都很重要:
1、执行器先找引擎取ID=2这一行。ID是主键,引擎直接用树搜索找到这一行。如果ID=2这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
2、执行器拿到引擎给的行数据,把这个值加上1,比如原来是N,现在就是N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
3、引擎将这行新数据更新到内存中,同时将这个更新操作记录到redo log里面,此时redo log处于prepare状态。然后告知执行器执行完成了,随时可以提交事务。
4、执行器生成这个操作的binlog,并把binlog写入磁盘。
5、执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成**提交(commit)**状态,更新完成。
通过以上流程,至少可以获取几个知识点:
1、执行器主要用来计算数据,并通知引擎
2、引擎主要用来查询数据,更新内存,编写redolog
3、更新后的数据在内存中,并没有写入磁盘,写入磁盘的是binlog
4、以上过程涉及到了两段式提交
undolog
undolog并没有实际的物理文件生成,它伴随着事物的提交而消失,用于回滚
在MySQL 5.5及以前的版本,回滚日志是跟数据字典一起放在ibdata文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。
redolog
rodolog是InnoDB自带的物理日志,在结构上属于一个环状的链表,它通过两段式提交的操作,来达到数据的不丢失的目的。
也就是说有了redo log,InnoDB就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe。
至于为什么可以保证,我稍后解释。先重新认识一下redolog的结构:
如上图,redolog分别有两个指针用来标识头和尾,当尾要追到头时就会去将redolog中的数据同步磁盘数据中,即上图中绿色的部分就是可供书写的位置。
当时我不是很明白,到底是怎样检测到文件中是否还有空余位置的,即考虑到日志在动态书写的过程中,IO读写无法同时操作,之间的空余大小也就无法计算。
之后我了解到,为了解决问题以及提高效率,redolog使用了分段式书写的能力,引擎会给redolog发分一块redologbuffer用来保存redolog,之后再一起写到实体文件中。
redolog的写入机制
redo log可能存在的三种状态:
这三种状态分别是:
1、存在redo log buffer中,物理上是在MySQL进程内存中,就是图中的红色部分;
2、写到磁盘(write),但是没有持久化(fsync),物理上是在文件系统的page cache里面,也就是图中的黄色部分;
3、持久化到磁盘,对应的是hard disk,也就是图中的绿色部分。
日志写到redo log buffer是很快的,wirte到page cache也差不多,但是持久化到磁盘的速度就慢多了。
为了控制redo log的写入策略,InnoDB提供了innodb_flush_log_at_trx_commit参数,它有三种可能取值:
1、设置为0的时候,表示每次事务提交时都只是把redo log留在redo log buffer中;
2、设置为1的时候,表示每次事务提交时都将redo log直接持久化到磁盘;
3、设置为2的时候,表示每次事务提交时都只是把redo log写到page cache。
InnoDB有一个后台线程,每隔1秒,就会把redo log buffer中的日志,调用write写到文件系统的page cache,然后调用fsync持久化到磁盘。
注意,事务执行中间过程的redo log也是直接写在redo log buffer中的,这些redo log也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的redo log,也是可能已经持久化到磁盘的。
实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的redo log写入到磁盘中。
一种是,redo log buffer占用的空间即将达到 innodb_log_buffer_size一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是write,而没有调用fsync,也就是只留在了文件系统的page cache。
另一种是,并行的事务提交的时候,顺带将这个事务的redo log buffer持久化到磁盘。假设一个事务A执行到一半,已经写了一些redo log到buffer中,这时候有另外一个线程的事务B提交,如果innodb_flush_log_at_trx_commit设置的是1,那么按照这个参数的逻辑,事务B要把redo log buffer里的日志全部持久化到磁盘。这时候,就会带上事务A在redo log buffer里的日志一起持久化到磁盘。
这里需要说明的是,我们介绍两阶段提交的时候说过,时序上redo log先prepare, 再写binlog,最后再把redo log commit。
如果把innodb_flush_log_at_trx_commit设置成1,那么redo log在prepare阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于prepare 的redo log,再加上binlog来恢复的。
每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB就认为redo log在commit的时候就不需要fsync了,只会write到文件系统的page cache中就够了。
而通常我们说MySQL的“双1”配置,指的就是sync_binlog和innodb_flush_log_at_trx_commit都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是redo log(prepare 阶段),一次是binlog。
这时候,你可能有一个疑问,这意味着我从MySQL看到的TPS是每秒两万的话,每秒就会写四万次磁盘。但是,我用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的TPS?
解释这个问题,就要用到组提交(group commit)机制了。
这里,我需要先和你介绍日志逻辑序列号(log sequence number,LSN)的概念。LSN是单调递增的,用来对应redo log的一个个写入点。每次写入长度为length的redo log, LSN的值就会加上length。
LSN也会写到InnoDB的数据页中,来确保数据页不会被多次执行重复的redo log。
如下图三个并发事务(trx1, trx2, trx3)在prepare 阶段,都写完redo log buffer,持久化到磁盘的过程,对应的LSN分别是50、120 和160。
从图中可以看到,
1、trx1是第一个到达的,会被选为这组的 leader;
2、等trx1要开始写盘的时候,这个组里面已经有了三个事务,这时候LSN也变成了160;
3、trx1去写盘的时候,带的就是LSN=160,因此等trx1返回时,所有LSN小于等于160的redo log,都已经被持久化到磁盘;
4、这时候trx2和trx3就可以直接返回了。
所以,一次组提交里面,组员越多,节约磁盘IOPS的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。
在并发更新场景下,第一个事务写完redo log buffer以后,接下来这个fsync越晚调用,组员可能越多,节约IOPS的效果就越好。
所以在fsync的时候,mysql还会进行一个拖时间的操作来一次携带更多的组员。
如上图,同样的binlog也会进行组提交。不过binlog的write和fsync间的间隔时间短,导致能集合到一起持久化的binlog比较少,因此binlog的组提交的效果通常不如redo log的效果那么好。
所以说,如果我们发现数据库的io处于一个瓶颈上,那我们就可以有以下方式进行优化。
1、设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count参数,减少binlog的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
2、将sync_binlog 设置为大于1的值(比较常见是100~1000)。这样做的风险是,主机掉电时会丢binlog日志。
3、将innodb_flush_log_at_trx_commit设置为2。这样做的风险是,主机掉电的时候会丢数据。
binlog
相对于redolog,binlog属于mysql的文件;
它的主要作用是归档以及数据库之间对于数据的同步,而不是防止崩溃;
redo log是物理日志,记录的是“在某个数据页上做了什么修改”;而binlog是逻辑日志,记录的是这个语句的原始逻辑,比如“给ID=2这一行的c字段加1 ”;
binlog是可以追加写入的,不限制大小的,而redolog是环状式的写法,有其大小的限制。
binlog的写入机制
其实,binlog的写入逻辑比较简单:事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。
一个事务的binlog是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了binlog cache的保存问题。
系统给binlog cache分配了一片内存,每个线程一个,参数 binlog_cache_size用于控制单个线程内binlog cache所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。
事务提交的时候,执行器把binlog cache里的完整事务写入到binlog中,并清空binlog cache。
可以看到,每个线程有自己binlog cache,但是共用同一份binlog文件。
图中的write,指的就是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快。
图中的fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为fsync才占磁盘的IOPS。
write 和fsync的时机,是由参数sync_binlog控制的:
sync_binlog=0的时候,表示每次提交事务都只write,不fsync;
sync_binlog=1的时候,表示每次提交事务都会执行fsync;
sync_binlog=N(N>1)的时候,表示每次提交事务都write,但累积N个事务后才fsync。
因此,在出现IO瓶颈的场景里,将sync_binlog设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成0,比较常见的是将其设置为100~1000中的某个数值。
但是,将sync_binlog设置为N,对应的风险是:如果主机发生异常重启,会丢失最近N个事务的binlog日志。
两段式提交
首先,两段式提交主要用来保证redolog和binlog的一致性,一般用于mysql异常奔溃重启,和搭建备库、分库的场景。可以这么理解,binlog中必然有记录redolog中所有的commit阶段的所有数据,我们主要从以下几个角度分析二者必须同时存在的必要性:
1、先写完redolog在写binlog,如果奔溃发生在redolog写完之后,那么binlog是来不及写的,这样两个日志就不一样了;重启之后,redolog将修改后的内容加载在内存中,binlog却无法将数据写在磁盘上,不管这个事务有没有提交,二者的数据就已经不一样了。从用户角度看来,他认为数据还未提交(因为奔溃了),但是提交记录已经存在,并且磁盘数据还处于原始数据状态。
2、先写binlog再写redolog,binlog完成时发生奔溃,那么在重启之后,磁盘数据是会改写成binlog的修改的,如果这时redolog还未完成,内存数据就和磁盘数据再一次不一致了。从用户来看,同样的数据没有提交,但是读取操作如果是从磁盘取出就会发现已经变成了他想要的数据。
3、
事物
事务特性:
A(原子性) | C(一致性) | I(隔离型) | D(持久性) |
---|---|---|---|
原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。 | 事务前后数据的完整性必须保持一致。 | 事务的隔离性是多个用户并发访问数据库时,数据库为每一个用户开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离。 | 持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来即使数据库发生故障也不应该对其有任何影响 |
事务隔离级别:
首先我认为事务的四种隔离级别没有对错之说,只有适用之分。
读未提交 | 读提交 | 可重复读 | 串行化 |
---|---|---|---|
一个事务还没提交时,它做的变更就能被别的事务看到。 | 一个事务提交之后,它做的变更才会被其他事务看到。 | 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。 | 对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。 |
脏读、幻读、不可重复读都存在 | 解决了脏读 | 又解决了不可重复读 | - |
脏读、幻读、不可重复读的概念
脏读:
张三的工资为5000,事务A中把他的工资改为8000,但事务A尚未提交。
与此同时,
事务B正在读取张三的工资,读取到张三的工资为8000。
随后,
事务A发生异常,而回滚了事务。张三的工资又回滚为5000。
最后,
事务B读取到的张三工资为8000的数据即为脏数据,事务B做了一次脏读。
幻读:
目前工资为5000的员工有10人,事务A读取所有工资为5000的人数为10人。
此时,
事务B插入一条工资也为5000的记录。
这是,事务A再次读取工资为5000的员工,记录为11人。此时产生了幻读。
不可重复读:
在事务A中,读取到张三的工资为5000,操作没有完成,事务还没提交。
与此同时,
事务B把张三的工资改为8000,并提交了事务。
随后,
在事务A中,再次读取张三的工资,此时工资变为8000。在一个事务中前后两次读取的结果并不致,导致了不可重复读。
MVCC
MySql通过MVCC和锁的机制来控制了同一条数据在读读,读写,写读,写写之间的并发操作。
MVCC本质上是一种主要用于多线程系统中乐观锁的思想,其主要用来解决读写,写读之间的并发问题
ReadView是MVCC控制中主要部分之一,他包含了一个活跃事务的列表,还有最大最小活跃事务的值以及自己的事务id,通过查询活跃事务列表中的数据来控制当前版本使用的到底是哪一个被修改的数据。
在MySql中,读提交和可重复读会使用MVCC;
在读提交下,ReadView会根据时间将每一条数据的修改版本建立一个版本链,通过活跃事务的id列表来找到读提交的那条信息。
而可重复读下,在事物开启时并不会进行快照,只有在执行了第一条语句时才会进行快照,这里的快照并不是将所有的数据统统拷贝一份,而是会记录一个事物id,所有的数据都会基于事务id进行查询,当查询一条数据时,会根据事物id直接找到对应视图,并在视图下寻找记录。我们称这种行为叫快照读。
其实在整个过程中,数据还是一直再改变的,只不过会通过undolog进行回滚。
相关文档《什么是MVCC》
锁
锁的互斥关系如下图
- | 读锁 | 写锁 |
---|---|---|
读锁 | 共存 | 不能共存 |
写锁 | 不能共存 | 不能共存 |
即写锁为排他锁
如果说MVCC解决了读写,写读之间的并发问题,那么锁就会解决了写写之间的问题。
但其实,锁的适用范围并不仅仅只是如此。
全局锁
对于开发,全局锁的使用并不是很普遍。
MySQL提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
全局锁一般用于数据库的备份,如果在数据库的备份中不加全局锁并且数据库并没有支持可重复读的引擎,那么在备份时,对于多个及联表之间的备份,他们拿到的视图就不是一个时间点的,这个时候他们的数据并不满足数据一致性。
例如:在银行转账的条件下:A有200元,B有0元,A转给B 200元
A->B=>200;
A=A-200;
B=B+200;
如果A转账之前备份了A所在的表,那么备份中A=200;
而B所在表是转账之后备份的,那备份中B=200;
此时数据就发生了数据不一致的问题。
其实,对与支持事务引擎的表,官方提供了相关的逻辑备份工具:mysqldump
他会针对于数据库建立事务,拿到当前的视图,而我们知道,事务由于redolog和mvcc的配合使用,他能保证拿到的事物是一个完成的强一致视图。
表级锁
表锁的作用对象粒度是一整张表,所以他的使用范围现在也很小,以前,在还没有支持行锁等小粒度的引擎出现时,表锁往往是解决并发问题的主要手段之一。而现在,表级锁主要是用于表结构发生变化时,防止有语句对于数据的查询和操作。
表锁有两种,一种是lock tables … read/write分别加读锁和写锁,需要注意的是,这句话是可以用unlock语句进行解锁的,并且他的操作范围不仅仅限于别的线程,包括他自己也会限制。
例如:
thread1 -> lock table t1 read,t2 write (此线程为t1加了读锁,为t2加了写锁)
此时如果有thread2对于t1或者t2进行写,都会被阻塞,同样的t1也是如此。二者只能对于t1进行读的操作。
另一类表级锁成为元数据锁(metadata lock) mysql对于元数据锁的使用是很频繁的,只是我们不知道,因为mysql加这个锁并不是显式的。
在对表结构进行修改的情况下,为了保证此时增删改查的正常返回,mysql会给这个表加一个mdl写锁,而在正常对于一张表增删改查时,会对这张表加一个mdl读锁。这样的话就可以保证在对于一张表增删改查时,他是可以并发进行的,而在修改表结构是是阻塞的。(详解:锁的互斥关系)
对,在修改一张表结构的时候,所有关于这张表及表中数据的操作都是阻塞的。所以我们需要知道,一定不可以将修改表结构的这种操作放在一个大事务中。
行锁
首先需要注意的是,行锁并不是mysql的锁机制,而是引擎的锁机制。而且innodb是支持行锁的。
顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务A更新了一行,而这时候事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新。
研究到这里的时,是不是有点怀疑,事务到底是不是隔离的? 不慌,慢慢来,首先可以说,事务的隔离是针对于操作而论的,对于读读,读写这种操作,事务是可以进行隔离的,隔离级别需要看引擎的设置,但是对于写写,为了数据的一致性和事物的原子性,需要使用两阶段锁,两阶段锁的原理也很简单:在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
- | sessionA | sessionB |
---|---|---|
T1 | update table1 set k = k+1 where id = 1;update table1 set k = k+1 where id = 2; | - |
T2 | - | update table1 set k = 3 where id = 1 |
T3 | commit; | - |
T4 | - | commit; |
在上面的修改过程中,T2时刻的update不能及时执行,需要等到T3时刻A事物提交之后,释放了id=1的行锁,它才可以执行。
死锁(锁的状态,不是一种类型的锁)
问题来了,行锁是将一整条数据全部独占,那么就一定有概率发生死锁。
死锁是什么?如果面试官问你这个问题,请告诉他,你先给我发offer,我就给你讲死锁到底是什么。(这句话本身和面试官的意图就是一种死锁关系)(《死锁面试题》,我认为死锁的四个条件是很重要的,只有掌握了其必备条件才能找到其破解方法)
那么发生了死锁怎么办。
我找到了大概以下几种常用的策略:
1、设置锁超时自动释放,通过设置innodb_lock_wait_timeout这个参数,来使mysql检测到锁的时间并将事物进行回滚以释放锁,mysql对于锁超时的默认设置是innodb_lock_wait_timeout = 50s,但是实际上这个50s,对于大部分的业务来说是不容忍的,可以适当调小,但是绝对不能太小,防止误伤,将没有死锁的事物也回滚掉。
2、mysql自带死锁检测,可以通过修改innodb_deadlock_detect=on来开启死锁检测,mysql默认其为开启状态,但是死锁检测是很耗时间的,简单理解到,死锁检测是每有一个事物被锁,就检测此线程是否被其他线程锁住。所以一般情况下,如果可以确定死锁不会发生,可以临时关闭其死锁检测来提升效率。但同样,风险值很大,一旦有死锁,那么使用相关记录的所有操作就都会被阻塞。
3、控制并发,通过服务端代码的方式来控制并发,或者利用消息组件来降低并发通常是解决热点数据的死锁问题的有效措施。
4、顺序加锁,对于两条记录先后加锁顺序一致那么也不会发生死锁的问题。
间隙锁
这里需要说明:
在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
如果只有当前读情况,需要对数据加上间隙锁和行锁来保证数据不会发生幻读的情况,但是如果一个事物中即包含了快照读,又包含了当前读,那么他的隔离性就会被破坏。
有以下数据
id | c(索引) | d |
---|---|---|
0 | 0 | 0 |
5 | 5 | 5 |
10 | 10 | 10 |
15 | 15 | 15 |
20 | 20 | 20 |
25 | 25 | 25 |
针对于数据有三个事物分别执行以下操作:
那么Q2的情况属于脏读,Q3的情况就属于幻读。
分析以上操作我们可以得到(实际上我们不会得到这些数据,这些分析是基于目前所说):
1、以 Q1,Q2,Q3单条sql看,他们均属于当前读的情况,从这个角度上看他们并没有问题。
但是,在Q1中,session A针对于所有关于d=5的行进行了当前读,并增加写锁,但是只有一条语句被锁到,而sessionB、C很快又增加了一条d=5的语句,单从sessionA来看,他的加锁声明等于说被破坏了。
2、而且,这里面有一个很严重的数据不一致的问题
T1:开启A事务,Q1=(5,5,5),并上锁d=5,将d=5的这一行修改为(5,5,100)
T2:虽然sessionA已经锁定d=5并且在T6进行提交,但是由于id=0这一行并没有上锁,所以sql可以执行,执行完成为(0,5,5)
T3:Q2=(5,5,100),(0,5,5)
T4:增加数据(1,5,5)
T5:Q3=(5,5,100),(0,5,5),(1,5,5)
T6:提交
最后的结果就变成了
id | d | c(索引) |
---|---|---|
0 | 5 | 5 |
5 | 5 | 100 |
1 | 5 | 5 |
10 | 10 | 10 |
15 | 15 | 15 |
20 | 20 | 20 |
25 | 25 | 25 |
按照时间顺序,他们之间没有锁冲突,结果应为如此。
但是我们再来看看这时候binlog里面的内容。
T2时刻,session B事务提交,写入了两条语句;
T4时刻,session C事务提交,写入了两条语句;
T6时刻,session A事务提交,写入了update t set d=100 where d=5 这条语句。
我统一放到一起的话,就是这样的:
update t set d=5 where id=0; /(0,0,5)/
update t set c=5 where id=0; /(0,5,5)/
insert into t values(1,1,5); /(1,1,5)/
update t set c=5 where id=1; /(1,5,5)/
update t set d=100 where d=5;/所有d=5的行,d改成100/
最后的结果就变成了
id | d | c(索引) |
---|---|---|
0 | 5 | 100 |
1 | 5 | 100 |
5 | 5 | 100 |
10 | 10 | 10 |
15 | 15 | 15 |
20 | 20 | 20 |
25 | 25 | 25 |
这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆一个库,这三行的结果,都变成了 (0,5,100)、(1,5,100)和(5,5,100)。
3、发生数据不一致的原因是:我们虽然奖索引d=5加锁了,但是其没法避免其他线程将操作改为5或者增添一个5.
接下来,我们从开始扫描,一直到找到值的所有带5的数据统统加锁,这样的id=0就应该可以保证数据一致了
首先将索引d的索引树子节点都拿出来,扫描过程中,我们加了行锁d=0,d=5,d=10,这样在sessionB更新id=0的时候我们就将他阻塞了,直到sessionA提交我们才可以释放这个资源,这其实就是表锁粒度解决并发问题。但是id=1还是添加上了。
4、这个时候,我们引入间隙锁,间隙锁的意思是“组织操作往这个间隙中插入一个记录”。而这个间隙就是上图方框中的内容(比如:(0,5))。
此时结合我们所提到的行锁,理论上就可以解决这种当前读环境下的幻读情况了。
间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。比如说,我们上面的表初始化以后,如果用
select id,c,d from table for update
要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。
加锁规则
在修改、增加、删除语句(或者主动加行锁)的情况下,mysql会给一些数据和间隙加锁,那他到底是如何进行加锁的呢?
加锁规则要分为唯一索引加锁和普通索引加锁,因为我们要修改数据的时候应该先查出来再修改,所以会和查询时所用的索引有关,其规则主要包括(我概括得不好,其实这里最简单的理解方法就是,把查询到的数据前后都加锁):
基本原则:加锁的基本单位是next-key lock,并且只有在查找过程中被访问到的对象才有可能加锁
- | 等值查询 | 范围查询 |
---|---|---|
唯一索引 | 会从next-key lock退化为行锁,向右遍历到最后一个不满足条件的数据时,会在此数据范围内退化为间隙锁 | 会访问到不满足条件的第一个值为止,将其也加锁 |
索引(包括唯一索引) | 向右遍历到最后一个不满足条件的数据时,会在此数据范围内退化为间隙锁 | 会访问到不满足条件的第一个值为止,将其也加锁 |
为方便理解,简述几个案例:
表数据
id | c(key) | d |
---|---|---|
0 | 0 | 0 |
5 | 5 | 5 |
10 | 10 | 10 |
15 | 15 | 15 |
20 | 20 | 20 |
25 | 25 | 25 |
eg1:唯一索引,等值查询,等值数据加行锁,最后遍历数据加间隙锁
A:加锁(5,10),查询id=7时,先找到5,然后找到10,发现没有id=7,所以2[1]就不存在了,接着向右遍历,最后一个不满足的是10,将原先加锁(5,10]退化为(5,10)。
eg2:非唯一索引等值,最后遍历数据加间隙锁
A:查询数据时遍历到的数据c为0,5,10,所以加锁为(0,5],(5,10],根据规则3,(5,10]退化为间隙锁,最后加锁为(0,5],(5,10),但是需要注意,c=5这一行加的是一个读锁(<《记录下for update 和 lock in share mode的区别》)
B:这句没有阻塞的原因是因为A在加锁过程中,使用的share mode,他只锁到了覆盖索引没有锁到主键索引,并且,只用一个覆盖索引就可以获取到id字段,所以B可以成功,如果使用的是for update,那么他就会连主键索引一起锁到
C:被A的锁阻塞到了
所以说,要用lock in share mode来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将session A的查询语句改成select d from t where c=5 lock in share mode。
eg3:唯一索引范围查询,最后遍历数据加next-key
A:使用主键索引搜索,查询到id=10(这里其实是用的是等值查询,因为id>=10),之后向右遍历得到(10,15]的锁,最后加锁[10,15]
B、C监测到A的锁
eg4:非唯一索引范围查询,最后遍历数据加next-key
A:为唯一索引加锁,虽然非唯一索引c>=10也是等值查询,但是不会退化至行锁,最后结果为(5,10],(10,15]
eg5:
A:在唯一索引范围查询中,顺序检索,扫描到最后一位不在查询范围内的值,(10,15],(15,20]
eg6:普通索引对应多行唯一索引
在以上基础之上多添加一条
insert into t values(30,10,30);
A:采用非唯一索引的等值加锁方式,加锁在c索引上,并且其锁定方式在c=10及其左右,及(5,15)。
eg 7:limit加锁方式
在eg6的基础上,发生如下事务过程:
A:delete语句加了 limit 2。你知道表t里c=10的记录其实只有两条,因此加不加limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B的insert语句执行通过了
这是因为在c索引下,找到c=10的第一条时,锁住了(5,10],第二条c=10时锁住了第二条的(10,10],之后已经足够limit 2了,索引不再遍历
这个例子对我们实践的指导意义就是,在删除数据的时候尽量加limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
eg8:举例死锁情况
现在,我们按时间顺序来分析一下为什么是这样的结果。
1、session A 启动事务后执行查询语句加lock in share mode,在索引c上加了next-key lock(5,10] 和间隙锁(10,15);
2、session B 的update语句也要在索引c上加next-key lock(5,10] ,进入锁等待;
3、然后session A要再插入(8,8,8)这一行,被session B的间隙锁锁住。由于出现了死锁,InnoDB让session B回滚。
你可能会问,session B的next-key lock不是还没申请成功吗?
其实是这样的,session B的“加next-key lock(5,10] ”操作,实际上分成了两步,先是加(5,10)的间隙锁,加锁成功;然后加c=10的行锁,这时候才被锁住的。
也就是说,我们在分析加锁规则的时候可以用next-key lock来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。
CRUD
有了MySql基础的算法和数据结构的知识,再来研究一下CRUD具体的执行过程这个维度。
如果说,锁和事务是用来处理多线操作的,那么MySql在单线操作过程中的执行就是另一个维度了。
查询
从基础架构目录的图上来看:
1、一条查询语句先由客户端发起,
2、之后进入连接器进行权限校验(如果是重用的长链接则不需要校验),
3、接着进入分析器对其语法分析
4、进入优化器,获取最佳执行方案
5、由执行器调用引擎接口执行执行方案
6、引擎会按照页的单位来将索引页读取至内存,之后进行查找。
7、返回
通过其执行过程了解到,优化器的优化方案,以及引擎的执行和查询快慢之间会有直接影响
多个索引之间的选择
优化器在查询过程中扮演着执行设计师的位置,它需要针对于一条语句,来选择最佳的优化方案,包括索引的选择,是否需要排序,是否使用临时表等因素来进行综合判断,以达到最佳的效果。
而往往索引的选择是最重要的。
举个栗子:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `b` (`b`)
) ENGINE=InnoDB;
接着循环插入10万条数据
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=100000)do
insert into t values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
查询数据
mysql> select * from t where a between 10000 and 20000;
这条语句会使用a索引进行查询,毋庸置疑。
但是如果开启了以下事务操作
并且将慢查询日志(slow log)时间缩减至0(set long_query_time=0;
)就会发现,B中的查询解析出来的日志并没有使用a索引,且日志中记录了B语句的扫描行数是十万
如果强制将索引指向a,则会发现
也就是在这种情况下,B中查询选错了索引
解析:
优化器选择索引主要通过以下两种方式进行:
1、基数判断
MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。
这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。
使用show index方法,看到一个索引的基数。虽然表t的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不同,而且都不准确。
而采样统计是因为,全表统计的代价比较高,所以使用了采用。
采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。
而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过1/M的时候,会自动触发重新做一次索引统计。
在MySQL中,有两种存储索引统计的方式,可以通过设置参数innodb_stats_persistent的值来选择:
设置为on的时候,表示统计信息会持久化存储。这时,默认的N是20,M是10。
设置为off的时候,表示统计信息只存储在内存中。这时,默认的N是8,M是16。
由于是采样统计,所以不管N是20还是8,这个基数都是很容易不准的。
但是从数据上来看,id,a,b的基数都在十万左右,显然,这个并不是优化器选择id索引的原因
2、扫描行数
其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要扫描多少行。
可以看到,a索引的扫描行数由原来的10001增加到了37116行,首先a索引扫描行数增大了,其次,优化器还是选择了主键索引的全扫描,依然没有选择a索引。
1、索引扫描行数增大是因为我们将A线锁住了,A线的表无法释放,导致B线在删除之后无法使用原先删除的空表,只能新增表行数。所以索引增加,但是我不是很明白,为什么A的操作和B的操作在索引上没有事务的隔离,还是A和B的操作就是因为隔离了导致此现象的发生
2、优化器选择a索引是因为优化器不只需要评估基数和扫描行数,还需要评估查询过程中,回表等操作,综合评估后,优化器选择了主键索引。
analyze table t 命令,可以用来重新统计索引信息。
或者刷表来重新计算索引。
修改|删除|新增
以修改为例
其中需要注意的是:
1、刷脏指的是内存数据页的的大小不够用时,将脏页数据同步至磁盘数据的过程(具体过程以下有分析)
数据在每一次查询(包括修改)的时候会通过LRU算法,将最新用到的数据移动到最前面,这样所有的数据页就维系为一个链表(flush-list),在数据内存不够时,会先选择脏页进行同步并释放,没有脏页,则会将链表尾端的数据页删除,并将读取到的数据页放入链表最前。
2、刷盘指的是redolog或者binlog从buffer同步至磁盘的过程
有印象,redolog buffer是从buffer pool分出来的,按照比例算的,但是找不到资料了
3、此过程指的是单线程下的操作,多线程情况下需要考虑到MVCC版本链和next-key lock的情况。
InnoDB刷脏页的控制策略
首先,要正确地告诉InnoDB所在主机的IO能力,这样InnoDB才能知道需要全力刷脏页的时候,可以刷多快。
这就要用到innodb_io_capacity这个参数了,它会告诉InnoDB你的磁盘能力。这个值我建议你设置成磁盘的IOPS。磁盘的IOPS可以通过fio这个工具来测试,下面的语句是我用来测试磁盘随机读写的命令:
fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest
其实,因为没能正确地设置innodb_io_capacity参数,而导致的性能问题也比比皆是。
例如:innodb_io_capacity的值设置的是300。于是,InnoDB认为这个系统的能力就这么差,所以刷脏页刷得特别慢,甚至比脏页生成的速度还慢,这样就造成了脏页累积,影响了查询和更新性能。
虽然我们现在已经定义了“全力刷脏页”的行为,但平时总不能一直是全力刷吧?毕竟磁盘能力不能只用来刷脏页,还需要服务用户请求。所以接下来,我们就一起看看InnoDB怎么控制引擎按照“全力”的百分比来刷脏页。
根据我前面提到的知识点,试想一下,如果你来设计策略控制刷脏页的速度,会参考哪些因素呢?
这个问题可以这么想,如果刷太慢,会出现什么情况?首先是内存脏页太多,其次是redo log写满。
所以,InnoDB的刷盘速度就是要参考这两个因素:一个是脏页比例,一个是redo log写盘速度。
InnoDB会根据这两个因素先单独算出两个数字。
参数innodb_max_dirty_pages_pct是脏页比例上限,默认值是75%。InnoDB会根据当前的脏页比例(假设为M),算出一个范围在0到100之间的数字,计算这个数字的伪代码类似这样:
F1(M)
{
if M>=innodb_max_dirty_pages_pct then
return 100;
return 100*M/innodb_max_dirty_pages_pct;
}
InnoDB每次写入的日志都有一个序号,当前写入的序号跟checkpoint对应的序号之间的差值,我们假设为N。InnoDB会根据这个N算出一个范围在0到100之间的数字,这个计算公式可以记为F2(N)。F2(N)算法比较复杂,你只要知道N越大,算出来的值越大就好了。
然后,根据上述算得的F1(M)和F2(N)两个值,取其中较大的值记为R,之后引擎就可以按照innodb_io_capacity定义的能力乘以R%来控制刷脏页的速度。
上述的计算流程比较抽象,不容易理解,所以我画了一个简单的流程图。图中的F1、F2就是上面我们通过脏页比例和redo log写入速度算出来的两个值。
现在你知道了,InnoDB会在后台刷脏页,而刷脏页的过程是要将内存页写入磁盘。所以,无论是你的查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用IO资源并可能影响到了你的更新语句,都可能是造成你从业务端感知到MySQL“抖”了一下的原因。
要尽量避免这种情况,你就要合理地设置innodb_io_capacity的值,并且平时要多关注脏页比例,不要让它经常接近75%。
其中,脏页比例是通过Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total得到的,具体的命令参考下面的代码:
mysql> select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty';
select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total';
select @a/@b;
接下来,我们再看一个有趣的策略。
一旦一个查询请求需要在执行过程中先flush掉一个脏页时,这个查询就可能要比平时慢了。而MySQL中的一个机制,可能让你的查询会更慢:在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好是脏页,就会把这个“邻居”也带着一起刷掉;而且这个把“邻居”拖下水的逻辑还可以继续蔓延,也就是对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会被放到一起刷。
在InnoDB中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为1的时候会有上述的“连坐”机制,值为0时表示不找邻居,自己刷自己的。
找“邻居”这个优化在机械硬盘时代是很有意义的,可以减少很多随机IO。机械硬盘的随机IOPS一般只有几百,相同的逻辑操作减少随机IO就意味着系统性能的大幅度提升。
而如果使用的是SSD这类IOPS比较高的设备的话,我就建议你把innodb_flush_neighbors的值设置成0。因为这时候IOPS往往不是瓶颈,而“只刷自己”,就能更快地执行完必要的刷脏页操作,减少SQL语句响应时间。
在MySQL 8.0中,innodb_flush_neighbors参数的默认值已经是0了。
部分常用sql关键词的使用
count(*)
count(),即查询总行数,他其实和普通的查询是一致的,只不过要求的是返回行数,而不是具体的值。
但是,在mysql不同的引擎下,统计方式是不同的。
在没有查询条件的情况下:
MyISAM引擎把一个表的总行数存在了磁盘上,因此执行count()的时候会直接返回这个数,效率很高;
而InnoDB引擎就麻烦了,它执行count()的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。
这是因为InnoDB有拥有对于事物的支持,在不同视图下,数据总数是不一致的,所以不会采用和MyISAM一样的查询机制。
理所应当,在大数据量的背景下,InnoDB的count()会发生慢查询的情况,因为它毕竟需要将表中所有的数据扫描一遍做统计。
那么在大数据量的情况下,我们可以将表格总数单独放在数据库或者缓存中,这里建议放在缓存中,利用InnoDB的事务来保证数据的一致性。
缓存中存储是需要使用分布式事务来调控的,并且根据其cap原则,无法做到高可用和高一致共存的情况。
order by limt
全字段排序
order by 即为排序
在使用explain查询后,一般会显示
图中using filesort表明MySQL会给语句分配一块内存用于排序,称为sort_buffer。
查询会将所有符合条件的语句从最小索引树上取出来,返回规定条目的数据(如limt 1000,就取出前1000条)。
排序时是怎么排序的
排序是分为两种排序方式的。
1、内存排序,mysql会根据排序所需要使用的内存与其参数sort_buffer_size作比较。
如果需要使用的内存在sort_buffer_size之内,就会使用内存作为排序空间,并使用快速排序来排序
2、外部排序,如果mysql发现排序使用空间是大于sort_buffer_size的,就会将所有的数据分片,将每一块分片记录到一个单独的磁盘文件,对于这些磁盘文件进行归并排序,最后将数据进行整合。
我们可以通过以下命令来看排序是否使用了外部排序。
/* 打开optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on';
/* @a保存Innodb_rows_read的初始值 */
select VARIABLE_VALUE into @a from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 执行语句 */
select city, name,age from t where city='杭州' order by name limit 1000;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`G
/* @b保存Innodb_rows_read的当前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 计算Innodb_rows_read差值 */
select @b-@a;
rowid排序
如果说我们需要排序的单行长度很大,就会使用rowid排序
SET max_length_for_sort_data = 16;
max_length_for_sort_data,是MySQL中专门控制用于排序的行数据的长度的一个参数。它的意思是,如果单行的长度超过这个值,MySQL就认为单行太大,要换一个算法。
rowid排序指的是排序时只会以普通索引的形式,对索引进行排序,之后会根据索引所标注的主键进行回表查询。
这就提示我们,在排序过程中,排序条件就好像查询条件一样,如果我们可以将排序条件和查询条件组成联合索引,那我们的查询效率会大幅度提高。
join
在实际生产中,关于join语句使用的问题,一般会集中在以下两类:
我们DBA不让使用join,使用join有什么问题呢?
如果有两个大小不同的表做join,应该用哪个表做驱动表呢?
group by 和 having
在join的过程中一般存在两种join算法,一种是Index Nested-Loop Join,另一种是Block Nested-Loop Join
Index Nested-Loop Join指的是驱动表查出数据后可以使用被驱动表上的索引来进行查询。
如
t1表查出数据之后,将t1表上的a索引获取到,接着将在被驱动表上的a索引中符合条件数据拿出来,组成结果集
Block Nested-Loop Join是根据Simple Nested-Loop Join而来的,Simple Nested-Loop Join,顾名思义,就是将驱动表上的数据查询出来之后,根据关联外键将被驱动表上的数据遍历出出来。而Block Nested-Loop Join是:
1、把驱动表的数据读入线程内存join_buffer中,如果我们这个语句中写的是select ,就把整个表t1放入了内存;
2、扫描被驱动表,把被驱动表中的中的每一行取出来,跟join_buffer中的数据做对比,满足join条件的,作为结果集的一部分返回。
对应的执行侧策略是
如果使用Simple Nested-Loop Join算法进行查询,驱动表被驱动表。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join算法的这10万次判断是内存操作,速度上会快很多,性能也更好。
接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。
假设小表的行数是N,大表的行数是M,那么在这个算法里:
两个表都做一次全表扫描,所以总的扫描行数是M+N;
内存中的判断次数是MN。
可以看到,调换这两个算式中的M和N没差别,因此这时候选择大表还是小表做驱动表,执行耗时是一样的。
然后,你可能马上就会问了,这个例子里表t1才100行,要是表t1是一个大表,join_buffer放不下怎么办呢?
join_buffer的大小是由参数join_buffer_size设定的,默认值是256k。如果放不下表t1的所有数据话,策略很简单,就是分段放。
上图中,会将驱动表分段放置与被驱动表比较,每一段为join_buffer_size的大小,直到驱动表分段完全。
这个流程才体现出了这个算法名字中“Block”的由来,表示“分块去join”。
可以看到,这时候由于表t1被分成了两次放入join_buffer中,导致表t2会被扫描两次。虽然分成两次放入join_buffer,但是判断等值条件的次数还是不变的,依然是(88+12)1000=10万次。
我们再来看下,在这种情况下驱动表的选择问题。
假设,驱动表的数据行数是N,需要分K段才能完成算法流程,被驱动表的数据行数是M。
注意,这里的K不是常数,N越大K就会越大,因此把K表示为λN,显然λ的取值范围是(0,1)。
所以,在这个算法的执行过程中:
扫描行数是 N+λNM;
内存判断 NM次。
显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在M和N大小确定的情况下,N小一些,整个算式的结果会更小。
所以结论是,应该让小表当驱动表。
当然,你会发现,在N+λNM这个式子里,λ才是影响扫描行数的关键因素,这个值越小越好。
刚刚我们说了N越大,分段数K越大。那么,N固定的时候,什么参数会影响K的大小呢?(也就是λ的大小)答案是join_buffer_size。join_buffer_size越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。
这就是为什么,你可能会看到一些建议告诉你,如果你的join语句很慢,就把join_buffer_size改大。
理解了MySQL执行join的两种算法,现在我们再来试着回答文章开头的两个问题。
第一个问题:
能不能使用join语句?
如果可以使用Index Nested-Loop Join算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
如果使用Block Nested-Loop Join算法,扫描行数就会过多。尤其是在大表上的join操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种join尽量不要用。
所以你在判断要不要使用join语句时,就是看explain结果里面,Extra字段里面有没有出现“Block Nested Loop”字样。
第二个问题是:
如果要使用join,应该选择大表做驱动表还是选择小表做驱动表?
如果是Index Nested-Loop Join算法,应该选择小表做驱动表;
如果是Block Nested-Loop Join算法:
在join_buffer_size足够大的时候,是一样的;
在join_buffer_size不够大的时候(这种情况更常见),应该选择小表做驱动表。
所以,这个问题的结论就是,总是应该使用小表做驱动表。
那什么是小表?
准确地说,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
分布式
在大数据量面前,单独的库表有时候会不满足于一个系统对于可用性的要求,例如查询过慢,高并发情况下数据更新较慢等等,针对于这种情况,专家提出了分布式数据库的概念,但其实要知道的是,虽然目前分布式数据库属于当下流行的一种设计,但是它其实是有美国在70-80年代开始研究的内容。也就是说我们可以得到两点内容。
1、革命任重道远
2、前途一片光明
在分布式数据库中,如何利用多个底层存储达到一个高效一致的持久化系统是一直在探索的问题。
目前初步我们会根据读写特点将设计思维分为读写分离和分库分表,这两种设计并不是互斥的。
由于使用了多个底层存储(mysql等),由于设计过程中建议每一个系统只做一件事情,我们需要有一个对多个底层存储进行一个统一管理和分析的系统,也就是数据库中间件的概念。
常用的数据库中间件有:MyCat(开源),RDS(各个公司针对于自身情况进行设计,常见有阿里云,华为云,美团等)
读写分离
在单台mysql实例的情况下,所有的读写操作都集中在这一个实例上。当读压力太大,单台mysql实例扛不住时,此时DBA一般会将数据库配置成集群,一个master(主库),多个slave(从库),master将数据通过binlog的方式同步给slave,可以将slave节点的数据理解为master节点数据的全量备份。
从应用的角度来说,需要对读(select、show、explain等)、写(insert、update、delete等)操作进行区分。如果是写操作,就走主库,主库会将数据同步给从库;之后有读操作,就走从库,从多个slave中选择一个,查询数据。实现读写分离的基本逻辑就是:让用户读的SQL走负责读的数据库;让用户写的SQL走负责写的数据库。
上述流程如下图所示:
读写分离优点
1、避免单点宕机风险
2、负载均衡,将读能力进行水平扩展
缺点:
1、对sql类型的判断,判断其实属于读操作,还是写操作
2、主从数据同步延迟问题。因为数据是从master节点通过网络同步给多个slave节点,因此必然存在延迟。因此有可能出现我们在master节点中已经插入了数据,但是从slave节点却读取不到的问题。对于一些强一致性的业务场景,要求插入后必须能读取到,因此对于这种情况,我们需要提供一种方式,让读请求也可以走主库,而主库上的数据必然是最新的。这个问题一般会在各个中间件上提供依赖来解决这个问题,当然如果是自己编写的中间件需要根据描述来判断自己的读写规则来强制指向。
3、**事务问题:**如果一个事务中同时包含了读请求(如select)和写请求(如insert),如果读请求走从库,写请求走主库,由于跨了多个库,那么jdbc本地事务已经无法控制,属于分布式事务的范畴。而分布式事务非常复杂且效率较低。因此对于读写分离,目前主流的做法是,事务中的所有sql统一都走主库,由于只涉及到一个库,jdbc本地事务就可以搞定。
4、**高可用问题。**主要包括:
新增slave节点:如果新增slave节点,应用应该感知到,可以将读请求转发到新的slave节点上。
slave宕机或下线:如果其中某个slave节点挂了/或者下线了,应该对其进行隔离,那么之后的读请求,应用将其转发到正常工作的slave节点上。
master宕机:需要进行主从切换,将其中某个slave提升为master,应用之后将写操作转到新的master节点上。
读写分离实现
1、判断语句读写
一般会封装一个方法判断用户提交的SQL语句
SqlType type = SqlUtil.getSqlType(sql);
if (type.isRead()){
return getReadConnection();
}else if (type.isWrite()){
return getWriteConnection();
}else{
return getWriteConnection();
}
2、代理
我们可以使用以上各个阶段的代理来实现,也可以自己实现一套代理来实现。
关于主备数据一致的代理操作(暂不考虑延迟和可用),一般都是围绕以下内容实现的。
数据一致性和可用性
关于数据一致性和可用性,通常的概念性思路都是通用的,这里我参阅了mysql,redis和kafka的主备同步策略来整合,正文在下。
Mysql主从一致性策略
Redis主从同步原理
kafka副本同步机制
这里我做了简短的介绍:
首先,主备延迟的准确性是值得认可的,我一直有一个想法,如果说从库上的时间后于主库时间,那么会不会造成备库延迟的问题。
但实际上是不会的。备库连接到主库的时候,会通过执行SELECT UNIX_TIMESTAMP()函数来获得当前主库的系统时间。如果这时候发现主库的系统时间与自己不一致,备库在执行seconds_behind_master计算的时候会自动扣掉这个差值。
主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产binlog的速度要慢。而其慢的原因一般是来源于以下内容:
1、备库所在机器的性能要比主库所在的机器性能差
2、最常见的一种:备库的压力大,对于这种操作我们一般会选择增加备机数量或者通过binlog输出到外部系统,比如Hadoop这类系统,让外部系统提供统计类查询的能力。
3、也是比较常见的一种情况:大事务,这种的话一般来说只能减少大事务的情况,并将操作性语句尽可能的放在事务后段
4、大表DDL
5、并行复制能力,在“读写分离”最后的图中,有两个箭头是黑色的,一个黑箭头粗,代表一个箭头代表了客户端写入主库,另一个一个黑箭头细,代表的是备库上sql_thread执行中转日志(relay log)。这里用箭头的粗细代表了并行度,即写主库的并行能力要高于写从库的并行能力。在写备库时,是通过sql-thread来进行的。sql-thread的写法就是并行复制,,sql-thread来进行读取中转日志并发送信息给各个woke-thread来进行写操作。由于系统自带的线程调度随机性,所以同一事务是不能由多条woker进行写的。
了解到,在MySql 5.5的版本中是没有并行复制策略的,所以一般可以采用按表并行复制和按行并行复制的策略进行并行复制。
简单理解,按表并行复制的意思就是:如果两个事务更新不同的表,它们就可以并行。因为数据是存储在表里的,所以按表分发,可以保证两个worker不会更新同一行。当然,如果有跨表的事务,还是要把两张表放在一起考虑的;按行并行复制的来源是由于部分热点表的存在,导致无法并行,所以按行并行复制一般是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求binlog格式必须是row。在5.6及之后就是基于MariaDB的并行复制策略,简述就是在从库并行复制时,按照主库的组提交模式模拟主库的并行模式。但其实在从库中,多个事务并行中,如果前一个事务存在大事务的情况,依然会拖慢组事务的提交。
关于读写分离一直是当前比较热点的话题,我浏览过的大部分文章,并没有能对分布式下的一致性和可用性有一个准确的判断和操作流程。大多数系统都是根据自身业务情况来判定,这部分我认为属于知识和经验的结合,我水平不到,理解不深了,并且这方面的知识还没有形成结构化。如果将来有机会,我会在文章后补齐。
分库分表
当单库访问出现了明显的瓶颈或在未来可预期时间内会产生瓶颈即需要分库分表。
分表时,建议单个物理分表的容量不超过1000万行数据。通常可以预估2到5年的数据增长量,用估算出的总数据量除以总的物理分库数,再除以建议的最大数据量1000万,即可得出每个物理分库上需要创建的物理分表数:
(未来3到5年内总共的记录行数) / 单张表建议记录行数 //(单张表建议记录行数 = 1000万)
表的数量不宜过多,涉及到聚合查询或者分表键在多个表上的SQL语句,就会并发到更多的表上进行查询。举个例子,分了4个表和分了2个表两种情况,一种需要并发到4表上执行,一种只需要并发到2张表上执行,显然后者效率更高。
表的数目不宜过少,少的坏处在于一旦容量不够就又要扩容了,而分库分表的库想要扩容是比较麻烦的。一般建议一次分够。
建议表的数目是2的幂次个数,方便未来可能的迁移。
分库时,计算公式:
按照存储容量来计算 = (3到5年内的存储容量)/ 单个库建议存储容量 //(单个库建议存储容量 <500G以内)
一般情况下,会把若干个分库放到一台实例上去。未来一旦容量不够,要发生迁移,通常是对数据库进行迁移。所以库的数目才是最终决定容量大小。
最差情况,所有的分库都共享数据库机器。最优情况,每个分库都独占一台数据库机器。一般建议一个数据库机器上存放8个数据库分库。
分表策略
策略 | 释义 | 优点 | 缺点 | 场景 |
---|---|---|---|---|
hash | 拿分表键的值Hash取模进行路由。最常用的分表方式。 | 数据量散列均衡,每个表的数据量大致相同。请求压力散列均衡,不存在访问热点 | 一旦现有的表数据量需要再次扩容时,需要涉及到数据移动,比较麻烦。所以一般建议是一次性分够。 | 在线服务。一般均以UserID或者ShopID等进行hash。 |
Range | 拿分表键按照ID范围进行路由,比如id在1-10000的在第一个表中,10001-20000的在第二个表中,依次类推。这种情况下,分表键只能是数值类型。 | 数据量可控,可以均衡,也可以不均衡.扩容比较方便,因为如果ID范围不够了,只需要调整规则,然后建好新表即可。 | 无法解决热点问题,如果某一段数据访问QPS特别高,就会落到单表上进行操作。 | 离线服务 |
time | 拿分表键按照时间范围进行路由,比如时间在1月的在第一个表中,在2月的在第二个表中,依次类推。这种情况下,分表键只能是时间类型。 | 扩容比较方便,因为如果时间范围不够了,只需要调整规则,然后建好新表即可。 | 数据量不可控,有可能单表数据量特别大,有可能单表数据量特别小。无法解决热点问题,如果某一段数据访问QPS特别高,就会落到单表上进行操作。 | 离线服务 |
分表健指的是数据库实体数据中,区分度较高的数据实体,比如用户,卖家、买家。
备注
- 数据库模式定义语言并非程序设计语言,DDL数据库模式定义语言是SQL语言(结构化查询语言)的组成部分。SQL语言包括四种主要程序设计语言类别的语句:数据定义语言(DDL),数据操作语言(DML),数据控制语言(DCL)和事务控制语言(TCL)
- MySql源码
- 参考文档
MySQL索引实现原理分析
MySQL Innodb 数据页结构分析
MySQL 通讯协议
ACID
mysql redo日志与刷脏
MYSQL三大日志-binlog、redo log、undo log
Mysql主从一致性策略
Redis主从同步原理
kafka副本同步机制
结语
文章写得还是有不足的,比如分布式事务,如何鉴别数据库是否存活(MHA),还有主从方面的一些内容。这是因为我对分布式的一些东西还是欠缺一些系统化结构化的认识。我如果有能力也有勇气的时候我会补全。
另外,这篇文章的主线是我自己编辑的,但是主线上的一些知识内容是根据极客时间,丁奇老师写的「MySql实战45讲」这套文章上的,很感谢丁奇老师,当然如果我的文章侵犯了部分作者或者机构的权益请联系我,我会尽快删除。文章中掺杂了许多我自己的理解和概况,如果有错误的补充的地方,还请各位兄弟们多多提点,感激不尽!