基础篇
参数配置要注意哪些?见各小节提问。
1.基础架构:一条SQL查询语句是如何执行的?
本篇主要讲MySQL的基础架构。
以下是MySQL的基本架构示意图,可以看到SQL语句是怎么在各个MySQL模块执行的。
总体来说,MySQL可分为Server层和存储引擎层。如下:
- Server 层:包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。
- 存储引擎层:负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等存储引擎。InnoDB从 MySQL 5.5.5 版本成为默认引擎。
不同的存储引擎共用一个Server 层。
以下进行详细分析。
1.连接器
连接器:负责与客户端建立连接、获取权限、维持和管理连接。
1.进行连接
连接命令示例:
mysql -h$ip -P$port -u$user -p
执行后效果如下:
- 用户名或密码不对,返回”Access denied for user”错误。
- 用户名密码通过,连接器会到权限表里面查出你拥有的权限。此后该连接的权限判断,基于此时读到的数据。
也就是说,用户的权限更改后,也不会影响已存在连接的权限。
2.查看连接状态
连接完成后,若无后续动作,则该连接处于空闲状态。
使用show processlist
命令查看连接状态:
可以看到,一个连接的Command 列是Sleep,即空闲状态。
长时间空闲,连接就会断开。该自动断开时间由参数 wait_timeout
控制,默认 8 小时。
建议尽量使用长连接,因为建立连接比较耗资源。
3.长连接占用内存过高
全部使用长连接,MqSQL内存会涨的比较快。
原因: MySQL 在执行过程中临时使用的内存都是管理在连接对象里面的。长连接累积后,可能造成内存过高,被系统强行杀掉。现象就是导致MqSQL异常重启。
解决方案:
- 方案一:定期断开长连接。
- 方案二:MqSQL5.7及以后,可通过执行
mysql_reset_connection
来重新初始化连接资源。此时无需重连,无需重做权限验证。
2.查询缓存
MySQL会缓存查询结果:之前执行过的语句及结果,可能会以 key-value 对的形式直接缓存在内存中。key 是查询的语句,value 是查询的结果。
MySQL 拿到一个查询请求后,会先到查询缓存查看,有则直接返回。没有则继续后面的阶段。
但是大多数情况下建议不要使用查询缓存,因为查询缓存往往弊大于利。因为查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。
MySQL 8.0 版本直接将查询缓存的整块功能删掉了。
3.分析器
未命中缓存,则开始进入分析器。
它的功能是对 SQL 语句做解析,解决“做什么”的问题。
先做“词法分析”识别串。
再做“语法分析”,判断SQL 语句是否满足语法要求。不满足则返回“You have an error in your SQL syntax”的错误提醒。
4.优化器
经过分析器后,继续进入优化器处理。解决“怎么做”的问题。
主要解决:
- 在表里面有多个索引的时候,决定使用哪个索引。
- 一个语句有多表关联(join)的时候,决定各个表的连接顺序。
优化器的作用就是决定选择使用哪一个方案。
至于优化器是怎么选择索引的,有没有可能选择错等,后面会详细展开。
5.执行器
前面解决了“做什么”和“怎么做”的问题,这里开始执行语句。
执行前验证对表有没有执行查询的权限,没有则返回错误。
有权限,则打开表继续执行。打开表时,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。
引擎统一提供了以前提前定义好的接口,直接调用即可,例如:
- 调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是 10,如果不是则跳过,如果是则将这行存在结果集中;
- 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
- 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。
像“取满足条件的第一行”、“满足条件的下一行”这些接口,都是引擎提前定义好的。
数据库的慢查询日志中有一个 rows_examined 字段,表示这个语句执行过程中扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。
在有些场景下,执行器调用一次,在引擎内部则扫描了多行,因此引擎扫描行数跟 rows_examined 并不是完全相同的。。后面会专门来讲存储引擎的内部机制,里面有详细说明。
提问
一条SQL查询语句在MySQL里怎么执行?结合MySQL架构谈一谈,连接器、查询缓存、分析器、优化器、执行器,各是什么功能?
连接器长连接太多会导致什么问题?需要注意什么?
判断SQL语句中的表或字段存不存在,在哪个阶段?在分析器阶段就执行了,MySQL受Oracle影响很大。
那么为什么表权限不能在分析器阶段就判断,而是延迟到执行器呢?因为SQL语句要操作的表不仅仅是字面上那些表,比如说触发器涉及的表,就不在SQL语句里。
2.日志系统:一条SQL更新语句是如何执行的?
MySQL 可以恢复到半个月内任意一秒的状态,这是怎么实现的呢?答案是日志系统。
本篇主要讲MySQL的日志系统。
还是以一个SQL更新语句举例:
mysql> create table T(ID int primary key, c int);
mysql> update T set c=c+1 where ID=2;
和前一篇的查询语句一样,同样会经历“客户端 -> 连接器 -> 查询缓存 -> 分析器 -> 优化器 -> 执行器 -> 存储引擎”阶段,不同的是,更新语句还会涉及两个重要的日志模块: binlog(归档日志)(Server层)和redo log(重做日志)(InnoDB )。
两个日志模块在设计上有很多有意思的地方,这些设计思路也可以用到你自己的程序里。
下面我们来一一讲解。
1.重做日志:redo log
redo log 是 InnoDB 引擎特有的日志(Server 自己的日志是binlog)。
redo log用来做什么?每次更新,直接写磁盘IO和查找成本太高。所以MySQL先写日志,最后再同步到磁盘。
如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。所以,MySQL使用WAL(Write-Ahead Logging)来解决这个问题,它的关键点就是先写日志,再写磁盘。
具体操作:当有一条记录需要更新时,InnoDB 引擎就会先把记录写到 redo log,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘,一般是OS比较空闲时。
redo log文件是固定大小且分组的:InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么总共就可以记录 4GB 的操作。循环写入,如图所示:
write pos
是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。*checkpoint
是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件*。
write pos 和 checkpoint 之间还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。
有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe。
2.归档模块:binlog
和前面的redo log不同,binlog是 Server 层自己的日志,称为 归档日志。
(1)为什么会有两份日志呢?
MySQL 自带的引擎是 MyISAM,binlog 日志只能用于归档,没有 crash-safe 能力。InnoDB 是另一个公司以插件形式引入 MySQL ,所以 InnoDB 使用另外一套日志系统——redo log 来实现 crash-safe 能力。
(2)两种日志的不同点:
- 共有与特有:redo log 是 InnoDB 引擎特有;binlog 是 MySQL 的 Server 层实现,所有引擎均可使用。
- 物理与逻辑记录:redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
- 循环写与追加写:redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
补充:
- Redo log不是记录数据页“更新之后的状态”,而是记录这个页 “做了什么改动”。
- binlog有两种模式,statement 格式的话是记sql语句, row格式会记录行的内容,记两条,更新前和更新后都有。
- 为何要保留binlog:redolog只有InnoDB有,别的引擎没有。另外,redolog是循环写的,不持久保存,binlog的“归档”这个功能,redolog是不具备的。
(3)执行器和 InnoDB 引擎在执行这个简单的 update 语句update T set c=c+1 where ID=2;
时的内部执行流程:
- 执行器->引擎查找:先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
- 执行器->引擎写入:拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
- 引擎:执行并写redo log:将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
- 执行器:写binlog日志:生成这个操作的 binlog,并把 binlog 写入磁盘。
- 执行器->引擎:调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。
执行流程如下图,浅色是在InnoDB内部,深色是在执行器:
redo log 的写入拆成了两个步骤:prepare 和 commit,这就是**”两阶段提交”**。详细流程就是上图。
3.两阶段提交
两阶段提交是为了让binlog 和 redo log两个日志一致。
先来说下,如何将数据库恢复到半个月前任一秒的状态?
binlog是追加写的。只需:1.取最近的一个全量备份,然后将此数据恢复到临时库。2.再从该全量备份的时间点开始,依次取出binlog,直到定位到误删表之前的那个时刻即可。
为什么要用两阶段提交?
因为不使用的话无法保证binlog和redo log数据一致。比如说,执行一个事务,将某值从0更新到1:
- 先写binlog:写完binlog数据库崩溃,此时binlog已经记录为1,而redo log由于事务还没执行完,还是0。所以实际上也是0,binlog更新语句就记录了脏数据1,用它恢复DB,肯定会有问题。
- 先写redo log:执行完redo log崩溃,此时binlog没能记录这个更新记录,用它恢复DB的话,这个更新语句是丢掉的。
简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。
4.参数建议:最佳实践
- redo log持久化:建议将
innodb_flush_log_at_trx_commit
设置成 1 ,表示每次事务的 redo log (prepare) 都直接持久化到磁盘。这样可以保证 MySQL 异常重启之后数据不丢失。 - binlog持久化:建议将
sync_binlog
设置成 1,表示每次事务的 binlog 都持久化到磁盘。这样可以保证 MySQL 异常重启之后 binlog 不丢失。
提问
redo log用来做什么?也是记录到磁盘上的。减少了查找和IO的成本。写入计算操作完成。
更新某一条记录时redo log操作的内部执行流程?直接写入redo log并更新内存就算更新完成。redo log文件固定大小,循环写入。具体是由两个指针控制……
binlog 和 redo log的区别?两阶段提交?
如何将数据库恢复到半个月前任一秒的状态?
参数配置:redo log和binlog参数配置建议?
日志一天一备好还是一周一备好?综合考虑,备份需要性能资源。另外,一天一备的话,恢复起来速度会更快。
3.事务隔离:为什么你改了我还看不见?
简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。
在 MySQL 中,事务支持是在引擎层实现的。MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
下面以InnoDB 为例,说明事务。
1.隔离性与隔离级别
事务有:原子性、隔离性、一致性、持久性。下面说一下隔离性。
数据库上有多个事务同时执行时,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。
1.四个隔离级别
隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。隔离级别的本质是一种可视化。
SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读已提交(read committed)、可重复读(repeatable read)和串行化(serializable )。下面逐一解释:
- 读未提交:一个事务还未提交,它做的变更就能被别的事务看到。可读到其他事务未提交的数据。
- 读已提交:一个事务提交之后,它做的变更才会被其他事务看到。可读到其他事务已提交的数据。
- 可重复读:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在该隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化:顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。可避免读到其他事务插入某个范围的数据。
下面举例说明:
假如表T只有一列,其中一行的值为1。
mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);
以下是两个事务按时间执行的行为:
不同隔离级别下,结果如下:
- “读未提交”: 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。
- “读已提交”:则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
- “可重复读”:则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
- “串行化”:则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。
2.隔离级别实现方式
具体实现总述如下:
- “读未提交”:直接返回记录上的最新值。
- “读已提交”:在每个 SQL 语句开始执行的时候创建一个视图,访问时以视图的逻辑结果为准。
- “可重复读”:在事务启动时创建一个视图的,整个事务存在期间都用这个视图。使用场景:涉及统计多条数据时。(临界锁防止幻读?)
- “串行化”:直接用加锁的方式来避免并行访问。
3.隔离级别参数设置
Oracle默认的隔离级别是“读已提交”,MqSQL是“可重复读”。一些从 Oracle 迁移到 MySQL 的应用,为保证数据库隔离级别的一致,要记得将 MySQL 的隔离级别设置为“读提交”。
MySQL隔离级别参数设置最佳实践:
将启动参数 transaction-isolation
的值设置成READ-COMMITTED
即可。
可以用 show variables
来查看当前的值:
mysql> show variables like 'transaction_isolation';
+-----------------------+----------------+
| Variable_name | Value |
+-----------------------+----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+----------------+
补充:隔离级别最佳实践:
为什么MySQL要使用RR作为默认隔离级别?MySQL使用可重复读(Repeatable-Read)作为默认的存储引擎主要是因为老版本的MySQL二进制文件的各种只支持statement这一种格式,这种格式的二进制文件如果使用提交读(Read-Committed)作为默认的隔离级别会导致丢失更新的问题,所以MySQL使用了可重复读(Repeatable-Read)作为默认的存储引擎。如果把二进制文件的格式改成row,那也可以使用提交读(Read-Committed)作为隔离级别,可以提高数据库的并发性能。MySQL后来新版本也加了一个判断,如果隔离级别为RC,则binlog格式必须要是Mix或者row。
为什么生产中都用RC隔离级别不用默认的mysql的默认隔离级别可重复读(待验证)?
生产中使用RC隔离级别主要原因如下:
使用RR隔离级别,1.如果存在间隙锁,就容易产生死锁,2.如果条件列未命中索引会引起锁表,其他事物只能读,不能写,影响性能。
还有在RC隔离级别下,半一致性读(semi-consistent)的特性增加了update操作的并发性。
mysql默认RR隔离级别是有历史原因的,mysql主从复制是基于binlog,在5.0以前binlog的格式(binlog_format)只支持statement格式(记录的是修改sql语句),这种格式在RC隔离级别下有BUG,因此mysql将RR隔离级别作为默认的隔离级别。
这个BUG核心是master执行先删后插,binlog记录为先插后删。大致是这样:一张表test有字段x并且是主键,x列有一行数据:1;sessionA设置隔离级别为RC,然后开启事务。sessionB设置隔离级别为RC,也开启事务。sessionA执行 delete from test where x >= 3;不提交。sessionB执行 insert into test select 2;sessionB执行commit;提交成功。然后sessionA执行commit;提交成功。此时在主(master)上执行 select * from test;可以查到x列有值为2。但是此时在从(slave)执行select * from test;查到是空的。这样就产生主从不一致的问题!原因其实就是,在master上执行的顺序为先删后插,而此时binlog为statement格式,记录的顺序是先插后删,从(slave)同步的是binlog,因此主从执行的顺序不一致,就会出现主从不一致。
解决这个问题的方案有两种,1.就是设置隔离级别为RR,在该隔离级别下引入间隙锁,当sessionA执行delete时,会锁住间隙,sessionB执行insert时就会阻塞。2.将binlog的格式binlog_format改为row(记录的是每行实际数据的变更)。此格式是基于行的复制,自然就不会出现sql执行顺序不一样的问题!奈何row格式是在mysql5.1版本开始才引入的。因此由于历史原因,mysql将默认的隔离级别设为RR可重复读,保证主从复制不出问题。
2.事务隔离的实现
这里以“可重复读”为例,看下隔离级别是怎么实现的。
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录:
如图,当前值是4。但是不同时刻启动的事务,它们有不同的视图(read-view ABC),也就是说,在不同的事务里面,它看到的值是不一样的。
如上,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。
回滚日志什么时候删除呢?系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。
如何判断事务不需要呢?查看系统的视图(read-view),当系统里没有比这个回滚日志更早的 read-view 的时候,该回滚日志就可以删除。
这也解释了为什么要尽量少用长事务。1.因为会缓存大量的回滚日志。而且,在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。2.除了回滚段,长事务还占用锁资源,也可能拖垮整个库。后续详解。
一个事务在执行到一半的时候实例崩溃了,在恢复的时候先恢复redo,再根据 redo构造 undo,从而回滚宕机前没有提交的事务。
3.事务的启动方式
前面提到少用长事务,其实很多时候是误用。以下是关于长事务的最佳实践:
MySQL 的事务启动方式有以下几种:
- 显式启动事务语句:
begin 或 start transaction
。配套的提交语句是 commit,回滚语句是 rollback。 - 关闭自动提交:
set autocommit=0
。会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,且不会自动提交。该事务会持续存在直到主动执行 commit 或 rollback 语句,或者断开连接。
有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。就会导致接下来的查询都是在事务中,如果是长连接,就导致了意外的长事务。
因此最佳实践建议,总是使用 set autocommit=1
, 通过显式语句的方式来启动事务。
使用commit work and chain
语法:比起autocommit=0的情况,用begin显式声明事务会多一次交互的开销,我们 。autocommit=1时,该语法的含义是提交本次事务,并自动启动下一次事务。
如何查询长事务:可以在 information_schema
库的 innodb_trx
这个表中查询长事务,如下为查找持续时间超过 60s 的事务:
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
提问
事务4个特性ACID?
什么是事务的隔离级别?用来解决什么问题?举例说明不同隔离级别下事务表现?
隔离级别是如何实现的?
为什么mysql选可重复读作为默认的隔离级别?Oracle等都是读已提交。我们在项目中MySQL一般也是用读已提交(Read Commited)。其他:见博客。
参数配置:MySQL 的隔离级别如何配置?
事务隔离的具体实现?回滚日志?
为什么要少用长事务?
事务有哪些启动方式?
参数配置?如何配置事务启动方式?
用什么方案来避免出现或者处理长事务?这个问题,我们可以从应用开发端和数据库端来看。
首先,从应用开发端来看:
- 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
- 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
- 控制每个语句执行的最长时间。业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)
其次,从数据库端来看:
- 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
- Percona 的 pt-kill 这个工具不错,推荐使用;
- 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
- 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。
4.深入浅出索引(上)
对于数据库的表而言,索引其实就是它的“目录”。
1.索引的常见模型
实现索引的方式却有很多种,这里介绍三种常见、也比较简单的数据结构。
它们分别是哈希表、有序数组和搜索树。
1.哈希表
思路:很简单,把值放在数组里,用一个哈希函数把 key 换算 value 存在放在这个数组的位置即可。
哈希冲突处理:拉个链表就行。其他的如往后放,也行。
哈希的缺点:做区间查询的速度很慢。比如,要找身份证号在 [ID_card_X, ID_card_Y] 这个区间的所有用户,就必须全部扫描一遍。
所以,哈希表这种结构适用于只有等值查询的场景,例如一些NoSQL引擎。
2.有序数组
有序数组的优点:和哈希表比起来,有序数组在等值查询和范围查询场景中的性能非常优秀。
结构图如下:
查某条记录:假如是按身份证递增保存。此时如果要查某个身份证号为XX的用户信息,那么用二分法,很快就能查到。时间复杂度是O(log(N))。
查某个范围内的记录:一样的,用二分法找到第一条记录,往后遍历即可。
有序数组的缺点:插入成本太高。往中间插一条记录,后面所有记录都要移动。
所以,有序数组索引只适用于静态存储引擎,比如你要保存的是 2017 年某个城市的所有人口信息,这类不会再修改的数据。
3.二叉树
结构图如下:
二叉树的特点:每个节点的左儿子小于父节点,父节点又小于右儿子。
查询单个节点:如果要查 ID_card_n2 的话,按照图中的搜索顺序就是按照 UserA -> UserC -> UserF -> User2 这个路径得到。这个时间复杂度是 O(log(N))。
更新开销:为了维持 O(log(N)) 的查询复杂度,插入新记录时就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 O(log(N))。
多叉树:树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的,不过为了减少磁盘读写次数,DB一般采用多叉树。
N 叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已被广泛应用在数据库引擎中。数据库技术发展到今天,跳表、LSM 树等数据结构也被用于引擎设计中,不再一一展开。
数据库底层存储的核心就是基于这些数据模型的。每碰到一个新数据库,我们需要先关注它的数据模型,这样才能从理论上分析出这个数据库的适用场景。
在 MySQL 中,索引是在存储引擎层实现的,所以并没有统一的索引实现标准,各个引擎实现不一样。下面主要讲InnoDB的实现方式。
2.InnoDB 的索引模型
索引组织表:在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。
InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中,每一个索引在 InnoDB 里面对应一棵 B+ 树。
B树与B+树:B树是每个节点都存了索引和数据,B+树是只在叶子节点存储数据,有点像跳表。
聚集索引与非聚集索引:Innobo引擎, data存的是数据本身。索引也是数据,数据和索引存在一个XX.IDB文件中,所以叫聚集索引。MyISAM引擎,data存的是数据地址。索引是索引、数据是数据。索引放在XX.MYI文件中,数据放在XX.MYD文件中,所以也叫非聚集索引。
补充:
- 聚集索引一个表只能有一个,而非聚集索引一个表可以存在多个
- 聚集索引存储记录是物理上连续存在,而非聚集索引是逻辑上的连续,物理存储并不连续
- 聚集索引:物理存储按照索引排序;聚集索引是一种索引组织形式,聚集索引的键值逻辑顺序决定了表数据行的物理存储顺序
- 非聚集索引:物理存储不按照索引排序;非聚集索引则就是普通索引了,仅仅只是对数据列创建相应的索引,不影响整个表的物理存储顺序.
- 索引是通过二叉树的数据结构来描述的,我们可以这么理解聚簇索引:聚簇索引的叶节点就是数据节点。而非聚簇索引的叶节点仍然是索引节点,只不过有一个指针指向对应的数据块。
下面是存储示例:
假设表如下,有一个ID,有一个列k。在主键ID上有索引(默认的),在k上也有索引。表创建语句如下:
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB;
两颗索引树结构如下:
由图中可以看出,索引类型分为主键索引和非主键索引:
- 主键索引:叶子节点存的是整行数据,被称为聚簇索引(clustered index)。
- 非主键索引:叶子节点内容是主键的值。非主键索引也被称为二级索引(secondary index)。
两个索引查询区别如下:
- 根据主键查询:直接搜索ID索引树。只需要搜索 ID 这棵 B+ 树,如select * from T where ID=500。
- 根据非主键查询:需要回表。即先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。如select * from T where k=5。
也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此我们要尽量用主键查询。
3.索引维护
B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。
下面我们来看出,插入一个记录时,有可能触发哪些变化。以如上图举例,影响从小到大排列:
- 直接追加:如果插入新的行 ID 值为 700,则只需在 R5 的记录后面直接插入一个新记录即可。
- 需要挪动部分数据:如果新插入的 ID 值为 400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。
- 需要页分裂:如果 R5 所在的数据页已经满了,根据 B+ 树的算法,此时需申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。当然,相反的,删除数据时也会进行页合并。
自增式ID的好处:通过前面的维护顺序我们可以看到,自增ID时,都是追加插入,没有数据挪动和页分裂,提高了效率。
用短ID的好处:每个二级索引的数据都是主键,用短主键可以节省大量存储空间。
使用业务字段做主键的场景:1.只有一个索引;2.且该索引必须是唯一索引。典型的 KV 场景,此时由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。此时也避免了每次都要查两棵树。
提问
说说常见的三种索引模型?哈希、有序数组、二叉树。
InnoDB使用什么结构?和MyISAM有啥区别?和B树有啥区别?
主键索引和非主键索引有啥区别?聚集索引和非聚集索引区别?
插入一个记录时,B+树有可能触发哪些变化?
使用自增的ID有什么意义?正好都是向后追加,不涉及挪动其他记录,也不会出发叶子节点分裂。
用短主键的意义?
为什么要重建索引?重建索引,语句怎么写?1.索引可能因为删除,或者页分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面的利用率最高,也就是索引更紧凑、更省空间。2.不论是删除主键还是创建主键,都会将整个表重建,所以不要用drop xx 和 create xx语句重建索引,而且连着执行这两个语句的话,第一个语句就白做了。这两个语句,你可以用这个语句代替 : alter table T engine=InnoDB。
5.深入浅出索引(下)
本篇聊几个索引优化的点。
先举个例子,还是前面的ID为主键,k为列。两个字段都建立了索引。
两棵树如下图:
执行select * from T where k between 3 and 5
,需要3次检索、两次回表:
1.在 k 索引树上找到 k=3 的记录,取得 ID = 300;
2.再到 ID 索引树查到 ID=300 对应的 R3;
3.在 k 索引树取下一个值 k=5,取得 ID=500;
4.再回到 ID 索引树查到 ID=500 对应的 R4;
5在 k 索引树取下一个值 k=6,不满足条件,循环结束。
1.覆盖索引(联合索引)
覆盖索引:如果执行的语句是select ID from T where k between 3 and 5
时,由于ID的值已经在k索引树上,所以无需回表。也就是说,在这个查询里,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。
基于以上原理,在一个市民信息表上,如果大量用到根据身份证号查名字,那么可以直接建立联合索引。虽然身份证号就是唯一字段,我们可以在它上面建索引就OK,但联合索引直接把回表干掉了,查询效率大幅提升。
当然,维护索引字段是有代价的,根据业务进行取舍。
关键字:高频查询,使用覆盖索引(即联合索引)提高效率。
2.最左前缀原则
(1)什么是最左前缀原则?
低频索引,例如,根据身份证号查询家庭地址,怎么优化呢?
最左前缀原则,包含两层含义:
- 对于联合索引,查询可以用最左一个索引快速定位。
- 对于单个索引,查询可以用最左几个字符快速定位。
即,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。
比如有联合索引(name, age),如图:
当单独查询到所有名字是“张三”的人时,可以用联合索引最左的name字段快速定位。
当查询所有叫”张%”的人时,也可以用最左的字段快速丁文。
基于上,我们来讨论:
(2)建立联合索引的时候,根据最左前缀原则,如何安排索引内的字段顺序
评估标准是,索引的复用能力。
- 原则一,少维护索引:优先考虑是否可以如果通过调整顺序,少维护一个索引。例如,当已经有了 (a,b) 这个联合索引后,一般就不需要单独在 a 上建立索引了。
- 原则二,索引占用空间少:如何又要联合索引,又要单独索引,怎么选。例如,(name, age),(name),(age)三个索引,三选二即可满足需求,干掉哪个?name 字段是比 age 字段大,所以我们干掉(name)索引即可。
3.索引下推
还是以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是 10 岁的所有男孩”。那么,SQL 语句是这么写的:
mysql> select * from tuser where name like '张 %' and age=10 and ismale=1;
这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录 ID3。之后就要一个一个回表,查询年龄。
但是年龄在我们的联合索引里已经有了呀!
- MySQL 5.6 之前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。
- MySQL 5.6 之后,引入索引下推优化。可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
两种执行方式差别,如下图:
小节
- 高频查询:可以建立联合索引来使用覆盖索引,不用回表。
- 非高频查询:再已有的联合索引基础上,使用最左前缀原则来快速查询。两种最左前缀原则的优化情况。
- 对于MySQL 5.6 引入索引下推,减少回表次数。
提问
MySQL索引可以如何优化?高频、低频、
什么是覆盖索引?
什么是最左前缀原则?
什么是索引下推优化?
以下表,ab联合索引作为主键,其他有c、ca、cb主键,是否需要都保留?
表记录
–a–|–b–|–c–|–d–
1 2 3 d
1 3 2 d
1 4 3 d
2 1 3 d
2 2 2 d
2 3 4 d
主键 a,b 的聚簇索引组织顺序相当于 order by a,b ,也就是先按 a 排序,再按 b 排序,c 无序。
索引 ca 的组织是先按 c 排序,再按 a 排序,同时记录主键
–c–|–a–|–主键部分b– (注意,这里不是 ab,而是只有 b)
2 1 3
2 2 2
3 1 2
3 1 4
3 2 1
4 2 3
这个跟索引 c 的数据是一模一样的。
索引 cb 的组织是先按 c 排序,在按 b 排序,同时记录主键
–c–|–b–|–主键部分a– (同上)
2 2 2
2 3 1
3 1 2
3 2 1
3 4 1
4 3 2
所以,结论是 ca 可以去掉,cb 需要保留。
6.全局锁和表锁 :给表加个字段怎么有这么多阻碍?
数据库锁设计的初衷是处理并发问题。例如多用户同时访问时。
根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。
锁的设计比较复杂,这里主要讲锁的现象和其背后的原理,不涉及具体实现细节。
1.全局锁
使用场景:数据备份。
(1)全局读锁 Flush tables with read lock
全局锁:对整个数据库实例加锁。
MySQL加全局锁的命令:Flush tables with read lock
(FTWRL)。此时整个库处于只读状态,其他线程的以下语句都会被阻塞:
- 数据更新语句(数据的增删改)。
- 数据定义语句(包括建表、修改表结构等)。
- 更新类事务的提交语句。
全局锁的典型使用场景:做全库逻辑备份,也就是把整库每个表都 select 出来存成文本。
整个库加全局锁置为只读,有以下风险:
- 在主库备份:备份期间无法执行更新,业务得停摆。
- 在从库备份:无法执行主库同步过来的binlog,会导致主从延迟。
但是不加全局锁就进行备份,也会有风险:
- 例如:用户买东西。一个余额表,一个已购表。两个表不在同一个逻辑时间点备份,就会导致扣款和已买的记录不同步。
(2)引擎支持 mysqldump –single-transaction
也就是说,其实只要保证备份是在同一个逻辑时间点就行。那么我们前面讲,在可重复读隔离级别下开启一个事务,就可以拿到一致性视图了。
由此导出了官方自带的逻辑备份工具: mysqldump。当 mysqldump 使用参数–single-transaction
的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。
有了mysqldump,为啥还要 FTWRL 呢?因为需要引擎要支持这个隔离级别。比如,MyISAM就是直接不支持事务的。
(3)系统变量 set global readonly=true
需求是全库只读,那么其实直接set global readonly=true
也可以。但是还是建议全局锁FTWRL,原因如下:
- readonly 的值可能会被用来做其他逻辑:比如在有些系统中,用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大。
- FTWRL异常处理机制更好:如果执行 FTWRL 命令之后,由于客户端发生异常断开,MySQL 会自动释放这个全局锁。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
数据的更新(DML)和修改表结构(DDL),在全局锁下,都不能进行。
2.表级锁
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。
(1)表锁lock tables … read/write
表锁:语法为lock tables … read/write
。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。
需注意,lock tables 语法除了会限制其他线程的读写外,也限定了本线程接下来的操作对象,即能执行且只能执行某几张表的读/写。例如,在某个线程 A 中执行 lock tables t1 read, t2 write; 此时其他线程不能写t1,不能读写t2。而线程A在unlock tables前,也只能读t1、读写t2,不能访问其他表。
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。
(2)元数据锁MDL(metadata lock)
元数据锁:无需显式使用,在访问一个表的时候会被自动加上。MDL 的作用是锁住表结构,保证读写的正确性。在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。即:
- 自动加读锁:可以同时对一张表增删查改
- 写锁互斥:同时修改一张表的字段,需要先后执行。
最佳实践:修改表字段时,即使用元数据锁时,要注意两个坑:
修改表结构会进行全数据扫描:修改大表要特别注意。
修改表结构会同时申请读写锁:即使修改的是小表,如果该小表访问十分频繁,那么获取锁的过程,以及修改表的操作会阻塞后面所有的读数据操作。此时如果有重试机制,客户端访问超时后如果大量重建session申请,这个库的线程会爆满。如下图:
如何安全的给热点小表加字段:
- 解决长事务:首先解决长事务,事务不提交,就会一直占着 MDL 锁。在 MySQL 的
information_schema
库的innodb_trx
表中,查询当前执行中的事务。如果有长事务在执行,考虑先暂停 DDL,或者 kill 掉这个长事务。 - 在 alter table 语句里面设定等待时间:如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃,之后再通过重试命令重复这个过程。
AliSQL和MariaDB提供了 DDL NOWAIT/WAIT n
这个语法支持:
ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...
提问
全库逻辑备份有哪些方法?各有啥特点?如何选择?引擎有事务用mysqldump –single-transaction、没有用全局锁FTWRL、不推荐set global readonly=true
如何用MyISAM实现事务?表锁。lock tables … read/write。注意表锁是能执行且只能执行某几张表的XX操作。
线上修改表结构要注意什么坑?1.全数据扫描。2.会同时拿读写锁,阻塞读操作。
如何安全的给热点小表加字段?一次一次试,等待时间太长就放弃再重试。
当备库用–single-transaction 做逻辑备份的时候,如果从主库的 binlog 传来一个 DDL 语句会怎么样?(后续看完主从复制可以回过头来看这里)
假设这个 DDL 是针对表 t1 的, 这里我把备份过程中几个关键的语句列出来:
Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2:START TRANSACTION WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3:SAVEPOINT sp;
/* 时刻 1 */
Q4:show create table `t1`;
/* 时刻 2 */
Q5:SELECT * FROM `t1`;
/* 时刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 时刻 4 */
/* other tables */
在备份开始的时候,为了确保 RR(可重复读)隔离级别,再设置一次 RR 隔离级别 (Q1);
启动事务,这里用 WITH CONSISTENT SNAPSHOT 确保这个语句执行完就可以得到一个一致性视图(Q2);
设置一个保存点,这个很重要(Q3);
show create 是为了拿到表结构 (Q4),然后正式导数据 (Q5),回滚到 SAVEPOINT sp,在这里的作用是释放 t1 的 MDL 锁 (Q6)。当然这部分属于“超纲”,上文正文里面都没提到。
DDL 从主库传过来的时间按照效果不同,我打了四个时刻。题目设定为小表,我们假定到达后,如果开始执行,则很快能够执行完成。
参考答案如下:
- 如果在 Q4 语句执行之前到达,现象:没有影响,备份拿到的是 DDL 后的表结构。
- 如果在“时刻 2”到达,则表结构被改过,Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;
- 如果在“时刻 2”和“时刻 3”之间到达,mysqldump 占着 t1 的 MDL 读锁,binlog 被阻塞,现象:主从延迟,直到 Q6 执行完成。
- 从“时刻 4”开始,mysqldump 释放了 MDL 读锁,现象:没有影响,备份拿到的是 DDL 前的表结构。
7.行锁功过:怎么减少行锁对性能的影响?
MySQL的行锁在引擎层实现,不是所有引擎都支持行锁。例如MyISAM就不支持,这导致它在并发控制时只能用表锁,同一张表,任意时刻,只能有一个更新在执行。所以它被InnoDB替代了。
下面我们来看InnoDB的行锁。行锁即,针对行记录的锁。
我们需要先明确一个两阶段锁的概念。
1.两阶段锁
(1)什么是两阶段锁协议
两阶段锁协议:在InnoDB事务中,行锁是需要时才会加上,并且要等事务结束才会一起释放。并不是用完就释放。
以下举例,如图,假设t是主键
结果是:事务B的update语句会被阻塞,直到A事务commit后,事务B才会继续执行。
(2)根据两阶段锁协议优化事务
这个两阶段锁协议对我们的启示是:晚点拿锁。即如果事务中需要锁多个行,那么要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
以下举例,假如做一个在线电影院卖票的引用,以下三步放在一个事务里:
- update:从顾客 A 账户余额中扣除电影票价;
- update:给影院 B 的账户余额增加这张电影票价;
- insert:记录一条交易日志。
很明显,根据两阶段锁协议,所有锁都是事务完成后才提交的。所以我们应该把第2步放到最后,因为第2步最有可能引起冲突。
2.死锁和死锁检测
(1)什么时候会死锁
上述根据两阶段锁协议提升了执行效率。但是行锁还有可能引起死锁的问题,我们要特别注意。
死锁:当并发系统中不同线程出现循环资源依赖,就会死锁。比如事务A和事务B,在互相等待对方的行锁。
如下图:
很明显,A在等B的id=2行锁,B在等A的id=1行锁,死锁了。
(2)如何解决死锁
最佳实践:解决死锁的两个思路:
- 设置超时时间:死锁时直接进入等待,直到超时。超时时间可通过参数
innodb_lock_wait_timeout
来设置,默认50s。 - 开启死锁检测,主动回滚:发现死锁后,主动回滚死锁链条中的某一个事务。将参数
innodb_deadlock_detect
设置为 on来开启,默认on。但是它也是有额外负担的,每当一个事务被锁的时候,都要看看它所依赖的线程有没有被别人锁住。
(3)如何解决热点数据的死锁检测
热点数据执行死锁检测会消耗大量性能:开启死锁检测,会造成大量的CPU消耗。假如有1000个并发要同时更新一行,死锁检测就要进行100级的操作。
如何解决热点数据的死锁检测?思路如下:
- 直接临时关掉死锁检测:虽然死锁了会自动回滚,业务无损,但是会造成大量超时。
- 控制访问并发度:在中间件控制,或者修改MySQL源码。对相同行的更新,进入引擎前要排队。死锁检测就少了。
- 将一行改为逻辑上的多行:比如影院账户余额,拆成逻辑上的10行,每次执行时选一行。这样代码要有特殊处理。
提问
InnoDB中,行锁什么时候获取到?什么时候释放?两阶段锁协议
如何安排正确的事务语句顺序?如果事务中需要锁多行,那么最可能冲突的锁放前面还是后面?为什么?
举个MySQL死锁的例子?如何解决死锁?
如何解决热点数据的死锁检测CPU消耗过高问题?
如果你要删除一个表里面的前 10000 行数据,有以下三种方法可以做到:
- 第一种,直接执行 delete from T limit 10000;
- 第二种,在一个连接中循环执行 20 次 delete from T limit 500;
- 第三种,在 20 个连接中同时执行 delete from T limit 500。
你会选择哪一种方法呢?为什么呢?
8.事务到底是隔离的还是不隔离的?
关键词:查询:一致性视图。更新:当前读。
我们在第3篇文章讲,可重复读隔离级别,会在事务开始的时候创建一个视图,期间事务看不到其他版本的数据。
我们在第7篇文章讲,A事务更新一行,如果此时该行行锁被B事务拿着,那么A就会被锁住。问题是,等到A事务获取到行锁时,它读到的又是哪个值呢?A自己视图的?B更新过的?
所以,事务到底是隔离还是不隔离的??下面我们来解释一下。
下面先举个例子,假设有以下表,只有两行两列:
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
事务启动顺序如下图:
需要注意一点,begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动,才会创建一致性视图。如果想马上启动一个事务,可以用 start transaction with consistent snapshot
。
默认 autocommit=1,自动提交。
在这个例子中,事务 C 没有显式地使用 begin/commit,表示这个 update 语句本身就是一个事务,语句完成的时候会自动提交。事务 B 在更新了行之后查询 ; 事务 A 在一个只读事务中查询,并且时间顺序上是在事务 B 的查询之后。事务 B 查到的 k 的值是 3,而事务 A 查到的 k 的值是 1。
为啥事务A和B结果不一样?
在 MySQL 里,有两个“视图”的概念:
- 一个是 view:用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
- InnoDB 在实现 MVCC 时用到的一致性读视图:即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
查询和更新是有区别的。查询是一致性视图,更新是当前读。以下把read view拆开,说明MVCC的实现。
1.查询:“快照”在 MVCC 里是怎么工作的?
可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然MVCC只在 读已提交 和 可重复读 两个隔离级别下工作。其他两个隔离级别够和MVCC不兼容,因为 读未提交总是读取最新的数据行,而不是符合当前事务版本的数据行。而 序列化则会对所有读取的行都加锁。
(1)MVCC和事务RR/RC隔离的运作机制
关键词:严格递增的事务ID -> 数据版本(事务ID+前一版本引用) -> undo -> 一致性视图 -> 事务ID数组 -> 高低水位
在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。
我们看下这个快照是怎么实现的:
事务有唯一的递增的事务ID:InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
根据事务,数据有多个版本:每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
如图所示,就是一个记录被多个事务连续更新后的状态:
前文说,语句更新会生成 undo log(回滚日志),实际上,图 2 中的三个虚线箭头,就是 undo log;而V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。
以上讲了多版本和 row trx_id 的概念,下面我们来看事务启动时,InnoDB如何定义快照:
实现逻辑:以实现可重复读的隔离级别为例:一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,或者我自己更新的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。如果“上一个版本”也不可见,那就得继续往前找。
具体实现:
- 事务ID数组:InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。
- 高低水位:数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。(高水位可能是当前事务ID + 1,也可能大一点点,因为申请trx_id和生成事务ID数组中间,其他事务也可能申请trx_id。)
- 一致性视图:这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)
数据版本的可见性规则实现:
基于数据的 row trx_id 和这个一致性视图的对比结果得到。
这个视图数组把所有的 row trx_id 分成了几种不同的情况,见下图:
由上图,事务启动瞬间,对于一个数据的各个版本的的row trx_id,有以下可能:
- 落在绿色区间:已提交事务生成的,可见。
- 落在红色区间:将来启动的事务生成的,不可见。
- 落在黄色区间:
- row trx_id在数组内:数组内的活跃事务生成的,还未提交,不可见。
- row trx_id不在数组内:已提交事务生成的(位于低水位前,但当前事务启动时已提交),可见。
- 等于当前事务ID:可见。代码实现上,当前事务ID没有放到水位的数组里。
上面第3条不好理解,看下面自己画的图,2号事务对应上述3.1不可见情况,3号事务对应上述3.2可见情况。很清楚:
InnoDB在每行数据都增加三个隐藏字段,一个唯一行号,一个记录创建的版本号,一个记录回滚的版本号。
注意到
所以,InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
(2)分析前面的查询例子(复习时可忽略)
分析图1的例子,
图一如下图:
不妨做如下假设:
1. 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
2. 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;
3. 三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。
这样,事务 A 的视图数组就是 [99,100], 事务 B 的视图数组是 [99,100,101], 事务 C 的视图数组是 [99,100,101,102]。
下面我们来看下A事务的查询。操作顺序如下图,
可以看到,在事务 A 查询的时候,其实事务 B 还没有提交,但是它生成的 (1,3) 这个版本已经变成当前版本了。然后一直往前undo,得到了数据为1。
所以,一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
- 版本未提交,不可见;
- 版本已提交,但是是在视图创建后提交的,不可见;
- 版本已提交,而且是在视图创建前提交的,可见。
下面我们来判断图4的结果:
- (1,3) 还没提交,属于情况 1,不可见;
- (1,2) 虽然提交了,但是是在视图数组创建之后提交的,属于情况 2,不可见;
- (1,1) 是在视图数组创建之前提交的,可见。
所以最后的得到数据为1。
2.更新逻辑
(1)更新使用的是当前读
上面的例子有个小问题:事务 B 的 update 语句,如果按照一致性读,好像结果不对,根据读到的数据版本+1,应该为2呀?
如下图,注意到数据的版本是先102后101:
事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。
而在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101。所以后面紧接着的get,拿到的是101版本的(1,3)。所以其实数据版本的事务ID并不是严格按+1有序排列的。
当前读:更新数据都是先读后写的,而这个读,只能读当前的已提交的值,称为“当前读”(current read)。即使不在当前视图里,读的也是最新值。
此时即使事务B加读锁或者写锁,读到的数据也是3:
# S 锁,共享锁:lock in share mode
mysql> select k from t where id=1 lock in share mode;
# X 锁,排他锁:for update
mysql> select k from t where id=1 for update;
(2)当前读未提交的数据会被阻塞
其实当前读的时候,如果该值还在被其他事务使用并未提交,那么此时其实它是被其他事务加了写锁的,必须等到其他事务释放锁,当前事务才会继续执行。
如下图举例,假设事务 C 不是马上提交的,而是变成了下面的事务 C1:
事务 B 的更新语句先发起,此时事务 C1还没提交,但是 (1,2) 这个版本也已经生成,且是当前最新版本。
此时,根据“两阶段协议”,C1事务会持有所有用到的数据的写锁,直到事务结束,才一并释放。所以事务B被阻塞,直到C1提交,释放锁。
(3)RR/RC事务隔离怎么实现
实现原理如下:
- 事务可重复读RR:查询时,一致性读(consistent read)。更新时,只能用当前读,数据被占用时则堵塞。
- 事务读已提交RC:实现与RR一样。不同的是,RR是在事务启动时(第一个语句执行,不是开始时)创建一致性视图,而RC是每一个语句执行前算出一个视图。
顺便提一句,“start transaction with consistent snapshot; ”在RC下没意义,因为是每个语句创一个视图。
再举个RC的例子,以下图是RC级别下,更新的操作:
此时事务A的查询语句,是在语句执行前才创建:
(1,3) 还没提交,属于情况 1,不可见;
(1,2) 提交了,属于情况 3,可见。
此时,事务 A 查询语句返回的是 k=2。事务 B 查询结果 k=3。
最后在稍微补充一句,表结构不支持可重复读,因为表结构没有对应的行数据,也没有 row trx_id,因此只能遵循当前读的逻辑。当然,MySQL 8.0 已经可以把表结构放在 InnoDB 字典里了,也许以后会支持表结构的可重复读。
提问
可重复读和序列化隔离级别,底层怎么实现?
MVCC和RR/RC事务隔离是怎么运作的?快照,事务ID,数据版本,事务可视数组,高低水位。查询时:一致性读。更新时:当前读/阻塞。
更新的时候,更新的是哪个版本的数据?当前读。看不到也会更新。
以下,删除id和c字段值相等的字段,删除时无法删除。如何复现该情况?A事务删除,删除前B事务更改了C值并提交。此时C值已经改变,A事务更新读,读到的C是最新值,此时c已经不等于id了。解决方法,用乐观锁,即类似update …set … where id = xxx and version = xxx给数据加版本即可。
实践篇
9.普通索引和唯一索引,应该怎么选择?
摘要:业务上已经能确定一个字段是唯一的,此时该字段选普通索引还是唯一索引?读多写少和读少写多的场合,change buffer有何优劣?普通索引 + change buffer可提高效率
本篇聊一下,在不同的业务场景下,应该选择普通索引,还是唯一索引。
- 唯一索引:unique
- 普通索引:key
下面以一个市民信息系统为例。假设每个人有一个唯一的身份证号。现在我们需要根据身份证查名字:
select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz';
那么我们肯定需要在身份证号上面建索引。由于身份证号太大,不推荐作为主键。所以我们可以选择建唯一索引或是普通索引。
唯一索引或是普通索引,哪个更好一点呢?
表结构如下图:
我们从查询和更新两个过程分别看下:
1.查询过程
查询过程,两者性能相差不大。
假设,执行查询的语句是 select id from T where k=5。两种索引的查询过程如下:
- 唯一索引:从二级索引的B+树根往下查,找到k=5后停止检索,直接返回主键,再到主索引树查记录。
- 普通索引:从二级索引的B+树根往下查,找到k=5后继续检索,直到碰到不满足k=5的记录停止检索,直接返回主键,再到主索引树查记录。
我们知道,InnoDB需要读一条记录时,并不是只把该条记录从磁盘读出,其实是以页为单位,将其整体读入内存。在 InnoDB 中,每个数据页的大小默认是 16KB。
普通索引,看似多了一步往后检索的操作,其实是在内存里进行的,也就是只多了一次指针查找和一次计算,影响微乎其微。即使退一步,下一个数据是在下一页,需要再读一页。那么我们知道,B+树16KB的页,其实可以存近千个key,所以这种概率是很小的。
2.更新过程
为了说清楚更新过程的性能差异,我们需要先了解change buffer
的概念。
1.change buffer
change buffer:当需要更新一个数据页时:
- 如果该数据页已在内存中:直接更新。
- 如果该数据页不在内存中:在不影响数据一致性的前提下,1.将这些更新操作缓存在 change buffer 中,这样就无需再从磁盘中读入这个数据页。2.在下次查询需要访问该数据页时,将数据页读入内存,再执行 change buffer 中与这个页有关的操作。
change buffer是可持久化的数据:也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上。在merge insert buffer之前,为了防止数据库意外宕机导致数据丢失,系统会周期性将 insert buffer 数据写入共享表空间中。
补充:change buffer写缓冲:在MySQL5.5之前,叫插入缓冲(insert buffer),只针对insert做了优化;现在对delete和update也有效,叫做写缓冲(change buffer)。
merge:将 change buffer 中的操作应用到原数据页,得到最新结果的过程称为 merge。
merge的时机:1.访问该数据页时。2.系统后台线程定期merge。3.数据库正常关闭的过程中,也会执行merge。
merge 的执行流程:
- 从磁盘读入数据页到内存(老版本的数据页);
- 从 change buffer 里找出这个数据页的 change buffer 记录 (可能有多个),依次应用,得到新版数据页;
- 写 redo log。这个 redo log 包含了数据的变更和 change buffer 的变更。
到此 merge 过程就结束了。这时候,数据页和内存中 change buffer 对应的磁盘位置都还没有修改,属于脏页,之后各自刷回自己的物理数据,就是另外一个过程了。
以上,merge减少了读磁盘的次数,而且数据读入内存是需要占用 buffer pool 的,merge也避免了占用内存,提高了内存利用率。
buffer pool:
- 是一块连续的内存,用来存储访问过的数据页面。
- innodb_buffer_pool_size 参数用来定义 innodb 的 buffer pool 的大小
- Innodb 中,数据的访问是按照页/块(默认为16KB)的方式从数据文件中读取到 buffer pool中,然后在内存中用同样大小的内存空间做一个映射。
- buffer pool 按照最近最少使用算法(LRU)来管理
- 是 MySQL 中拥有最大的内存的模块
2.什么条件下可使用 change buffer
对于唯一索引,所有更新操作都要先判断是否违反了唯一约束,所以必须要读磁盘加载数据页。
所以,唯一索引的更新不能使用 change buffer,实际上也只有普通索引可以使用。
change buffer 用的是 buffer pool 里的内存。change buffer 的大小,可以通过参数 innodb_change_buffer_max_size
来动态设置。这个参数设置为 50 的时候,表示 change buffer 的大小最多只能占用 buffer pool 的 50%。
举例。我们来看下插入一条数据 (4,400),InnoDB怎么处理:
- 要更新的目标页已在内存中:
- 唯一索引:找到位置,判断没有冲突,直接插入,结束。
- 普通索引:找到位置,直接插入,结束。
- 要更新的目标页不在内存中
- 唯一索引:从磁盘读入目标页,判断没有冲突,插入,结束。
- 普通索引:直接把更新记录在chang buffer,结束。
3.change buffer 的使用场景
综上,在使用普通索引时,change buffer 对更新有加速作用。唯一索引则无法使用change buffer。
那么普通索引的所有场景,使用 change buffer 都可以起到加速作用吗?
其实 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。
所以,写多读少的业务,change buffer 的效果最好。因为写入后不用马上读。常见的业务模型就是账单类、日志类的系统。
相反,读多写少的业务,change buffer 的效果不好。因为写入后马上就要读,也就是马上会出发磁盘读数据页,以及merge操作。这样不但没能降低多少随机访问IO的次数,反而增加了change buffer 的维护代价。
4.索引选择和实践
综上,索引如何实践:
- 选普通索引:唯一索引和普通索引在查询能力上区别不大,考虑到更新性能,应该选普通索引。
- change buffer:更新后马上就查询,那么关闭chang buffer。其他场景下,开启它。
普通索引和 change buffer 配合使用,对于数据量大的表的更新优化很明显。特别地在使用机械硬盘时。
对“是否使用唯一索引”,因为“业务可能无法确保”的情况。稍作补充:
- 首先,业务正确性优先:如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。
- 然后,在一些“归档库”的场景,你考虑使用普通索引:比如,线上数据只需要保留半年,然后历史数据保存在归档库。此时,归档数据已经确保没有唯一键冲突了。要提高归档效率,可以考虑把表里面的唯一索引改成普通索引。
5.change buffer 和 redo log
区分一下 redo log(Write-Ahead Logging) 和 change buffer的概念,有点容易混淆。
1.更新插入操作
以下举例,假如要执行以下语句:
mysql> insert into t(id,k) values(id1,k1),(id2,k2);
我们假设,k1 所在的数据页在内存 (InnoDB buffer pool) 中,k2 所在的数据页不在内存中。以下是状态图:
该条语句涉及4个部分:内存(buffer pool)、磁盘redo log(ib_log_fileX)、磁盘数据表空间(t.ibd)、磁盘系统表空间(ibdata1)。
更新语句做了如下的操作:
1. 写内存:Page 1 在内存中,直接更新内存;
2. 写内存:Page 2 没有在内存中,暂时更新内容记录到内存的change buffer 区、,就避免了读磁盘;
3. 写磁盘:将上述两个动作记入 redo log 中(图中 3 和 4)。
涉及到两个写内存,一个写磁盘,事务结束。后续的虚线操作由系统后台完成。
2.紧接着的读请求
再看接下来紧接着的读请求:
此时内存中数据还在,因此与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关,下图未画出:
读请求执行顺序:
1. 读 Page 1 的时候,直接从内存返回。
2. 要读 Page 2 的时候,需要把 Page 2 从磁盘读入内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。
可以看到:
- redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写,数据存到内存并写入日志就算执行成功)。批量插入。
- change buffer 主要节省的则是随机读磁盘的 IO 消耗(多个更新合并后只读少数次磁盘)。批量更新。
更新过程:重要:
- 数据页在内存,直接更新内存再写redo log。
- 数据页不在内存,先写到change buffer,change buffer(可临时持久化到磁盘共享表),然后将此操作写入redo log,就不用读数据页到内存。数据页下次读到内存里时,再执行merge操作。
建议:业务服务能够确保唯一性时,唯一的字段用普通索引效率更高。
提问
字段长的属性适合做主键吗?不适合。二级索引存的都是主键。
唯一的字段,选择普通索引或唯一索引,对查询效率有什么影响?
唯一的字段,选择普通索引或唯一索引,对更新效率有什么影响?buffer pool,changebuffer,merge
changeBuffer最适合什么业务场景?写多读少、机械磁盘。
change buffer 一开始是写内存的,那么如果这个时候机器掉电重启,会不会导致 change buffer 丢失呢?
changebuffer和redo log有什么区别?一条语句插入的过程?前者节省随机读IO,后者节省随机写IO。
change buffer 一开始是写内存的,那么如果这个时候机器掉电重启,会不会导致 change buffer 丢失呢?不会丢。虽然是只更新内存,但是在事务提交的时候,我们把 change buffer 的操作也记录到 redo log 里了,所以崩溃恢复的时候,change buffer 也能找回来。
10.MySQL为什么有时候会选错索引?
优化器如何决定走哪个索引?
摘要:不断地删除历史数据和新增数据的场景,MySQL 可能会选错索引。如何解决?索引统计的更新机制,并提到了优化器存在选错索引的可能性。优化器是根据什么规则选择索引的?
1.不断删除和新增数据时可能会选错索引
MySQL一张表可以建立多个索引。使用SQL语句时,由MySQL决定用哪条索引。
不过有可能MySQL会选错索引,以下举例:
假如有一张表如下,id,索引a,索引b:
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万条数据,从(1,1,1)到(100000,100000,100000)。
此时执行以下语句:
mysql> select * from t where a between 10000 and 20000;
查看执行计划,看到索引走a,OK,没问题。如下图的key:
但是如果我们在已经有10万条数据的表上,执行以下操作:
A开启事务。B开启事务,删掉所有数据,然后再调用存储过程插入10万条数据,再查询,执行流程如下图:
我们再加一条强制使用a索引,来对比两条语句的区别:
-- 将慢查询日志的阈值设置为 0,表示这个线程接下来的语句都会被记录入慢查询日志中;
set long_query_time=0;
-- Q1 是 session B 原来的查询;
select * from t where a between 10000 and 20000; /*Q1*/
-- Q2 是加了 force index(a) 来和 session B 原来的查询语句执行情况对比。
select * from t force index(a) where a between 10000 and 20000;/*Q2*/
以下图是两个慢查询日志比对:
我们看到,Q1扫描了 10 万行,显然是走了全表扫描,执行时间是 40 毫秒。Q2 扫描了 10001 行,执行了 21 毫秒。也就是说,Q1没有走索引。
以上就是我们平时不断地删除历史数据和新增数据的场景。这时,MySQL 竟然会选错索引?以下我们对这种现象进行分析。
2.优化器的逻辑
选择索引是优化器的工作。
优化器会根据扫描行数、是否回表、是否使用临时表、是否排序等因素进行综合判断。前述语句只用到了扫描行数。
那么上述肯定是扫描行数判断出问题了。它是怎么判断的呢?
1.扫描行数是怎么判断的?
通过索引基数判断。
MySQL在执行语句前,不知道有多少行,只能根据统计信息来估算记录数。
索引的“区分度”:就是统计信息。一个索引上不同的值的个数(叫做基数)越多,区分度越好。也就是,索引的基数越大,区分度越好。
可以使用 show index 方法,查看索引的基数,如下图:
2.如何得到索引基数
MySQL使用采样统计。逐行扫描精度高,但代价大。所以使用采样统计。
如何得到索引基数:用采样统计:1.InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。2.数据表是会持续更新的,当变更的数据行数超过 1 / M 的时候,会自动触发重新做一次索引统计。
索引统计存在哪?在 MySQL 中,有两种存储索引统计的方式,可以通过设置参数 innodb_stats_persistent
的值来选择:
- 统计信息持久化存储:设置为 on 。这时,默认的 N 是 20,M 是 10。
- 统计信息只存在内存:设置为 off 。这时,默认的 N 是 8,M 是 16。
3.预测扫描行数,因行数干扰优化器
拿到索引基数,就可以根据语句来判断会扫描多少行数。
如下图,是优化器预估的,两个SQL语句的扫描行数:
可以看到,两种执行方式:Q1:不走索引,估计扫描10万行。Q2:走索引,估计扫描3.7万行(根据索引基数,估计的不是很准)。
那么为什么放着3.7万行(虽然不太准)的方案不走,非得去走Q1的全表扫描呢?
那是因为,如果使用索引 a,每次索引拿到一个值,还需要回表到主索引查询其他数据,这个代价优化器也要算进去的。优化器认为直接全表扫描查主索引,比用索引查后再回表查,更快。
所以上述问题的根源是,没能准确地判断出扫描行数。
修复索引统计不准确方法:analyze table t
命令重写统计索引信息,即可。如果发现 explain 的结果预估的 rows 值跟实际情况差距比较大,可以采用这个方法来处理。
4.因排序干扰优化器
举一个因排序,优化器选择多扫描行数的例子:
还是上面的表,执行:
mysql> select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
发现MySQL放着a的小索引不走(只需验证1000个),去走了b的大索引。原因是,优化器认为,走b可以避免排序。
3.索引选择和异常处理
大多数时候MySQL都能正确找到索引。偶尔碰到,原本可以执行很快的语句,就是执行的慢,怎么处理呢?
- 用 force index 强行选择。优点是简单暴力。缺点是1.不优雅灵活。索引名字会改,库也会迁移,可能导致语法不兼容。2.变更不敏捷:一般都是线上发现问题后,才去查看,然后改完还要测试发布。
- 修改SQL语句诱导优化器使用期望的索引:例如把之前的“order by b limit 1” 改成 “order by b,a limit 1”,语义相同。之前之所以不选a而选b,是因为优化器认为b是有序的,可以避免排序。这里我们强制要排序加上a,那么此时两个索引都要排序,所以又通过扫描行数来判断了。
- 直接新建或删除索引来改变优化器行为:某些场景下适用。
总之抓住关键点,优化器会根据扫描行数(可analyze table
保证统计正确)、是否排序(可通过SQL诱导)、是否使用临时表等因素,来综合选择是否走索引,走哪个索引。扫描行数并不是唯一因素。
小结
优化器选错索引的可能:
- 扫描行数估算错误。
- 避免排序、临时表等更优先,导致走了全表扫描或大索引。
而对于其他优化器误判的情况,你可以在应用端用 force index 来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。
教训:索引优化,扫描行数不是唯一判断标准,注意因其他标准引起的优化器错选索引。
提问
使用索引时,分析器评判索引时,什么索引是好索引?
如何取得索引基数?索引基数统计信息存在哪?
优化器如何判断走不走索引?如何判断走哪条索引?索引基数+是否回表+是否临时表+是否排序等。
什么情况下会导致索引统计扫描条数错误?
通过 session A 的配合,让 session B 删除数据后又重新插入了一遍数据,然后就发现 explain 结果中,rows 字段从 10001 变成 37000 多。这是什么原因呢?delete 语句删掉了所有的数据,然后再插入 10 万行数据。此时A事务并未提交,所以之前的数据每一行数据都有两个版本,旧版本是 delete 之前的数据,新版本是标记为 deleted 的数据。这样,索引 a 上的数据其实就有两份。另外,主键是直接按照表的行数来估计的,优化器直接用的是 show table status 的值,所以主键的扫描数统计没问题。
11.怎么给字符串字段加索引?
MySQL 是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。
1.普通索引与前缀索引的比较
比如:可以有以下两种方式定义:
-- index1:全部做索引
mysql> alter table SUser add index index1(email);
-- index2:前6个字符做索引
mysql> alter table SUser add index index2(email(6));
两种索引区别如下图:
两者索引的优缺点:
- index1:
- 优点:一次,或者很少的次数就可以定位到。可以使用到覆盖索引优化。
- 缺点:索引字段很长。
- index2:
- 优先:索引字段很短。
- 缺点:额外增加扫描次数,且扫描后必回表。同样的,也没法用到覆盖索引优化。
执行同样的语句:
select id,name,email from SUser where email='zhangssxyz@xxx.com';
两索引执行流程如下:
-- 如果使用的是 index1(即 email 整个字符串的索引结构),执行顺序是这样的:
1.从 index1 索引树找到满足索引值是’zhangssxyz@xxx.com’的这条记录,取得 ID2 的值;
2.到主键上查到主键值是 ID2 的行,判断 email 的值是正确的,将这行记录加入结果集;
3.取 index1 索引树上刚刚查到的位置的下一条记录,发现已经不满足 email='zhangssxyz@xxx.com’的条件了,循环结束。
整个过程,只需要回主键索引取一次数据,所以系统认为只扫描了一行。
-- 如果使用的是 index2(即 email(6) 索引结构),执行顺序是这样的:
1.从 index2 索引树找到满足索引值是’zhangs’的记录,找到的第一个是 ID1;
2.到主键上查到主键值是 ID1 的行,判断出 email 的值不是’zhangssxyz@xxx.com’,这行记录丢弃;
3.取 index2 上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出 ID2,再到 ID 索引上取整行然后判断,这次值对了,将这行记录加入结果集;
4.重复上一步,直到在 idxe2 上取到的值不是’zhangs’时,循环结束。
在这个过程中,要回主键索引取 4 次数据,也就是扫描了 4 行。
2.如何确定前缀索引长度
1.首先算出这个列上有多少个不同的值:
mysql> select count(distinct email) as L from SUser;
2.然后一次查询不同的前缀区分度即可:
mysql> select
count(distinct left(email,4))as L4,
count(distinct left(email,5))as L5,
count(distinct left(email,6))as L6,
count(distinct left(email,7))as L7,
from SUser;
3.前缀索引会导致无法使用覆盖索引
查询语句:
select id,email from SUser where email='zhangssxyz@xxx.com';
用普通索引,可以用到覆盖索引优化。用前缀索引,即使全部匹配了,InnoDB也会多一次回表查询,因为系统并不确定前缀索引的定义是否截断了完整信息。
4.前几个字符雷同的优化
比如说身份证,前几个字符基本上是一样的,此时用前缀索引区分度也不高。考虑用以下两种方式:
使用倒序存储。查询时:
mysql> select field_list from t where id_card = reverse('input_id_card_string');
增加一个Hash字段。索引长度缩短为4个字节。不过查询时where后面需要加多一次判断:
mysql> select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string'
两种方式对比:
- 从占用的额外空间来看:倒序存储方式在主键索引上,不会消耗额外的存储空间。hash 字段方式需要增加一个字段。
- 在 CPU 消耗方面:倒序方式每次写和读的时候,都需要额外调用一次 reverse 函数。 hash 字段的方式需要额外调用一次 crc32() 函数。reverse 函数额外消耗的 CPU 资源会更小些。
- 从查询效率上看:使用 hash 字段方式的查询性能相对更稳定一些。因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。
小结
字符串字段创建索引的场景。可以使用的方式有:
- 直接创建完整索引:比较占用空间;
- 创建前缀索引:节省空间,但会增加查询扫描次数,且不能使用覆盖索引;
- 倒序存储,再创建前缀索引:用于绕过字符串本身前缀的区分度不够的问题,不支持范围扫描。
- 创建 hash 字段索引:查询性能稳定,有额外的存储和计算消耗,不支持范围扫描。
教训:建立字符串索引时,灵活运用最左前缀原则。
提问
字符串索引怎么建立?短的:全索引。长的:最前n个字符索引(会多扫描几次,且一定会回表)。长的且前几个字符相同的:倒叙索引(没法查范围)、hash索引(也没法查范围,新增了1个字段,但减少了遍历数)
如何确定前缀索引长度?用distinct查下区分度。
12.为什么我的MySQL会“抖”一下?
业务场景:一条 SQL 语句,正常执行的时候特别快,但是有时也不知道怎么回事,它就会变得特别慢,并且这样的场景很难复现,它不只随机,而且持续时间还很短。从现象看,就是数据库“抖”了一下。刷脏页。
1.你的 SQL 语句为什么变“慢”了
1.刷脏页的概念
我们知道,MySQL是WAL的,即InnoDB 在处理更新语句的时候,只做了更新内存,然后写日志(redo log)这一个磁盘操作。数据真正写到磁盘的表,是之后在后台执行的事情,也就是刷脏页。
flush 操作:把内存里的数据(redo log呢???是否删掉??)写入磁盘的过程。即刷脏页。
- 脏页:内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。
- 干净页:内存数据页写入到磁盘后,内存和磁盘上的数据页的内容就一致了,此时内存页为“干净页”。
不论是脏页还是干净页,都在内存中。
以下是“孔乙己赊账”的示意图,原来欠10文,现在再借9文:
所以,开头的问题,平时执行很快的更新操作,其实就是在写内存和日志,数据库“抖一下”,很有可能就是在刷脏页(flush)。
2.什么时候会引发刷脏页
想一下,MySQL操作是,有个缓存机制。是先写到磁盘日志(根据参数可以选择每个事务完都持久化,或者每隔几秒,或者后台自动持久化等)就算执行成功,相应的数据先缓存在内存,不写到具体B+树磁盘页。所以要更新磁盘,无非就是两种情况:内存不够用了、日志空间不够用了。
什么时候会flush?主要涉及4种场景:
- redo log 写满了
- 系统内存不足
- 系统空闲时
- MySQL正常关闭时
(1)redo log 写满了
InnoDB 的redo log 写满了,系统会停止所有更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写。
如下图:
此时,checkpoint 位置从 CP 推进到 CP’,将两个点之间的日志(浅绿色部分)对应的所有脏页都 flush 到磁盘。之后,图中从 write pos 到 CP’之间就是可以再写入的 redo log 的区域。
(2)系统内存不足
需要新的内存页,而内存不够用的时候,就要淘汰一些内存数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。
其实想一下,redo log已经记录了改变,redo log写满时其实也会自动去把数据刷到磁盘。可以直接把内存里的脏页干掉即可,下次用到时,从磁盘读入原始数据页,然后结合redo log更新使用即可。但是为什么不呢?这里把flush内存的操作,主要是基于性能的考虑:如果刷脏页一定会写盘,就保证了每个数据页有两种状态:
- 数据页在内存里:内存里的数据肯定是正确的,直接返回;
- 数据页不在内存:磁盘数据表里的数据肯定是正确的,直接读入。
这样就省去了从磁盘读入数据页时,还要去判断redo log的步骤。读数据肯定是要响应速度很快的,所以数据一致的问题放到刷脏页步骤去解决了(可以后台刷脏页)。
淘汰的时候,刷脏页过程也不用动redo log文件。这个有个额外的保证,是redo log在“重放”的时候,如果一个数据页已经是刷过的,会识别出来并跳过。
(3)系统空闲时
空闲时间由MySQL判断。
(4)MySQL正常关闭时
很好理解。
3.刷脏页对性能有什么影响
针对上述4种情况的3种进行讨论,MySQL正常关闭的情况不考虑。
(1)redo log 写满了,要 flush 脏页
这种情况InnoDB要尽量避免。
此时全系统的更新操作被阻塞,因为不能写redo log了嘛。
(2)内存不够用了,要先将脏页写到磁盘
这种情况是常态。
InnoDB 用缓冲池(buffer pool)管理内存(最近最少使用算法),缓冲池中的内存页有三种状态:
- 还没有使用的内存:长时间运行的系统,这种页数很少。因为InnoDB的策略是尽量使用内存。
- 使用了且是干净页:直接干掉,让出内存来。
- 使用了且是脏页:需要先将脏页flush到磁盘。
所以,刷脏页是常态,但是会明显影响性能。当一个查询要淘汰的脏页个数太多时,尤其如此。
所以,InnoDB 需要有控制脏页比例的机制,来尽量避免上面的这两种情况。
2.InnoDB 刷脏页的控制策略
接下来说InnoDB 脏页的控制策略,以及这些策略相关的参数。
1.innodb_io_capacity
参数
代表主机全力刷脏页能多快。
InnoDB 需要知道所在主机的 IO 能力,才能知道需要全力刷脏页的时候,可以刷多快。
最佳实践:这个值建议你设置成磁盘的 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
一般MySQL主机IO压力不大,但是MySQL的TPS很低,可能就是因为这个原因。
2.innodb_max_dirty_pages_pct
参数
当然主机也不能全力刷脏页,要留点IO来服务请求的。也就是说,要定义一个刷脏页的IO资源占所有IO资源的百分比。
我们先来看一下,如果脏页刷的慢,会怎样呢?首先是内存脏页太多,其次是 redo log 写满。
所以InnoDB 的刷盘速度就是要参考这两个因素:一个是(buffer pool)内存脏页比例,一个是 redo log 写盘速度。
InnoDB 会根据这两个因素先单独算出两个数字:
参数
innodb_max_dirty_pages_pct
:脏页比例上限,默认值是 75%。InnoDB 会根据当前的脏页比例(假设为 M),算出 0 - 100 的数字,计算这个数字的伪代码类似这样:// 刷页速度 = (当前脏页比例 / 脏页比率上限)* 100% * innodb_io_capacity磁盘最大IO F1(M){ if M>=innodb_max_dirty_pages_pct then return 100; return 100*M/innodb_max_dirty_pages_pct; }
redo log满不满:(checkPoint - writePos)。InnoDB 每次写入的日志都有一个序号,当前写入的序号跟 checkpoint 对应的序号之间的差值,我们假设为 N。InnoDB 会根据这个 N 算出一个范围在 0 到 100 之间的数字,这个计算公式可以记为 F2(N)。F2(N) 算法比较复杂,只需知道 N 越大,算出来的值越大就好了。
根据上述算得的 F1(M) 和 F2(N) 两个值,取其中较大的值记为 R,之后引擎按照 innodb_io_capacity
定义的能力乘以 R% 来控制刷脏页的速度。
总体流程图如下:
最佳实践:所以,尽量避免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;
3.innodb_flush_neighbors
参数
“连坐”机制:在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好是脏页,就会把这个“邻居”也带着一起刷掉。邻居的邻居还是脏页的话,就继续蔓延。。。
innodb_flush_neighbors
参数就是用来控制这个行为的,值为 1 时“连坐”,值为 0 时不“连坐”。
“连坐”机制只在机械硬盘时代有意义,可以减少随机 IO。机械硬盘的随机 IOPS 一般只有几百。
最佳实践:如果是 SSD 这类 IOPS 比较高的设备的, innodb_flush_neighbors
的值建议设置成 0。因为这时候 IOPS 往往不是瓶颈,而“只刷自己”,就能更快地刷完脏页,减少 SQL 语句响应等待。
在 MySQL 8.0 中,innodb_flush_neighbors 参数的默认值已经是0了。
教训:要设置好IO能力参数。redo log大小也要注意。
提问
InnoB的更新操作是写到redo log,redo log什么时候flush?四种情况下会flush
flush时对MySQL性能会有什么影响?4种情况,分情况讨论
InnoDB 刷脏页可以怎么优化? innodb_io_capacity
参数设置成磁盘的 IOPS,innodb_flush_neighbors
的值建议设置成 0。
InnoDB刷脏页的速度怎么确定?cap * R%
解决MySQL“抖动”问题?机器IO不高,但是MySQL就是卡?需要调节哪些参数?两个都是同样的问题。设置IO能力,脏页比例参数。以及可以加大内存。关键就两个点:内存脏页比例,redo log是否写满。
13.为什么表数据删掉一半,表文件大小不变
把一张表删掉,为什么表文件的大小还是不变呢?本篇讨论数据库表的空间回收问题,针对InnoDB 引擎。
一个 InnoDB 表包含两部分:表结构定义和数据。MySQL 8.0 前,表结构保存在以 .frm 为后缀的文件里,8.0后允许把表结构定义放在系统数据表中。
下面主要讨论表数据,因为表结构定义占用的空间很少。
1.参数 innodb_file_per_table
innodb_file_per_table
参数:
- 值为ON:表数据放在放在单独的文件,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。
- 值为OFF:表数据放在共享表空间,跟数据字典放在一起。
从 MySQL 5.6.6 版本开始,默认值为 ON。建议不过哪个版本,都设置为ON。单独放置的好处:
- 文件单独放置更易管理。
- 便于删除:删除表时,通过drop table命令,系统会直接删除该文件。如果放在共享表空间,即使表删掉了,空间也不会回收。
上述直接删除表,空间回收没问题。但是如果单独删除N行,会发现表空间没有被回收。
以下做分析,分析的前提是上述参数的设置为ON。
2.数据删除流程
以下是InnoDB的索引示意图:
删除数据时,该数据或数据页只是被标记删除,它的位置后续可以复用。
- 删除某条记录产生的空洞可以复用:例如上图,删除R4,那么它的位置,当下次插入时300-600间的记录时就可以复用。
- 删除数据页产生的空洞可以复用:比如删除了300-600范围的数据,那么这整个数据页就可以复用了。和删除某一条有一点小区别,这个数据页空洞是可以全局复用的,不用满足之前的范围。例如现在插入50,就可以用它。
- 插入数据导致的页分裂空洞可以复用:比如插入550,此时300-600这个页满了,就会分裂成两个页,也就产生了空洞。
- 更新数据而导致的页分裂空洞:更新索引操作可以看做是“删除旧值”+“插入新值”的结合,和前述几条同理。
另外,如果相邻的两个数据页利用率都很小,系统就会把这两个页合到一个页上,另外一个数据页就被标记为可复用。
用 delete 命令把整个表的数据删除呢?一样的,所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。
所以,经过大量增删改的表,都是可能是存在空洞的。
表重建:把这些空洞去掉,从而达到收缩表空间的目的。
3.重建表
1.重建表整体思路
重建表整体思路:新建一个与表 A 结构相同的临时表 B,然后按照主键 ID 递增的顺序,逐一从表 A 里读出来再插入到表 B 中,之后再用表B替换表A即可。
2.alter table A engine=InnoDB
alter table A engine=InnoDB
:使用该命令重建表即可。整体流程就是前述的流程。MySQL 会自动完成转存数据、交换表名、删除旧表的操作。如下图
DDL 不是 Online 的,会阻塞增删改:最耗时的步骤是往临时表插入数据,在这个过程中,如果有新的数据要写入到表 A 的话,就会造成数据丢失。因此,在整个 DDL 过程中,表 A 中不能有更新。
MySQL 5.6 支持 Online DDL:MySQL 5.6后引入了Online DDL,对这个操作流程做了优化。假设重建表A,临时表时B。大概的思路是,DDL 拿到 MDL 写锁(alter table产生),之后开始拷贝数据,此时写锁退化为读锁(退化成读锁是为了允许增删改,不直接删掉锁而保留读锁是为了防止其他线程对本表做DDL操作),原表A此时允许增删改,增删改的操作保存到单独的日志文件(row log),拷贝完成后,有一个短暂的停顿,把row log的变更更新到新的表中,最后完成重建。由于停顿时间短,可以认为值Online的。整体流程如下:
1.建立一个临时文件,扫描表 A 主键的所有数据页;
2.用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;
3.生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;
4.临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 的状态;
5.用临时文件替换表 A 的数据文件。
图示如下,临时表也由原来的server层移到了InnoDB层:
补充一点,在重建表的时候,InnoDB 不会把整张表占满,而是每个页留了 1/16 给后续的更新用,所以重建后的表也不是最紧凑的。
再补充一点,对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的,MySQL5.5及之前就更不用说了,直接阻塞增删改。所以如果是线上服务,要重点注意控制操作时间。如果想要比较安全的操作的话,推荐使用 GitHub 开源的 gh-ost 来做。
4.Online 和 inplace
两个概念都是跟DDL有关的,稍微有点容易混淆,区分一下。
inplace的概念:
- 在图 3 中,我们把表 A 中的数据导出来的存放位置叫作 tmp_table。这是一个临时表,是在 server 层创建的
- 在图 4 中,根据表 A 重建出来的数据是放在“tmp_file”里的,这个临时文件是 InnoDB 在内部创建出来的。整个 DDL 过程都在 InnoDB 内部完成。对于 server 层来说,没有把数据挪动到临时表,是一个“原地”操作,这就是“inplace”名称的来源。
可以看到:
- 图3:server层强制拷贝表方式,相当于
alter table t engine=innodb,ALGORITHM=copy;
- 图4:InnoDB内部,相当于
alter table t engine=innodb,ALGORITHM=inplace;
Online的概念:
- 就是单纯的表示,copy的时候不阻塞增删改而已。
- DDL 过程如果是 Online 的,就一定是 inplace 的(因为在InnoDB内部执行,Server层没有数据挪动)
- 反过来未必,也就是说 inplace 的 DDL,有可能不是 Online 的。截止到 MySQL 8.0,添加全文索引(FULLTEXT index)和空间索引 (SPATIAL index) 就属于这种情况,会阻塞增删改。
扩展:optimize table、analyze table 和 alter table 的区别:
- 从 MySQL 5.6 版本开始,alter table t engine = InnoDB(也就是 recreate)默认就是上面图 4 的流程;
analyze table t
不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了 MDL 读锁;optimize table t
:等于 recreate + analyze。即“重建表减少空洞”+“重新统计索引优化索引选择逻辑”
教训:快速删除表时使用drop。重建表时生产环境注意写锁时间过长。
提问
InnoDB的数据删除流程?删除N行会怎样?为什么删除数据,文件大小不变?删除行、数据页、表。
drop table效率为什么比delete效率高?前者直接删除,后者一个一个标记,留位置后续复用。
表重建有什么用?MySQL重建表的命令?流程?
Inplace啥意思?Online呢?
optimize table、analyze table 和 alter table 的区别?
假设现在有人碰到了一个“想要收缩表空间,结果适得其反”的情况,看上去是这样的:
- 一个表 t 文件大小为 1TB;
- 对这个表执行 alter table t engine=InnoDB;
- 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了 1.01TB。
你觉得可能是什么原因呢 ?这张表本身就是紧凑的,没有空洞。然后两个地方引入了空洞:1.重建表时Online时对表又进行了修改,生成了新的空洞。2.在重建表的时候,InnoDB 不会把整张表占满,而是每个页留了 1/16 给后续的更新用。也就是说,其实重建表之后不是“最”紧凑的。
14.count(*)这么慢,我该怎么办?
记录很多的时候,count(*)会执行得很慢。本篇来说一下,count(*) 语句到底是怎样实现的,以及 MySQL 为什么会这么实现。最后给出一些需要频繁使用count(*)时,业务设计的建议。
1.count(*) 的实现方式
1.两种引擎的实现方式
不同的引擎,count(*) 实现不同。两种引擎的实现方式:
- MyISAM:引擎把一个表的总行数存在了磁盘上。因此执行 count(*) 的时候会直接返回,效率很高。不过加了过滤条件的计数,也是一行一行计数。
- InnoDB :把数据一行一行地从引擎里面读出来,然后累积计数。比较麻烦。
2.为什么InnoDB和MyISAM不一样
为什么 InnoDB 不学 MyISAM ,也把数字存起来?因为InnoDB支持事务。有多版本并发控制(MVCC)。不同的事务,在不同的隔离级别下,返回的count(*) 有可能不同。
以下举例:
假设表 t 中现在有 10000 条记录,我们设计了三个用户并行的会话:
会话 A 先启动事务并查询一次表的总行数;
会话 B 启动事务,插入一行后记录后,查询表的总行数;
会话 C 先启动一个单独的语句,插入一行记录后,查询表的总行数。
流程如下图:
可以看到,最后三个事务拿到的结果不一样。 InnoDB的默认隔离级别是可重复读。
其实InnoDB也做了优化:count(*) 操作,会选取普通索引树,而不是主键索引树。因为结果是一样的,而普通索引树主键索引树小很多,能够减少扫描的数据量。(每一个数据页能存更多数据,所以减少扫描的数据页?)
show table status
不能提供准确数据:我们用show table status
命令,可以发现一个TABLE_ROWS用于显示该表有多少行。但是这个数据不准确,是从索引统计值统计时的采样估算的,根据官方文档说误差可能达到 40% 到 50%。
以上统计计数的方式,总结一下
- MyISAM 表虽然 count(*) 很快,但是不支持事务;
- show table status 命令虽然返回很快,但是不准确;
- InnoDB 表直接 count(*) 会遍历全表,虽然结果准确,但会导致性能问题。
那么,回到开头的问题,如果现在有一个页面经常要显示交易系统的操作记录总数,到底应该怎么办呢?答案是,只能自己计数。需要自己找一个地方,把操作记录表的行数存起来。
2.用缓存系统保存计数
可以尝试使用Redis保存,但是有以下问题:
- Redis需要持久化,不然可能会丢数据。崩溃时再恢复也可能有脏数据。
- Redis计数更新和数据库的插入操作,存在并发时,缓存同步问题。
3.在数据库保存计数
把这个计数直接放到数据库里单独的一张计数表 C 中:
- 崩溃问题:数据库,天生解决了崩溃问题。
- 精确问题:把“计数加1的操作”和“数据新增的操作”放到同一个事务里,就能保证和其他事务隔离,可保证精确。
4.不同的 count()的用法
count()是一个聚合函数。那么count(*)、count(主键 id)、count(字段) 和 count(1)有什么区别?
以下还是基于 InnoDB 引擎。
count()函数的逻辑:我们知道,count() 是一个聚合函数。所以,它的操作逻辑是,对于返回的结果集,逐行进行判断,如果参数不是 NULL,累计值就加 1,否则不加。
不同参数区别如下:
- count(主键 id):InnoDB遍历全表,取出值ID,返回给server层。server层每一行取到ID,判断ID是不可能为空的,直接按行累加。
- count(1):InnoDB遍历全表,但不取值。server层每一行直接用“1”填充,判断“1”是不可能为空的,直接按行累加。所以count(1)要比count(主键ID)快。返回 id 会涉及到解析数据行,以及拷贝字段值。
- *count(字段)*:
- 字段定义不允许null:逐行地从记录中读出该字段,判断不能为 null,直接按行累加;
- 字段定义允许null:逐行地从记录中读出该字段,判断可能为 null,还要把值取出来再判断一下,不是null才按行累加;
- count(*):是个例外,专门做了优化,不取值。因为count(*) 肯定不是 null,直接按行累加。
以上,按照执行效率,**count(字段)<count(主键 id)<count(1)≈count(*)**,尽量用count(*)即可。
教训:计数时使用count(*)更合适。
提问
InnoDB和MyISAM如何实现count(*) ?
如有有一个计数经常被用到,如何提高查询效率?自己单独建一张表存计数。就不用每次都去扫描数据行数
count(*)、count(主键 id)、count(字段) 和 count(1)有什么区别?
用单独一张表计数的场景,从并发系统性能的角度考虑,你觉得在这个事务序列里,应该先插入操作记录,还是应该先更新计数表呢?先更新计数表,插入会取写锁,要减少写锁等待时间。?
当 MySQL 去更新一行,但是要修改的值跟原来的值是相同的,这时候 MySQL 会真的去执行一次修改吗?还是看到值相同就直接返回呢?加锁直接验证,很容易得出答案。该修改的修改,该更新的更新。
15.答疑文章(一):日志和索引相关问题
本篇主要归总一下前面的内容,把日志和索引的一些知识点串起来。
1.日志相关问题
我们知道,binlog(归档日志)和 redo log(重做日志)是两阶段提交的。
那么,在两阶段提交的不同瞬间,MySQL 如果发生异常重启,是怎么保证数据完整性的呢?
先复习下日志的两阶段提交,如下图:
1.commit概念区分
- “commit 语句”: MySQL 语法中,用于提交一个事务的命令。一般跟 begin/start transaction 配对使用。
- 图中的“commit 步骤”:指的是事务提交过程中的一个小步骤,也是最后一步。当这个步骤执行完成后,这个事务就提交完成了。
- “commit 语句”执行的时候,会包含“commit 步骤”。
2.重启处理
时刻A,即写入 redo log 处于 prepare 阶段之后、写 binlog 之前,发生了崩溃(crash)
- 此时 binlog 还没写,redo log 也还没提交,所以崩溃恢复的时候,这个事务会回滚。这时候,binlog 还没写,所以也不会传到备库。
时刻 B,也就是 binlog 写完,redo log 还没 commit 前,发生 crash:
- redo log 里面的事务是完整的,也就是已经有了 commit 标识:直接提交。
- redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 情况:
- binlog 存在且完整:直接提交事务。
- binlog 不存在或不完整:回滚事务。
3.追问 1:MySQL 怎么知道 binlog 是完整的?
事务的 binlog 是有完整格式的:
- statement 格式的 binlog,最后会有 COMMIT;
- row 格式的 binlog,最后会有一个 XID event。
在 MySQL 5.6.2 版本以后,还引入了 binlog-checksum 参数,用来验证 binlog 内容的正确性。由于磁盘原因产生的日志错误,MySQL 也可以通过校验 checksum 的结果来发现。
4.追问 2:redo log 和 binlog 是怎么关联起来的?
它们有一个共同的数据字段,叫 XID。崩溃恢复的时候,会按顺序扫描 redo log:
- 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
- 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。
5.追问 3:处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢复,MySQL 为什么要这么设计?
在时刻 B,也就是 binlog 写完以后 MySQL 发生崩溃,这时候 binlog 已经写入了,之后就会被从库(或者用这个 binlog 恢复出来的库)使用。
所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。
6.追问 4:如果这样的话,为什么还要两阶段提交呢?干脆先 redo log 写完,再写 binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是一样的逻辑?
两阶段提交是经典的分布式系统问题,并不是 MySQL 独有的。
对于 InnoDB 引擎来说,如果 redo log 最后提交完成了,事务就不能回滚(如果这还允许回滚,就可能覆盖掉别的事务的更新)。而如果 redo log 直接提交,然后 binlog 写入的时候失败,InnoDB 又回滚不了,数据和 binlog 日志又不一致了。(binlog是可以回滚的?)
7.追问 5:不引入两个日志,也就没有两阶段提交的必要了。只用 binlog 来支持崩溃恢复,又能支持归档,不就可以了?
如果流程改成这样:…-> “数据更新到内存” -> “写 binlog” -> “提交事务”,不可以:
历史原因:InnoDB 不是 MySQL 的原生存储引擎。MySQL 的原生引擎是 MyISAM没有支持崩溃恢复的能力。
实现上的原因:binlog 没有能力恢复“数据页”。如图,两个事务写到同一页上,此时在图中位置崩溃,数据是数据页级的丢失,对于事务1,系统认为已经提交,不会再恢复它:
InnoDB 引擎使用的是 WAL 技术,执行事务时,写完内存和日志,事务就算完成了。如果之后崩溃,要依赖于日志来恢复数据页。
8.追问 6:那能不能反过来,只用 redo log,不要 binlog?
只用redo log,确实可以保证 cash-safe。
但是在正式的生产库上,binlog 都是开着的。因为 binlog 有着 redo log 无法替代的功能:
- 归档:redo log 是循环写,无法归档。
- 原生依赖:MySQL 系统依赖于 binlog,binlog 复制是MySQL 系统高可用的基础。很多系统机制都依赖于binlog。
还有很多公司有异构系统(比如一些数据分析系统),这些系统就靠消费 MySQL 的 binlog 来更新自己的数据。
9.追问 7:redo log 一般设置多大?
redo log 太小的话,会导致很快就被写满,然后不得不强行刷 redo log,这样 WAL 机制的能力就发挥不出来。
如果是现在常见的几个 TB 的磁盘的话,就不要太小气了,直接将 redo log 设置为 4 个文件、每个文件 1GB 吧。
10.追问 8:正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新过来的还是从 buffer pool 更新过来的呢?
redo log 并没有记录数据页的完整数据,它并没有能力自己去更新磁盘数据页,也就不存在“数据最终落盘,是由 redo log 更新过去”的情况。
- 正常运行的实例:数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,与 redo log 毫无关系。
- 崩溃恢复场景:InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。
11.redo log buffer 是什么?是先修改内存,还是先写 redo log 文件?
一个事务的更新过程中,日志是要写多次的。例如:
begin;
insert into t1 ...
insert into t2 ...
commit;
日志还没 commit 的时候不能直接写到 redo log 文件,所以redo log buffer 就是一块内存,用来先存 redo 日志。
真正把日志写到 redo log 文件(文件名是 ib_logfile+ 数字),是在执行 commit 语句的时候做的。详情也可参考后续第22篇。
2.业务设计问题(略)
提问
两阶段提交时MySQL重启,数据如何恢复?追问?
16.“order by”是怎么工作的?
假设有以下表:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`city` varchar(16) NOT NULL,
`name` varchar(16) NOT NULL,
`age` int(11) NOT NULL,
`addr` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `city` (`city`)
) ENGINE=InnoDB;
执行以下SQL语句:
select city,name,age from t where city='杭州' order by name limit 1000 ;
我们下面来分析一下它的执行流程。
1.全字段排序
上述查询,我们给city加上索引。用explain命令查看执行计划如下图:
可以看到,Extra 字段中的“Using filesort”,表示需要排序。
MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。
索引示意如下图:
1.全字段查询执行流程
全字段排序执行流程:其实就是把所有满足条件的记录查出来,放到sort_buffer,然后排序,最后拿出符合条件的前N条记录即可。具体如下:
1.初始化 sort_buffer,确定放入 name、city、age 这三个字段;
2.从索引 city 找到第一个满足 city='杭州’条件的主键 id,也就是图中的 ID_X;
3.到主键 id 索引取出整行,取 name、city、age 三个字段的值,存入 sort_buffer 中;
4.从索引 city 取下一个记录的主键 id;
5.重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;
6.对 sort_buffer 中的数据按照字段 name 做快速排序;
7.按照排序结果取前 1000 行返回给客户端。
执行示意图如下图:
“按 name 排序”这个动作,如果sort_buffer内存不够,就需要使用外部排序,即利用磁盘临时文件辅助排序。
sort_buffer_size
参数:MySQL 为排序开辟的内存(sort_buffer)的大小。
2.查看排序详情及是否使用临时文件
可以用以下方法查询排序执行情况,包括是否用到外部文件,排序的模式等:
/* 打开 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;
通过查看 OPTIMIZER_TRACE 的结果来确认,可以从 number_of_tmp_files 中看到是否使用了临时文件,如图:
number_of_tmp_files
表示,排序过程使用了12个临时文件。外部排序一般使用归并排序算法。可以简单理解为MySQL 将需要排序的数据分成 12 份,每一份单独排序后存在这些临时文件中。然后把这 12 个有序文件再合并成一个有序的大文件。
当然,如果sort_buffer_size
足够大,就不用外部排序。sort_buffer_size 越小,需要分成的份数越多。
examined_rows=4000
表示,参与排序的行数是 4000 行。就是符合条件的记录有4000条,然后他们排序。
packed_additional_fields
表示,排序过程对字符串做了“紧凑”处理。name 字段的定义是 varchar(16),排序时则是要按照实际长度来分配空间的。
select @b-@a
表示,整个执行过程只扫描了 4000 行。
2.rowid 排序
全字段排序的问题:假如要返回的字段很多,那么sort_buffer
里面要放的字段数太多,这样能放下的行数就很少,要分成很多临时文件,排序性能会很差。
此时,MySQL会采用rowid排序算法。
rowid排序执行流程:简单的理解,就是不缓存全字段,而是只缓存id和参与排序的字段进行排序,拿到符合条件的id集后,再多一次回表操作拿到所有记录,最后返回结果。
max_length_for_sort_data
参数:设置用于排序的行数据长度,单行长度超过这个值,MySQL就会认为该行太大,会切换算法。
我们把上述参数设小,看下切换成rowid算法后的结果,详情如下:
1.初始化 sort_buffer,确定放入两个字段,即 name 和 id;
2.从索引 city 找到第一个满足 city='杭州’条件的主键 id,也就是图中的 ID_X;
3.到主键 id 索引取出整行,取 name、id 这两个字段,存入 sort_buffer 中;
4.从索引 city 取下一个记录的主键 id;
5.重复步骤 3、4 直到不满足 city='杭州’条件为止,也就是图中的 ID_Y;
6.对 sort_buffer 中的数据按照字段 name 进行排序;
7.遍历排序结果,取前 1000 行,并按照 id 的值回到原表中取出 city、name 和 age 三个字段返回给客户端。
总体示意图如下:
对比图三的全字段方式,可以发现:rowid 排序多访问了一次表 t 的主键索引,就是步骤 7。
最后的“结果集”是一个逻辑概念,是直接从原表查到数据后返回。
排序详情见下图:
可以看到:
- sort_mode 变了:变成了 <sort_key, rowid>,表示排序时只有这两个字段。
- number_of_tmp_files :变成了10。是因为排序的每行变小了,所以文件变小了。
- select @b-@a 是5000:多1000是因为排序完成后,又回表取符合要求的前1000行数据。
3.全字段排序 VS rowid 排序
MySQL的设计思想是尽量使用内存:内存够的时候直接全字段排序,减少回表次数。内存不够时,才会用rowid排序,多一次回表操作。
排序的成本是比较高的。但并不是所有的order by都会用到排序,如果数据本来就是有序的,那压根用不到排序操作。
4.建立联合索引
此时数据加了索引,本来就是有序的,直接取,用不到排序操作。
在这个市民表上创建一个 city 和 name 的联合索引:
alter table t add index city_user(city, name);
索引示意图:
此时,还是执行select city,name,age from t where city='杭州' order by name limit 1000 ;
。那么只要city相等,name的值一定是有序的。流程如下:
1.从索引 (city,name) 找到第一个满足 city='杭州’条件的主键 id;
2.到主键 id 索引取出整行,取 name、city、age 三个字段的值,作为结果集的一部分直接返回;
3.从索引 (city,name) 取下一个记录主键 id;
4.重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city='杭州’条件时循环结束。
示意图如下图:
explain 的结果:
可以看到,Extra字段中,没有用到Using filesort。
扫描行数:由于索引本身就有序,所以只扫描了前1000行符合条件的记录,没有把所有4000行“杭州”的记录扫出来。
5.建立覆盖索引
还可以用覆盖索引进一步简化:
alter table t add index city_user_age(city, name, age);
这样就直接连回表都不需要。
执行流程:
1.从索引 (city,name,age) 找到第一个满足 city='杭州’条件的记录,取出其中的 city、name 和 age 这三个字段的值,作为结果集的一部分直接返回;
2.从索引 (city,name,age) 取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
3.重复执行步骤 2,直到查到第 1000 条记录,或者是不满足 city='杭州’条件时循环结束。
执行示意图:
执行explain 的结果:
当然,上覆盖索引,维护索引也是有代价的。需要自己权衡。
小结
全文总结:四种方式比较,假如总记录数是M,结果数limit N:
- 全字段排序:全表扫描,缓存所有字段排序,回表1次。内存不够,使用外部临时文件归并排序。
- rowid排序:全表扫描 + N次扫描,缓存排序字段排序,回表2次。减少了内存占用,但多1次回表。
- 索引排序:扫描N次。不排序,无需临时表,需要回表。
- 联合索引排序:扫描N次。不排序,无需临时表,无需回表,因为用到了覆盖索引优化。
教训:select语句不要随便用select *,特别是有order by排序时。
提问
order by底层怎么实现?有什么区别?走不走索引?底层是排序。有:全字段排序、rowid排序、索引排序、联合索引排序。
如何优化 select city,name,age from t where city=’杭州’ order by name limit 1000 ?有哪些思路
假设你的表里面已经有了 city_name(city, name) 这个联合索引,然后你要查杭州和苏州两个城市中所有的市民的姓名,并且按名字排序,显示前 100 条记录。如果 SQL 查询语句是这么写的 :
mysql> select * from t where city in ('杭州'," 苏州 ") order by name limit 100;
那么,这个语句执行的时候会有排序过程吗,为什么?
如果业务端代码由你来开发,需要实现一个在数据库端不需要排序的方案,你会怎么实现呢?
进一步地,如果有分页需求,要显示第 101 页,也就是说语句最后要改成 “limit 10000,100”, 你的实现方法又会是什么呢?
17.如何正确地显示随机消息?
上一篇讲了MySQL排序正常情况下的需求场景。本篇讲讲另外一种排序需求,主要内容是临时表排序的实现。
现在的需求:在一张单词表里,访问时要随机选出3个单词,且访问的频率很高。
建表和初始数据语句如下:
mysql> CREATE TABLE `words` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`word` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=0;
while i<10000 do
insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10))));
set i=i+1;
end while;
end;;
delimiter ;
call idata();
10000 行记录,随机选3个单词。看下可以怎么实现,以及如何改进。
1.内存临时表(memory引擎)
即*order by rand()*,随机排序,取前三个:
mysql> select word from words order by rand() limit 3;
来分析下执行流程。
1.执行流程
explain 命令分析执行情况,如下图:
Extra 字段:Using temporary
,表示需要使用临时表。Using filesort
,表示需要执行排序操作。综合起来,就是需要临时表,且要在临时表上排序。
什么情况下会产生临时表Using temporary?查询需要临时表的时候,比如我们这个例子里,需要临时表来放rand()结果。
那么在临时表排序会选用哪种算法呢?回顾下前文中的两种排序算法:全字段排序和rowid 排序的内容,如下图:
内存临时表排序选用哪种算法呢,内存临时表选用的是rowid方法:
- 对于 InnoDB 表:选择全字段排序,以减少一次回表磁盘访问。
- 对于内存表:由于是在内存中,回表操作不用访问磁盘,因此会优先考虑 rowId 排序。
语句的执行,大概流程是,创建临时表,sort_buffer给临时表排序,回临时表取值。流程详细如下:
- 创建一个临时表。这个临时表使用的是 memory 引擎,表里有两个字段,第一个字段是 double 类型,记为字段 R。第二个字段是 varchar(64) 类型,记为字段 W。并且,这个表没有建索引。
- 将原表中的 word 值随机Hash后放入临时表。从 words 表中,按主键顺序取出所有的 word 值。对于每一个 word 值,调用 rand() 函数生成一个大于 0 小于 1 的随机小数,并把这个随机小数和 word 分别存入临时表的 R 和 W 字段中,到此,扫描行数是 10000。即:key是hash值,value是word值。
- 初始化 sort_buffer。sort_buffer 中有两个字段,一个是 double 类型,另一个是整型。
- 将临时表的Hash值及位置信息放入sort_buffer。从内存临时表中一行一行地取出 R 值和位置信息(后面会解释为什么是“位置信息”),分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加 10000,变成了 20000。由于临时表没有ID,这里就用默认的ID(即位置信息)插入。即:即:key是hash值,value是临时表默认ID。
- 在 sort_buffer 中根据 Hash值排序。在 sort_buffer 中根据 R 的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。
- 排序后根据位置信息(默认ID,这里是数组下标)到临时表获取word值。排序完成后,取出前三个结果的位置信息,依次到临时表中取出 word 值,返回给客户端。这个过程中,访问了表的三行数据,总扫描行数变成了 20003。
通过慢查询日志(slow log)来验证一下,Rows_examined:20003 ,扫描行数正确:
# Query_time: 0.900376 Lock_time: 0.000347 Rows_sent: 3 Rows_examined: 20003
SET timestamp=1541402277;
select word from words order by rand() limit 3;
完整的执行流程图如下图:
2.位置信息的概念
上图中的位置信息,即pos,牵扯到MySQL是如何来定位“一行数据”的。
MySQL通过rowid来唯一的定位一条数据:
- 对于有主键的 InnoDB 表:这个 rowid 就是主键 ID;
- 对于没有主键的 InnoDB 表:InnoDB 会自己生成一个长度为 6 字节的 rowid 来作为主键。
- 内存临时表用的是 MEMORY 引擎,它不是索引组织表。在上述例子中,你可以认为它就是一个数组。因此,这个 rowid 其实就是数组的下标。
小结一下:order by rand() 使用了内存临时表,内存临时表是MEMORY引擎,排序的时候使用了 rowid 排序方法。
2.磁盘临时表(默认InnoDB引擎)
不是所有临时表都是内存临时表。内存不够时,使用磁盘临时表。
tmp_table_size
参数:内存临时表大小的参数。默认16M,超过这个大小则转换为磁盘临时表。
internal_tmp_disk_storage_engine
参数:配置磁盘临时表的引擎,默认 InnoDB。
1.排序过程
排序过程复现:
-- 把 tmp_table_size 设置成 1024,把 sort_buffer_size 设置成 32768, 把 max_length_for_sort_data 设置成 16。
set tmp_table_size=1024;
set sort_buffer_size=32768;
set max_length_for_sort_data=16;
/* 打开 optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on';
/* 执行语句 */
select word from words order by rand() limit 3;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
分析结果:我们把每行的最大数据量max_length_for_sort_data设成16,所以排序是用到了rowid。但是奇怪的是,sort_buffer_size不够用(R 字段存放的随机值 8 个字节,rowid 是 6 个字节,数据总行数是 10000,这样算出来就有 140000 字节,超过了 sort_buffer_size 定义的 32768 字节了),确没有用到临时文件?
因为filesort_priority_queue_optimization.chosen=true,表示使用了优先队列排序算法。
2.归并排序VS优先队列排序
原因是,该次排序没有用归并排序,而是用到了优先队列排序。因为我们只要最前面的N的树,用归并全部数据排好序,显然浪费了。
优先队列算法,MySQL5.6后引入,大概就是构建一个堆,然后依次向后比较即可:
- 对于这 10000 个准备排序的 (R,rowid),先取前三行,构造成一个堆;
- 取下一个行 (R’,rowid’),跟当前堆里面最大的 R 比较,如果 R’小于 R,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’);
- 重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。
简单示意图如下:
优先队列排序算法,不需要临时文件。
那么,前文中,limit 1000为什么用归并不用优先队列呢?因为1000,要维护的堆有1000行,超出了sort_buffer_size 大小,所以改为了归并。
总结:不论是使用内存还是磁盘类型的临时表,order by rand() 都会让计算过程非常复杂,需要大量的扫描行数,因此排序过程的资源消耗也会很大。
3.随机排序方法
回到文章开头,如何选择合适的随机排序算法?
1.随机算法1
算法简述:ID范围映射到0-1,随机取一个。
算法流程:假设简化为随机取一个word:
- 取得这个表的主键 id 的最大值 M 和最小值 N;
- 用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
- 取不小于 X 的第一个 ID 的行。
mysql> select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;
这个算法有缺陷,因为ID并不是严格随机分布的,会有空洞。极端情况,比如1、2、40000、40001 ,几乎只会取到后半部分。具体处理方法,可以上线前整理单词表去除空洞,也可以自己定义递增的ID。
2.随机算法2
算法简述:统计记录总行数,然后映射到0-1,随机取第N行。
流程:
- 取得整个表的行数,并记为 C。
- 取得 Y = floor(C * rand())。 floor 函数在这里的作用,就是取整数部分。
- 再用 limit Y,1 取得一行。
mysql> select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;
性能分析:需要扫描 Y+1 行。再加上,第一步扫描的 C 行,总共需要扫描 C+Y+1 行,执行代价比随机算法 1 的代价要高。当然,比rand()临时表方式效率高。
3.随机算法3
随机算法2改造成取3个随机数:
mysql> select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; // 在应用代码里面取 Y1、Y2、Y3 值,拼出 SQL 后执行
select * from t limit @Y2,1;
select * from t limit @Y3,1;
教训:临时表排序代价很高,使用到临时表时,要注意能不能避开。另外可以调高sort_buffer
参数提高性能。还可以调整max_length_for_sort_data
参数,决定每次排序时每行的大小,以调整全字段排序和rowid排序的选择优先顺序。
提问
内存临时表排序执行流程?使用什么引擎?
磁盘临时表排序执行流程?使用什么引擎?
随机取一个值,访问频率很高,怎么设计?
上面的随机算法 3 的总扫描行数是 C+(Y1+1)+(Y2+1)+(Y3+1),实际上它还是可以继续优化,来进一步减少扫描行数的。如果你是这个需求的开发人员,你会怎么做,来减少扫描行数呢?说说你的方案,并说明你的方案需要的扫描行数。
18.为什么这些SQL语句逻辑相同,性能却差异巨大?
在 MySQL 中,有很多看上去逻辑相同,但性能却差异巨大的 SQL 语句。
原因可能是:对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。
本篇分享3个案例。
1.案例一:条件字段函数操作
条件字段用了函数包装,可能导致只走全索引扫描,不走索引快速定位。
假设有一个交易记录表,字段是流水号、交易员ID、交易时间:
mysql> CREATE TABLE `tradelog` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`operator` int(11) DEFAULT NULL,
`t_modified` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`),
KEY `t_modified` (`t_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
假设,现在已经记录了从 2016 年初到 2018 年底的所有数据。
现在的需求是,统计发生在所有年份中 7 月份的交易记录总数。我们SQL这么写:
mysql> select count(*) from tradelog where month(t_modified)=7;
我们知道,t_modified已经有索引了。但是上述语句执行的时候却很慢?
原因是,如果对字段做了函数计算,就可能用不上索引了。
那么。为什么 where t_modified=’2018-7-1’的可以用上索引,而改成 where month(t_modified)=7 就不行呢?
t_modified 索引的示意图如下图:
图中可以看到:
- 索引用t_modified=’2018-7-1’:引擎会按照上面绿色箭头的路线快速定位到’2018-7-1’。
- 索引用month(t_modified)=7:7传入到第一层,无法操作。优化器放弃了通过索引快速定位,改为通过索引一条条全索引扫描。对比主键索引和t_modified索引,后者更小,所以最终选择通过t_modified索引一一扫描。
所以,对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。
用where month(t_modified)=7
,用explain 命令分析结果,见下图:
执行结果分析:
key="t_modified":用了 t_modified 这个索引
rows=100335:这条语句扫描了整个索引的所有值
Extra=Using index:用到了覆盖索引
也就是说,由于在 t_modified 字段加了 month() 函数操作,导致了全索引扫描。我们把语句优化如下,即可正常通过索引快速定位,因为B+树的兄弟节点是天生有序的:
mysql> select count(*) from tradelog where
-> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or
-> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or
-> (t_modified >= '2018-7-1' and t_modified<'2018-8-1');
优化器会偷懒:只要是涉及计算,即使不改变索引顺序,也可能就不走索引。例如select * from tradelog where id + 1 = 10000
不会快速定位,改为 where id = 10000 -1
才可以。
2.案例二:隐式类型转换
条件字段中有隐式类型转换,可能导致不走索引快速定位。
SQL语句举例:
mysql> select * from tradelog where tradeid=110717;
tradeid 是 varchar(32),而输入的参数却是整型,所以需要做隐士类型转换。
1.数据转换的规则
怎么看比较时数据库是把数字转换成字符串,还是相反呢?
直接看 select “10” > 9:
- 规则是“将字符串转成数字”,那么就是做数字比较,结果应该是 1;
- 规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是 0。
在MySQL验证,返回为1,所以,在 MySQL 中,字符串和数字做比较的话,是将字符串转换成数字。
2.为什么走了全索引
也就是说,语句
mysql> select * from tradelog where tradeid=110717;
对于优化器相当于
mysql> select * from tradelog where CAST(tradid AS signed int) = 110717;
所以,这里对字段做函数操作了。也就同前一个案例一样,走了全索引。
另外,select ‘a’ = 0 ; 的结果是1,说明无法转换成数字的字符串都被转换成0来处理了。
3.案例三:隐式字符编码转换
连接过程中要求在被驱动表的索引字段上加函数操作,会导致对被驱动表做全表扫描。典型的就是字符集转换。
1.举例
以下举例:
假如还有一张表交易详情表 trade_detail:
mysql> CREATE TABLE `trade_detail` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`trade_step` int(11) DEFAULT NULL, /* 操作步骤 */
`step_info` varchar(32) DEFAULT NULL, /* 步骤信息 */
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into tradelog values(1, 'aaaaaaaa', 1000, now());
insert into tradelog values(2, 'aaaaaaab', 1000, now());
insert into tradelog values(3, 'aaaaaaac', 1000, now());
insert into trade_detail values(1, 'aaaaaaaa', 1, 'add');
insert into trade_detail values(2, 'aaaaaaaa', 2, 'update');
insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit');
insert into trade_detail values(4, 'aaaaaaab', 1, 'add');
insert into trade_detail values(5, 'aaaaaaab', 2, 'update');
insert into trade_detail values(6, 'aaaaaaab', 3, 'update again');
insert into trade_detail values(7, 'aaaaaaab', 4, 'commit');
insert into trade_detail values(8, 'aaaaaaac', 1, 'add');
insert into trade_detail values(9, 'aaaaaaac', 2, 'update');
insert into trade_detail values(10, 'aaaaaaac', 3, 'update again');
insert into trade_detail values(11, 'aaaaaaac', 4, 'commit');
要查询 id=2 的交易的所有操作步骤信息,SQL 语句写法:
mysql> select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /* 语句 Q1*/
查看执行结果:
分析结果:
- 第一行:优化器先在交易记录表 tradelog 上查到 id=2 的行,用到了主键索引,rows=1只扫描一行。
- 第二行:key=NULL,没有用上交易详情表 trade_detail 上的 tradeid 索引,进行了全表扫描。
因为是先从 tradelog 表中取 tradeid 字段,再去 trade_detail 表里查询匹配字段。所以, tradelog 是驱动表,trade_detail 是被驱动表,把 tradeid 是关联字段。
看下explain的执行流程:
上图流程:
第 1 步:根据 id 在 tradelog 表里找到 L2 这一行;
第 2 步:从 L2 中取出 tradeid 字段的值;
第 3 步:是根据 tradeid 值到 trade_detail 表中查找条件匹配的行。explain 的结果里面第二行的 key=NULL 表示的就是,这个过程是通过遍历主键索引的方式,一个一个地判断 tradeid 的值是否匹配。
所以是第3步出问题了,没有走索引。被驱动表 trade_detail.tradeid 字段没有走索引。
其实就是因为两张表字符集不同,导致两个关联字段字符集不同,导致引起了字符集转换。
关于驱动表和被驱动表,d.tradeid=l.tradeid
等号谁在前谁在后无所谓,关键看先查谁的数据,他就是驱动表。
为什么没用上索引呢?为啥字符集不同就用不上?
2.为什么没用上索引
以上第3步,写成SQL就是:
mysql> select * from trade_detail where tradeid=$L2.tradeid.value;
驱动表查出来的中间值$L2.tradeid.value
的字符集是 utf8mb4。
utf8mb4 是 utf8 的超集,因此需要转换被驱动表 tradeid(utf8) 的字段,所以它会被一个一个转换成utf8mb4格式。相当于以下:
select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value;
很明显,在索引字段上用到了函数操作,所以没法走该索引。
3.对比操作
前述是语句,l是驱动表(先查的l数据,再去查d):
/* 语句 Q1*/
mysql> select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;
我们执行以下语句对比结果,d是驱动表(先查的d数据,再去查l):
sql>select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;
执行分析:
此时,trade_detail (utf8) 是驱动表。第二行显示,被驱动表走了索引。
和前面有什么不一样呢?
我们分析看到,第3步连接时,相当于:
select operator from tradelog where traideid =$R4.tradeid.value;
此时,驱动表查出来的中间结果$R4.tradeid.value
字符集是utf8,同样的,需要utf8转utf8mb4,那么和前面相反,此时是驱动表查出来的中间结果需要转换。哪个需要转换,就看哪个是utf8,它就要被转成utf8mb4。语句等价于:
select operator from tradelog where traideid = CONVERT($R4.tradeid.value USING utf8mb4);
可以看到,和前面相反,此时转换的是索引的参数,而不是索引字段。所以,可以走索引了。
4.语句优化
回过最开始,trade_detail(utf8)
和 tradelog(utf9mb4)
的语句,怎么优化?
select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;
两个思路:
直接统一字符集:把 trade_detail 表上的 tradeid 字段的字符集也改成 utf8mb4。
修改SQL语句:业务不允许统一字符集的情况下:
select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2;
主动把 l.tradeid 转成 utf8即可。
小补充:
- 执行explain 语句后,使用show warnings; 可以看到MySQL 实际执行的SQL,包括字符集的转换。
- SQL语句MySQL执行顺序:先where ,再order by 最后limit。
教训:1.函数计算可能不走索引:不要对字段进行函数计算,否则就用不上索引。2.隐式类型转换可能导致不走索引。3.两个表的字符集不同,可能导致不走索引。
提问
设计索引使用的字段时,举例说说有哪些索引失效的场景?见上面教训。
a表有100条记录,b表有10000条记录,两张表做关联查询时,是将a表放前面效率高,还是b表放前面效率高?如果是考察语句写法,这两个表谁放前面都一样,优化器会调整顺序选择合适的驱动表;如果是考察优化器怎么实现的,你可以这么想,每次在树搜索里面做一次查找都是log(n), 所以对比的是100*log(10000)和 10000*log(100)哪个小,显然是前者,所以结论应该是让小表驱动大表。
遇到过别的、类似本篇提到的性能问题吗?你认为原因是什么,又是怎么解决的呢?
一个有趣的场景,值得一说。我把他的问题重写一下,表结构如下:
mysql> CREATE TABLE `table_a` (
`id` int(11) NOT NULL,
`b` varchar(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `b` (`b`)
) ENGINE=InnoDB;
假设现在表里面,有 100 万行数据,其中有 10 万行数据的 b 的值是’1234567890’, 假设现在执行语句是这么写的:
mysql> select * from table_a where b='1234567890abcd';
这时候,MySQL 会怎么执行呢?
最理想的情况是,MySQL 看到字段 b 定义的是 varchar(10),那肯定返回空呀。可惜,MySQL 并没有这么做。
那要不,就是把’1234567890abcd’拿到索引里面去做匹配,肯定也没能够快速判断出索引树 b 上并没有这个值,也很快就能返回空结果。
但实际上,MySQL 也不是这么做的。
这条 SQL 语句的执行很慢,流程是这样的:
- 在传给引擎执行的时候,做了字符截断。因为引擎里面这个行只定义了长度是 10,所以只截了前 10 个字节,就是’1234567890’进去做匹配;
- 这样满足条件的数据有 10 万行;
- 因为是 select *, 所以要做 10 万次回表;
- 但是每次回表以后查出整行,到 server 层一判断,b 的值都不是’1234567890abcd’;
- 返回结果是空。
这个例子,是我们文章内容的一个很好的补充。虽然执行过程中可能经过函数操作,但是最终在拿到结果后,server 层还是要做一轮判断的。
19.为什么我只查一行的语句,也执行这么慢
本篇主要涉及表锁、行锁和一致性读的概念。隔离级别:可重复读。
有时候数据库CPU和IO都不高,但是只查一行也会执行得很慢。有可能是哪些原因呢?
假如有以下表,插入了10万行数据:
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=100000)do
insert into t values(i,i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
1.第一类:查询长时间不返回
例如执行SQL:
select * from t where id=1;
然后就一直阻塞等待,不返回:
一般这种情况,大概是表t被锁住了。
show processlist
命令:分析原因,我们一般直接执行 show processlist
命令,看看当前语句处于什么状态。
以下对各种可能的状态进行分析。
1.等MDL锁
(1)查看状态
show processlist 命令:
看到了**状态Waiting for table metadata lock
*。表示,现在有一个线程正在表 t 上请求或者持有 MDL 写锁*,把 select 语句堵住了。
(2)复现
直接拿到表的写锁即可:
session A 通过 lock table 命令持有表 t 的 MDL 写锁,而 session B 的查询需要获取 MDL 读锁。所以,session B 进入等待状态。
(3)处理方式
直接找到谁持有MDL写锁,然后把它kill掉。
上述命令是Sleep,不好查找。
直接用sys系统库来查找:MySQL启动时需要设置performance_schema=on
,开启会有大概10%的性能损失。
直接查 sys.sys.schema_table_lock_waits
即可,找出阻塞的process,用kill命令干掉他。
2.等 flush
SQL语句:
-- 执行sql,被阻塞
select * from information_schema.processlist where id=1;
(1)查看状态
show processlist 命令:
看到了**状态Waiting for table flush
*。表示,现在现在有一个线程正要对表 t 做 flush 操作*,把 select 语句堵住了。
flush操作一般如下:
# 只关闭表 t
flush tables t with read lock;
# 关闭 MySQL 里所有打开的表
flush tables with read lock;
但是一般上述命令执行很快。所以,除非是它们也被别的线程堵住了。
Waiting for table flush 状态的可能情况:有一个 flush tables 命令被别的语句堵住了,然后它又堵住了我们的 select 语句。
(2)复现
session A一直打开,session B想关闭表,只能等A结束。此时C查询,就又被B堵住。
(3)解决
show processlist 结果:
和前面一样,查询ID,然后直接kill掉就行。
3.等行锁
前面两个是表级锁。这里是行锁。
SQL语句:
-- 加共享锁,即读锁
mysql> select * from t where id=1 lock in share mode;
访问 id=1 这个记录时要加读锁,如果已经有其他事务在上面持有写锁,那么我们访问时,就会被行锁锁住。
(1)复现
此时,事务A有更新,拿着写锁,还没提交。事务B要读,就被堵住了。
(2)解决
怎么查出是谁占着写锁呢?MySQL 5.7 版本及以上,可以通过 sys.innodb_lock_waits
表查到。
mysql> select * from t sys.innodb_lock_waits where locked_table=`'test'.'t'`\G
可以看到,4 号线程是造成了堵塞。也提供了解决命令,直接干掉线程即可: KILL QUERY 4 或 KILL 4。
不过这里的命令分析一下:
- KILL QUERY 4:没用,它表示停止 4 号线程当前正在执行的语句。而占用行锁的是update语句,已经执行完毕了。
- KILL 4:有用,直接关闭连接。它隐含的意思是,关闭连接时,会自动回滚该连接里正执行的线程,也就释放了行锁。
2.第二类:查询慢
1.没有索引
SQL:
mysql> select * from t where c=50000 limit 1;
解决:
很简单,就是没有索引而已。
看一下慢查询日志(为了显示效果,暂时降低阈值set long_query_time=0
):
2.一致性读,回滚巨量undolog
只扫描一行,但是执行也很慢?
(1)场景
SQL:
mysql> select * from t where id=1;
两个语句为啥差别那么大?而且按理说,后一句加了锁,应该更慢才对呀?
慢查询日志:
(2)复现
事务B执行了100万次更新,undo日志爆炸了。。:
两个语句比较:
- 带 lock in share mode 的 SQL 语句:share mode是当前读,因此会直接读到 1000001 这个结果。
- select * from t where id=1:一致性读,需要执行undo log。
教训:没有启示。参见提问部分的阻塞排查方法。
提问
查询一条语句,执行得很慢,有可能是哪些原因?怎么操作分析解决?分析是表级锁、行锁、还是一致性读的问题。用慢查询日志识别慢查询,用show processlist,看下有没有阻塞的state。然后查sys.sys.schema_table_lock_waits表的blocking_pid,直接把它kill掉。
怎样加读锁?怎样加写锁?都是什么含义?for update,in share lock。含义参照前文基础篇。
上述最后的例子,如果是下面的 SQL 语句,
begin;
select * from t where c=5 for update;
commit;
这个语句序列是怎么加锁的呢?加的锁又是什么时候释放呢?
20.幻读是什么,幻读有什么问题?
以下是本篇用于举例的表结构:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
id、c、d三列,id是主键,c上有索引,d上没有索引。
我们来看下以下语句,是如何加锁和解锁的?
begin;
select * from t where d=5 for update;
commit;
执行结果:进行全表扫描。定位到 id=5 这一行,这一行加上写锁。两阶段锁协议,写锁在commit时释放。
那么,在 id != 5 的其他行,会不会加锁呢?
以下本篇举例都是在可重复读隔离级别下。
1.什么是幻读
下面我们来看下,如果只是 id = 5 这一行加锁,其他行不加锁,有什么问题:
看以下假设的场景,注意是假设的,假设加锁只是加锁了 id = 5 这一行,如下图:
可以看到,session A中的语句是,每次查询 d = 5 的行,是当前读,且加了写锁。
可以看到,三次查询Q1、Q2、Q3。需要注意的是,是只有Q3这里读到 id=1 这一行(即插入的行)的现象,才被称为“幻读”。Q2读的是更新的,不是幻读。
幻读的概念:指一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行(所以插入的行才算是幻读,更新的行不算,因为前面已经是可以看到的)。
关于幻读的补充说明:
- 幻读在“当前读”下才会出现:在可重复读隔离级别下,普通的查询是快照读(加了写锁
for update
,都是当前读),是不会看到别的事务插入的数据的。 - 幻读仅针对“新插入的行”:上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读,因为是之前就已经能看到的行,并不是之前没看到后来又看到的行。
按之前的当前读的规则,上图的执行结果是没有问题的。那么,当前读能读到最新的值,包括最新的插入的值,是不是真的没问题呢?幻读有什么问题呢?
2.幻读的问题
1.语义有问题
重新看下上面的图。session A 在 T1 时刻就声明了,“我要把所有 d=5 的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。
下面是一个更明显的例子,在 session B、C中加了一个语句,见下图:
可以明显的看到,T1时刻,session A说了,id = 5的行都要锁住。然而,T2时刻 sessionB 更新 id =0 的行,d值为5,此时该行没被锁住。同样的,T4时刻session C插入 id = 1的行,d值为5,该行也没被锁住。
2.数据一致性问题
锁的设计是为了保证数据的一致性:这个一致性,包括数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。
为了说明这个问题,再给 session A 增加一个更新语句,如下图:
分析下执行结果:
数据库:
- 经过 T1 时刻,id=5 这一行变成 (5,5,100),当然这个结果最终是在 T6 时刻正式提交的 ;
- 经过 T2 时刻,id=0 这一行变成 (0,5,5);
- 经过 T4 时刻,表里面多了一行 (1,5,5);
- 其他行跟这个执行序列无关,保持不变。
这样看,数据没啥问题,但是我们再来看看这时候 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; /*session B: (0,0,5)*/
update t set c=5 where id=0; /*session B: (0,5,5)*/
insert into t values(1,1,5); /*session C: (1,1,5)*/
update t set c=5 where id=1; /*session C: (1,5,5)*/
update t set d=100 where d=5;/* session A: 所有 d=5 的行,d 改成 100 。数据库里,这个语句应该是在最前面。binlog里放到了最后面 */
现在问题出现了,数据和日志在逻辑上不一致:数据库执行SQL语句的序列,和binlog语句的序列不一样。此时如果用binlog来克隆库的话,会变成 (0,5,100)、(1,5,100) 和 (5,5,100),显然和数据库里面的数据不一致。
那么,上述不一致是怎么引入的呢?就是我们假设 for update 只给 id = 5 这一行加锁引起的,所以这个假设有问题,也就是不能这么玩。
那么上述假设有问题,如何改正?先说结论,不仅要全部扫描过的行加上写锁,由于幻读的问题,还要加上间隙锁。
(1)全部加上锁只能锁住更新的行:扫描过程中碰到的行,全部都加上写锁
如下图:
上述可以看到,session B的更新操作,被锁阻塞住了。
binlog的执行顺序:
insert into t values(1,1,5); /*session C: (1,1,5)*/
update t set c=5 where id=1; /*session C: (1,5,5)*/
update t set d=100 where d=5;/* session A: 所有 d=5 的行,d 改成 100*/
update t set d=5 where id=0; /*session B: (0,0,5)*/
update t set c=5 where id=0; /*session B: (0,5,5)*/
可以看到,session B的更新操作,binlog和数据库已经一致了。但是session C还是不一致。原因还是幻读的问题,虽然我们在session A已经很“凶残”的把所有行都加上了锁,但是此时session C的行还不存在的,所以也就没法锁住它。
也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。
(2)插入的行,即幻读问题,还是要通过其他方式解决
下面看下,InnoDB 怎么解决幻读的问题
3.如何解决幻读问题?
前文幻读产生问题的原因:行锁只能锁住已有行的更新,新插入的行,要更新的是行间的“间隙”,没法锁住。
InnDB如何解决幻读产生的问题:引入新的锁,间隙锁(Gap lock)。
1.间隙锁
间隙锁:锁住两条记录间的间隙。例如本篇开头的表,6条记录,产生7个间隙,如下图:
注意一点:主键索引和其他索引上都有间隙锁。
此时执行 select * from t where d=5 for update
,实际上是加了6个行锁,7个间隙锁。
2.间隙锁和行锁的区别
跟行锁有冲突关系的,是“另外一个行锁”,如下图:
跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。不太好理解,以下举例,如下图:
这里 session B 并不会被堵住。因为表 t 里并没有 c=7 这个记录,因此 *session A 加的是间隙锁 (5,10)*。而 session B 也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。
3.next-key lock
next-key lock:间隙锁和行锁合称 next-key lock。每个 next-key lock 是前开后闭区间。
例如,select * from t for update
把整个表所有记录锁起来,形成了7个 next-key lock: (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。。
实现上,InnoDB 给每个索引加了一个不存在的最大值 supremum。另外,本篇中,间隙锁记为开区间
4.引入间隙锁带来的死锁问题
带来了死锁问题。核心的原因是间隙锁之间都不存在冲突关系,因此可以同时获取到。此时大家都去插入数据,就同时等待对方释放。
假设有以下业务逻辑:任意锁住一行,如果这一行不存在的话就插入,如果存在就更新它的数据,代码如下:
begin;
select * from t where id=N for update;
/* 如果行不存在 */
insert into t values(N,N,N);
/* 如果行存在 */
update t set d=N where id=N;
commit;
可以用insert … on duplicate key update
,但是有多个唯一键时,该方法不满足需求。后续会说。
以上逻辑,遇到的问题是,一旦有并发,就会碰到死锁。已经有 for update 了,为啥还会死锁呢?
以下用两个session模拟分析,见下图:
可以看到,session A 和 session B都拿到了间隙锁,但是都想插入一条数据,即都在等待对方释放间隙锁,就造成了死锁。
当然,InnoDB 的死锁检测马上就发现了这对死锁关系,让 session A 的 insert 语句报错返回了。
也就是说:间隙锁的引入导致的问题:可能会导致同样的语句锁住更大的范围,这影响了并发度。
Innob只是在当前读的情况下,加间隙锁,解决了幻读问题。
补充:
Innob通过“MVCC + 行锁 + 间隙锁”的机制解决了幻读问题,保证了事务A select之后,其他事务相应的insert操作会阻塞。
(MVCC其实就是无锁?)
RR到底有没有解决幻读?RR级别下:
- 如果事务中都使用快照读,那么就不会产生幻读现象。
- 如果事务中都使用当前读,那么就通过临键锁(即)解决了幻读。
- 但是快照读和当前读混用就会产生幻读。因为虽然RR读不会受到其他事务update、insert的影响,但是自己执行了update就会把其他事务insert的数据更新成自己的版本号,下一次读取就会读到了
当前读与快照读:
- 单条普通的select语句属于快照读
- select for update , insert, update, delete 属于当前读
- 快照读由mvcc+undolog实现
- 当前读由行锁+间隙锁实现
其他总结:
- 记录锁、间隙锁、临键锁,都属于排它锁;临键锁 = 记录锁 + 间隙锁
- 记录锁就是锁住一行记录;
- 间隙锁只有在事务隔离级别 RR 中才会产生;事务级别是RC(读已提交)级别的话,间隙锁将会失效。
- 唯一索引只有锁住多条记录或者一条不存在的记录的时候,才会产生间隙锁,指定给某条存在的记录加锁的时候,只会加记录锁,不会产生间隙锁;
- 普通索引不管是锁住单条,还是多条记录,都会产生间隙锁;
- 间隙锁会封锁该条记录相邻两个键之间的空白区域,防止其它事务在这个区域内插入、修改、删除数据,这是为了防止出现 幻读 现象;
- 普通索引的间隙,优先以普通索引排序,然后再根据主键索引排序(多普通索引情况还未研究);
4.其他不解决幻读的方式:读已提交 + binlog_format=row
最佳实践:为了解决幻读问题,引入间隙锁,但是也带来了死锁的问题。如果无需解决幻读,那么直接用读已提交,不过要解决日志不一致问题:
- 可重复读 + 间隙锁:会引起死锁。使用间隙锁,间隙锁在该级别下才生效。读提交隔离级别下,在语句执行完成后,是只有行锁的。而且语句执行完成后,InnoDB 就会把不满足条件的行行锁去掉。
- 读已提交 + row格式 binlog:在语句执行完成后,只有行锁没有间隙锁。而且语句执行完成后,InnoDB 就会把不满足条件的行行锁去掉。但是要解决binlog日志和数据一致性问题。
即,在业务不需要 RR 支持下,如果想提高并发率,可以将隔离级别设置成 RC 并将 binlog 格式设置成 row。好多公司也是这么用的,但是要具体问题具体分析。
使用读已提交会有什么问题:
- 逻辑备份的时候,mysqldump 为什么要把备份线程设置成可重复读。
- 备份期间,备份线程用的是可重复读,而业务线程用的是读提交。同时存在两种事务隔离级别,会不会有问题?
- 两个不同的隔离级别现象有什么不一样的,关于我们的业务,“用读提交就够了”这个结论是怎么得到的?
要注意什么?用间隙锁:可重复读。不用间隙锁:读已提交+binlog_format=row
提问
幻读是什么?
幻读引起了什么问题?举例?语义问题,明明说锁,还是锁不住。数据一致性问题。
如何解决幻读问题?这种解决方式有什么问题?可重复读的情景下,会有幻读,间隙锁在该场景下才生效。
你们公司MySQL用什么隔离级别?要注意什么?用间隙锁:可重复读。不用间隙锁:读已提交+binlog_format=row
读已提交和可重复读有什么区别?
21.为什么我只改一行的语句,锁这么多?(暂略待补充)
本篇主要讲加锁规则,间隙锁和行锁的加锁规则。
0.加锁规则
规则只适用于当前版本:MySQL 后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即 5.x 系列 <=5.7.24,8.0 系列 <=8.0.13。
间隙锁在可重复读隔离级别下才有效,所以本篇文章,默认是可重复读隔离级别。
加锁规则,包含了两个“原则”、两个“优化”和一个“bug”:
- 原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1:唯一索引、等值查询,所有的 next-key lock 都退化为行锁、不加间隙锁。
- 优化 2:普通索引、等值查询,向右遍历若最后一个值不等时,该 next-key lock 退化为间隙锁。
- 1个 bug:唯一索引、范围查询,加锁时会一直访问到不满足条件的第一个值为止。
以下进行举例,表结构如下,初始时共5条记录:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
以下是案例,可能执行预期会很不一样。结合图文理解。
1.案例一:等值查询间隙锁
提问
可重复读隔离级别下,InnoDB加锁的规则?
22.MySQL有哪些“饮鸩止渴”提高性能的方法?
业务场景:生产环境,业务高峰期,需要临时提升MySQL性能。
方案肯定都不是无损的。
1.短连接风暴
正常的短连接:连接到DB,执行很少的SQL,然后立马断开。
业务高峰期,连接数可能会暴涨。
连接的代价是比较高的:除了正常的网络连接三次握手,还需要做登录权限判断和获得这个连接的数据读写权限。
max_connections
参数:控制一个 MySQL 实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。
以下是几个提升性能的方法,当然,都是有损的。
1.调高连接参数
调高 max_connections
的值:设计 max_connections 这个参数的目的是想保护 MySQL,该参数太大,可能反而适得其反。因为更多的连接进来,系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,已经连接的线程反而拿不到 CPU 资源去执行业务的 SQL 请求。
所以一般我们不这么干。
2.方法一:干掉占着连接但是不工作的线程
对于不需要保持的连接,我们可以通过 kill connection
主动踢掉。
这个和 wait_timeout
参数效果一样,它的意思是一个线程空闲 wait_timeout
这么多秒之后,就会被 MySQL 直接断开连接。
(1)应该优先断开空闲的连接
需要注意,在 show processlist 的结果里,踢掉显示为 sleep 的线程,可能是有损的。
如下图:
断开还未提交的连接sessionA,MySQL只能回滚事务。所以我们应该优先断开像session B 这中空闲连接。
(2)如何判断空闲连接
如何判断空闲连接呢?
show processlist
查看sleep的连接,如图:
事务具体的状态,查询 information_schema
库的 nnodb_trx
表,如图:
上述trx_mysql_thread_id=4
,表示 id=4 的线程还处在事务中。
怎么干掉一个连接:用 kill connection + id
的命令去干掉sleep的连接即可。优先干掉trx_mysql_thread_id=4
的。
客户端不会马上发现:客户端处于 sleep 状态时,连接被断开后,它要到下一次请求才能发觉。
从DB端断开连接可能有损:有的应用端收到这个错误后,不重新连接,而还是用这个被干掉用的句柄重试查询。
3.方法二:减少连接过程的消耗
其实也就是跳过权限验证阶段。
怎么跳过权限验证:重启数据库,并使用–skip-grant-tables
参数启动。这样,整个 MySQL 会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。
这种方法风险太高,不建议使用,特别是外网可访问的服务器。
处理风险:MySQL 8.0时,如果启用–skip-grant-tables
参数,MySQL 会默认把 --skip-networking
参数打开,表示这时候数据库只能被本地的客户端连接。
除了连接数超标,性能问题还有慢查询、SPQ突增等场景。
2.慢查询性能问题
MySQL中,性能问题的慢查询,大概由以下三种:
- 索引没有设计好
- SQL 语句没写好
- MySQL 选错了索引
1.索引没有设计好
解决方法:紧急创建索引:一般就是通过紧急创建索引来解决。MySQL 5.6 版本以后,创建索引都支持 Online DDL 了。
应该先在备库执行,先关掉binlog,然后修改索引即可。大体流程:
- 备库执行:在备库 B 上执行
set sql_log_bin=off
,也就是不写 binlog,然后执行 alter table 语句加上索引; - 主备切换:执行主备切换;
- 原主库执行:这时候主库是 B,备库是 A。在 A 上执行 set sql_log_bin=off,然后执行 alter table 语句加上索引。
线上紧急处理可以按上述方案。日常应该考虑 ghost 方案。
2.语句没写好
典型的例子就是第18篇的例子,导致语句没用上索引。
解决方法:线上改写 SQL 语句即可:MySQL 5.7 提供了 query_rewrite
功能,可以把输入的一种语句改写成另外一种模式。
具体执行方法,以改写select * from t where id + 1 = 10000
为例:
mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values ("select * from t where id + 1 = ?", "select * from t where id = ? - 1", "db1");
-- 这里这个存储过程,是让插入的新规则生效,也就是我们说的“查询重写”。
call query_rewrite.flush_rewrite_rules();
验证改写成功,直接show warnmings
即可,如下图验证:
3.MySQL 选错了索引
具体见第10篇文章的例子。
解决方法:修改SQL语句,给这个语句加上 force index
即可。
方法同上,使用查询重写。
4.最好的方法是防范于未然
上述3中情况,1、2最常见。恰恰可以通过上线前的操作,预先发现问题:
- 慢查询调整为记录所有查询信息:上线前,在测试环境,把慢查询日志(slow log)打开,并且把 long_query_time 设置成 0,确保每个语句都会被记录入慢查询日志;
- 做回归测试:在测试表里插入模拟线上的数据,做一遍回归测试;
- 通过慢查询分析语句是否有问题:观察慢查询日志里每类语句的输出,特别留意慢查询日志的 Rows_examined 字段是否与预期一致。即扫描行数。(我们在前面文章中已经多次用到过 Rows_examined 方法了,相信你已经动手尝试过了。如果还有不明白的,欢迎给我留言,我们一起讨论)。
如果是修改了原有项目的 表结构设计,全量回归测试都是必要的。这时候,可以使用工具帮检查所有的 SQL 语句的返回结果。比如,开源工具 pt-query-digest(https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html)。
3.QPS 突增问题
业务场景:突然出现业务高峰,或者Bug导致某个语句QPS疯涨。
解决方法:直接把业务下掉。如何在DB端干掉一个功能呢:
- 全新的业务,根据白名单加入:直接把该业务的白名单干掉。
- 新功能使用的是单独的数据库用户:直接把这个用户删掉,然后断开现有连接。
- 新增的功能跟主体功能是部署在一起:通过处理语句来限制。直接用查询重写改写,把压力最大的 SQL 语句直接重写成”select 1”返回。
上述第3个操作风险很高,要特别注意,不到最后绝对不用:
- 会误伤:如果别的功能里面也用到了这个 SQL 语句模板,会有误伤;
- 会连坐:很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以 select 1 的结果返回的话,可能会导致后面的业务逻辑一起失败。
方案 1 和 2 更稳妥,但是都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离。
这篇文章中我提到的解决方法主要集中在 server 层。在下一篇文章中,我会继续和你讨论一些跟 InnoDB 有关的处理方法。
提问
线上如何短时间提升短连接数性能?
如何解决线上慢查询问题?
突发QPS怎么解决?
你是否碰到过,在业务高峰期需要临时救火的场景?你又是怎么处理的呢?
1.磁盘空间爆满。处理方法是把最老的binlog移动到别的盘(如果确定日志已经备份到备份系统了就删掉)强制重启还是有点伤的,不过核心还是做好监控,不让出现磁盘100%写满的情况。
2.一般情况都是慢sql 语句没有使用索引,我们所有线上的数据库,全部部署了实时kill 脚本,针对查询语句全部进行一个阀值的制定,例如是5秒,超过以后自动kill,这样会保证线上的稳定。 二就是在测试环境严格把控没有使用索引的语句。
23.MySQL是怎么保证数据不丢的?
本篇继续介绍在业务高峰期临时提升性能的方法。
在专栏的第 2 篇和第 15 篇文章中,介绍的是,如果 redo log 和 binlog 是完整的,MySQL 是如何保证 crash-safe 的。今天这篇文章,我着重和你介绍的是 MySQL 是“怎么保证 redo log 和 binlog 是完整的”。
今天,我们就再一起看看 MySQL 写入 binlog 和 redo log 的流程。
1.binlog 的写入机制
binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。
事务的 binlog 是不能拆开:不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题。
如何一次性写入binlog cache :
- 每个线程一个binlog cache:系统给 binlog cache 分配了一片内存,每个线程一个,参数
binlog_cache_size
用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。 - 提交时 binlog cache 一次完整写入 binlog:事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。状态如图所示。
如上图。每个线程有自己 binlog cache,但是共用同一份 binlog 文件:
- write:把日志写入到文件系统的 page cache,此时并没有把数据持久化到磁盘,所以速度比较快。
- fsync:数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。
data -> binlog cache -> page cache -> 磁盘。
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 中的某个数值。对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。
page cache:CPU如果要访问外部磁盘上的文件,需要首先将这些文件的内容拷贝到内存中,由于硬件的限制,从磁盘到内存的数据传输速度是很慢的,如果现在物理内存有空余,干嘛不用这些空闲内存来缓存一些磁盘的文件内容呢,这部分用作缓存磁盘文件的内存就叫做page cache。
2.redo log 的写入机制
1.redo log的写入机制
redo log 没提交之前是写到 redo log buffer:事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的,到commit的时候才会一次性写入日志。
redo log buffer的内容会丢: 里面的内容,不是每次生成后都要直接持久化到磁盘,如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。但由于事务并没有提交,所以这时日志丢了也不会有损失。
事务还没提交的时候,redo log buffer 中的部分日志也可能会被持久化到磁盘。
以下做分析:
redo log 可能存在三种状态,如下图:
三个状态分别是:
- 红色:redo log存在 redo log buffer 中,物理上是在 MySQL 的进程内存中。
- 黄色:redo log写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
- 绿色:持久化到磁盘,对应的是 hard disk。
上图中,日志写到 redo log buffer 和 page cache都很快,但是持久化到磁盘的速度就慢多了。
InnoDB 提供了 *innodb_flush_log_at_trx_commit
参数来规定 redo log 的写入策略*,它有3种可能取值:
- 值为0:每次事务提交时都只是把 redo log 留在 redo log buffer 中。
- 值为1:每次事务提交时都将所有redo log 直接持久化到磁盘。
- 值为2:每次事务提交时都只是把 redo log 写到 page cache。
InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。
data -> redo log buffer -> page cache -> 磁盘。
事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。
也就是说不管事务有没有提交,redo log完整不完整,它都可能在内存redo log buffer 中,也可能在page cache中,还可能存在磁盘中,都是不确定的。
可以将一个没有提交的事务的 redo log 写入到磁盘的3个场景:
- 后台线程直接写到磁盘:事务执行中间过程的 redo log 直接写在 redo log buffer,之后后台线程每秒一次的轮询,将redo log buffer 写到磁盘。
- buffer空间到一半暂时写到page cache:redo log buffer 占用的空间即将达到
innodb_log_buffer_size
参数 一半,后台线程会主动写盘。此时只write,不fsync,即不持久化到磁盘。 - 写到一半,被其他事务代为持久化到磁盘:并行的事务提交的时候,顺带将这个事务的 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和binlog执行顺序:
- 介绍两阶段提交的时候说过,时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。
- 如果把
innodb_flush_log_at_trx_commit
参数设置成 1,那么 redo log 在 prepare 阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。(参见第15篇)
每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,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是2w/s,那么此时如果是双1设置的话,就会写4w次磁盘。但是其实用工具测,是2万次,为什么呢?这涉及到了组提交(group commit)机制。
2.组提交机制
先补充第15篇文章。在从本篇最开始的地方开始。
日志逻辑序列号(log sequence number,LSN):LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。
如下,如果三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,此时3个事务都已经写完了redo log buffer,那么持久化到磁盘时,哪个事务先到就把他选为leader,然后一次顺带把所有准备好的事务持久化,LSN则增长到最后面,如下图:
可以看到:
- trx1 第一个到达,被选为这组的 leader;
- 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
- trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
- 这时候 trx2 和 trx3 就可以直接返回了
也就是说,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。
那么我们可以想到,其实一次能带的组员越多,越能节约IOPS。
3.binlog 和redo log利用组提交机制节省IOPS
针对组提交机制,MySQL进行了一个有意思的优化,就是“拖时间”。每次多等几个事务到prepare,再fsync。
前面说的两阶段提交,如下图:
图中的“写 binlog”是一个动作,其实实际上是两个动作:
- 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
- 调用 fsync 持久化。
实际上,MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。两阶段提交由上图变成了下图:
这么一来,binlog 也可以组提交了。图 5 中第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。(因为插入了3,而让2可以等一下再执行4?)
不过一般第3步执行得很快,所以binlog的组提交效果不明显。
想提升binlog的组提交效果,可以修改参数binlog_group_commit_sync_delay
和 binlog_group_commit_sync_no_delay_count
来实现:
binlog_group_commit_sync_delay
参数:表示延迟多少微秒后才调用 fsync;binlog_group_commit_sync_no_delay_count
参数:表示累积多少次以后才调用 fsync。
两个参数是或的关系。所以,当 binlog_group_commit_sync_delay
设置为 0 时,binlog_group_commit_sync_no_delay_count
也无效了。
4.为什么说WAL 机制可以减少磁盘写
我们在想,WAL 机制每次提交事务都要写 redo log 和 binlog,这磁盘读写次数也没变少呀?
其实通过前文的分析,知道了,WAL 机制通过以下来优化:
- 顺序写:redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
- 组提交机制:可以大幅度降低磁盘的 IOPS 消耗。
3.瓶颈在 IO 上,可以通过哪些方法来提升性能
考虑以下三种方法:
- 组提交额外等待:设置
binlog_group_commit_sync_delay
和binlog_group_commit_sync_no_delay_count
参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。 - binlog延迟持久到磁盘:将
sync_binlog
设置为大于 1 的值(比较常见是 100~1000)。每次提交事务都 write,但累积 N 个事务后再 fsync。这样做的风险是,主机掉电时会丢 binlog 日志。 - redo log 都只写到page cache:将
innodb_flush_log_at_trx_commit
设置为 2。每次事务的 redo log 都只写到page cache,由后台线程去fsync。这样做的风险是,主机掉电的时候会丢数据。
不建议把 innodb_flush_log_at_trx_commit 设置成 0。redo log 只保存在内存,MySQL重启就会丢数据,风险太大。因为redo log 写到page cache的速度也非常快,所以直接设置成2。
小节
redolog 和 binlog的写入机制,详细研究第2、15,以及本篇文章。
实际上数据库的 crash-safe 保证的是:
- 如果客户端收到事务成功的消息,事务就一定持久化了;
- 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
- 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。
个人总结,两种日志方法异同:
- binlog:
- 每个线程单独维护 binlogcache(因为写入不能打断)
- 每次执行完一个完整事务提交。
- data -> binlog cache -> page cache -> 磁盘
- redo_log:
- 大家共用redo_log_buffer,
- 随时可以写到page cathe或磁盘,且事务间可以搭便车。
- data -> redo log buffer -> page cache -> 磁盘
- 此外redo_log的两阶段提交,只有第一阶段保证sync,第二阶段只保证写到redo_buffer即可。
- 此外,redo_log 和 binlog 都有共用的 XID,用来保持一致。
第15篇一些日志问题补充:
问题 1:执行一个 update 语句以后,我再去执行 hexdump 命令直接查看 ibd 文件内容,为什么没有看到数据有改变呢?
回答:这可能是因为 WAL 机制的原因。update 语句执行完成后,InnoDB 只保证写完了 redo log、内存,可能还没来得及将数据写到磁盘。
问题 2:为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?
回答:MySQL 这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。
而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。
问题 3:事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?
回答:不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。
问题 4:如果 binlog 写完盘以后发生 crash,这时候还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是 bug?
回答:不是。
你可以设想一下更极端的情况,整个事务都提交成功了,redo log commit 完成了,备库也收到 binlog 并执行了。但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到“网络断开”的异常。这种也只能算是事务成功的,不能认为是 bug。
提问
binlog的整体写入机制?
redo log的整体写入机制?
binlog和redo log的完整写入机制?包括中途cash怎么办?参照第15篇和本篇文章
如果 redo log 和 binlog 是完整的,MySQL 是如何保证 crash-safe 的?
MySQL 是“怎么保证 redo log 和 binlog 是完整的?
什么是组提交机制?
生产是设置为双1吗?为什么?风险?有调到非双1的时候,在大促时非核心库和从库延迟较多的情况。
==============
什么时候会把线上生产库设置成“非双 1”。我目前知道的场景,有以下这些:
- 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。
- 备库延迟,为了让备库尽快赶上主库。@永恒记忆和 @Second Sight 提到了这个场景。
- 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似。
- 批量导入数据的时候。
一般情况下,把生产库改成“非双 1”配置,是设置 innodb_flush_logs_at_trx_commit=2、sync_binlog=1000。
==============
有调到非双1的时候,在大促时非核心库和从库延迟较多的情况。
设置的是sync_binlog=0和innodb_flush_log_at_trx_commit=2
针对0和2,在mysql crash时不会出现异常,在主机挂了时,会有几种风险:
1.如果事务的binlog和redo log都还未fsync,则该事务数据丢失
2.如果事务binlog fsync成功,redo log未fsync,则该事务数据丢失。
虽然binlog落盘成功,但是binlog没有恢复redo log的能力,所以redo log不能恢复.
=========
主从模式下,内网从库如果设置双1,刚还原的数据发现根本追不上主库,所以从库设置了0,
24.MySQL是怎么保证主备一致的?
这篇文章主要介绍主备的基本原理。
虽然MySQL的高可用架构越来越复杂的趋势,但都是从最基本的一主一备演化过来的。
MySQL几乎所有的高可用架构,都直接依赖于 binlog。
binlog 可以用来归档,也可以用来做主备同步,但它的内容是什么样的呢?为什么备库执行了 binlog 就可以跟主库保持一致了呢?今天就介绍一下。
1.MySQL 主备的基本原理
基本的主备切换流程如下图:
A是主库,B是备库。B点把A点的数据同步过来,没有写操作。
状态1是正常状态,状态2主从是切换之后的状态。
建议把备库设置为只读模式:
- 防止误操作:有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
- 防止切换时Bug导致主从不一致:防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致;
- 可根据状态判断角色:可以用 readonly 状态,来判断节点的角色。
无需担心只读模式限制主从同步:readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程,就拥有超级权限。所以不用担心会有限制。
主从节点同步内部流程,如下图:
一个 update 语句在节点 A 执行,然后同步到节点 B 的完整流程:
上图可以看到,主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写 binlog。
备库 B 跟主库 A 之间维持了一个长连接。主库 A 内部有一个线程,专门用于服务备库 B 的这个长连接。一个事务日志同步的完整过程如下的:
- 在备份库上设置请求参数:在备库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。
- 在备库 B 上执行 start slave 命令:这时候备库会启动两个线程,就是图中的
io_thread
和sql_thread
。其中 io_thread 负责与主库建立连接。 - 主库 A发送binlog:主库 校验完用户名、密码后,开始按照备库 B 传过来的位置,从本地读取 binlog,发给 B。
- 备库 B 写到中转日志:拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
- 备库 B 解析中转日志并执行:sql_thread 读取中转日志,解析出日志里的命令,并执行。
下面我们来看下,binlog里面的内容是什么。
2.binlog 的三种格式对比
binlog有两/三种格式:statement、row、mixed(前面两种的混合)。
以下举例说明三种日志区别:
假设有下面的表:
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `t_modified`(`t_modified`)
) ENGINE=InnoDB;
insert into t values(1,1,'2018-11-13');
insert into t values(2,2,'2018-11-12');
insert into t values(3,3,'2018-11-11');
insert into t values(4,4,'2018-11-10');
insert into t values(5,5,'2018-11-09');
如果要在表里删除一行数据,看下binlog怎么记录delete语句:
mysql> delete from t /*comment*/ where a>=4 and t_modified<='2018-11-10' limit 1;
1.statement格式:生产中几乎不用,可能有歧义
binlog_format=statement
时,binlog 里面记录的就是 SQL 语句的原文:
查看binlog命令:
mysql> show binlog events in 'master.000001';
binlog内容如下图:
分析binlog内容:
- 第一行:
SET @@SESSION.GTID_NEXT='ANONYMOUS’
,先忽略,后面主备切换的时候再提; - 第二行: BEGIN,跟第四行的 commit 对应,事务的开始;
- 第三行:真实执行的语句。可以看到,在真实执行的 delete 命令之前,还有一个“use ‘test’”命令,表示用这张表。这条命令是 MySQL 根据当前要操作的表所在的数据库,自行添加的。这样可以保证日志传到备库去执行的时候,不论当前的工作线程在哪个库里,都能够正确地更新到 test 库的表 t。
use ‘test’命令之后的 delete 语句,是我们输入的 SQL 原文。可以看到,binlog“忠实”地记录了 SQL 命令,甚至连注释也一并记录了。 - 最后一行:一个 COMMIT。可以看到里面写着 xid=61。说明事务执行成功了。具体可见第15篇。
delete 命令的执行后的效果图:
看到产生了一个warning。原因是 binlog 设置 statement 格式时,语句中有 limit,这个命令可能是 unsafe 的。
因为delete 带 limit,很可能会出现主备数据不一致的情况:
- 如果 delete 语句使用的是索引 a,那么根据索引 a 找到第一个满足条件的行,删除的是 a=4 这一行;
- 如果使用的是索引 t_modified,那么删除的就是 t_modified=’2018-11-09’,也就是 a=5 这一行。
在主库和备库执行相同的语句,有可能走不同的索引,所以语句中有limit,是unsafe的。
2.row格式:生产中用的比较普遍,但占用空间大
上述语句,如果binlog_format=‘row’
。
binlog内容,如图:
和前面的statement对比:
- 前后的 BEGIN 和 COMMIT 是一样的。
- SQL 语句的原文,替换成了两个 event:
- Table_map event,用于说明接下来要操作的表是 test 库的表 t;
- Delete_rows event,用于定义删除的行为。
row格式详细的信息,需要借助 mysqlbinlog 工具查看。图5中看到,binlog从8900开始,所以执行查看命令:
mysqlbinlog -vv data/master.000001 --start-position=8900;
binlog内容详细信息,如下图:
分析上述信息:
- server id 1:表示该事务在 server_id=1 的这个库上执行。
- 每个 event 都有校验码: CRC32 的值,这是因为我把参数
binlog_checksum
设置成了 CRC32。 - Table_map event 跟在图 5 中看到的相同,显示了接下来要打开的表,map 到数字 226。现在我们这条 SQL 语句只操作了一张表,如果要操作多张表呢?每个表都有一个对应的 Table_map event、都会 map 到一个单独的数字,用于区分对不同表的操作。
- 我们在 mysqlbinlog 的命令中,使用了 -vv 参数是为了把内容都解析出来,所以从结果里面可以看到各个字段的值(比如,@1=4、 @2=4 这些值)。
binlog_row_image
的默认配置是 FULL,因此 Delete_event 里面,包含了删掉的行的所有字段的值。如果把binlog_row_image
设置为 MINIMAL,则只会记录必要的信息,在这个例子里,就是只会记录 id=4 这个信息。- 最后的 Xid event:用于表示事务被正确地提交了。
可以看到,row 格式的binlog 里面记录了真实删除行的主键 id,这样 binlog 传到备库去的时候,就肯定会删除 id=4 的行,不会有主备删除不同行的问题。
3.mixed 格式的 binlog
为什么会有 mixed:
- statement 格式的 binlog 可能会导致主备不一致,所以要使用 row 格式。
- row 格式的缺点是占空间太大。加入一个delete语句删除10w行数据,statement就是记下语句,而row是要把10万条记录都写到日志。
- 所以MySQL提供了mixed格式:MySQL 自己会判断这条 SQL 语句是否可能引起主备不一致,如果有可能,就用 row 格式,否则就用 statement 格式。
但是现在很多场景要求把 MySQL 的 binlog 格式设置成 row,是为了便于恢复数据。
4.如何用row格式恢复数据
row格式恢复数据很方便,举例:
- delete:执行完一条delete语句,发现删错数据,可以直接把binlog 中记录的 delete 语句转成 insert即可。
- insert:row 格式下,insert 语句的 binlog 里会记录所有的字段信息。误操作时,直接把insert改成delete就OK。
- update:row 格式下,binlog 里面会记录修改前整行的数据和修改后的整行数据。误操作时,只需要把这个 event 前后的两行信息对调一下,在回去数据库执行即可。
MariaDB 的Flashback工具就是基于上面介绍的原理来回滚数据的。
数据恢复的标准做法是用工具:
用 binlog 来恢复数据的标准做法是,用 mysqlbinlog 工具解析出来,然后把解析结果整个发给 MySQL 执行。类似下面的命令:
## 将 master.000001 文件里面从第 2738 字节到第 2973 字节中间这段内容解析出来,放到 MySQL 去执行。
mysqlbinlog master.000001 --start-position=2738 --stop-position=2973 | mysql -h127.0.0.1 -P13000 -u$user -p$pwd;
手动直接Copy命令有风险:
用 mysqlbinlog 解析出日志,然后把里面的 statement 语句直接拷贝出来执行。有很大的风险。因为语句的执行是和上下文有关的。
举例:
mysql> insert into t values(10,10, now());
主库和备库,执行时,now函数的结果会不一样。所以其实binlog底层,会自动加一个当前时间。而如果直接执行语句的话,就有错误了。
3.循环复制问题
上述说明了,binlog 的特性确保了在备库执行相同的 binlog,可以得到与主库相同的状态。因此,我们可以认为正常情况下主备的数据是一致的。
生产中,双主用的比较多。如下图,其实比起主备,就是多了一条连线。
双主结构:一般说的是互为主备。一般说双M是只AB之间设置为互为主备,不过任何时刻只有一个节点在接受更新的。
双主结构的循环赋值问题:
- 备库执行了主库的binlog:业务逻辑在节点 A 上更新了一条语句,然后再把生成的 binlog 发给节点 B,节点 B 执行完这条更新语句后也会生成 binlog。(建议把参数 log_slave_updates 设置为 on,表示备库执行 relay log 后生成 binlog)。
- 主库又执行了备库执行主库binlog的binlog:如果节点 A 同时是节点 B 的备库,相当于又把节点 B 新生成的 binlog 拿过来执行了一次,然后节点 A 和 B 间,会不断地循环执行这个更新语句,也就是循环复制。
如何解决循环赋值问题,简单说就是给binlog加一个server id来识别过滤:
- 规定两个库的 server id 必须不同:如果相同,则它们之间不能设定为主备关系;
- binlog最初属于谁,备份时就用谁的id生成新的binlog:一个备库接到 主库binlog 并在重放的过程中,生成与原 主库binlog 的 server id 相同的新的 binlog;
- 接收binlog时,过滤掉和自己server id相同的binlog:每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。
小结
本篇内容是所有 MySQL 高可用方案的基础。在这之上演化出了诸如多节点、半同步、MySQL group replication 等相对复杂的方案。
提问
主从模式,需要把从库设为只读吗?为什么?只读会不会阻塞主从同步?
更新一条语句,主备底层流程?
binlog有几种格式?各有什么区别?优缺点?语句在主备可能执行不一致,但是省空间。另一个反之。mixed兼备。
生产环境binlog用什么格式?row格式,误操作时如何恢复数据?用什么工具?能手动copy执行吗?
双M结构,日志循环复制问题怎么解决?
说到循环复制问题的时候,我们说 MySQL 通过判断 server id 的方式,断掉死循环。但是,这个机制其实并不完备,在某些场景下,还是有可能出现死循环。你能构造出一个这样的场景吗?又应该怎么解决呢?
一种场景是,在一个主库更新事务后,用命令 set global server_id=x 修改了 server_id。等日志再传回来的时候,发现 server_id 跟自己的 server_id 不同,就只能执行了。
另一种场景是,有三个节点的时候,trx1 是在节点 B 执行的,因此 binlog 上的 server_id 就是 B,binlog 传给节点 A,然后 A 和 A’搭建了双 M 结构,就会出现循环复制。
如果出现了循环复制,可以在 A 或者 A’上,执行如下命令:
stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=(server_id_of_B);
start slave;
这样这个节点收到日志后就不会再执行。过一段时间后,再执行下面的命令把这个值改回来。
stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=();
start slave;
25.MySQL是怎么保证高可用的?
MySQL提供了主库和从库的最终一致性:只要主库执行更新生成的所有 binlog,都可以传到备库并被正确地执行,备库就能达到跟主库一致的状态。
要保证高可用,最终一致性还不够。以下举例。
下面是上篇中,双主结构的图:
1.主备延迟
1.主备延迟的概念
与数据同步有关的时间点主要包括以下三个:
- 主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;
- 之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2;
- 备库 B 执行完成这个事务,我们把这个时刻记为 T3。
主备延迟:即同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1。
2.查看主备延迟 seconds_behind_master
在备库执行show slave status
命令,返回的结果里面会显示 seconds_behind_master
,就是用来表示当前备库延迟了多少秒。
3.主备延迟的计算方法
- 事务里有主库写入时间:每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;
- 当前时间减去事务主库写入时间即可:备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。
如果主备库机器的系统时间设置不一致,会不会导致主备延迟的值不准呢?
其实不会,因为备库连接到主库的时候,会通过执行 SELECT UNIX_TIMESTAMP()
函数来获得当前主库的系统时间。如果这时候发现主库的系统时间与自己不一致,备库在执行 seconds_behind_master
计算的时候会自动扣掉这个差值。
4.主备延迟的主要原因
- T2 - T1很短:网络正常时,日志从主库传给备库所需的时间是很短的。
- T3 - T2较长:即主备延迟的主要来源是备库接收完 binlog 和执行完这个事务之间的时间差
所以,主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产 binlog 的速度要慢。
那么,为什么会导致主备库这个差距呢?下面分析。
2.主备延迟的来源
1.备库机器性能比主库差
比如备库机器比主库差,又比如或者20台主库部4台机器,而所有备库放到一台机器上。
但是考虑到主备切换,现在一般都是对称部署了。
追问1:不过对称部署之后还是会有延迟,为什么呢?
2.备库的压力大
比如读业务都压到备库上,导致备库压力山大。一般处理如下:
- 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。
- 通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。
备注:从库和备库在概念上其实差不多,为了方便描述,我把会在 HA 过程中被选成新主库的,称为备库,其他的称为从库。
一般使用一主多从,因为从库很合适用来做全量备份。
追问2:一主多从,已经保证备库的压力不会超过主库,还是会有延迟,为什么呢?
3.大事务
主库执行的SQL是一个大事务,比如要执行10分钟。此时备库也会执行10分钟。
- 典型的大事务场景1:批量delete大量存档日志。处理方案:分多批次删除。
- 典型的大事务场景2:大表DDL。处理方案:计划内的 DDL,建议使用 gh-ost 方案。
追问3:如果主库上也不做大事务了,还有什么原因会导致主备延迟?
4.备库的并行复制能力
下一篇介绍。
由于主备延迟的存在,所以在主备切换的时候,就相应的有不同的策略。
3.主动主备切换:可靠性优先策略
在图 1 的双 M 结构下,从状态 1 到状态 2 切换的详细如下:
- 保证主备延迟不是太大:判断备库 B 现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;
- 主库 A 停止写:改成只读状态,即把 readonly 设置为 true;此时相当于整个系统都没有数据写入,全系统只能读不能写。
- 等待备库B追上主库A:等待判断备库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止;
- 备库B改为可以写:把备库 B 改成可读写状态,也就是把 readonly 设置为 false;
- 业务请求切到B:把业务请求切到备库 B,主备切换完成。
这一整个流程,一般是由专门的 HA 系统来完成。如下图:
分析如下:
- 系统有不可读状态:注意到在步骤2之后,主库 A 和备库 B 都处于 readonly 状态,系统不可读,直到步骤5才恢复。
- 最耗时的是步骤3:所以要在步骤1确保 seconds_behind_master 的值足够小.
4.主动主备切换:可用性优先策略
执行策略:直接切库,即直接把前述第4、5步骤提到最前面,那么系统就没有不可用时间了。代价是可能出现主备数据不一致。
以下举例说明:
假设表如下:
mysql> CREATE TABLE `t` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`c` int(11) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(c) values(1),(2),(3);
初始化是3行数据,接下来业务上直接插入两行数据:
insert into t(c) values(4);
insert into t(c) values(5);
假设,现在主库上其他的数据表有大量的更新,导致主备延迟达到 5 秒。在插入一条 c=4 的语句后,发起了主备切换。
1.可用性优先策略:binlog_format=mixed
以下是binlog_format=mixed
时的主备切换:
流程图中很明显:前后插入的A、B两条记录,在主库中是A、B顺序插入,主从切换同步后,在原备库中成了B、A顺序插入,从而产生了数据不一致:
- 步骤 2 中,主库 A 执行完 insert 语句,插入了一行数据(4,4),之后开始进行主备切换。
- 步骤 3 中,由于主备之间有 5 秒的延迟,所以备库 B 还没来得及应用“插入 c=4”这个中转日志,就开始接收客户端“插入 c=5”的命令。
- 步骤 4 中,备库 B 插入了一行数据(4,5),并且把这个 binlog 发给主库 A。
- 步骤 5 中,备库 B 执行“插入 c=4”这个中转日志,插入了一行数据(5,4)。而直接在备库 B 执行的“插入 c=5”这个语句,传到主库 A,就插入了一行新数据(5,5)。
2.可用性优先策略:binlog_format=row
因为 row 格式在记录 binlog 的时候,会记录新插入的行的所有字段值,所以最后只会有一行不一致。
而且,两边的主备同步的应用线程会报错 duplicate key error 并停止。也就是说,这种情况下,备库 B 的 (5,4) 和主库 A 的 (5,5) 这两行数据,都不会被对方执行。
如下图:
综上,从上面的分析中,可以看到一些结论:
- 使用 row 格式的 binlog 时,数据不一致的问题更容易被发现。而使用 mixed 或者 statement 格式的 binlog 时,数据很可能悄悄地就不一致了。
- 主备切换的可用性优先策略会导致数据不一致。因此,大多数情况下,我都建议你使用可靠性优先策略。毕竟对数据服务来说的话,数据的可靠性一般还是要优于可用性的。
3.可以使用可用性优先策略的情况
比如记录日志的库,必须保证可用,否则系统无法运行。
此时可以用可用性优先,事后根据binlog修复日志。
当然也可以用可靠性优先,DB不可用时,把日志暂时存放到其他文件或临时表,最后切换完成后再统一恢复即可。
5.异常主备切换:可靠性优先策略
假设,主库 A 和备库 B 间的主备延迟是 30 分钟,此时主库 A 掉电了,HA 系统要切换 B 作为主库。
本来我们在主动切换的时候,可以等到主备延迟小于 5 秒的时候再启动切换,但这时候已经别无选择了。
如下图:
切换过程:
- 一定是备库B一直保持只读,直到SBM=0,才能切到备库变为可写。等待的这段时间,整个系统只能读不能写。
- 不能直接切换到备库 B,但是保持 B 只读:因为,这段时间内,中转日志还没有应用完成,如果直接发起主备切换,客户端查询看不到之前执行完成的事务,会认为有“数据丢失”。虽然随着中转日志的继续应用,这些数据会恢复回来,但是对于一些业务来说,查询到“暂时丢失数据的状态”也是不能被接受的。
综上,在满足数据可靠性的前提下,MySQL 高可用系统的可用性,是依赖于主备延迟的。延迟的时间越小,在主库故障的时候,服务恢复需要的时间就越短,可用性就越高。
提问
什么是主备延迟?是怎么计算出来的?主备库时间不一致,算出来的延迟准吗?
主备延迟主要的延迟来源?是什么导致的?各怎么处理?备库消费比主库生产慢。硬件…
主备切换有哪些切换策略?各有什么特点?如何抉择?
发生异常导致主备切换会是什么效果?比如主库直接掉电?
最后,我给你留下一个思考题吧。
一般现在的数据库运维系统都有备库延迟监控,其实就是在备库上执行 show slave status,采集 seconds_behind_master 的值。假设,现在你看到你维护的一个备库,它的延迟监控的图像,是一个 45°斜向上的线段,你觉得可能是什么原因导致呢?你又会怎么去确认这个原因呢?备库的同步在这段时间完全被堵住了。产生这种现象典型的场景主要包括两种:一种是大事务(包括大表 DDL、一个事务操作很多行)。还有一种情况比较隐蔽,就是备库起了一个长事务,然后就不动了。这时候主库对表 t 做了一个加字段操作,即使这个表很小,这个 DDL 在备库应用的时候也会被堵住,也不能看到这个现象。
26.备库为什么会延迟好几个小时?
上一篇介绍了几种可能导致备库延迟的原因,一般都是临时偶发的,大事务之类的,本库延迟是分钟级的。
还有一种情况,就是备库一直追不上主库,甚至可能会延迟几小时。这里面更多的就是性能原因了。本篇我们来看下。
本篇的主题是,主备的并行复制能力。
1.备库长期延迟原因
先来复习下第24篇的主备流程图。如下图:
从图中可以看到,主要是关注两个黑色箭头,一生产一消费:
- 客户端写入主库
- 备库上 sql_thread 执行中转日志(relay log)
很明显,备库能否追上主库,取决于两个因素:1.客户端写主库的能力。2.备库执行relay log的速率。
- 对于主库:影响并发度的只有锁。但是由于 InnoDB 引擎支持行锁,只要不是所有并发事务都更新同一行,多线程下锁对并发度影响不是很大。
- 对于备库:关键点就在于上述图中的sql_thread,5.6之前是单线程执行的,此时主库太快,备库就跟不上。最新版已改为多线程执行。
2.备库的改进方案
1.总体方案
下面说下备库从5.6单线程到最新版多线程的演进路线。
总体来说,就是把原来单线程执行 relay log,改为多线程执行。如下图:
coordinator 就是原来的 sql_thread。不过此时它只负责日志的读取和转发,不再直接更新数据。数据由worker 线程更新。
参数 slave_parallel_workers
:可以设置work 线程的个数。一般如果是32 核物理机,那么设置为 8~16 之间最好。因为备库还有可能要提供读查询,不能把 CPU 都吃光。
2.总体规则
coordinator 在分发的时候,需要满足以下这两个基本要求:
- 单个事务原子性:同一个事务不能被拆开,必须放到同一个 worker 中。
- 相关事务有序性:不能造成更新覆盖。即更新同一行的两个事务,必须被分发到同一个 worker 中。否则执行顺序有可能被破坏。
各个版本的多线程复制,都遵循了这两条基本原则。下面来看各个版本的分发方案。
3.MySQL 5.5 版本的并行复制策略
MySQL 5.5 版本是不支持并行复制。但是我们可以提供两个方案,为后面的官方改进抛砖引玉。分别是“按表分发”和“按行分发”。
1.按表分发策略
思路:
- 如果两个事务更新不同的表,它们就可以并行。
- 如果有跨表的事务,还是要把两张表放在一起考虑。
如下图:
每个 worker 线程对应一个 hash 表,key 是“库名. 表名”,value 是一个数字,表示队列中有多少个事务修改这个表。
在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。
上图中,hash_table_1 表示,现在 worker_1 的“待执行事务队列”里,有 4 个事务涉及到 db1.t1 表,有 1 个事务涉及到 db2.t2 表;hash_table_2 表示,现在 worker_2 中有一个事务会更新到表 t3 的数据。
假设在图中的情况下,coordinator 从中转日志中读入一个新事务 T,这个事务修改的行涉及到表 t1 和 t3。现在我们用事务 T 的分配流程,来看一下分配流程。简单说就是,跟多余1个worker冲突,分配线程就进入等待:
- 由于事务 T 中涉及修改表 t1,而 worker_1 队列中有事务在修改表 t1,事务 T 和队列中的某个事务要修改同一个表的数据,这种情况我们说事务 T 和 worker_1 冲突。
- 按照这个逻辑,顺序判断事务 T 和每个 worker 队列的冲突关系,会发现事务 T 跟 worker_2 也冲突。
- 事务 T 跟多于一个 worker 冲突,coordinator 线程就进入等待。
- 每个 worker 继续执行,同时修改 hash_table。假设 hash_table_2 里面涉及到修改表 t3 的事务先执行完成,就会从 hash_table_2 中把 db1.t3 这一项去掉。
- 这样 coordinator 会发现跟事务 T 冲突的 worker 只有 worker_1 ,就把它分配给 worker_1。
- coordinator 继续读下一个中转日志,继续分配事务。
就是说,每个事务在分发的时候,跟所有 worker 的冲突关系包括以下三种情况:
- 跟所有 worker 都不冲突:coordinator 线程就会把这个事务分配给最空闲的 woker;
- 只跟一个 worker 冲突:coordinator 线程就会把这个事务分配给这个冲突的 worker。
- 跟多于一个 worker 冲突:coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
以上方案,按表分配,在多个表负载均匀的场景里应用效果很好。
缺点是,碰到热点表时,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就退化为单线程复制了。
2.按行分发策略
为了解决热点表问题。
思路:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求binlog 格式必须是 row。
- 考虑主键冲突:同样的,为每个 worker,分配一个 hash 表。此时的 key,必须是“库名 + 表名 + 唯一键的值”。
- 考虑唯一键冲突:注意到,上述的唯一键,不仅仅是主键。还要考虑到唯一索引。即 key 应该是“库名 + 表名 + 索引 a 的名字 +a 的值”。例如,a是唯一索引,a的原始值是1,事务A更新为3,之后事务B再更新为1。此时如果事务A和事务B没有分配到同一个worker上,那么可能会导致事务B先执行,就直接报错了。
因此,我们要执行update t1 set a=1 where id=2
,coordinator 在解析这个语句的 binlog 的时候,这个事务的 hash 表就有三个项:
- key=hash_func(db1+t1+“PRIMARY”+2), value=2; 这里 value=2 是因为修改前后的行 id 值不变,出现了两次。
- key=hash_func(db1+t1+“a”+2), value=1,表示会影响到这个表 a=2 的行。
- key=hash_func(db1+t1+“a”+1), value=1,表示会影响到这个表 a=1 的行。
按表并行,按行并行都有约束条件。不过一般也是DBA要求的线上条件:
- 主库的 binlog 格式必须是 row:因为要能够从 binlog 里面解析出表名、主键值和唯一索引的值。
- 表必须有主键;
- 不能有外键。表上如果有外键,级联更新的行不会记录在 binlog 中,这样冲突检测就不准确。
按表并行、按行并行的缺点:
- 耗费内存。比如一个语句要删除 100 万行数据,这时候 hash 表就要记录 100 万个项。
- 耗费 CPU。解析 binlog,然后计算 hash 值,对于大事务,这个成本还是很高的。
所以我们会设置一个阈值,比如大事务单个事务更新的行数超过 10 万行就退化为单线程。退化逻辑如下:
- coordinator 暂时先 hold 住这个事务;
- 等待所有 worker 都执行完成,变成空队列;
- coordinator 直接执行这个事务;
- 恢复并行模式。
以上是抛砖引玉。下面我们看官方的改进。
4.MySQL 5.6 版本的并行复制策略
MySQL5.6 版本,支持了并行复制。不过是按库并行。即以库名为key进行分发。
就是在库层面的分发,如果各个库压力差不多的话,分发效果才OK。
相比于按表和按行分发,该策略有两个优势:
- 构造 hash 值的时候很快,只需要库名;而且一个实例上 DB 数也不会很多,不会出现需要构造 100 万个项这种情况。
- 不要求 binlog 的格式。因为 statement 格式的 binlog 也可以很容易拿到库名。
缺点是,如果主库上的表都放在同一个 DB 里面,或者各个库各管一个业务热点,就没效果了。
5.MariaDB 的并行复制策略
在23篇中有提到, redo log 的组提交 (group commit) 优化。
MariaDB 的并行复制利用了这个特性:
- 在同一组里提交的事务,一定不会修改同一行;
- 主库上可以并行执行的事务,备库上也可以并行执行。
主要思路就是根据上述原理模拟主库的并行模式。具体实现如下:
- 给事务增加提交组id:在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
- 提交组id写到binlog:commit_id 直接写到 binlog 里面;
- 到备库上时主库上同一组提交的事务可以并行执行:传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
- 这一组全部执行完成后,coordinator 再去取下一批。
该策略的缺点如下:
- 没能全部模拟主库并行:主库的并行是,一组事务在 commit 时,下一组事务是同时处于“执行中”状态的。而备库的模拟,必须是要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。
- 容易被大事务拖后腿:比如一组事务里有trx1、2、3,其中2是大事务。那么1和3很快执行完,worker就会空闲先来,只能等着2一个worker执行完,才能执行下一组事务,浪费资源。
主库和备库模拟不同步,如下图所示。
总体来说,该策略很创新,实现优雅,对原系统改造也少。
6.MySQL 5.7 的并行复制策略
MySQL5.7给出类类似 MariaDB 的并行复制策略。
由参数 slave-parallel-type
来控制并行复制策略:
- 配置为 DATABASE:表示使用 MySQL 5.6 版本的按库并行策略;
- 配置为 LOGICAL_CLOCK:表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。这个优化的思路也很有意思。
MariaDB策略的核心,是“所有处于 commit”状态的事务可以并行。事务处于 commit 状态,表示已经通过了锁冲突的检验了,可以同时执行。
那么其实我们不必等到 commit 阶段,只要能够到达 redo log prepare 阶段,就表示事务已经通过锁冲突的检验了。
回顾一下事务两阶段提交,如下图:
那么,MySQL 5.7 并行复制策略的思想是:
- 同时处于 prepare 状态的事务,在备库执行时是可以并行的;
- 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在备库执行时也是可以并行的。
参数调整:
第23 篇的 binlog 的组提交的时候,介绍过两个参数:
binlog_group_commit_sync_delay
参数:表示延迟多少微秒后才调用 fsync。binlog_group_commit_sync_no_delay_count
参数:表示累积多少次以后才调用 fsync。
这两个参数是用于故意拉长 binlog 从 write 到 fsync 的时间,以此减少 binlog 的写盘次数。
在 MySQL 5.7 的并行复制策略里,它们可以用来制造更多的“同时处于 prepare 阶段的事务”。这样就增加了备库复制的并行度。也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些。在 MySQL 5.7 处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。
7.MySQL 5.7.22 的并行复制策略
2018 年 4 月份发布。增加了一个新的并行复制策略,基于 WRITESET 的并行复制。
相应地,新增了一个参数 binlog-transaction-dependency-tracking
,用来控制是否启用这个新策略:
- COMMIT_ORDER:前面介绍的,根据同时进入 prepare 和 commit 来判断是否可以并行的策略。
- WRITESET:表示的是对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。
- WRITESET_SESSION:是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。
为了唯一标识,这个 hash 值是通过“库名 + 表名 + 索引名 + 值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert 语句对应的 writeset 就要多增加一个 hash 值。
这个策略类似于前面介绍的按行分发。不过进行了以下改进:
- writeset 在主库生成后直接写入到 binlog :这样在备库执行的时候,不需要解析 binlog 内容(event 里的行数据),节省了很多计算量;
- 无需把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存;
- binlog 可以是 statement 格式:因为备库的分发策略不依赖于 binlog 内容。
和前面按行分发一样,对于“表上没主键”和“外键约束”的场景,WRITESET 策略也没法并行,会暂时退化为单线程模型。
小节
为什么备库要有多线程复制呢?是因为对于更新压力较大的主库,备库是可能一直追不上主库的。从现象上看就是,备库上 seconds_behind_master 的值越来越大。
我们分析还发现,大事务不仅会影响到主库,也是造成备库复制延迟的主要原因之一。因此,在平时的开发工作中,我建议你尽量减少大事务操作,把大事务拆成小事务。
官方 MySQL5.7 版本新增的备库并行策略,修改了 binlog 的内容,也就是说 binlog 协议和之前的版本不兼容,在主备切换、版本升级的时候要注意。
提问
备库追不上主库甚至差几个小时是什么原因?怎么解决?升级DB版本。调参,调大worker数。
各个版本的备库worker分发策略?按表分配?按行分配?按commit组并行?按prepare事务并行?按WRITESET并行?
假设一个 MySQL 5.7.22 版本的主库,单线程插入了很多数据,过了 3 个小时后,我们要给这个主库搭建一个相同版本的备库。这时候,你为了更快地让备库追上主库,要开并行复制。在 binlog-transaction-dependency-tracking 参数的 COMMIT_ORDER、WRITESET 和 WRITE_SESSION 这三个取值中,你会选择哪一个呢?WRITESET。由于主库是单线程压力模式,所以每个事务的 commit_id 都不同,那么设置为 COMMIT_ORDER 模式的话,从库也只能单线程执行。同样地,由于 WRITESET_SESSION 模式要求在备库应用日志的时候,同一个线程的日志必须与主库上执行的先后顺序相同,也会导致主库单线程压力模式下退化成单线程复制。所以,应该将 binlog-transaction-dependency-tracking 设置为 WRITESET。
27.主库出问题了,从库怎么办?
前面24、25、26讲的都是一主一备。
读多写少的情况下,我们会用到一主多从。这也是接下来27、28两篇的重点。
本篇的主题是,一主多从的切换正确性。下一篇的主题是,一主多从的查询逻辑正确性的方法。
如下图,是一个一主多从结构。
如图。虚线为主备关系。图中,A为主库,A’为备库,B、C、D为从库。
一主多从一般用于读写分离,主库负责所有的写入和一部分读,其他库负责读。
本篇要讨论的是,在一主多从架构下,主库故障如何进行主备切换。
一下是主库发送故障时,切换示意图。此时*备库A’成为新的主库,B、C、D改接到A’*。多了B、C、D重新切换的过程,所以比较复杂。如下图:
1.基于位点的主从切换
1.同步位点的概念
我们把节点 B 的主库切换到 A’时,需要执行一条 change master
命令:
CHANGE MASTER TO
MASTER_HOST=$host_name # 主库IP地址
MASTER_PORT=$port # 主库端口
MASTER_USER=$user_name # 主库用户名
MASTER_PASSWORD=$password # 主库密码
MASTER_LOG_FILE=$master_log_name # 从主库的 master_log_name 日志文件进行同步
MASTER_LOG_POS=$master_log_pos # 从日志文件的 master_log_pos 位置开始同步
上面的6个参数中,master_log_pos 就是我们说的同步位点。
前4个参数好说。那么如何设置后两个参数?即如何找到同步位点?
2.如何找同步位点(也就是binlog的位置)
主备切换之前,B记录的是A的位点。主备切换之后,对接的日志是A’的日志,A 的位点和 A’的位点是不同的,所以要先“找同步位点”。
(1)位点只能取一个大概位置
思路:根据A发生故障的时刻,找到A’中相同时刻的日志位点:
- 等待新主库 A’把中转日志(relay log)全部同步完成;
- 在 A’上执行 show master status 命令,得到当前 A’上最新的 File 和 Position;
- 取原主库 A 故障的时刻 T;
- 用 mysqlbinlog 工具解析 A’的 File,得到 T 时刻的位点。
mysqlbinlog File --stop-datetime=T --start-datetime=T
可以看到,T故障时刻,A’写入binlog的位置是123,就取这里作为同步位点。
这个时刻不精确。因为主库断开时多个从库间数据可能不同步,可能会重复:
假设在 T 这个时刻,主库 A 已经执行完成了一个 insert 语句插入了一行数据 R,且已将 binlog 传给了 A’和 B,然后在传完的瞬间主库 A 的主机就掉电了。
那么,那一瞬间A’和B的数据就不同步了,那一条数据有可能A’没插入,B插入了,此时B再同步A’就会插入重复记录。这时候系统的状态是这样的:
- 在从库 B 上:由于同步了 binlog, R 这一行已经存在;
- 在新主库 A’上: R 这一行也已经存在,日志是写在 123 这个位置之后的;
- 切主库时同步了重复的记录:我们在从库 B 上执行 change master 命令,指向 A’的 File 文件的 123 位置,就会把插入 R 这一行数据的 binlog 又同步到从库 B 去执行。
这时候,从库 B 的同步线程就会报告 Duplicate entry ‘id_of_R’ for key ‘PRIMARY’
错误,提示主键冲突,然后停止同步。
(2)直接跳过错误位置
有两种方式。
一种做法是,主动跳过一个事务。跳过命令的写法:
# 在从库 B 刚开始接到新主库 A’时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况
set global sql_slave_skip_counter=1;
start slave;
另外一种方式是,通过设置 slave_skip_errors 参数,直接设置跳过指定的错误。
执行主备切换时,有两类错误经常会遇到的:
- 1062 错误:插入数据时唯一键冲突;
- 1032 错误:删除数据时找不到行。
因此,我们可以把 slave_skip_errors
设置为 “1032,1062”,这样中间碰到这两个错误时就直接跳过。
需要注意的是,这种直接跳过指定错误的方法,针对的是主备切换时,由于找不到精确的同步位点,所以只能采用这种方法来创建从库和新主库的主备关系。等到主备间的同步关系建立完成,并稳定执行一段时间之后,我们还需要把这个参数设置为空,以免之后真的出现了主从数据不一。
2.GTID概念
上述两种方法,跳过事务和忽略错误,很容易出错。MySQL 5.6 版本引入了 GTID,彻底解决了这个困难。
(1)GTID的概念
GTID :全称 Global Transaction Identifier,即全局事务 ID。是一个事务在提交的时候生成的,是这个事务的唯一标识。它由两部分组成,格式是GTID=server_uuid:gno
(官方是source_id:transaction_id
,概念一样。transaction_id容易混淆,一般它是指事务 id,事务 id 是在事务执行过程中分配的,如果这个事务回滚了,事务 id 也会递增,而 gno 是在事务提交的时候才会分配。):
- server_uuid:是一个实例第一次启动时自动生成的,是一个全局唯一的值;
- gno :是一个整数,初始值是 1,每次提交事务的时候分配给该事务,并加 1。
(2)启动GTID模式
只需要在启动一个 MySQL 实例时,加上参数 gtid_mode=on
和 enforce_gtid_consistency=on
即可。
(3)运作原理
每个 MySQL 实例都维护了一个 GTID 集合,用来对应“这个实例执行过的所有事务”。所以有重复事务时会直接跳过,不会报错:
在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。这个 GTID 有两种生成方式,而使用哪种方式取决于 session 变量 gtid_next 的值。
- 如果
gtid_next=automatic
,代表使用默认值。这时,系统自动生成GTID,即把 server_uuid:gno 分配给这个事务。
a. 记录 binlog 的时候,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’;
b. 把这个 GTID 加入本实例的 GTID 集合。 - 如果 gtid_next 是一个指定的 GTID 的值,比如通过
set gtid_next='current_gtid’
指定为 current_gtid,那么就有两种可能:
a. 如果 current_gtid 已经存在于实例的 GTID 集合中,接下来执行的这个事务会直接被系统忽略;
b. 如果 current_gtid 没有存在于实例的 GTID 集合中,就将这个 current_gtid 分配给接下来要执行的事务,也就是说系统无需给这个事务生成新的 GTID,因此 gno 也不用加 1。
注意,一个 current_gtid 只能给一个事务使用。这个事务提交后,如果要执行下一个事务,就要执行 set 命令,把 gtid_next 设置成另外一个 gtid 或者 automatic。
3.GTID用法举例
在实例 X 中创建一个表 t:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t values(1,1);
插入语句的binlog如下:
可以看到,事务开始之前,有一条SET @@SESSION.GTID_NEXT
命令。
- 如果X是主库,那么该语句同步过去,这两个GTID就会加入从库的GTID集合。
- 如果X是从库,同步主库Y时,发现了需要同步主库Y的
insert into t values(1,1);
语句,且Y 上的 GTID 是 “aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”。那么此时显然跟本地的数据主键冲突了,怎么处理呢?
我们可以执行下面的语句序列:
-- 通过提交空事务,把主库Y的GTID纳入本地的GTID集合
set gtid_next='aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10';
begin;
commit;
-- 再次执行同步,此时该事务就会被跳过。也就不会出现主键冲突。
set gtid_next=automatic;
start slave;
4.基于 GTID 的主备切换
(1)主备切换场景
在 GTID 模式下,备库 B 要设置为新主库 A’的从库的语法如下:
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
master_auto_position=1 # 表示使用主备关系使用的是 GTID 协议
可以看到,前面基于位点切换的两个参数,被GTID参数取代了。
我们把此时实例 A’的 GTID 集合记为 set_a,实例 B 的 GTID 集合记为 set_b。那么主备切换逻辑如下:
- 实例 B 指定主库 A’,基于主备协议建立连接。
- 实例 B 把 set_b 发给主库 A’。
- 实例 A’算出 set_a 与 set_b 的差集,也就是所有存在于 set_a,但是不存在于 set_b 的 GTID 的集合,判断 A’本地是否包含了这个差集需要的所有 binlog 事务。
a. 如果不包含,表示 A’已经把实例 B 需要的 binlog 给删掉了,直接返回错误;(如何处理?见后面提问)
b. 如果确认全部包含,A’从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B; - 之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行。
我们也可以看成两种模式的一些不同:
- 基于GTID主备切换的理念是,主库发给备库的日志必须是完整的。所以上面第3步a会返回错误。
- 而基于位点的主备切换协议,则是由备库决定的,备库指定哪个位点,主库就发哪个位点,不做日志的完整性判断。
(2)一主多从切换场景
无需再找位点,因为找位点这个工作,在实例 A’内部就已经自动完成了。从库 B、C、D 只需要分别执行 change master 命令指向实例 A’即可。
5.GTID 和在线 DDL
第22篇,线上临时新增索引。为了避免对主库造成影响,可以先在备库DDL,在把备库切为主库。
那么在双M模式下,DDL也会传回被主库。如何避免这种情况?
假设主备库为实例 X 和实例 Y,且当前主库是 X,都打开了 GTID 模式。这时的主备切换流程如下:
停从库:在实例 X 上执行 stop slave。
主库执行DDL:在实例 Y 上执行 DDL 语句。注意,这里并不需要关闭 binlog。
查出主库GTID:执行完成后,查出这个 DDL 语句对应的 GTID,并记为 server_uuid_of_Y:gno。
将主库GTID手动加入从库:到实例 X 上执行以下语句序列:
# 这样做的目的在于,既可以让实例 Y 的更新有 binlog 记录,同时也可以确保不会在实例 X 上执行这条更新。 set GTID_NEXT="server_uuid_of_Y:gno"; begin; commit; set gtid_next=automatic; start slave;
接下来,执行完主备切换,然后照着上述流程再执行一遍即可。
小结
建议尽量使用 GTID 模式来做一主多从的切换。
核心思想:备库切为主库并同步完成后,比较原来两个备库、从库的全局事务ID集GTID,看下有咩有不一样。即保证了日志的完整性,也保证了同步的正确性。
提问
什么是一主多从架构?主库故障时如何切换?除了一主多从还有哪些集群模式?关键就是同步点位,以及GTID。
主从切换时可能会有什么坑?怎么处理?不能精确定位同步位点。手动一一跳过事务,或忽略错误。
GTID是什么?GTID模式怎么切换?其实就是提交一个事务,集合里增加一个GTID。主从切换时,根据GTID的差集,来确定开始复制binlog的位置即可。
你在 GTID 模式下设置主从关系的时候,从库执行 start slave 命令后,主库发现需要的 binlog 已经被删除掉了,导致主备创建不成功。这种情况下,你觉得可以怎么处理呢?
- 如果业务允许主从不一致的情况,那么可以在主库上先执行 show global variables like ‘gtid_purged’,得到主库已经删除的 GTID 集合,假设是 gtid_purged1;然后先在从库上执行 reset master,再执行 set global gtid_purged =‘gtid_purged1’;最后执行 start slave,就会从主库现存的 binlog 开始同步。binlog 缺失的那一部分,数据在从库上就可能会有丢失,造成主从不一致。
- 如果需要主从数据一致的话,最好还是通过重新搭建从库来做。
- 如果有其他的从库保留有全量的 binlog 的话,可以把新的从库先接到这个保留了全量 binlog 的从库,追上日志以后,如果有需要,再接回主库。
- 如果 binlog 有备份的情况,可以先在从库上应用缺失的 binlog,然后再执行 start slave。
28.读写分离有哪些坑?
本篇主题:
- 一主多从架构的应用场景:读写分离。
- 怎么处理主备延迟导致的读写分离问题。
1.两种主从架构比较
常见的两种主从结构:
- 客户端负载均衡,访问哪个库由客户端决定。
- proxy负载均衡,客户端统一访问proxy。如下图:
但目前看,趋势是往带 proxy 的架构方向发展的。两种架构优缺点:
- 客户端直连方案:
- 优点:少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。
- 缺点:客户端不透明。主备切换、库迁移等操作的时候,客户端都会感知到,且需要调整数据库连接信息。
- 带 proxy 的架构:
- 优点:客户端透明。
- 缺点:整体比较复杂。对后端维护团队的要求会更高,proxy 也需要有高可用架构。
两种架构都会碰到“过期读”的问题。
2.过期读
过期读:就是主从模式,主库刚更新完数据,从库不能读到最新的数据。读到的是“过期的”数据。
如何解决呢?主要有以下几种方案:
- 强制走主库方案;
- sleep 方案;
- 判断主备无延迟方案;
- 配合 semi-sync 方案;
- 等主库位点方案;
- 等 GTID 方案。
3.强制走主库方案
将查询请求做分类:
- 必须读到最新结果的请求,强制将其发到主库上。
- 可以读到旧数据的请求,才将其发到从库上。
这个方案简单,用得最多。缺点是,当碰到“所有查询都不能是过期读”的业务需求时,所有的压力都会压到主库上。
4.Sleep 方案
主库更新后,读从库之前先 sleep 一下。类似于执行一条 select sleep(1) 命令。
但是还是不精确的:
- 不够快:如果这个查询请求本来 0.5 秒就可以在从库上拿到正确结果,也会等 1 秒;
- 不够慢:如果延迟超过 1 秒,还是会出现过期读。
5.判断主备无延迟方案
前面我们知道,可以通过show slave status
结果里的 seconds_behind_master
参数值,来衡量主备延迟时间的长短。
所以判断主备无延迟,有三种做法:
从库seconds_behind_master为0才查询:每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0。如果还不等于 0 ,那就必须等到这个参数变为 0 才能执行查询请求。结果如下图:
对比位点确保主备无延迟:上图中以下两组值完全相同:
- Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
- Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。
对比 GTID 集合确保主备无延迟:上图中Gtid_Set完全相同:
- Auto_Position=1 ,表示这对主备关系使用了 GTID 协议。
- Retrieved_Gtid_Set,是备库收到的所有日志的 GTID 集合;
- Executed_Gtid_Set,是备库所有已经执行完成的 GTID 集合。
后两种做法更精确一些。
但是,还是不够精确。分析,事务正常执行binlog如下:
- 主库执行完成,写入 binlog,并反馈给客户端;
- binlog 被从主库发送给备库,备库收到;
- 在备库执行 binlog 完成。
前述判断无延迟的逻辑是“备库收到的日志都执行完成了”,但其实我们忽略了一小部分日志:客户端已经收到提交确认,而备库还没收到日志的状态。如下图:
上图中,此时:
- trx1 和 trx2 已经传到从库,并且已经执行完成了;
- trx3 在主库执行完成,并且已经回复给客户端,但是还没有传到从库中。
所以还是出现了过期读。
补救方法呢?
6.配合 semi-sync半同步复制
解决上述问题,要引入半同步复制,也就是 semi-sync replication。
其设计如下,其实就是引入了一个确认机制:
- 事务提交的时候,主库把 binlog 发给从库;
- 从库收到 binlog 以后,发回给主库一个 ack,表示收到了;
- 主库收到这个 ack 以后,才能给客户端返回“事务完成”的确认。
第23篇的问题,主库掉电的时候,有些 binlog 还来不及发给从库,会不会导致系统数据丢失?答案是,如果使用的是普通的异步复制模式,就可能会丢失,但 semi-sync 就可以解决这个问题。
但是semi-sync 配合判断主备无延迟的方案,存在两个问题:
- 一主多从时可能会有过期读:在某些从库执行查询请求会存在过期读的现象;因为假如主库收到了从库A的ack,而查询却是在B上。
- 可能会过度等待:在持续延迟的情况下,可能出现过度等待的问题。如果备库一直追不上主库,就一直等。
接下来,介绍的等主库位点方案,可以解决这两个问题。
当发起一个查询请求以后,我们要得到准确的结果,其实并不需要等到“主备完全同步”。如下时序图:
上图中,从库一直和主库有延迟,如果用上述方案,就一直不能执行。
其实客户端是在发完 trx1 更新后发起的 select 语句,我们只需要确保 trx1 已经执行完成就可以执行 select 语句了。即状态3时就可以执行查询。
7.等主库位点方案
(1)命令介绍
先介绍一条命令:
select master_pos_wait(file, pos[, timeout]);
命令的逻辑如下:
- 命令是在从库执行的;
- 参数 file 和 pos 指的是主库上的文件名和位置;
- timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。
正常返回的结果:
- 返回一个正整数 M:表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务。
还会返回一些其他结果:
- 执行期间,备库同步线程发生异常:返回 NULL;
- 等待超过 N 秒:返回 -1;
- 刚开始执行的时候,就发现已经执行过这个位置:返回 0。
那么以上述图5举例,主库trx1执行后,我们可以用以下方法,确保再次查询备库时能查到最新数据:
- 取得主库的 file 和 pos :trx1 事务更新完成后,马上执行 show master status 得到当前主库执行到的 File 和 Position;
- 选定一个从库执行查询语句;
- *查询从库是否已同步过该事务(可选择等待)*:在从库上执行 select master_pos_wait(File, Position, 1);
- 如果返回值是 >=0 的正整数,则在这个从库执行查询语句;
- 退化避免无限等待:否则,到主库执行查询语句。也可以直接返回查询超时失败。看业务。
整体流程如下图:
8.GTID 方案
上述方案,对应的也有等待GTID方案。
命令如下:
select wait_for_executed_gtid_set(gtid_set, 1);
命令逻辑:
- 等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
- 超时返回 1。
MySQL 5.7.6 版本开始,允许在执行完更新类事务后,把这个事务的 GTID 会返回给客户端。等 GTID 的方案比起前述方案就可以减少一次查询。执行流程变为:
- 获取事务GTID:trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1;
- 选定一个从库执行查询语句;
- 等待从库同步GTID:在从库上执行 select wait_for_executed_gtid_set(gtid1, 1);
- 如果返回值是 0,则在这个从库执行查询语句;
- 退化:否则,到主库执行查询语句。也可退化为超时。看业务。
整体流程图如下:
如何让 MySQL 在执行事务后,返回包中带上 GTID ?
- 将参数
session_track_gtids
设置为 OWN_GTID,然后通过客户端调用 API 接口mysql_session_track_get_first
从返回包解析出 GTID 的值即可。
要在MySQL客户端显示,可改造如下图:
小结
几种方案权衡利弊即可。
等待位点和等待 GTID 这两个方案,比较复杂。
在实际应用中,这几个方案是可以混合使用。
当然,也可能有不需要等待就可以水平扩展的数据库方案,但这往往是用牺牲写性能换来的,也就是需要在读性能和写性能中取权衡。
提问
主从模式,主库刚更新完一个数据,从库读不到最新的数据怎么办?有哪些处理方法?select master_pos_wait(file, pos[, timeout]);命令。
假设你的系统采用了等 GTID 的方案,现在要对主库的一张大表做 DDL,可能会出现什么情况呢?为了避免这种情况,你会怎么做呢?假设,这条语句在主库上要执行 10 分钟,提交后传到备库就要 10 分钟(典型的大事务)。那么,在主库 DDL 之后再提交的事务的 GTID,去备库查的时候,就会等 10 分钟才出现。这样,这个读写分离机制在这 10 分钟之内都会超时,然后走主库。这种预期内的操作,应该在业务低峰期的时候,确保主库能够支持所有业务查询,然后把读请求都切到主库,再在主库上做 DDL。等备库延迟追上以后,再把读请求切回备库。
29.如何判断一个数据库是不是出问题了?
第25和27提到,主备切换的流程:
- 一主一备双M架构:只需把客户端流量切到备库即可。
- 一主多从架构:将客户端流量切到备库,还需把从库接到新主库。
主备切换有两种场景:
- 主动切换:人工主动切换。
- 被动切换:一般是主库出问题,由HA系统发起。
那么就引出了本篇的主题,如何判断一个主库出问题了?
1.select 1 判断
思路:连上 MySQL,执行个 select 1 ,看select 1 是否成功返回。
但是该方法并不能说明主库是好的,该方法不能检测 InnoDB 并发线程数过多导致的系统不可用。select 1 成功返回,只能说明这个库的进程还在,并不能说明主库没问题。
以下举例:
1.模拟大查询
表如下:
set global innodb_thread_concurrency=3;
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t values(1,1)
执行查询如下图:
上图模拟了大查询。注意到sessionD,此时InnoDB其实已经被阻塞了,但是select 1还是可以返回正常结果的。
innodb_thread_concurrency
参数:用于控制 InnoDB 的并发线程上限。大于该值时,InnoDB收到新的请求就会进入等待。默认值为0,表示不做限制。
一般该参数会做限制,设置为64~128 比较好。因为CPU 核数有限,线程全冲进来,上下文切换的成本就会太高。
2.并发连接和并发查询
注意并发连接和并发查询的区别。上述参数,设置的是并发查询数:
- 并发连接:show processlist里显示的,数千连接。
- 并发查询:“当前正在执行”的语句。
并发连接多,只是多占一些内存。并发查询高,则会有严重的线程上下文切换。
第7篇讲到的热点更新和死锁检测。如果同一行出现热点更新,会不会把并发查询数耗尽?
- 实际上,在线程进入锁等待后,并发线程的计数会减一,也就是说等行锁(也包括间隙锁)的线程是不算在 128 里面的。这样就避免了热点行等待锁时,新进入的等待线程把整个系统并发查询数耗尽,导致全系统锁死,影响到其他查询。
- 真正执行查询的线程,才会被算进并发线程计数。如前面模拟的大查询。
2.查表判断
综上,select 1 不能判断 InnoDB 并发查询耗尽的阻塞情况。
我们进行改进。
思路:访问InnoDB,即要访问到表。系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行:
select * from mysql.health_check;
这样,我们就可以检测出由于并发线程过多导致的数据库不可用的情况。
但是,还是不能检测空间满了的情况。空间满的时候,是可以查询成功的。
我们可以用更新语句检测,因为更新事务要写 binlog,如果磁盘满了,那么更新语句和事务提交的 commit 语句就都会被堵住。
3.更新判断
常用的是更新timestamp。如下:
update mysql.health_check set t_modified=now();
但是又遇到新的问题:我们一般会把数据库 A 和 B 的主备关系设计为双 M 结构,所以在备库 B 上执行的检测命令,也要发回给主库 A。也就是说,主库A更新了第101行,把更新语句发到备库B,备库B又会把该语句发回主库A,即备库有写操作了。
解决思路:mysql.health_check 这个表就不能只有一行数据。可以在 mysql.health_check 表上存入多行数据,并用 A、B 的 server_id 做主键。这样就可以保证主、备库各自的检测命令不会发生冲突。
新的问题:没法检测到“更新慢”的问题,例如磁盘IO利用率满了时。假如磁盘的IO利用率满了,此时其他更新线程很难抢到IO资源,但是我们的检测线程由于比较轻,容易抢到IO资源,此时检测结果是DB正常,其实IO利用率已经接近100%了,DB会很慢。
解决思路:检测不到更新慢,是由于上面的所有的方法都是基于外部检测的,具有随机性。而且都是定时轮训,发生问题时,要到下一次轮训才能发现,切换也比较慢。我们可以启用MySQL的内部统计。
4.内部统计
针对磁盘利用率问题:MySQL 5.6 版本后提供的 performance_schema
库,就在 file_summary_by_event_name
表里统计了每次 IO 请求的时间。
表数据如下图:
来看event_name='wait/io/file/innodb/innodb_log_file’
这一行,表示统计的是 redo log 的写入时间。解析:
- EVENT_NAME:统计的类型
- 第一组共五列:IO 类型的统计
- COUNT_STAR 是所有 IO 的总次数
- SUM:IO总和,单位皮秒。
- MIN:IO最小值,单位皮秒。
- AVG:IO平均值,单位皮秒。
- MAX:IO最大值,单位皮秒。
- 第二组共六列:读操作的统计。最后一列SUM_NUMBER_OF_BYTES_READ统计的是,总共从 redo log 里读了多少个字节。
- 第三组共六列:写操作的统计
- 第四组:其他类型数据的统计。在 redo log 里,认为就是对 fsync 的统计。
另外,binlog 对应的是 event_name = "wait/io/file/sql/binlog"
这一行。统计数据都一样。
打开这个统计功能是有性能损耗的:每一次操作DB,performance_schema 都需要额外地统计这些信息。打开所有的 performance_schema 项,性能大概会下降 10% 左右。
所以一般单独打开需要的监控项就行,比如单独打开redo log的时间监控:
mysql> update setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innodb_log_file%';
已经统计了redo log 和 binlog 两个信息,那么如何把这个信息用在实例状态诊断上呢?通过 MAX_TIMER 的值来判断数据库是否出问题即可。
例如,设定单次 IO 请求时间超过 200 毫秒属于异常,用以下语句检测:
mysql> select event_name,MAX_TIMER_WAIT FROM performance_schema.file_summary_by_event_name where event_name in ('wait/io/file/innodb/innodb_log_file','wait/io/file/sql/binlog') and MAX_TIMER_WAIT>200*1000000000;
取到信息后,再把之前的统计信息清空。下次再出现此异常时,就可以假如累计值:
mysql> truncate table performance_schema.file_summary_by_event_name;
小节
几个方法总结:
- select 1:无法检测InnoDB阻塞。但用途也广,MHA默认。
- 查表判断:无法判断磁盘满了时的情况。
- 更新判断:无法判断磁盘IO打满更新慢的情况。另外要注意主备库server_id更新多行,避免更新一行主备冲突。
- 内部统计:有一定性能损耗。
具体方案根据业务权衡。推荐优先考虑 update 系统表,然后再配合增加检测 performance_schema 的信息。
提问
有哪些方法可以检测到主库异常?各有什么优缺点?
并发连接数与并发查询数的区别?
业务系统一般也有高可用的需求,在你开发和维护过的服务中,你是怎么判断服务有没有出问题的呢?1.主要思路就是关于服务状态和服务质量的监控。其中,服务状态的监控,一般都可以用外部系统来实现;而服务的质量的监控,就要通过接口的响应时间来统计。2.按照监控的对象,将监控分成了基础监控、服务监控和业务监控,并分享了每种监控需要关注的对象。
30.答疑文章(二):用动态的观点看加锁(暂略待补充)
本篇主题:用动态的观点看加锁。
加锁规则复习,包含了两个“原则”、两个“优化”和一个“bug”:
- 原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1:索引上的等值查询,给唯一索引加锁时,next-key lock 退化为行锁。
- 优化 2:索引上的等值查询,给普通索引加锁时,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
- 1个 bug:索引上的范围查询,给唯一索引加锁时,会一直访问到不满足条件的第一个值为止。
以下的讨论基于下表:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
后续内容待补充。
小节
提问
31.误删数据后除了跑路,还能怎么办?
本篇主题:误删数据。怎样减少误删数据的风险,怎样挽救由误删数据带来的损失。
误删数据,分类如下:
- 误删行:使用 delete 语句误删数据行;
- 误删表:使用 drop table 或者 truncate table 语句误删数据表;
- 误删库:使用 drop database 语句误删数据库;
- 误删整个实例:使用 rm 命令误删整个 MySQL 实例。
1.误删行
1.如何恢复
第24篇讲到,使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回来恢复数据。
Flashback 恢复数据的原理:修改 binlog 的内容,拿回原库重放。前提是,需要确保 binlog_format=row
和 binlog_row_image=FULL
。
对单个事务的恢复:
- 对于 insert 语句:对应的 binlog event 类型是 Write_rows event,把它改成 Delete_rows event 即可;
- 对于 delete 语句:同理,也是将 Delete_rows event 改为 Write_rows event;
- 对于 Update_rows :binlog 里面记录了数据行修改前和修改后的值,对调这两行的位置即可。
多个事务的恢复:
- 将事务按相反顺序执行。
如:
# 误操作
(A)delete ...
(B)insert ...
(C)update ...
# 恢复操作
(reverse C)update ...
(reverse B)delete ...
(reverse A)insert ...
恢复操作不建议直接在主库上执行。比较安全的做法,是恢复出一个备份。或者找一个从库作为临时库,在临时库恢复数据后,再将确认过的临时库的数据,恢复回主库。
因为主库是一直在变的,可能误删除以后,已经在此基础上又增加很多脏数据了。仅仅恢复一两条不做确认,可能就对数据造成二次破坏。
2.如何预防
最佳实践:如下:
- 把 sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行就会报错。
- 代码上线前,必须经过 SQL 审计。
如果真的需要删除某个小表的记录,加id>=0即可。delete删除:
- delete 全表是很慢的,需要生成回滚日志、写 redo、写 binlog。所以,从性能角度考虑,你应该优先考虑使用 truncate table 或者 drop table 命令。
- 使用 delete 命令删除的数据,还可以用 Flashback 来恢复。而使用 truncate /drop table 和 drop database 命令删除的数据,就没办法通过 Flashback 来恢复了。因为,即使我们配置了 binlog_format=row,执行这三个命令时,记录的 binlog 还是 statement 格式。binlog 里面就只有一个 truncate/drop 语句,这些信息是恢复不出数据的。
如果真的drop了,想恢复,怎么办?
2.误删库 / 表
1.如何恢复
drop以后,只能使用“全量备份 + 增量日志”恢复。
用全量备份恢复的前提条件:
- 线上有定期的全量备份。
- 实时备份 binlog。
假设中午12点误删了一个库,恢复数据的流程如下:
- 取全量备份:取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
- 恢复全量备份到临时库:用备份恢复出一个临时库;
- 取binlog日志:从日志备份里面,取出凌晨 0 点之后的日志;
- 将日志记录追加到临时库:把这些日志,除了误删除数据的语句外,全部应用到临时库。
流程示意图如下图:
需要注意几个点以提高恢复效率:
- 日志恢复时指定误删表所在的库:为了加速数据恢复,如果这个临时库上有多个数据库,可以在使用 mysqlbinlog 命令时加上一个–database 参数,用来指定误删表所在的库。这样,就避免了在恢复数据时还要恢复其他库日志。
- 应用日志时,跳过误删除操作语句的binlog:
- 原实例没有使用 GTID 模式:只能根据位置跳过误操作。即用–stop-position 参数执行到误操作之前,再用–start-position 从误操作之后的位置继续执行。
- 原示例有GTID:把误操作的 GTID 加到临时实例的 GTID 集合即可,日志恢复时就会自动跳过该误操作。假设误操作命令的 GTID 是 gtid1,只需执行 set gtid_next=gtid1;begin;commit; 即可。
但是效率还是不够高,主要因为以下两点:
- mysqlbinlog 工具不能单独指定一张表:所以虽然是误删一张表,但恢复的时候是整个数据库一起恢复。
- mysqlbinlog 是单线程: 用该工具解析出日志应用,应用日志的过程只能是单线程。
2.加速恢复:临时库接到备库
上述问题的解决思路:在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:
- 只同步误操作的表:在 start slave 之前,先通过执行
change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表; - 并行复制:这样做也可以用上并行复制技术,来加速整个数据恢复过程。
示意图如下:
图中虚线表示,如果binlog已被删除,可以把备份binlog放回备库。
例如:我们发现当前临时实例需要的 binlog 是从 master.000005 开始的,但是在备库上执行 show binlogs 显示的最小的 binlog 文件是 master.000007,意味着少了两个 binlog 文件。这时,我们就需要去 binlog 备份系统中找到这两个文件。
把删掉的 binlog 放回备库的操作步骤:
- 找到备份文件:从备份系统下载 master.000005 和 master.000006 这两个文件,放到备库的日志目录下;
- 将备份文件加入索引名单:打开日志目录下的 master.index 文件,在文件开头加入两行,内容分别是 “./master.000005”和“./master.000006”;
- 重启备库:重启备库,目的是要让备库重新识别这两个日志文件;
- 建立主备关系进行同步:现在这个备库上就有了临时库需要的所有 binlog 了,建立主备关系,就可以正常同步了。
以上提到了两个方案,用mysqlbinlog 工具解析出的 binlog 文件应用到临时库。把临时库接到备库上。
- 思路都是通过“备份+ 应用 binlog”恢复数据。
- 前提都是,备份系统定期备份全量日志,而且需要确保 binlog 在被从本地删除之前已经做了备份。
建议要把这个数据恢复功能做成自动化工具,并且经常拿出来演练。
3.进一步加速恢复:延迟复制备库
临时库接到备库,已经加速了,但是“恢复时间仍然不可控”。
假如全量备份的文件或库比较大,例如备份了6天的数据,那么可能要恢复1天才能恢复回来。
如果有非常核心的业务,需要快速恢复,我们可以考虑搭建延迟复制的备库。这个功能是 MySQL 5.6 版本引入的。
延迟复制的备库:一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。
也就是说,即使我们误删了,那么只要在短时间内发现,就可以用延迟复制的备库来恢复我们的操作。
4.如何预防
一些减少误删操作风险的建议:
- 账号分离,避免写错命令。例如:只给业务开发同学 DML 权限。DBA日常操作也用只读账号。
- 制定操作规范,避免写错要删除的表名。例如:删表前先做改名操作。该表明加固定后缀,且用管理系统来删除这些固定后缀的表。
3.误删实例:rm操作
有高可用机制的 MySQL 集群,最不怕的就是 rm 删除数据。
只要不是恶意的删除整个集群,删除某一个节点,HA会立马选出新节点的。
我们要做的仅仅是恢复该节点数据,再接入集群即可。
安全措施最多也就是加一个分机房、分城市部署。
小结
可以用 show grants 命令查看账户的权限,如果权限过大,可以建议 DBA 同学给你分配权限低一些的账号。
提问
误删行怎么恢复?怎么预防?
delete和truncate / drop table / drop database有什么区别?
误删表怎么恢复?如何提高恢复效率?
误删实例怎么办?
经历过的误删数据事件?用了什么方法来恢复数据?在这个过程中得到的经验又是什么呢?修改生产的数据,或者添加索引优化,都要先写好四个脚本:备份脚本、执行脚本、验证脚本和回滚脚本。备份脚本是对需要变更的数据备份到一张表中,固定需要操作的数据行,以便误操作或业务要求进行回滚;执行脚本就是对数据变更的脚本,为防Update错数据,一般连备…
32.为什么还有kill不掉的语句?
MySQL中有两个kill命令:
- kill query + 线程 id:终止这个线程中正在执行的语句;
- kill connection + 线程 id:断开这个线程的连接(如果该线程有语句在执行,则先停止语句执行)。connection可以省略。
不过可能会有kill不掉的情况:使用了 kill 命令,却没能断开这个连接。再执行 show processlist
命令,看到这条语句的 Command 列显示的是 Killed。按理说不应该是直接看不到了吗?
其实当线程在进行大查询,或处于锁等待时,直接kill是OK的。那么什么时候不OK呢?
1.收到 kill 以后,线程做什么?
1.kill的执行过程
其实 kill 不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”。
这跟 Linux 的 kill 命令类似,kill -N pid 并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。
只是对于 MySQL 的 kill 命令来说,不需要传信号量参数,就只有“停止”这个命令。
用户执行 kill query thread_id_B 时,处理 kill 命令的线程执行如下两步:
- 修改运行状态:把 session B 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);
- 发停止信号:给 session B 的执行线程发一个信号:让执行线程停止执行。
执行线程的停止过程:
- 停止是有过程的:从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。
- 执行线程通过埋点感知停止信号:一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是 THD::KILL_QUERY,才开始进入语句终止逻辑;
- 执行线程可能不会被马上感知到埋点:如果执行线程处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;
2.未能直接kill举例
以下举例,没感知到埋点的情况:
如下,set global innodb_thread_concurrency=2
,将 InnoDB 的并发线程上限数设置为 2;
执行如下序列,如下图:
可以看到:
- sesssion C 执行的时候被阻塞;
- kill query没效果:但是 session D 执行的 kill query C 命令却没什么效果,
- kille connection虽然断开了连接:直到 session E 执行了 kill connection 命令,才断开了 session C 的连接,提示“Lost connection to MySQL server during query”,
- 但是仍然还在后台运行:但是这时候,如果在 session E 中执行 show processlist,就能看到下面这个图。
为什么update可以kill掉,上面的session C不行呢?
- 原因是,等行锁时,使用的是 pthread_cond_timedwait 函数,这个等待状态可以被唤醒。
- 而上述sessionC等待执行线程限制数,无法唤醒。等待逻辑是这样的:每 10 毫秒判断一下是否可以进入 InnoDB 执行,如果不行,就调用 nanosleep 函数进入 sleep 状态。
所以sessionC的 kill connection流程如下:
- 把 12 号线程状态设置为 KILL_CONNECTION;
- 关掉 12 号线程的网络连接。因为有这个操作,所以你会看到,这时候 session C 收到了断开连接的提示。
- 执行 show processlist 时,约定线程的状态是 KILL_CONNECTION,就把 Command 列显示成 Killed。
也就是说,虽然客户端推出了,但是线程状态仍然在等待。只有等到sessionC的执行线程满足条件,进入InnoDB后,才能通过埋点感知到停止状态,最终停止。
2.线程kill无效的情景总结
总共两种情况:
- 线程没有感知到埋点:即线程执行到判断线程状态的逻辑。情景有:
- 并行查询数不足等待进入InnoDB
- 由于 IO 压力过大,读写 IO 的函数一直无法返回,导致不能及时判断线程的状态。
- 终止逻辑耗时较长,此时从 show processlist 结果上看也是 Command=Killed:
- 超大事务执行期间被 kill:回滚操作需要对事务执行期间生成的所有新数据版本做回收。
- 大查询回滚:如果查询时生成了很大的临时文件,此时文件系统压力也大,删除临时文件需要等待IO资源。
- DDL 命令执行到最后阶段:此时kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。
3.关于客户端的几个误解
1.直接在客户端通过 Ctrl+C 命令就可以直接终止线程?
答案是,不可以。
其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。
而且MySQL 是停等协议。下一个结果没返回之前,继续发命令也没用。
执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 kill query 命令。
2.如果库里面的表特别多,连接就会很慢。
如果某个线上库有很多表,比如有6万张。此时用客户端连接,都会卡在下面这个画面:
实际上,当使用默认参数连接的时候,MySQL 客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作:
- 执行 show databases;
- 切到 db1 库,执行 show tables;
- 把这两个命令的结果用于构建一个客户端本地的哈希表。
所以如何表很多,那么构建表的过程就会很长。所以其实并不是连接慢,也不是服务端慢,而是客户端慢。
我们不用自动补全时,加-A参数就OK。
3.-quick参数可以让服务端加速
上述除了用-A参数,也可以用-quick参数。
但是其实-quick参数可能会降低服务端的性能。
MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:
- 本地缓存:也就是在客户端本地开一片内存,先把结果存起来。如果用 API 开发,对应的就是 mysql_store_result 方法。
- 不缓存:读一个处理一个。如果用 API 开发,对应的就是 mysql_use_result 方法。
加上–quick 参数,就会使用第二种不缓存的方式。由于本地不缓存,那么本地处理的慢,就会导致服务端发送结果被阻塞,服务端就变慢了。
其实-quick不是服务端加速,而是客户端加速。具体体现如下:
- 前面提到的,跳过表名自动补全功能。
- 减少本地耗用内存:mysql_store_result 需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能;
- 不记录执行命令:不会把执行命令记录到本地的命令历史文件。
小结
如果发现一个线程处于 Killed 状态,可以做的事情就是,通过影响系统环境,让这个 Killed 状态尽快结束。
比如,如果是第一个例子里 InnoDB 并发度的问题,就可以临时调大 innodb_thread_concurrency 的值,或者停掉别的线程,让出位子给这个线程执行。
而如果是回滚逻辑由于受到 IO 资源限制执行得比较慢,就通过减少系统压力让它加速。
做完这些操作后,其实已经没有办法再对它做什么了,只能等待流程自己完成。
提问
MySQL中有哪些kill命令?
执行kill命令时,MySQL发生了什么?
如果一个被 killed 的事务一直处于回滚状态,应该直接把 MySQL 进程强行重启,还是应该让它自己执行完成呢?为什么呢?因为重启之后该做的回滚动作还是不能少的,所以从恢复速度的角度来说,应该让它自己结束。当然,如果这个语句可能会占用别的锁,或者由于占用 IO 资源过多,从而影响到了别的语句执行的话,就需要先做主备切换,切到新主库提供服务。切换之后别的线程都断开了连接,自动停止执行。接下来还是等它自己执行完成。这个操作属于我们在文章中说到的,减少系统压力,加速终止逻辑。
33.我查这么多数据,会不会把数据库内存打爆?
如果主机内存只有 100G,现在要对一个 200G 的大表做全表扫描,会不会把数据库主机的内存用光?
对大表做全表扫描,流程到底是怎么样的呢?
1.全表扫描对 server 层的影响
对一个200G的表进行全表扫描,然后保存到客户端。命令大概如下:
mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > $target_fil
那么我们知道,全表扫描会直接扫描主键索引,然后把查到的结果放到结果集,然后返回给客户端。
结果集存在哪呢?
实际上,没有保存整个完整结果。MySQL是边读边发的,取发数据流程如下:
- 获取一行,写到 net_buffer 中:这块内存的大小由参数
net_buffer_length
定义,默认是 16k。 - 重复获取行,直到 net_buffer 写满,调用网络接口发出去。
- 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
- 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。
socket send buffer
默认定义/proc/sys/net/core/wmem_default
整体流程图如下:
net_buffer是每个线程一块。
比如下图,故意让客户端不读socket receive buffer的内容,在服务端showprocesslist,如下图:
注意到State 的值一直处于“Sending to client”,表示服务器端的网络栈写满了。
两个查询接口的选择:
- mysql_store_result接口:查询返回结果不多时使用,直接把结果保存到本地内存,加快服务端执行。
- mysql_use_result接口:查询返回结果很多,不用本次内存,防止把客户端撑爆。
多个线程都处于Sending to client”状态,怎么处理:
- 评估查询语句:说明返回的数据太多了,评估返回那么多数据是否恰当。
- 调大net_buffer_length 参数:增加每次服务端查询时返回的结果数量,加快处理速度。
查询语句的状态变化(“Sending data”与“Sending to client”状态的区别):
- 查询语句进入执行阶段后,首先把状态设置成“Sending data”;
- 发送执行结果的列相关的信息(meta data) 给客户端;
- 再继续执行语句的流程;
- 执行完成后,把状态设置成空字符串。
“Sending data”和”Sending to client”状态区分
- “Sending data”状态可能处于任意阶段,表示正在执行。仅当一个线程处于“等待客户端接收结果”的状态,才会显示”Sending to client”。
综上,在server层,全表扫描不会把数据库搞爆。
2.全表扫描对 InnoDB 的影响
第2和15有介绍到WAL 机制,即 Write-Ahead Logging,先写日志、再写磁盘,事务写到日志就算执行完了。
InnoDB 内存的一个作用,是保存更新的结果,再配合 redo log,就避免了随机写盘。
1.关于Buffer Pool
内存的数据页是在 Buffer Pool (BP) 中管理的。Buffer Pool的作用:
- 加速更新:在 WAL 里,更新时,最新数据不用立刻写到磁盘,而是保存到 Buffer Pool 。
- 加速查询:磁盘是旧数据,Buffer Pool 是新数据。此时新的查询过来,不用立刻更新磁盘记录,而是直接返回Buffer Pool数据即可。
Buffer Pool 对查询的加速效果,依赖于一个重要的指标,即:内存命中率。
查看内存命中率:可以通过执行 show engine innodb status 命令,查看“Buffer pool hit rate”即可。如下图:
内存命中率多少合适:稳定的线上系统,内存命中率要在 99% 以上。
Buffer Pool多大合适:InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的,一般建议设置成可用物理内存的 60%~80%。
2.InnoDB 的内存管理算法
用的是最近最少使用算法,即LRU算法(Least Recently Used)。
(1)LRU算法
如下是一个基础LRU算法模型,见下图:
InnoDB 管理 Buffer Pool 的 LRU 算法,用链表来实现:
- 最近访问数据移到链表表头:图中表头 P1 是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;
- 访问已有的记录更新到表头:这时候有一个读请求访问 P3,因此变成状态 2,P3 被移到最前面;
- 访问没有的记录,删除队尾老记录,新记录从磁盘加载后插到队头:状态 3 表示,这次访问的数据页是不存在于链表中的,所以需要在 Buffer Pool 中新申请一个数据页 Px,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾 Pm 这个数据页的内存,存入 Px 的内容,然后放到链表头部。
- 从效果上看,就是最久没有被访问的数据页 Pm,被淘汰了。
基础算法模型的缺点:
- 全表扫描时会出大问题。如果我们扫码一个200G的表,那么会发现当前的记录全部被淘汰了,buffer pool被新查到的记录填满。此时如果在线上,就会发现,Buffer Pool 的内存命中率急剧下降,磁盘压力增加,SQL 语句响应变慢。
(2)InnoDB对LRU的改进
综上,对LRU算法进行了改进。如下图:
InnoDB的实现改进:
按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域,执行流程变为:
- 访问young区时:逻辑和基础LRU算法一样。
- 访问不存在的数据页时:也是淘汰掉数据页 Pm,但是新插入的数据页 Px,是放在 LRU_old 处。
- 访问old区时,做如下判断:
- 该数据页在链表存在时间超过1秒:移到链表头部。
- 该数据页在链表存在时间短于1秒:位置保持不变。
1 秒这个时间,是由参数 innodb_old_blocks_time 控制的,默认1秒。
那么我们会发现,全表扫描问题解决了,保证了 Buffer Pool 响应正常业务的查询命中率。执行流程变为如下:
- 新插入的数据页被放到old区:扫描过程中,需要新插入的数据页,都被放到 old 区域 ;
- 该数据页里面的多条记录会保留在old区:这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;
- 该数据页记录扫描完成后数据页被淘汰:再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。
小结
- Server层,MySQL 采用的是边算边发的逻辑,数据量很大的查询结果来说,不会在 server 端保存完整的结果集。如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆。
- InnoDB 引擎内部, 对 LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。
全表扫描还是比较耗费 IO 资源的,所以业务高峰期不能直接在线上主库执行。
提问
全表扫描时,server层怎么动作?
全表扫描时,InnoDB层怎么动作?如何避免全表扫码占满buffer pool导致命中率下降问题?
如果由于客户端压力太大,迟迟不能接收结果,会导致 MySQL 无法发送结果而影响语句执行。这还不是最糟糕的情况。你可以设想出由于客户端的性能问题,对数据库影响更严重的例子吗?或者你是否经历过这样的场景?你又是怎么优化的?客户端执行“长事务”时会对DB产生影响:a.写事务。如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住。b.读事务也有问题,会导致 undo log 不能被回收,导致回滚段空间膨胀。
34.到底可不可以使用join?
join 语句一般涉及两个问题:
- DBA 不让使用 join,为什么?
- 大小两个表做 join,应该用哪个表做驱动表?
下面来讲解一下join 语句是怎么执行的。
举例,创建两个表 t1 和 t2:
CREATE TABLE `t2` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
create table t1 like t2;
insert into t1 (select * from t2 where id<=100)
创建了表t1和t2。字段都为id(主键索引)、a(索引)、b。t1 表100条数据,t2 表1000条数据。
1.Index Nested-Loop Join
即被驱动表能用上索引的情况。
1.NLJ执行过程
以下语句:
-- 用straight_join强制t1 是驱动表,t2 是被驱动表。防止优化器优化。
select * from t1 straight_join t2 on (t1.a=t2.a);
该语句explain结果如下图:
遍历过程是:先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。
NLJ:上述遍历过程,用到了驱动表的索引,而且过程和写程序时的嵌套查询类似,所以叫“Index Nested-Loop Join
”。
对应流程图如下图:
遍历流程如下:
- 全表扫描驱动表:对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
- 针对驱动表的每一行,去被驱动表里查找:而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描 100 行;
- 所以,整个执行流程,总扫描行数是 200。
2.能不能用join
分析下,假如不用join,我们单表查询如下:
- 执行
select * from t1
,查出表 t1 的所有数据,这里有 100 行; - 循环遍历这 100 行数据:
- 从每一行 R 取出字段 a 的值 $R.a;
- 执行
select * from t2 where a=$R.a
; - 把返回的结果和 R 构成结果集的一行。
也是扫描200行,但是总共执行了101条语句。比直接用join多了100次交互。
3.怎么选驱动表
扫描次数计算如下:
- 假设驱动表是N行,那么需要扫描N次。
- 假设被驱动表是M行,那么有索引的情况下,搜索到一条记录大概需要执行 2 * log2M次。(先在二级索引a,再到主键索引)。
- 总的加起来,整个执行过程扫描 N + N*2*log2M次
可以看到,M扩大时,对整体扫描次数影响较少。所以,应该让小表来当驱动表。
4.总结
被驱动表能用上索引的情况的前提下,我们得到了两个结论:
- 使用 join 性能更好:性能比强行拆成多个单表执行 SQL 语句的性能要好;
- 选小表做驱动表:如果使用 join 语句的话,需要让小表做驱动表。
2.Simple Nested-Loop Join
下面我们看被驱动表用不上索引的情况。
SQL 语句改成这样:
select * from t1 straight_join t2 on (t1.a=t2.b);
此时被驱动表即 t2 上面做的是全表扫描。
显然,如果是驱动表的每一条,都去被驱动表全表扫描,扫描次数会成平方级增长。虽然能执行,但是算法太笨重。
3.Block Nested-Loop Join
上述算法太笨重。所以不能走索引时,MySQL用的是 Block Nested-Loop Join 算法。即将驱动表移入内存。
1.执行过程
算法流程:
- 将驱动表读入内存:把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
- 逐行取被驱动表数据来和驱动表中数据做比对:扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。
流程图如下图:
explain 结果如下图所示:
看到,扫描次数也是100*1000=10 万次。和Simple Nested-Loop Join一样。但是由于这 10 万次判断是内存操作,速度上会快很多。
2.驱动表能全部放入内存怎么选驱动表
假设小表的行数是 N,大表的行数是 M,那么在这个算法里:
- 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
- 内存中的判断次数是 M*N。
此时选大表还是小表做驱动表,没有区别。
3.join_buffer 放不下怎么办:分段存
驱动表需要放入内存。内存放不下怎么办呢?
答案分段放。join_buffer 的大小是由参数 join_buffer_size
设定的,默认值是 256k。
执行过程变为:
- 扫描表 t1,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
- 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;
- 清空 join_buffer;
- 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。
相当于把大表拆成了N个小表,用N个小表一一加载入内存执行。相当于:1000 * 10000 -> (500 + 500) * 10000。
这就是这个算法名字中“Block”的由来,表示“分块去 join”。
执行流程图如下:
4.驱动表能分段存入内存怎么选驱动表
假设,驱动表的数据行数是 N,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M。
这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然λ的取值范围是 (0,1)。
所以,在这个算法的执行过程中:
- 扫描行数是 N+λ*N*M;
- 内存判断 N*M 次。
可以看到,N小一些,扫描行数就小一些。所以选小表当驱动表。
此外,调大参数 join_buffer_size
,可以减小K,即减小λ,提升join性能。所以 join 语句很慢,可以把 join_buffer_size
改大。
4.几种情况总结
总结如下:
1.能不能使用 join 语句?
- 如果被驱动表能走索引,即使用 Index Nested-Loop Join 算法,可以使用join。
- 如果被驱动表不能走索引,即使用 Block Nested-Loop Join 算法,可能要扫描被驱动表很多次,如果驱动表很大,尽量不要用join。
总之,看 explain 结果里面,Extra 字段里面如果出现“Block Nested Loop”字样,就不要用join。
2.使用 join时选大表还是小表做驱动表?
- 如果被驱动表能走索引,即使用 Index Nested-Loop Join 算法,选小表。
- 如果被驱动表不能走索引,即使用 Block Nested-Loop Join 算法:
- 在 join_buffer_size 足够大的时,大表小表一样;
- 在 join_buffer_size 不够大的时(这种情况更常见),选小表。
3.什么是“小表”?
在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。
优化器也会自动选择驱动表。
以下举两个例子,表数据和上面一样,t1总共100条数据,t2总共1000条数据。
例1:
-- 为了避免优化器优化选择驱动表,使用straight_join手动指定驱动表。
-- 两个语句都没有走索引,a有索引,b没有索引。
select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50;
第一个语句,join_buffer放入整个t1表,共100行。第二个语句,join_buffer只需放入where过滤出来的50行(而不是1000行)。综上,t2过滤后数据行数少,此时t2是小表。
例2:
-- 为了避免优化器优化选择驱动表,使用straight_join手动指定驱动表。
-- 两个语句都没有走索引,a有索引,b没有索引。
select t1.b,t2.* from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=100;
select t1.b,t2.* from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=100;
此时两条语句都是扫描100行。但是第一条语句,只需加载t1.b进入join_buffer。第二条语句,需要加载t2.*进join_buffer。此时条数一样,但t1加载的字段少,所以t1是小表。
小结
假设驱动表为N行,被驱动表为M行:
- 被驱动表走索引,选小表当驱动表。扫描XX次,判断 N + N * 2 * log2M 次。
- 被驱动表不走索引,驱动表能全部放入内存,选哪个没有区别。扫描XX次,判断 N * M 次。
- 被驱动表不走索引,驱动表需分段放入内存,选小表当驱动表。扫描XX次,判断 XX次。
join用法总结:
- 如果可以使用被驱动表的索引,join 语句还是有其优势的;
- 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
- 在使用 join 的时候,应该让小表做驱动表。
提问
使用join时,被驱动表能走索引时的遍历流程?能用join吗?怎么选驱动表?为什么?
使用join时,被驱动表不能走索引时的遍历流程?能用join吗?怎么选驱动表?为什么?
调整哪个参数可以增强join性能?
使用join时,什么是小表?如果被驱动表是一个大表,并且是一个冷数据表,除了查询过程中可能会导致 IO 压力大以外,你觉得对这个 MySQL 服务还有什么更严重的影响吗?还影响buffer pool的young去。具体影响见下一篇。
35.join语句怎么优化?
上篇中介绍了NLJ算法和BNL算法:
- NLJ算法走索引,效果不错。
- BNL算法在大表join时很耗费CPU资源,建议改为在应用层拆分多个语句再拼装结果。
今天的主题是,如何优化这两个算法。
以下是本篇的例子:
create table t1(id int primary key, a int, b int, index(a));
create table t2 like t1;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t1 values(i, 1001-i, i);
set i=i+1;
end while;
set i=1;
while(i<=1000000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
两张表都是id主键,a索引,b非索引。其中 t1 插入1000条数据,a的值是逆序的。t2插入100万条数据。
1.Multi-Range Read 优化:先排个序
核心思路:范围查询时,id拿过来后,排序再依次回表。
1.优化流程
MRR优化,主要目的是尽量使用顺序读盘(除了机械磁盘,固态硬盘的顺序写还是比随机写快)。
范围查询时,可以开启MRR,来优化查询效率。
优化原理:
- 回表是按行查的:我们知道,回表的操作,是先在普通索引 a 上查到主键 id 的值后,再根据一个个主键 id 的值到主键索引上去一行一行的查整行数据的过程。
- 数据的存放大多数都是按照主键递增顺序插入的。
- 改进原理:由上面两条可以得出,虽然我们不能改变回表一行一行查(而不是批量)的模式,但是如果我们按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。
优化思路,范围查询时,先把id统一拿过来排个序,再去依次回表:
- 范围查询到的id值放到缓存:根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
- 将id排序:将 read_rnd_buffer 中的 id 进行递增排序;
- 依次回表:排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。
read_rnd_buffer
的大小是由 read_rnd_buffer_size
参数控制的。
使用时注意:
- 缓存不够时进行分段缓存:如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。
- MRR需要进行强制设置:官方文档的指出,现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR。如果要固定使用 MRR ,需要设置
set optimizer_switch="mrr_cost_based=off"
。
2.优化举例
假设执行语句:
select * from t1 where a>=1 and a<=100;
MRR优化前执行情况如下图,可以看到,回表时不是顺序读:
MRR优化后执行情况如下图,可以看到,回表时大体上是按顺序读的:
explain结果如下图,可以看到 Extra 字段多了 Using MRR:
MRR 能够提升性能的核心在于,查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。
2.NJL优化:Batched Key Access算法:批量取驱动表数据
核心思路:驱动表数据由一条一条取变批量取。交给被驱动表时也是批量交。从而可以用到MRR(即ID排序)。
BKA算法依赖于MRR。
MySQL 在 5.6 版本后开始引入,BKA算法是对 NLJ 算法的优化。
我们复习下NLJ算法,如下图:
可以看到,NJL算法,是从驱动表 t1 一条一条取出a的值,再到被驱动表 t2 去join。
那么我们其实可以,批量的从 t1 取一批数据出来,再批量的给到 t2(此时 t2 可以用到MRR排序)。
这个 t1 批量取出来的数据就放到 join_buffer里(BNL用来分段存驱动表,NJL算法则用来放MRR批量驱动表数据)。
以下是优化后流程,见下图:
注意到,和前面的原始方式比。由于 t1 驱动表是批量把数据给 t2 被驱动表的,所以此时 t2 相当于变成了范围查询,就可以用到MRR优化。
启用BKA 优化算法,你需要在执行 SQL 语句之前,先设置:
set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
前两个参数是开启MRR,后一个参数是开启bka。
3.BNL 算法的性能问题
BNL(分块把被驱动表数据读入join buffer)读大表时,超过1秒会把young区占满,影响Buffer pool正常运转。
先来回顾一下InnoDB 对 Bufffer Pool 的 LRU 算法优化:
- 第一次从磁盘读入内存的数据页,会先放在 old 区域。如果 1 秒之后这个数据页不再被访问了,就不会被移动到 LRU 链表头部,这样对 Buffer Pool 的命中率影响就不大。
那么此时,如果使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表做多次扫描。那么就会造成这个效果:
- 冷表较小时:语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部。
- 冷表较大时:冷表大小超过整个 Buffer Pool 的 3/8,此时业务正常访问的数据页,将没有机会进入 young 区域。因为young区域被被驱动表填满了。
理论上来说,这个大表的缓存数据用完就直接丢掉。但是现在由于表比较大,执行时间超过1秒,被误认为是热点数据,它就把young区填满了,大幅降低了内存命中率。这对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。可以考虑增大 join_buffer_size
的值,减少对被驱动表的扫描次数。
综上,BNL 算法对系统的影响主要包括三个方面:
- 耗费IO资源:可能会多次扫描被驱动表,占用磁盘 IO 资源;
- 耗费CPU资源:判断 join 条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
- 误占满Buffer Pool的young区:可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。
因此,我们通过explain看到优化器会使用 BNL 算法时,就要给join字段加上索引,把 BNL 算法转成 BKA 算法。
4.BNL优化:转BKA算法或临时表
方法就是:直接在被驱动表上的 join字段建索引。或者使用临时表,在临时表里加索引。
1.直接建索引
select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;
使用 BNL 算法来 join ,语句执行流程如下:
- 缓存驱动表:把表 t1 的所有字段取出来,存入 join_buffer 中。这个表只有 1000 行,join_buffer_size 默认值是 256k,可以完全存入。
- 扫描表 t2,取出每一行数据跟 join_buffer 中的数据进行对比,
- 如果不满足 t1.b=t2.b,则跳过;
- 如果满足 t1.b=t2.b, 再判断其他条件,也就是是否满足 t2.b 处于 [1,2000] 的条件,如果是,就作为结果集的一部分返回,否则跳过。
判断等值条件的次数是 1000*100 万 =10 亿次。
这是t2加入索引,扫描次数就大大缩小。
2.使用临时表
但是如果这个查询不常用,加索引显然不划算,因为这个表有100万行数据。
此时可以考虑使用临时表,思路都是加索引。大致思路是:
- 把表 t2 中满足条件的数据放在临时表 tmp_t 中;
- 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 加上索引;
- 让表 t1 和 tmp_t 做 join 操作。
SQL 语句的写法如下:
create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);
此时计算下执行次数:一个全表扫描,加1000次带索引的查询。
5.BNL优化:扩展 -hash join
核心思路:在业务端模拟 hash join。
hash join的好处:
- 前面由于join_buffer维护的是一个无序数组,所以要扫描 1000 * 100万 = 10亿次。(驱动表1000条,被驱动表100万条)。
- 如果join_buffer维护的是一个hash数组呢,那我们只需要扫描 1 * 100万 = 100万次。
遗憾的是 MySQL 的优化器和执行器一直被诟病的:不支持哈希 join。
所以我们只能在应用层手动模拟,流程大概是:
- 在业务端构建驱动表hash结构:
select * from t1;
取得表 t1 的全部 1000 行数据,在业务端存入一个 hash 结构,比如 C++ 里的 set、PHP 的数组这样的数据结构。 - 获取被驱动表的过滤数据:
select * from t2 where b>=1 and b<=2000;
获取表 t2 中满足条件的 2000 行数据。 - 用hash表和被驱动表查询结果拼装结果集:把这 2000 行数据,一行一行地取到业务端,到 hash 结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。
理论上,这个方案比临时表方案还快。
小节
Index Nested-Loop Join(NLJ)和 Block Nested-Loop Join(BNL)的优化方法总结:
- NLJ算法优化:BKA 优化是 MySQL 已经内置支持,建议默认使用;
- BNL算法优化:尽量转成 BKA 算法。优化的方向就是给被驱动表的关联字段加上索引;
- BNL算法优化:基于临时表的改进方案。对于能够提前过滤出小数据的 join 语句来说,该方案效果很好;
- BNL算法优化:应用端模拟hash join。MySQL 目前的版本还不支持 hash join,但可以配合应用端自己模拟出来,理论上效果要好于临时表的方案。
- 手动模拟Hash join
提问
范围查询,一个一个回表,可以怎么优化?什么是MRR?
NJL算法可以怎么优化?原理?单个数据回表变批量回表,缓存到join_buffer批量提交给被驱动表,让被驱动表可以进行MRR优化。
BNL算法可以怎么优化?原理?
为了得到最快的执行速度,三表join的索引如何设计?要看join算法。比如如果都是BKA算法,那就是1关联2再马上关联3,得到结果,这样循环
36.为什么临时表可以重名?
本篇主题:临时表。
临时表不一定就是内存表。两者差别:
- 内存表:指使用 Memory 引擎的表,建表语法是 create table … engine=memory。这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”外,从其他的特征上看,它就是一个正常的表。
- 临时表:可使用各种引擎类型 。如果是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,写数据的时候是写到磁盘上的。当然,临时表也可以使用 Memory 引擎。
1.临时表特性
临时表在使用上有以下几个特点:
- 建表语法: create temporary table …。
- session 内可见:一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
- 临时表可与普通表同名。
- session A 内有同名的临时表和普通表的时候,show create 语句、增删改查语句优先访问的是临时表。
- show tables 命令不显示临时表,只显示普通表。
- session 结束时,会自动删除临时表。
举例如下图:
2.临时表的应用
1.使用场景
由于线程之间不会有重名冲突,所以复杂查询的优化过程经常可以用到临时表。
典型的使用场景:分库分表系统的跨库查询。
假设有以下大表ht,以字段f为key,拆为1024个分表,然后分布到32个数据库实例。如下图:
proxy有没有都行。
此时如果查询条件中有key(这里就是f),是可以正常分表的:
select v from ht where f=N;
但是如果条件没有key,比如用的是另一个索引k。此时只有到分区表中一一查找,然后汇总到一起order by:
select v from ht where k >= M order by t_modified desc limit 100;
此时有两种思路:
- 思路一:在 proxy 层的进程代码中实现排序。缺点是开发工作量大,且proxy端内存和CPU压力很大。
- 思路二:把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作。
2.临时表解决思路
上述思路二。执行流程可以类似:
建立临时表:在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified;
在各个分库查询符合条件的数据:在各个分库上执行
select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
分库执行结果汇总到临时表:把分库执行的结果插入到 temp_ht 表中;
在临时表中获取结果:
select v from temp_ht order by t_modified desc limit 100;
整体流程,如下图:
在具体实践中,因为一般分库压力都不大,所以我们可以把临时表放到随意一个分库中,而不是汇总库。
3.为什么临时表可以重名?
假设我们执行以下语句:
create temporary table temp_t(id int primary key)engine=innodb;
1.物理层面处理临时表
MySQL 动作,在临时文件目录创建文件保存表结构信息和表数据信息:
- 创建frm文件存放表结构定义:给这个 InnoDB 表创建一个 frm 文件保存表结构定义。 该frm 文件放在临时文件目录下,文件名的后缀是
.frm
,前缀是“#sql{进程 id}_{线程 id}_ 序列号
”。你可以使用select @@tmpdir
命令,来显示实例的临时文件目录。 - 存放表数据:
- 5.6 以及之前:在临时文件目录下创建一个相同前缀、以
.ibd
为后缀的文件。 - 5.7 以及之后:引入了一个临时文件表空间,专门用来存放临时文件的数据。无需再创建 ibd 文件。
- 5.6 以及之前:在临时文件目录下创建一个相同前缀、以
所以,假如临时表叫 t1,其实它在存储上和普通表 t1的名字和位置都是不一样的。
2.内存逻辑层面处理临时表
MySQL 维护数据表,除了物理上要有文件外,内存里面也有一套机制区别不同的表。
*每个表都对应一个 table_def_key
*:
- 普通表的 table_def_key:“库名 + 表名”
- 临时表的 table_def_key:“server_id + thread_id” + “库名 + 表名”
具体实现:
- 每个线程都维护了自己的临时表链表。
- 每次 session 内操作表的时候,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表;
- session 结束时删除临时表,对链表里的每个临时表,执行 “DROP TEMPORARY TABLE + 表名”操作。
一般来说,临时表只在线程自己内部访问。但是binlog也会记录“DROP TEMPORARY TABLE
”命令,为什么?这就涉及到主备复制。
4.临时表和主备复制
假如在主库执行以下语句:
create table t_normal(id int primary key, c int)engine=innodb;/*Q1*/
create temporary table temp_t like t_normal;/*Q2*/
insert into temp_t values(1,1);/*Q3*/
insert into t_normal select * from temp_t;/*Q4*/
此时发现,如果备库的 binlog_format=statment/mixed
,就需要记录临时表的记录,否则会保存。当然,row格式时无需记录。
两个有意思的点:
- binlog为非row格式:主库断开连接时,它自己临时表会自动删除,但是备库的临时表需要主库触发删除,因为备库的同步线程是一直在运行的。此时需要在主库上再写一个 DROP TEMPORARY TABLE 传给备库执行。
- binlog为row格式:主库drop临时表时,传给备库的drop语句需要改写该操作。因为此时备库没有临时表。例如,主库一次删两张表”
drop table t_normal, temp_t
“,到备库只能删一张表”DROP TABLE t_normal /* generated by server */
“
主库上不同线程可以创建同名临时表,但是到了备库,备份的应用日志线程是共用的,即要在同一个线程create两张同名表两次(即时多线程复制,也是在同一个worker)。此时备库备份时创建同名临时表会冲突吗?
答案是不会。MySQL 在记录 binlog 的时候,会把主库执行这个语句的线程 id 写到 binlog 中。并用该id来构造临时表的table_def_key
:
- session A 的临时表 t1,在备库的 table_def_key 就是:库名 +t1+“M 的 serverid”+“session A 的 thread_id”;
- session B 的临时表 t1,在备库的 table_def_key 就是 :库名 +t1+“M 的 serverid”+“session B 的 thread_id”。
小结
临时表的好处:
- 线程自己可见。
- 线程退出时自动删除。
- 在 binlog_format=’row’的时候,临时表的操作不记录到 binlog 中。
本篇说的是用户临时表,是用户自己创建的。另一种是内部临时表,前面17篇已经讲过。
提问
临时表是内存表吗?临时表和内存表有什么区别?
分库分表,不走分库key,怎么汇总查询?临时表
临时表在物理上怎么存储?在内存里怎么区分?实现上怎么记录一个线程的内存表?
binlog里会有临时表记录吗?为什么?row方式没有,其他方式会有删除临时表的记录。
备库备份时创建同名临时表会冲突吗?
37.什么时候会使用内部临时表?Union和GroupBy
用来辅助SQL存放中间数据的数据结构目前有三个:(好像还有一个update相关的buffer?)
- sort buffer:排序时候使用。
- join buffer:join语句时使用。
- 内部临时表:需要额外的内存来保存中间结果时,如果执行逻辑需要用到二维表特性,就优先考虑使用临时表。
本篇重点讲解 group by 的几个算法,中间穿插内部临时表内容。
1.union 执行流程
举例,新建表 t1,插入了1000条数据,id是主键,a有索引,b没索引:
create table t1(id int primary key, a int, b int, index(a));
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t1 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
执行这条语句:
(select 1000 as f) union (select id from t1 order by id desc limit 2);
union的语义:取两个子查询结果的并集。并集的意思就是这两个集合加起来,重复的行只保留一行。
explain结果如下图:
看到,第二行的key是id,走了索引。第三行Extra有Using temporary,用了内部临时表。
union执行流程:
- 创建一个内存临时表用来保存语句执行结果:这个临时表只有一个整型字段 f,并且 f 是主键字段。
- 执行第一个子查询,存入临时表中。得到 1000 这个值。
- 执行第二个子查询,存入临时表:
- 拿到第一行 id=1000,试图插入临时表中。但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行;
- 取到第二行 id=999,插入临时表成功。
- 从临时表中按行取出数据,返回结果,并删除临时表,结果中包含两行数据分别是 1000 和 999。
union执行过程流程图如下图:
如果把上面这个语句中的 union 改成 union all 的话,此时就用不到临时表。因为无需去重,只需要依次执行每个子查询并返回结果即可。explain 结果如下图:
2.group by 执行流程
group by 的语义:根据分组key统计不同的key出现的个数,group by 本质上就是统计。它的意思是,根据某个字段来分组,然后分组到一起的数据用聚合函数来处理,比如可以计数(COUNT),求和(SUM),求平均数(AVG)等。然后加Having则可以根据聚合函数的结果做进一步过滤。。比如:
-- 统计员工人数大于30000的部门
SELECT
( SELECT d.dept_name FROM departments d WHERE de.dept_no = d.dept_no ) AS 部门,
count( de.emp_no ) AS 人数
FROM
dept_emp de
WHERE
de.to_date = '9999-01-01'
GROUP BY
de.dept_no
HAVING
count( de.emp_no ) > 30000
group by也会用到临时表。
执行以下语句:
-- 把表 t1 里的数据,按照 id%10 进行分组统计,并按照 m 的结果排序后输出。
select id%10 as m, count(*) as c from t1 group by m;
explain 结果如下图:
分析explain结果,Extra 字段:
- Using index:这个语句使用了覆盖索引,选择了索引 a,不需要回表;
- Using temporary:使用了临时表;
- Using filesort:需要排序。
语句执行流程:
- 创建内存临时表:表里有两个字段 m 和 c,主键是 m;
- 查询结果放入临时表:扫描表 t1 的索引 a,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x;
- 如果临时表中没有主键为 x 的行,就插入一个记录 (x,1);
- 如果表中有主键为 x 的行,就将 x 这一行的 c 值加 1;
- 在临时表处理后返回结果:遍历完成后,再根据字段 m 做排序,得到结果集返回给客户端。
流程的执行图如下图:
最后一步,内部临时表排序,在第17篇有提及。如下图:
语句的执行结果,0是10%10,如下图:
如果不需要对结果排序,在末尾增加 order by null即可。此时最后排序阶段跳过,直接从临时表拿数据返回,如下图:
内存临时表的大小是有限制的,参数 tmp_table_size
就是控制该内存大小的,默认 16M。
内存临时表不够放时,会转换为磁盘临时表。磁盘临时表默认使用InnoDB引擎。改下效果如下图:
3.group by 优化方法 – 索引
group by 建立临时表,比较消耗性能。能不能不建临时表呢?
group by 语句为什么需要临时表:group by 的语义逻辑,是统计不同的值出现的个数。我们 group by id%100 时,每一行的 id%100 的结果是无序的,所以就需要有一个临时表,来记录并统计结果。
如果 group by 后时根据分组的key顺序排列的,那么不用再建立临时表,逐行统计结果就行。如下图:
刚好,InnobDB的索引就满足这个要求。
优化思路:直接新增一个有索引的列,列的值就是 group by 的 key。然后根据该列group by,结果就是有序的,逐行统计处理即可。
新增索引列:
alter table t1 add column z int generated always as(id % 100), add index(z);
group by 语改成:
select z, count(*) as c from t1 group by z;
explain 结果如下图,可以看到,Extra处不再走临时表:
4.group by 优化方法 – 直接排序
如果不能加索引的时候怎么办呢?还有有优化的空间。
提示是大量数据:我们可以直接提示优化器查出来的是大量数据,让它走直接排序优化,省去了临时表的步骤。即先放内存临时表,发现放不下才走磁盘临时表。
提示方法: group by 语句中加入 SQL_BIG_RESULT
这个提示(hint)。
语句改为:
select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m;
改进结果:
- 省空间:直接用数组存数据:此时优化器看到时大量数据,考虑到B+树存储效率不高,为了节省磁盘空间,它会直接用数组存储。
- 省时间:运用排序:数组里会直接用排序算法排序。
执行流程:
- 初始化分组key的 sort_buffer:确定放入一个整型字段,记为 m;
- 将分组key存入 sort_buffer:扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
- 将分组key排序:扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够,会利用磁盘临时文件辅助排序);
- 得到有序的key数组:排序完成后,就得到了一个有序数组。
- 根据有序数组统计数据。
执行流程图如下:
explain 结果:
可以看到,没有用到临时表,而是直接用了排序算法。
小结
MySQL 什么时候会使用内部临时表?
- 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
- join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构;
- 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中,union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数。
group by 的使用原则:
- group by 的结果如果无需排序:在语句后面加
order by null
,结果加入内存临时表后,可以省去再进行排序的步骤。 - group by 的key尽量用上表的索引:可以避免用临时表。确认方法是 explain 结果里没有 Using temporary 和 Using filesort;
- 如果 group by 统计的数据量不大:尽量只使用内存临时表;也可以通过适当调大
tmp_table_size
参数,来避免用到磁盘临时表; - 如果 group by 统计的数据量很大:使用
SQL_BIG_RESULT
提示,告诉优化器直接使用排序算法得到 group by 的结果,避免用临时表。
sort_buffer、join_buffer、内存临时表和磁盘临时表 都是server层的,引擎间共用。
提问
MySQL中哪些地方用到缓存?sort buffer、join buffer、buffer pool、update xx……
union用到内部临时表吗?union all呢?
group by语义?执行流程?
内存临时表放不下时怎么办?
如何优化group by?分情况讨论?a.加索引:根据group by的key建立索引列,让结果天然有序,无需再用内部临时表排序。b.提示大量数据。此时不会再建立临时表,而是直接走排序让结果有序,然后一一统计。和索引类似。小结里有详细总结。
38.都说InnoDB好,那还要不要使用Memory引擎?
以下面两张表举例,t1是Memory引擎,t2是Innodb引擎:
create table t1(id int primary key, c int) engine=Memory;
create table t2(id int primary key, c int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);
insert into t2 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);
分别查询 select *,可以看到得到的结果不一样,一个0在最前面,一个在最后面。如下图:
原因是,两种引擎组织数据存储的方式不一样。
1.内存表的数据组织结构
1.InnoDB和Memory引擎的数据组织结构
结果分析:
- InnoDB我们很熟:数据和索引放在一起,就放在主索引树上,数据是天然有序的。所以0在结果第一行。
- Memory 引擎:数据和索引是分开存放的。索引里存的hash索引,即主键id,以及它对应数据行的位置。数据则是存到一个数组里,按插入顺序放入。所以,全表扫描是按顺序扫描数组,0最后插入,所以在结果最后一行。
两种引擎数据组织方式比较:
- InnoDB 引擎:数据放在主键索引上,其他索引上保存主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)。
- Memory 引擎:数据单独存放,索引上保存数据位置,我们称之为堆组织表(Heap Organizied Table)。
两种引擎数据组织方式示意图如下图:
InnoDB引擎:
Memory引擎:
知道了数据组织结构,我们就可以推出两个引擎的一些典型不同:
- 数据有序性:InnoDB 表的数据总是有序的,Memory数据按照写入顺序存放。
- 插入新数据:InnoDB必须插到特定位置,Memory有空位就可以插入。
- 索引更新:数据位置变化时,InnoDB只需修改主键索引(因为二级索引是绑定的主键,而不是位置),Memory要修改所有索引。
- 索引查询次数:InnoDB主键索引是走1次,二级索引走2次。Memory都是1次。
- 是否支持变长数据:InnoDB数据长度是可变的,如varchar。Memory数据长度是固定的,如Blob、Text。即使定义了varchar(N),实际上也是存的固定长度的char(N)。Memory的每行数据长度是相同的。
由于Memory数据长度固定,所以删除数据时留下的空洞,可以很轻松的被之后插入的数据复用。
另外由于Memory用Hash索引,索引范围查询时只能走全表扫描。
2.内存表的hash索引和B-Tree索引
如前所述,内存表的主键是Hash索引,内存表也另外支持B-Tree索引。语句如下:
alter table t1 add index a_btree_index using btree (id);
数据组织形式就变了,如下图:
此时如果是范围查询就会优先走B-Tree,下面是范围查询,以及强制走主键Hash索引的两次查询,注意到0的位置前后两次不一样,说明走的索引不一样。如下图:
建议,不要在生产环境上使用内存表。原因有两个:
- 锁粒度问题:只支持表锁,不支持行锁,对事务并发度支持不高。
- 数据持久化问题:数据库重启时,所有内存表都会被清空。
3.内存表问题:锁粒度
内存表不支持行锁,只支持表锁。这个表锁跟之前我们介绍过的 MDL 锁不同,但都是表级的锁。
以下语句,sessionA睡了50s,sessionB就被堵了50s。如下图:
4.内存表问题:数据持久性
数据只放在内存中。数据库重启时,所有内存表都会被清空。
主备架构时,备库重启后,备库的内存表数据会丢失,如下图:
双主架构时,备库重启后,主库的内存表数据也会丢失。
原因是,为了防止主库重启后主备不一致,MySQL在数据库重启后,会在binlog加入删除内存表的记录,即写入一行DELETE FROM t1。如下图:
小结
综上,生产环境不建议用Memory引擎。此外,还可以这么考虑:
- 表更新量很大时:需要支持高并发读,选有行锁的InnoDB;
- 表数据量不大时:能放到内存表的数据量都不大。如果是考虑读性能,一个读 QPS 很高且数据量不大的表,即使是用 InnoDB,数据也都是缓存在 InnoDB Buffer Pool 里。因此,使用 InnoDB 表的读性能也不会差。
生产环境唯一建议使用Memory引擎的场景,用户手动建立临时表且数据少且等值查询时:
- 内存表不需要写磁盘,速度快。
- 等值查询时:索引 b 使用 hash 索引,等值查找速度比 B-Tree 索引快;
- 数据少时:临时表数据少,占用的内存有限。
提问
Memory引擎数据怎么存的?主键索引组织方式?InnoDB引擎呢?、
Memroty引擎有哪些索引?如何支持范围查询?Hash索引,B-Tree索引。
生产环境可以用Memory引擎吗?为什么?什么情况下可以使用?锁粒度,持久性(主备,双主)。唯一建议使用的就是,用户自建临时表的场景。此时不用考虑并发度,也不用考虑持久性。
生产上有一个内存表,为了避免引备库重启导致备库内存表情况,进而导致主备同步停止。需要将它修改成 InnoDB 引擎表。但是此时业务场景暂时不允许修改引擎,那么可以加上什么自动化逻辑,来避免主备同步停止?
39.自增主键为什么不是连续的?
第4篇提过,主键索引应该尽量递增,避免页分裂。
如果主键用的是自增ID,不应该把业务依赖到ID的自增性。因为自增ID不保证绝对是自增的。
用以下表举例:
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;
1.自增值保存在哪里
表结构定义不保存自增值:虽然我们是通过表定义来定义一个字段是递增的,但是自增值并不是保存在表定义里。表的结构定义存放在后缀名为.frm的文件中,但是并不会保存自增值。
不同的引擎对于自增值的保存策略不同:
- MyISAM 引擎:保存在数据文件中。
- InnoDB 引擎:保存在内存里。MySQL 8.0 版本后,才可以持久化自增值,即重启后表的自增值可以恢复为 MySQL 重启前的值。具体情况:
- MySQL 5.7 及之前:保存在内存里。每次重启后,第一次打开表的时,去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。即MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。
- MySQL 8.0 版本:自增值的变更记录在 redo log 中,重启的时候依靠 redo log 恢复重启之前的值。
2.自增值修改机制
一个字段比如id,被定义为自增时,自增逻辑如下:
- 插入时id为0、null、未指定:取AUTO_INCREMENT 值。
- 插入时指明了具体值:插入指定的具体值。
- 插入的值X < 当前自增值Y:当前自增值不变。
- 插入的值X >= 当前自增值Y:当前自增值改为新的自增值。
新的自增值修改逻辑:从 auto_increment_offset
开始,以 auto_increment_increment
为步长,逐步叠加,直到找到第一个大于 X 的值,作为新的自增值。
auto_increment_offset
和 auto_increment_increment
是两个系统参数:分别用来表示自增的初始值和步长,默认值都是 1。
比如,双M的主备结构有双写时,我们就可以把步长设置为2。这是一个库是奇数,一个库是偶数,避免两个主库主键冲突。
3.自增值修改时机
为什么会出现自增值不连续呢?
举例:
-- 假设null位置是自增主键
insert into t values(null, 1, 1);
执行逻辑:
- 将插入数据构造加入自增值,假设自增值由2增长到3,即插入(3,1,1)
- 假如此时发现,已经有一行数据为(2,1,1),唯一索引冲突,无法插入。此时插入语句回退。
- 但是注意到,自增值并不会回退。
所以说,自增主键 id 不能保证是连续的。唯一键冲突或事务等情况下回滚时,自增值并不会回滚。
MySQL这么设计是为了提升性能。因为多个事务时,回滚时涉及多个事务,代价很大。
举例:
- A事务和B事务同时执行,A申请到 id=2,B申请到 id=3。
- 此时B成功。A需要回滚。
- 那么A回滚后,当前自增值变为2。
- 这就导致了,之后再自增时,还要去判断自增的值,如3,有没有被使用掉了。或者直接把自增锁的粒度增大到事务级别,必须一个一个事务执行,性能更低。
所以,只能保证自增id是自增的,不能保证是连续的。
4.自增锁的优化
自增锁的特点:由上面的分析可以看到,自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。
自增锁的范围:
- MySQL 5.0 版本:锁范围为语句级别。
- MySQL 5.1.22 版本及之后:引入
innodb_autoinc_lock_mode
参数,默认为1:- 为0时:锁范围为语句级别,即语句执行结束后才释放锁。
- 为1时:
- 普通 insert 语句插入:申请之后马上释放。
- insert … select等批量插入:锁范围为语句级别。
- 为2时:申请之后马上释放。
即,虽然之后优化为申请后马上释放,但批量插入时还是保留了语句级别的锁。
上面说的批量插入并不是insert()
这样做是为了保证数据一致性。
1.批量插入语句:自增锁级别与日志格式搭配
此时必须保证自增锁是语句级别的,因为必须保证批量插入的语句,生成的id是连续自增的,因为statement格式的日志记录批量插入这一条语句,生成的id必须是连续递增的,否则无法保证数据一致性。
此时innodb_autoinc_lock_mode
设置为0或1。
批量插入概念:这里的批量插入,不是说insert(111,222,333)这种批量插入。而是insert … select、replace … select 和 load data 语句。这几个批量插入语句是可以被其他事务打断的。
以下举例:
在sessionB执行insert …select语句时,如果自增锁不是语句级别,那么这个语句执行过程中可能被打断,即会被sessionA申请到自增值。此时它的id就不是连续的。那么statement的语句恢复时,就没办法描述这一段不连续的数据。
可以设想一下,如果 session B 是申请了自增值以后马上就释放自增锁,那么就可能出现这样的情况:
1.session B 先插入了两个记录,(1,1,1)、(2,2,2);
2.然后,session A 来申请自增 id 得到 id=3,插入了(3,5,5);
3.之后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。
原库 session B 的 insert 语句,生成的 id 不连续。这个不连续的 id,用 statement 格式的 binlog 来串行执行,是执行不出来的。
要解决以上问题,就只有两个思路:
- 自增锁用语句级别:此时就id就是固定连续的,statement日志可以描述。即binlog_format = statement + innodb_autoinc_lock_mode = 0/1。
- 用row格式的日志:既然statement日志没法描述,那么binlog_format 设置为 binlog_format = row + innodb_autoinc_lock_mode = 2。
批量插入指的是insert … select、replace … select 和 load data 语句。
2.批量插入语句:申请锁数量的优化。
上面说的是自增锁批量语句的优化1,即除了批量插入语句,其他全部改为申请完立即释放。
下面优化2,即批量申请id。
单条insert语句,即insert(aaa, bbb, ccc..),是知道要申请多少个自增id的,所以批量申请即可。
批量插入语句,并不知道会插入多少条数据。所以是多次申请。此时申请数量对申请次数改变:
- 语句执行过程中,第一次申请自增 id,会分配 1 个;
- 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
- 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
- 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。
这也是自增id不连续的第3个原因。批量插入时,申请的id会有浪费。
小结
自增值不同引擎不同,InnoDB8.0后保存在redo log。
自增值在语句执行完成前就会先修改。
自增锁主要是考虑批量插入语句的问题。
提问
自增主键一定是自增的吗?自增值保存在哪里?不同引擎不一样。MyISAM保存到数据文件。InnoDB在5.7版本及之前,保存到内存,每次重启时去找,此时可能会重置自增值。8.0版本后保存到redo log日志。
自增值增长逻辑?指定/不指定具体值时?增长的步长?
自增id能保证是连续的吗?为什么这么设计?唯一索引回滚、事务回滚、批量插入时申请的id会有浪费。
自增锁什么级别?为什么?默认是,普通插入为立即释放,批量插入为语句级别。可以根据参数变更。保证数据一致性。
自增所级别如何设置?binlog为statement时,必须设置为0或1,因为必须保证批量插入的id是连续的,否则statement格式的日志无法精确的记录不连续的id值。binlog为row时可设置为2,即全部立即释放。
自增锁有哪些优化?批量插入时,用语句级别锁,其他时候立即释放。批量插入时,申请id数量随申请次数成指数递增。
在最后一个例子中,执行 insert into t2(c,d) select c,d from t; 这个语句的时候,如果隔离级别是可重复读(repeatable read),binlog_format=statement。这个语句会对表 t 的所有记录和间隙加锁。你觉得为什么需要这么做呢?
40.insert语句的锁为什么这么多
在MySQL中,普通的insert是一个比较轻量的操作。
但是有些insert的情况比较特殊,需要加锁等。本篇就来讨论这些情况,比如前面的insert…select批量插入语句。
1.insert … select 语句
加锁场景:insert…select语句,在可重复读隔离级别下,binlog_format=statement 时,执行时需要对表 t 的所有行和间隙加锁。
加锁原因:为了保持statement格式的binlog日志和数据的一致性。根本原因还是statement的描述性差的问题。
以下举例:
假设数据如下:
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t
在binlog_format=statement,隔离级别为可重复读时执行:
insert into t2(c,d) select c,d from t;
此时有两个事务,执行序列如下图:
假如执行顺序是:sessionB开始执行批量插入 -> sessionA插入数据 -> sessionB执行批量插入完毕,写入binlog。那么此时binlog如下:
insert into t values(-1,-1,-1);
insert into t2(c,d) select c,d from t;
很显然,t2实际插入的数据是包含了-1的,但是根据上述binlog恢复数据的话,显然不会包含-1这一条记录。所以造成了数据和日志的不一致性。
2.insert 循环写入
也是数据 insert select 情况的一种。
一遍遍历一遍插入的情况:从表t中查询一个数据出来,再插入入该表。为了防止读到自己插入的数据,因此会进行全表扫描。扫描的结果放到临时表,计算完结果再插入原表。
以下举例:
1.非循环插入的情况:
假如现在的需求是:找到表 t 中 c 列的最大值+1,插入表 t2:
insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1);
加锁范围:此时只加了 t 索引 c 上的 (3,4] 和 (4,supremum] 这两个 next-key lock,以及主键索引上 id=4 这一行。
扫描行数:扫描1行。很简单,从表 t 中按照索引 c ,倒序扫描第一行,拿到结果写入到表 t2 中即可。
2.循环插入的情况
假设需求变更为:找到表 t 中 c 列的最大值+1,插入表 t:
insert into t(c,d) (select c+1, d from t force index(c) order by c desc limit 1);
加锁范围:给索引 c 上的所有间隙都加上共享的 next-key lock。也就是说,这个语句执行期间,其他事务不能在这个表上插入数据。
扫描行数:4 + 1 = 5行。如下:
- 创建临时表,表里有两个字段 c 和 d。
- 按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表,读到 c 和 d 的值写入临时表。这时,Rows_examined=4。
- 由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中。这时,Rows_examined 的值加 1,变成了 5。
为什么需要临时表:防止一边遍历一边插入时,读到刚刚插入的记录,新插入的记录参与计算逻辑,破坏语义。
优化方案:
先insert into到临时表,在从临时表取出来。防止直接insert造成全表扫描:
-- 数据量小,可以用内存临时表
create temporary table temp_t(c int,d int) engine=memory;
insert into temp_t (select c+1, d from t force index(c) order by c desc limit 1);
insert into t select * from temp_t;
drop table temp_t;
3.insert 唯一键冲突
前面两种是边查询边插入的情况,即 insert…select。
下面说下单独 insert 时,唯一键冲突。
1.单个唯一索引,多个事务访问
隔离级别:可重复读。
唯一键索引冲突时,会导致在该索引上加锁。并不是简单的直接返回。
冲突举例如下图:
可以看到,sessionA唯一索引冲突时,并不只是简单地报错返回,还在冲突的索引上加了锁。
此时,session A 持有索引 c 上的 (5,10] 共享 next-key lock(读锁)。从作用上来看,这样做可以避免这一行被别的事务删掉(以确保冲突的语义在本语句内是正确的一致的?)。
2.多个唯一索引,多个事务访问导致死锁
冲突场景:A、B、C同时执行。A拿了记录锁,B、C拿了读锁。此时A回滚,即A瞬间释放了锁。此时B和C都想执行插入,都要加上写锁,也就都在同时等待对方的行锁,从而造成死锁。
举例如下图:
分析:
- A拿到记录锁:在 T1 时刻,启动 session A,并执行 insert 语句,此时在索引 c 的 c=5 上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁(可以回顾下第 21篇介绍的加锁规则)。
- B和C插入时唯一索引冲突,同时拿到读锁:在 T2 时刻,session B 要执行相同的 insert 语句,发现了唯一键冲突,加上读锁;同样地,session C 也在索引 c 上,c=5 这一个记录上,加了读锁。(唯一键索引冲突,会加读锁,见前文)
- A瞬间释放记录锁,B和C同时等待对方释放行锁:T3 时刻,session A 回滚。这时候,session B 和 session C 都试图继续执行插入操作,都要加上写锁。两个 session 都要等待对方的行锁,所以就出现了死锁。
流程变化图如下图:
3.insert into … on duplicate key update
上述是主键冲突后报错,可以改为insert into … on duplicate key update
:
insert into t values(11,10,10) on duplicate key update d=100;
insert into … on duplicate key update
语义:插入一行数据碰到唯一键冲突,就执行后面的更新语句。
违反多个唯一性约束时,按照索引的顺序,修改跟第一个索引冲突的行。
举例:
现在表 t 里面已经有了 (1,1,1) 和 (2,2,2) 这两行,执行语句如下图:
可以看到,主键 id 是先判断的,MySQL 认为这个语句跟 id=2 这一行冲突,所以修改的是 id=2 的行。
此时执行该语句返回的affected rows 是 2,是因为insert和update都认为自己执行成功了,所以都+1。
小结
- insert … select :很常见的在两个表之间拷贝数据的方法。在可重复读隔离级别下,这个语句会给 select 的表里扫描到的记录和间隙加读锁。
- insert 和 select 是同一个表:有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。
- insert 时如果出现唯一键冲突:会*在冲突的唯一值上加共享的 next-key lock(S 锁)*。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。
提问
insert…select执行时会加什么锁?为什么?
insert批量操作有什么要注意的?唯一性索引有什么要注意的?
你平时在两个表之间拷贝数据用的是什么方法,有什么注意事项吗?在你的应用场景里,这个方法,相较于其他方法的优势是什么呢?
41.怎么最快地复制一张表?
如何在两张表中拷贝数据?
- 数据量和加锁范围小:直接 insert…select
- 无锁方案:现将数据写到外部数据文件,再写回目标表。
一般线上使用无锁方案。有两种常用方法,本篇进行讲解。
本篇用例:db1.t 有1000行数据,需要将 db1. t 中 a>900 的数据导入到 db2.t。
create database db1;
use db1;
create table t(id int primary key, a int, b int, index(a))engine=innodb;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t values(i,i,i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
create database db2;
create table db2.t like db1.t
1.mysqldump 方法
操作流程:
- 用 mysqldump 命令将数据导出成一组 INSERT 语句,并把结果输出到临时文件。
- 将这些 INSERT 语句放到 db2 库里去执行。可使用source命令。
1.mysqldump 命令
执行如下:
mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql
命令参数解析:
- –single-transaction :导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
- –add-locks 设置为 0:表示在输出的文件结果里,不增加” LOCK TABLES t WRITE;” ;
- –no-create-info :不需要导出表结构;
- –set-gtid-purged=off :不输出跟 GTID 相关的信息;
- –result-file :指定输出文件的路径。其中 client 表示生成的文件是在客户端机器上的。
生成insert语句如下图:
可以看到,insert是value对是在一起的,为了加快执行速度。如果需要分开,每个insert 语句一个value,可以加参数–skip-extended-insert
。
2.用source命令将sql语句写回
执行如下:
mysql -h127.0.0.1 -P13000 -uroot db2 -e "source /client_tmp/t.sql"
source是命令,用来打开文件并执行里面的sql。
2.导出 CSV 文件方法
1.执行备份语句
MySQL 提供了下面的语法,用来将查询结果导出到服务端本地目录:
select * from db1.t where a>900 into outfile '/server_tmp/t.csv';
该语句需要注意:
- 结果是保存在服务端:也就是说,如果执行命令的客户端和 MySQL 服务端不在同一个机器上,客户端机器不会生成 t.csv 文件。
- into outfile 指定了文件的生成位置(/server_tmp/),该位置必须受参数 secure_file_priv 的限制。参数的可选值和作用分别是:
- 设置为 empty:不限制文件生成的位置,这是不安全的设置;
- 设置为一个表示路径的字符串:要求生成的文件只能放在这个指定的目录,或者它的子目录;
- 设置为 NULL:禁止在这个 MySQL 实例上执行 select … into outfile 操作。
- 该命令覆盖文件:需确保 /server_tmp/t.csv 这个文件不存在,否则执行语句时会报错。
- 命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\”这个转义符,这样就可以跟字段之间、数据行之间的分隔符区分开。
2.写回数据
可以用下面的 load data 命令将数据导入到目标表 db2.t:
load data infile '/server_tmp/t.csv' into table db2.t;
load语句的执行流程如下:
- 打开文件 /server_tmp/t.csv,以制表符 (\t) 作为字段间的分隔符,以换行符(\n)作为记录之间的分隔符,进行数据读取;
- 启动事务。
- 判断每一行的字段数与表 db2.t 是否相同:
- 若不相同,则直接报错,事务回滚;
- 若相同,则构造成一行,调用 InnoDB 引擎接口,写入到表中。
- 重复步骤 3,直到 /server_tmp/t.csv 整个文件读入完成,提交事务。
3.备库binlog_format=statement时如何备份重放load语句
比较好理解,因为CSV文件只放在本地嘛。
其实备库备份过程是:主库把CSV写到binlog里,然后创造一个临时文件位置。备库从binlog里拿出csv数据,写到该临时文件位置。然后在从该位置执行load命令。具体如下:
- 主库csv写到binlog:主库执行完成后,将 /server_tmp/t.csv 文件的内容直接写到 binlog 文件中。
- 主库创建临时文件位置:往 binlog 文件中写入语句 load data local infile ‘/tmp/SQL_LOAD_MB-1-0’ INTO TABLE
db2
.t
。 - 把这个 binlog 日志传到备库。
- 备库的 apply 线程在执行这个事务日志时:
a. 先将 binlog 中 t.csv 文件的内容读出来,写入到本地临时目录 /tmp/SQL_LOAD_MB-1-0 中;
b. 再执行 load data 语句,往备库的 db2.t 表中插入跟主库相同的数据。
执行流程如下图:
注意到备库执行load文件时,加了local参数。
load加不加local的区别:
- 不加“local”:读取服务端的文件,这个文件必须在 secure_file_priv 指定的目录或子目录下;
- 加上“local”:读取的是客户端的文件。此时,MySQL 客户端会先把本地文件传给服务端,然后执行上述的 load data 流程。
另外,select …into outfile 方法不会生成表结构文件。所以导入前要先建好表。mysqldump 提供了一个–tab 参数,可以同时导出表结构定义文件和 csv 数据文件。使用方法如下:
mysqldump -h$host -P$port -u$user ---single-transaction --set-gtid-purged=OFF db1 t --where="a>900" --tab=$secure_file_priv
3.物理拷贝方法
前述两种方法是逻辑方法,即先导出为文件,再把文件导回。
有物理方法吗?比如,直接把 db1.t 表的.frm 文件和.ibd 文件拷贝到 db2 目录下?
答案是不行。一个 InnoDB 表,除了包含这两个物理文件外,还需要在数据字典中注册。直接拷贝这两个文件的话,因为数据字典中没有 db2.t 这个表,系统是不会识别和接受它们的。
MySQL 5.6 版本引入了可传输表空间(transportable tablespace) 的方法,可以通过导出 + 导入表空间的方式,实现物理拷贝表的功能。
假设我们现在的目标是在 db1 库下,复制一个跟表 t 相同的表 r,具体的执行步骤如下:
- 执行 create table r like t,创建一个相同表结构的空表;
- 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
- 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
- 在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
- 执行 unlock tables,这时候 t.cfg 文件会被删除;
- 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。
至此,拷贝表数据的操作就完成了。这个流程的执行过程图如下图:
关于拷贝表的这个流程,有以下几个注意点:
- 在第 3 步执行完 flsuh table 命令之后,db1.t 整个表处于只读状态,直到执行 unlock tables 命令后才释放读锁;
- 在执行 import tablespace 的时候,为了让文件里的表空间 id 和数据字典中的一致,会修改 r.ibd 的表空间 id。而这个表空间 id 存在于每一个数据页中。因此,如果是一个很大的文件(比如 TB 级别),每个数据页都需要修改,所以你会看到这个 import 语句的执行是需要一些时间的。当然,如果是相比于逻辑导入的方法,import 语句的耗时是非常短的。
小结
对比一下这三种方法的优缺点。
- 物理拷贝方式:速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性:
- 必须是全表拷贝,不能只拷贝部分数据;
- 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用;
- 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用。
- 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件,来实现只导出部分数据。这个方式的不足之一是,不能使用 join 这种比较复杂的 where 条件写法。
- 用 select … into outfile生成csv 的方法:是最灵活的,支持所有的 SQL 写法。缺点之一是,每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份。
后两种方式都是逻辑备份方式,是可以跨引擎使用的。
提问
快速的复制导入表数据,有哪些方法?各有什么优缺点?
MySQL 解析 statement 格式的 binlog 的时候,对于 load data 命令,解析出来为什么用的是 load data local?
(1)这样做的一个原因是,为了确保备库应用 binlog 正常。因为备库可能配置了 secure_file_priv=null,所以如果不用 local 的话,可能会导入失败,造成主备同步延迟。(2)另一种应用场景是使用 mysqlbinlog 工具解析 binlog 文件,并应用到目标库的情况。你可以使用下面这条命令,把日志直接解析出来发给目标库执行。增加 local,就能让这个方法支持非本地的 $host。:
mysqlbinlog $binlog_file | mysql -h$host -P$port -u$user -p$pwd
42.grant之后要跟着flush privileges吗?
grant 语句是用来给用户赋权的。一些操作文档会要求,grant 之后要马上跟着执行一个 flush privileges 命令,才能使赋权语句生效。
grant 之后真的需要执行 flush privileges 才能生效吗?本篇进行解答。先说结论,不用。grant命令会同时更改表数据和内存数据。只有不规范的操作时需要使用,比如直接修改表数据,此时需要用flush privileges更新内存数据,即删掉内存数据,拉最新的表数据填入。
我们先创建一个用户:
-- 在 % 创建用户ua,密码是pa
-- 注意到,在 MySQL 里面,用户名 (user)+ 地址 (host) 才表示一个用户,因此 ua@ip1 和 ua@ip2 代表的是两个不同的用户。
create user 'ua'@'%' identified by 'pa';
该create命令做了两个动作:
- 磁盘上:往 mysql.user 表里插入一行。由于没有指定权限,所以这行数据所有表示权限的字段的值都是 N;(有权限为Y,没有为N)
- 内存里:往数组 acl_users 里插入一个 acl_user 对象,这个对象的 access 字段值为 0。(有权限为1,没有为0)。
数据行如下图:
下面一一说下各个权限范围下,数据的情况:
- 全局范围
- 数据库范围
- 表范围
1.全局权限
作用范围:整个 MySQL 实例。
权限表:保存在 mysql.user 表。
内存位置:数组 acl_users。
赋值权限语句:grant all privileges on *.* to 'ua'@'%' with grant option;
赋值语句解析:
- 磁盘上,将 mysql.user 表里,用户’ua’@’%’这一行的所有表示权限的字段的值都修改为‘Y’;
- 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 值(权限位)修改为二进制的“全 1”。
执行后效果:新的用户登录时,为新连接生成一个线程对象,权限信息放到线程内部保存,从acl_users数组拷贝。也就是说,已经存在的连接,全局权限不受 grant 命令的影响。
收回权限语句:revoke all privileges on *.* from 'ua'@'%';
收回语句解析:
- 磁盘上,将 mysql.user 表里,用户’ua’@’%’这一行的所有表示权限的字段的值都修改为‘N’;
- 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 值(权限位)修改为二进制的“全 0”。
2.db 权限
作用范围:某一个db。
权限表:保存在 mysql.db 表。
内存位置:数组 acl_dbs。
赋值权限语句:grant all privileges on db1.* to 'ua'@'%' with grant option;
赋值语句解析:
- 磁盘上,往 mysql.db 表中插入了一行记录,所有权限位字段设置为“Y”;
- 内存里,增加一个对象到数组 acl_dbs 中,这个对象的权限位为“全 1”。
此时用户 ua 在 db 表中的状态如下图:
执行后效果:每次需要判断用户对db读写权限的时候,都需要遍历一次 acl_dbs 数组,根据 user、host 和 db 找到匹配的对象,然后根据对象的权限位来判断。即db的权限会在use db后缓存到会话变量里,切出该db后失效。也就是说,grant以后,如果一个会话执行use dbXX,那么才会拿到最新权限。
3.表权限和列权限
作用范围:某一个表权限或列权限。
权限表:表权限保存在 mysql.tables_priv 表。列权限保存在mysql.columns_priv表。
内存位置:两种权限组合起来,放到内存的 hash 结构 column_priv_hash 中。
赋值权限语句:
create table db1.t1(id int, a int);
grant all privileges on db1.t1 to 'ua'@'%' with grant option;
GRANT SELECT(id), INSERT (id,a) ON mydb.mytbl TO 'ua'@'%' with grant option;
赋值语句解析:
- 磁盘上,往 mysql.tables_priv 表或mysql.columns_priv表中插入了一行记录,所有权限位字段设置为“Y”;
- 内存里,增加一个对象到column_priv_hash中。
执行后效果:马上影响到已经存在的连接。
4.flush privileges 使用场景
flush privileges
语义:清空内存数据,从表中加载最新数据。
如果内存的权限数据和磁盘数据表相同的话,不需要执行 flush privileges。而如果我们都是用 grant/revoke 语句来执行的话,内存和数据表本来就是保持同步更新的。因此,正常情况下,grant 命令之后,没有必要跟着执行 flush privileges 命令。
显然,当数据表中的权限数据跟内存中的权限数据不一致时,flush privileges 语句可以用来重建内存数据,达到一致状态。这种情况一般都是不规范操作引起的,比如直接修改数据表来修改权限。不要这样操作。
小结
grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。因此,规范地使用 grant 和 revoke 语句,是不需要随后加上 flush privileges 语句的。
flush privileges 语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以我们尽量不要使用这类语句。
另外,在使用 grant 语句赋权时,你可能还会看到这样的写法:
grant super on *.* to 'ua'@'%' identified by 'pa';
这条命令加了 identified by ‘密码’, 语句的逻辑里面除了赋权外,还包含了:
- 如果用户’ua’@’%’不存在,就创建这个用户,密码是 pa;
- 如果用户 ua 已经存在,就将密码修改成 pa。
这也是一种不建议的写法,因为这种写法很容易就会不慎把密码给改了。
提问
grant 之后真的需要执行 flush privileges 才能生效吗?是立即生效的吗?不用。会同时更新表和内存的。全局级别缓存到会话线程里,db级别在每次use db时重新缓存到会话,表或列级别grant后立即更新权限。
43.要不要使用分区表?
分区表有什么问题,为什么公司规范不让使用分区表呢?本篇来聊下分区表的使用行为。
场景都是单机分区。集群用不了分区(NDB引擎好像支持cluster模式)。
1.分区表是什么?
举例。创建以下表 t:
CREATE TABLE `t` (
`ftime` datetime NOT NULL,
`c` int(11) DEFAULT NULL,
KEY (`ftime`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
insert into t values('2017-4-1',1),('2018-4-1',1);
生成了多个ibd文件如下图:
表中的两行数据,分别落到了p_2018和p_2019分区。
注意到,该表有1个.frm文件,4个.ibd文件(每个分区一个)。也就是说:
- 对于引擎层来说:这是 4 个表;
- 对于 Server 层来说:这是 1 个表。
以上结论非常重要。
2.分区表的引擎层行为
1.InnoDB分区表的例子
对于InnoDB来说,是4个表。举例如下图:
我们初始时插入了2017-4-1’ 和’2018-4-1’两条数据。
那么T1时刻,对于普通标来说,间隙和加锁状态如下图:
但是,从上面的实验效果可以看出,加锁是分开的。如下图:
所以此时InnoDB是当做4个不同的表来处理。
2.MyISAM分区表的例子
执行情况如下图:
MyISAM的锁级别是表级别的。但是可以看到,sessionB第一条语句是执行成功的。
所以可以看到,MyISAM也是把4个分区看做是4张不同的表。
3.分区表和手动分区的区别
分区表和手工分表:一个是由 server 层来决定使用哪个分区,一个是由应用层代码来决定使用哪个分表。因此,从引擎层看,这两种方式也是没有差别的。
区别主要是在Server层。最大的问题是打开表的行为。
3.分区策略
我们来看下是怎么分区的。
每当第一次访问一个分区表时,MySQL 需要把所有的分区都访问一遍。
假设一个表的分区很多,那么如果使用的是MyISAM引擎,可能会在第一次访问分区表时,由于分区的数量超过了1024(open_files_limit参数的默认值)而报错。例如:
很明显,插入语句只需要访问1个分区,但是由于分区太多超上限,第一次访问的时候就报错了。
当然,如果用的是InnoDB引擎就不会有以上问题。
下面是两种分区策略:
- 通用分区策略(generic partitioning):每次访问分区都由 server 层控制。MySQL一开始支持分区表时就使用的策略,在文件管理、表管理的实现上很粗糙,性能也很差。MyISAM引擎使用的分区策略。
- 本地分区策略(native partitioning):InnoDB引擎引入的,在 InnoDB 内部自己管理打开分区的行为。
MySQL 从 5.7.17 开始,将 MyISAM 分区表标记为即将弃用 (deprecated)。8.0版本禁止创建MyISAM分区表。
目前支持本地分区策略的引擎:仅有InnoDB 和 NDB。
4.分区表的 server 层行为
从server层来看,一个分区就是一个表。
如下图举例,在两个分区分别执行MDL操作,此时会被闭锁,说明是作为一个表来看待:
小结下:
- MySQL 在第一次打开分区表的时候,需要访问所有的分区;
- 在 server 层,认为这是同一张表,因此所有分区共用同一个 MDL 锁;
- 在引擎层,认为这是不同的表,因此 MDL 锁之后的执行过程,会根据分区表规则,只访问必要的分区。
必要的分区的判断:
- where ftime=‘2018-4-1’:根据计算结果,落在p_2019分区。
- where ftime>=‘2018-4-1’:虽然计算结果相同,但落在p_2019 和 p_others 这两个分区。
- where中没有分区key:扫描所有分区。
5.分区表的应用场景
分区表:
优点:
- 对业务透明
- 还可以很方便的清理历史数据。例如,直接通过 alter table t drop partition …这个语法删掉分区,可以一次drop掉部分历史数据。与使用 delete 语句删除数据相比,优势是速度快、对系统影响小。
小结
上面讲的是范围分区,MySQL还支持hash 分区、list 分区等分区方法。
分区表的缺点主要是:
- 第一次访问的时候需要访问所有分区。
- 共用 MDL 锁。
因此,如果决定使用分区表,分区不要创建太多太细。
此外,上面讲的是单服务器的分区。集群分区可以看NDB引擎。
用分区表还是用中间件,根据团队自己决定。
比如,阿里云的DRDS就是分库分表的中间件典型代表。自己实现了一个层Server访问层在这一层进行分库分表(对透明),然后MySQL只是相当于存储层。一些Join、负载Order by/Group by都在DRDS中间件这层完成,简单的逻辑插叙计算完对应的分库分表后下推给MySQL
提问
分区表是什么?底层是怎么分割分区表的?分区表优缺点?server层、引擎层
44.答疑文章(三):一些好问题(暂略待补充)
45.自增id用完怎么办?
本篇分析MySQL 里面的几种自增 id,它们的值达到上限以后,会出现什么情况。
1.表定义自增值 id
此时上限是表字段的最大数上限。
create table t(id int unsigned auto_increment primary key) auto_increment=4294967295;
insert into t values(null);
// 成功插入一行 4294967295
show create table t;
/* CREATE TABLE `t` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4294967295;
*/
insert into t values(null);
//Duplicate entry '4294967295' for key 'PRIMARY'
增到最大后,就不再增大。再次取自增值,会取到最大值,插入时报错。
2.InnoDB 系统自增 row_id
就是我们的表不指定主键时,InnoDB帮我们自动创建的隐式主键。
隐式主键长度为 6 个字节的 row_id。
InnoDB 维护了一个全局的 dict_sys.row_id
值,所有无主键的 InnoDB 表,每插入一行数据,都将当前的 dict_sys.row_id
值作为要插入数据的 row_id,然后把 dict_sys.row_id 的值加 1。
InnoDB代码实现上时8个字节长度,但是设计上是6个字节长度。所以row_id特性如下:
- row_id 写入表中的值范围:从 0 到 2^48-1;
- 当 dict_sys.row_id=2^48时:如果申请 row_id,拿到以后再取最后 6 个字节的话就是 0(代码是8个字节)。
也就是说,写入表的 row_id 是从 0 开始到 2^48-1。达到上限后,下一个值就是 0,然后继续循环。
在 InnoDB 逻辑里,申请到 row_id=N 后,就将这行数据写入表中;如果表中已经存在 row_id=N 的行,新写入的行就会覆盖原有的行。所以建议一定要创建自己的主键。
要验证这个结论的话,你可以通过 gdb 修改系统的自增 row_id 来实现。
3.Xid
15篇讲到, redo log 和 binlog 相配合的时候,提到了它们有一个共同的字段叫作 Xid。它在 MySQL 中用来对应事务。
MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1。如果当前语句是这个事务执行的第一条语句,那么同时把 Query_id 赋值给这个事务的 Xid。
global_query_id
只在内存里维护,重启后会清零。- 所以在同一个数据库实例中,不同事务的 Xid 也是有可能相同的。
- MySQL 重启之后会重新生成新的 binlog 文件,这就保证了,同一个 binlog 文件里,Xid 一定是惟一的。
global_query_id 达到上限后,就会继续从 0 开始计数。此时才有可能在同一个binlog里有相同的Xid。
global_query_id
定义的长度是 8 个字节,这个自增值的上限是 2^64-1。
4.Innodb trx_id
- Xid 是由 server 层维护的。InnoDB 内部使用 Xid,是为了能够在 InnoDB 事务和 server 之间做关联。
- 但是,InnoDB 自己的 trx_id,是另外维护的。
第8篇讲到事务可见性时,用到了事务 id(transaction id)。
InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。
InnoDB 数据可见性的核心思想:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。对于正在执行的事务,你可以从 information_schema.innodb_trx
表中看到事务的 trx_id。
1.举例
假设有以下事务现场:
sessionA的线程id是5,就是sessionB中查到的会话线程。
两次查到的trx_id为什么不一样?因为涉及到更新时才会分配 trx_id。第一次查到的trx_id其实是0,这个很大的数只是显示时使用的。
注意:如果在 select 语句后面加上 for update,这个事务也不是只读事务。
2.trx_id 值并不是按照加 1 递增的
- update 和 delete 语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到 purge 队列里等待后续物理删除,这个操作也会把 max_trx_id+1, 因此在一个事务中至少加 2;
- InnoDB 的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id 值并不是按照加 1 递增的。
3.T2代表只读事务的很大的数字怎么来的
每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 2^48。该算法有以下好处:
- 保证同一个事务查出来的trx_id相同:因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx 还是在 innodb_locks 表里,同一个只读事务查出来的 trx_id 就会是一样的。
- 保证不同的并发事务查出的trx_id不同:如果有并行的多个只读事务,每个事务的 trx 变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的 trx_id 就是不同的。
- 为什么加2^48:为了让它显眼。
4.只读事务不分配 trx_id,有什么好处
好处如下:
- 可以减小事务视图里面活跃事务数组的大小:因为当前正在运行的只读事务,是不影响数据的可见性判断的。所以,在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id。
- 可以减少 trx_id 的申请次数:因为哪怕是select,也是一个事务。这样做大大减少可以并发事务申请 trx_id 的锁冲突。
5.trx_id达到上限怎么办
max_trx_id
是持久化存储,而且重启也不会重置为 0。所以理论上来说,事务数量是可以超过max_trx_id
达到 2^48-1 的上限的,此时重新从 0 开始的情况。
重新从0开始后,MySQL 就会持续出现一个脏读的 bug,即导致了可见性问题。
验证方式:
手动把当前的 max_trx_id 先修改成 2^48-1。如下图:
此时:
- T1时刻,session A 启动的事务 TA 的低水位就是 2^48-1。
- T2时刻,session B 执行第一条 update 语句的事务 id 就是 2^48-1,此时第二条 update 语句的事务 id 就是 0 了,这条 update 语句执行后生成的数据版本上的 trx_id 就是 0。
- T3时刻,session A 执行 select 语句,判断可见性发现,c=3 这个数据版本的 trx_id,小于事务 TA 的低水位,因此认为这个数据可见。从而导致了脏读。
并且,以上Bug,重启了还在存在,无法消除。
bug 也是只存在于理论上吗?用50万/秒的TPS,跑上17.8年,就会出现。
5.thread_id
线程 id 才是 MySQL 中最常见的一种自增 id。平时我们在查各种现场的时候,show processlist 里面的第一列,就是 thread_id。
thread_id 生成逻辑:系统保存了一个全局变量 thread_id_counter
,每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量。
thread_id_counter 定义的大小是 4 个字节,因此达到 2^32-1 后,它就会重置为 0,然后继续增加。
不过不会出现重复的线程Id,因为MySQL 设计了一个唯一数组的逻辑,给新线程分配 thread_id 的时候,逻辑代码如下:
do {
new_id= thread_id_counter++;
} while (!thread_ids.insert_unique(new_id).second);
小结
每种自增 id 有各自的应用场景,在达到上限后的表现也不同:
- 表的自增 id 达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。
- row_id 达到上限后,则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据。
- Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计。
- InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,所以我们文章中提到的脏读的例子就是一个必现的 bug,好在留给我们的时间还很充裕。
- thread_id 是我们使用中最常见的,而且也是处理得最好的一个自增 id 逻辑了。
当然,在 MySQL 里还有别的自增 id,比如 table_id、binlog 文件序号等
提问
常见的id有哪些?用完怎么办?
转载请注明来源