维护一个支持邮箱登录的系统,用户表是这么定义的:
mysql> create table SUser( ID bigint unsigned primary key, email varchar(64), ... )engine=innodb; 登录操作,有类似这样的语句 mysql> select f1, f2 from SUser where email='xxx';
如果 email 这个字段上没有索引,那么这个语句就只能做全表扫描。
同时,MySQL 是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。
比如,这两个在 email 字段上创建索引的语句:
mysql> alter table SUser add index index1(email); 或 mysql> alter table SUser add index index2(email(6));
前缀索引占用的空间会更小,这同时带来的损失是,可能会增加额外的记录扫描次数。
分析语句的执行流程:
select id,name,email from SUser where email='zhangssxyz@xxx.com';
如果使用的是 index1(即 email 整个字符串的索引结构),这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。
如果使用的是 index2(即 email(6) 索引结构),可能要扫描多行。
使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。
语句:
mysql> select count(distinct email) as L from SUser; 可以一同使用 DISTINCT 和 COUNT 关键词,来计算非重复结果的数目。 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;
当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例。
语句:
select id,email from SUser where email='zhangssxyz@xxx.com'; select id,name,email from SUser where email='zhangssxyz@xxx.com';
如果使用 index1(即 email 整个字符串的索引结构)的话,可以利用覆盖索引,从index1 查到结果后直接就返回了,不需要回到 ID 索引再去查一次。
而如果使用 index2(即email(6) 索引结构)的话,就不得不回到 ID 索引再去判断 email 字段的值。
使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀索引时需要考虑的一个因素。
第一种方式是使用倒序存储。
如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写:
mysql> select field_list from t where id_card = reverse('input_id_card_string');
第二种方式是使用 hash 字段。
你可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。
mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc);
使用倒序存储和使用 hash 字段这两种方法的异同点:
而 hash字段方法需要增加一个字段。
当然,倒序存储方式使用 4 个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个 hash 字段也差不多抵消了。
而hash 字段的方式需要额外调用一次 crc32() 函数。
如果只从这两个函数的计算复杂度来看的话,reverse 函数额外消耗的 CPU 资源会更小些。
因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。
而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。
当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。
内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。
MySQL 偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。
例如这几种场景:
系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。
MySQL 认为系统“空闲”的时候,也要见缝插针地找时间,只要有机会就刷一点“脏页”。
MySQL 正常关闭的情况。这时候,MySQL 会把内存的脏页都 flush到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快。
其中第三第四种情况不需要多考虑,
第一种是“redo log 写满了,要 flush 脏页”,这种情况是 InnoDB 要尽量避免的。
因为出现这种情况的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。如果你从监控上看,这时候更新数会跌为 0。
第二种是“内存不够用了,要先将脏页写到磁盘”,这种情况其实是常态。
InnoDB 用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:
InnoDB 的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。
所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:
要知道 InnoDB 所在主机的 IO 能力,这样 InnoDB 才能知道需要全力刷脏页可以刷多快。
这就要用到 innodb_io_capacity 这个参数了,它会告诉 InnoDB 你的磁盘能力。
这个值我建议你设置成磁盘的 IOPS。磁盘的 IOPS 可以通过 fio 这个工具来测试。
InnoDB 的刷盘速度就是要参考这两个因素:
现在你知道了,InnoDB 会在后台刷脏页,而刷脏页的过程是要将内存页写入磁盘。
所以,无论是你的查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用 IO 资源并可能影响到了你的更新语句,都可能是造成你从业务端感知到 MySQL“抖”了一下的原因。
要尽量避免这种情况,你就要合理地设置 innodb_io_capacity 的值,并且平时要多关注脏页比例,不要让它经常接近 75%。
其中,脏页比例是通过 Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total
得到的。
接下来,我们再看一个有趣的策略。 一旦一个查询请求需要在执行过程中先 flush 掉一个脏页时,这个查询就可能要比平时慢了。而 MySQL 中的一个机制,可能让你的查询会更慢:在准备刷一个脏页的时候,如果这个数据页旁 边的数据页刚好是脏页,就会把这个“邻居”也带着一起刷掉;而且这个把“邻居”拖下水的逻 辑还可以继续蔓延,也就是对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会 被放到一起刷。 在 InnoDB 中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为 1 的时候会有上 述的“连坐”机制,值为 0 时表示不找邻居,自己刷自己的。 找“邻居”这个优化在机械硬盘时代是很有意义的,可以减少很多随机 IO。机械硬盘的随机 IOPS 一般只有几百,相同的逻辑操作减少随机 IO 就意味着系统性能的大幅度提升。 而如果使用的是 SSD 这类 IOPS 比较高的设备的话,我就建议你把 innodb_flush_neighbors 的值设置成 0。因为这时候 IOPS 往往不是瓶颈,而“只刷自己”,就能更快地执行完必要的刷 脏页操作,减少 SQL 语句响应时间。 在 MySQL 8.0 中,innodb_flush_neighbors 参数的默认值已经是 0 了。
一个 InnoDB 表包含两部分,即:表结构定义和数据。
在 MySQL 8.0 版本以前,表结构是存在以.frm 为后缀的文件里。而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了。因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据。
表数据既可以存在共享表空间里,也可以是单独的文件。
这个行为是由参数 innodb_file_per_table 控制的:
这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。
从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。
我建议你不论使用 MySQL 的哪个版本,都将这个值设置为 ON。
因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。
而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。
在B+树的结构中就算是删除了,它还是没有释放这个空间,而是可能会复用这个位置,所以磁盘的文件大小不会缩小。
(就算是一页的数据给删除了,那也只是说明,这一页可以片被复用了)
但是,数据页的复用跟记录的复用是不同的。
delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。
也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。
实际上,不止是删除数据会造成空洞,插入数据也会。
重建表
alter table A engine=InnoDB 命令来重建表,重建之后更紧凑,数据页的利用率也更高。
在 MySQL 5.5 版本之前,这个命令的执行流程跟我们前面描述的差不多,区别只是这个临时表 B 不需要你自己创建, MySQL 会自动完成转存数据、交换表名、删除旧表的操作。
但是这个 DDL 不是 Online 的。
而在MySQL 5.6 版本开始引入的 Online DDL,对这个操作流程做了优化。
1. 建立一个临时文件,扫描表 A 主键的所有数据页; 2. 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中; 3. 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中 4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件 5. 用临时文件替换表 A 的数据文件。
简单来说就是:+ row log,把DDL过程中的操作弄进去了。
这里需要注意的是,我们在这篇文章里讨论的是没有过滤条件的 count(*), 如果加了 where 条件的话,MyISAM 表也是不能返回得这么快的。
那为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢?
这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因, InnoDB表“应该返回多少行”也是不确定的。
count(*) 操作的时候还是做了优化的:
在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。
你可以用一个 Redis 服务来保存这个表的总行数。这个表每被插入一行 Redis 计数就加 1,每被删除一行 Redis 计数就减 1。
问题:可能会丢失更新。
还有:将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使 Redis 正常工作,这个值还是逻辑上不精确的。
如果我们把这个计数直接放到数据库里单独的一张计数表 C 中,又会怎么样呢?
count() 的语义:
count() 是一个聚合函数,对于返回的结果集,一行行判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。
分析性能差别的时候,你可以记住这么几个原则: 1. server 层要什么就给什么; 2. InnoDB 只给必要的值; 3. 现在的优化器只优化了 count(*) 的语义为“取行数”,其他“显而易见”的优化并没有做。
对于 count(主键 id) 来说,
InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。
对于 count(1) 来说,
InnoDB 引擎遍历整张表,但不取值server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。
对于 count(字段) 来说:
如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。
但是 count() 是例外,
并不会把全部字段取出来,而是专门做了优化,不取值。count() 肯定不是 null,按行累加。
把计数放在 Redis 里面,不能够保证计数和 MySQL 表里的数据精确一致的原因,
是这两个不同的存储构成的系统,不支持分布式事务,无法拿到精确一致的视图。
而把计数值也放在MySQL 中,就解决了一致性视图的问题。
InnoDB 引擎支持事务,我们利用好事务的原子性和隔离性,就可以简化在业务开发时的逻辑。这也是 InnoDB 引擎备受青睐的原因之一。
这一章没有我记载,因为自己还有些不明白,抱歉。