2017-我必须得告诉大家的 MySQL 优化原理

原文地址

我必须得告诉大家的 MySQL 优化原理

说起 MySQL 的查询优化,相信大家收藏了一堆奇淫技巧:不能使用 SELECT *、不使用 NULL 字段、合理创建索引、为字段选择合适的数据类型….. 你是否真的理解这些优化技巧?是否理解其背后的工作原理?在实际场景下性能真有提升吗?我想未必。因而理解这些优化建议背后的原理就尤为重要,希望本文能让你重新审视这些优化建议,并在实际业务场景下合理的运用。

如果能在头脑中构建一幅 MySQL 各组件之间如何协同工作的架构图,有助于深入理解 MySQL 服务器。下图展示了 MySQL 的逻辑架构图。

MySQL 逻辑架构

MySQL 逻辑架构整体分为三层,最上层为客户端层,并非 MySQL 所独有,诸如:连接处理、授权认证、安全等功能均在这一层处理。最下层为存储引擎,其负责 MySQL 中的数据存储和提取。和 Linux 下的文件系统类似,每种存储引擎都有其优势和劣势。中间的服务层通过 API 与存储引擎通信,这些 API 接口屏蔽了不同存储引擎间的差异。

MySQL 查询优化

我们总是希望 MySQL 能够获得更高的查询性能,最好的办法是弄清楚 MySQL 是如何优化和执行查询的。一旦理解了这一点,就会发现:很多的查询优化工作实际上就是遵循一些原则让 MySQL 的优化器能够按照预想的合理方式运行而已。当向 MySQL 发送一个请求的时候,MySQL 到底做了些什么呢?

MySQL 请求处理过程

客户端/服务端通信协议

MySQL 客户端/服务端通信协议是“半双工”的:在任一时刻,要么是服务器向客户端发送数据,要么是客户端向服务器发送数据,这两个动作不能同时发生。一旦一端开始发送消息,另一端要接收完整个消息才能响应它,所以我们无法也无须将一个消息切成小块独立发送,也没有办法进行流量控制。客户端用一个单独的数据包将查询请求发送给服务器,所以当查询语句很长的时候,需要设置 max_allowed_packet 参数。但是需要注意的是,如果查询实在是太大,服务端会拒绝接收更多数据并抛出异常。

与之相反的是,服务器响应给用户的数据通常会很多,由多个数据包组成。但是当服务器响应客户端请求时,客户端必须完整的接收整个返回结果,而不能简单的只取前面几条结果,然后让服务器停止发送。因而在实际开发中,尽量保持查询简单且只返回必需的数据,减小通信间数据包的大小和数量是一个非常好的习惯,这也是查询中尽量避免使用 SELECT *以及加上 LIMIT 限制的原因之一。

查询缓存

在解析一个查询语句前,如果查询缓存是打开的,那么 MySQL 会检查这个查询语句是否命中查询缓存中的数据。如果当前查询恰好命中查询缓存,在检查一次用户权限后直接返回缓存中的结果。这种情况下,查询不会被解析,也不会生成执行计划,更不会执行。MySQL 将缓存存放在一个引用表(不要理解成 table,可以认为是类似于 HashMap 的数据结构),通过一个哈希值索引,这个哈希值通过查询本身、当前要查询的数据库、客户端协议版本号等一些可能影响结果的信息计算得来。所以两个查询在任何字符上的不同(例如:空格、注释),都会导致缓存不会命中。

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

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

  • 任何的查询语句在开始之前都必须经过检查,即使这条 SQL 语句永远不会命中缓存
  • 如果查询结果可以被缓存,那么执行完成后,会将结果存入缓存,也会带来额外的系统消耗

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

  • 用多个小表代替一个大表,注意不要过度设计
  • 批量插入代替循环单条插入
  • 合理控制缓存空间大小,一般来说其大小设置为几十兆比较合适
  • 可以通过 SQL_CACHE 和 SQL_NO_CACHE 来控制某个查询语句是否需要进行缓存

最后的忠告是不要轻易打开查询缓存,特别是写密集型应用。如果你实在是忍不住,可以将 query_cache_type 设置为 DEMAND,这时只有加入 SQL_CACHE 的查询才会走缓存,其他查询则不会,这样可以非常自由地控制哪些查询需要被缓存。当然查询缓存系统本身是非常复杂的,这里讨论的也只是很小的一部分,其他更深入的话题,比如:缓存是如何使用内存的?如何控制内存的碎片化?事务对查询缓存有何影响等等,读者可以自行阅读相关资料,这里权当抛砖引玉吧。

语法解析和预处理

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

查询优化

经过前面的步骤生成的语法树被认为是合法的了,并且由优化器将其转化成查询计划。多数情况下,一条查询可以有很多种执行方式,最后都返回相应的结果。优化器的作用就是找到这其中最好的执行计划。MySQL 使用基于成本的优化器,它尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。在 MySQL 可以通过查询当前会话的 last_query_cost 的值来得到其计算当前查询的成本。

mysql> select * from t_message limit 10;
...省略结果集

mysql> show status like 'last_query_cost';
+-----------------+-------------+
| Variable_name   | Value       |
+-----------------+-------------+
| Last_query_cost | 6391.799000 |
+-----------------+-------------+

示例中的结果表示优化器认为大概需要做 6391 个数据页的随机查找才能完成上面的查询。这个结果是根据一些列的统计信息计算得来的,这些统计信息包括:每张表或者索引的页面个数、索引的基数、索引和数据行的长度、索引的分布情况等等。有非常多的原因会导致 MySQL 选择错误的执行计划,比如统计信息不准确、不会考虑不受其控制的操作成本(用户自定义函数、存储过程)、MySQL 认为的最优跟我们想的不一样(我们希望执行时间尽可能短,但 MySQL 值选择它认为成本小的,但成本小并不意味着执行时间短)等等。

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

  • 重新定义表的关联顺序(多张表关联查询时,并不一定按照 SQL 中指定的顺序进行,但有一些技巧可以指定关联顺序)
  • 优化 MIN()和 MAX()函数(找某列的最小值,如果该列有索引,只需要查找 B+Tree 索引最左端,反之则可以找到最大值,具体原理见下文)
  • 提前终止查询(比如:使用 Limit 时,查找到满足数量的结果集后会立即终止查询)
  • 优化排序(在老版本 MySQL 会使用两次传输排序,即先读取行指针和需要排序的字段在内存中对其排序,然后再根据排序结果去读取数据行,而新版本采用的是单次传输排序,也就是一次读取所有的数据行,然后根据给定的列排序。对于 I/O 密集型应用,效率会高很多)
  • 随着 MySQL 的不断发展,优化器使用的优化策略也在不断的进化,这里仅仅介绍几个非常常用且容易理解的优化策略,其他的优化策略,大家自行查阅吧。

查询执行引擎

在完成解析和优化阶段以后,MySQL 会生成对应的执行计划,查询执行引擎根据执行计划给出的指令逐步执行得出结果。整个执行过程的大部分操作均是通过调用存储引擎实现的接口来完成,这些接口被称为 handler API。查询过程中的每一张表由一个 handler 实例表示。实际上,MySQL 在查询优化阶段就为每一张表创建了一个 handler 实例,优化器可以根据这些实例的接口来获取表的相关信息,包括表的所有列名、索引统计信息等。存储引擎接口提供了非常丰富的功能,但其底层仅有几十个接口,这些接口像搭积木一样完成了一次查询的大部分操作。

返回结果给客户端

查询执行的最后一个阶段就是将结果返回给客户端。即使查询不到数据,MySQL 仍然会返回这个查询的相关信息,比如改查询影响到的行数以及执行时间等等。如果查询缓存被打开且这个查询可以被缓存,MySQL 也会将结果存放到缓存中。

结果集返回客户端是一个增量且逐步返回的过程。有可能 MySQL 在生成第一条结果时,就开始向客户端逐步返回结果集了。这样服务端就无须存储太多结果而消耗过多内存,也可以让客户端第一时间获得返回结果。需要注意的是,结果集中的每一行都会以一个满足 ① 中所描述的通信协议的数据包发送,再通过 TCP 协议进行传输,在传输过程中,可能对 MySQL 的数据包进行缓存然后批量发送。回头总结一下 MySQL 整个查询执行过程,总的来说分为 6 个步骤:

  • 客户端向 MySQL 服务器发送一条查询请求
  • 服务器首先检查查询缓存,如果命中缓存,则立刻返回存储在缓存中的结果。否则进入下一阶段
  • 服务器进行 SQL 解析、预处理、再由优化器生成对应的执行计划
  • MySQL 根据执行计划,调用存储引擎的 API 来执行查询
  • 将结果返回给客户端,同时缓存查询结果

性能优化建议

看了这么多,你可能会期待给出一些优化手段,是的,下面会从 3 个不同方面给出一些优化建议。但请等等,还有一句忠告要先送给你:不要听信你看到的关于优化的“绝对真理”,包括本文所讨论的内容,而应该是在实际的业务场景下通过测试来验证你关于执行计划以及响应时间的假设。

Scheme 设计与数据类型优化

选择数据类型只要遵循小而简单的原则就好,越小的数据类型通常会更快,占用更少的磁盘、内存,处理时需要的 CPU 周期也更少。越简单的数据类型在计算时需要更少的 CPU 周期,比如,整型就比字符操作代价低,因而会使用整型来存储 ip 地址,使用 DATETIME 来存储时间,而不是使用字符串。

这里总结几个可能容易理解错误的技巧:

  • 通常来说把可为 NULL 的列改为 NOT NULL 不会对性能提升有多少帮助,只是如果计划在列上创建索引,就应该将该列设置为 NOT NULL。
  • 对整数类型指定宽度,比如 INT(11),没有任何卵用。INT 使用 32 位(4 个字节)存储空间,那么它的表示范围已经确定,所以 INT(1)和 INT(20)对于存储和计算是相同的。
  • UNSIGNED 表示不允许负值,大致可以使正数的上限提高一倍。比如 TINYINT 存储范围是-128 ~ 127,而 UNSIGNED TINYINT 存储的范围却是 0 - 255。
  • 通常来讲,没有太大的必要使用 DECIMAL 数据类型。即使是在需要存储财务数据时,仍然可以使用 BIGINT。比如需要精确到万分之一,那么可以将数据乘以一百万然后使用 BIGINT 存储。这样可以避免浮点数计算不准确和 DECIMAL 精确计算代价高的问题。
  • TIMESTAMP 使用 4 个字节存储空间,DATETIME 使用 8 个字节存储空间。因而,TIMESTAMP 只能表示 1970 - 2038 年,比 DATETIME 表示的范围小得多,而且 TIMESTAMP 的值因时区不同而不同。
  • 大多数情况下没有使用枚举类型的必要,其中一个缺点是枚举的字符串列表是固定的,添加和删除字符串(枚举选项)必须使用 ALTER TABLE(如果只只是在列表末尾追加元素,不需要重建表)。
  • schema 的列不要太多。原因是存储引擎的 API 工作时需要在服务器层和存储引擎层之间通过行缓冲格式拷贝数据,然后在服务器层将缓冲内容解码成各个列,这个转换过程的代价是非常高的。如果列太多而实际使用的列又很少的话,有可能会导致 CPU 占用过高。
  • 大表 ALTER TABLE 非常耗时,MySQL 执行大部分修改表结果操作的方法是用新的结构创建一个张空表,从旧表中查出所有的数据插入新表,然后再删除旧表。尤其当内存不足而表又很大,而且还有很大索引的情况下,耗时更久。当然有一些奇淫技巧可以解决这个问题,有兴趣可自行查阅。

创建高性能索引

索引是提高 MySQL 查询性能的一个重要途径,但过多的索引可能会导致过高的磁盘使用率以及过高的内存占用,从而影响应用程序的整体性能。应当尽量避免事后才想起添加索引,因为事后可能需要监控大量的 SQL 才能定位到问题所在,而且添加索引的时间肯定是远大于初始添加索引所需要的时间,可见索引的添加也是非常有技术含量的。接下来将向你展示一系列创建高性能索引的策略,以及每条策略其背后的工作原理。但在此之前,先了解与索引相关的一些算法和数据结构,将有助于更好的理解后文的内容。

索引相关的数据结构和算法

通常我们所说的索引是指 B-Tree 索引,它是目前关系型数据库中查找数据最为常用和有效的索引,大多数存储引擎都支持这种索引。使用 B-Tree 这个术语,是因为 MySQL 在 CREATE TABLE 或其它语句中使用了这个关键字,但实际上不同的存储引擎可能使用不同的数据结构,比如 InnoDB 就是使用的 B+Tree。B+Tree 中的 B 是指 balance,意为平衡。需要注意的是,B+树索引并不能找到一个给定键值的具体行,它找到的只是被查找数据行所在的页,接着数据库会把页读入到内存,再在内存中进行查找,最后得到要查找的数据。

在介绍 B+Tree 前,先了解一下二叉查找树,它是一种经典的数据结构,其左子树的值总是小于根的值,右子树的值总是大于根的值,如下图 ①。如果要在这课树中查找值为 5 的记录,其大致流程:先找到根,其值为 6,大于 5,所以查找左子树,找到 3,而 5 大于 3,接着找 3 的右子树,总共找了 3 次。同样的方法,如果查找值为 8 的记录,也需要查找 3 次。所以二叉查找树的平均查找次数为(3 + 3 + 3 + 2 + 2 + 1) / 6 = 2.3 次,而顺序查找的话,查找值为 2 的记录,仅需要 1 次,但查找值为 8 的记录则需要 6 次,所以顺序查找的平均查找次数为:(1 + 2 + 3 + 4 + 5 + 6) / 6 = 3.3 次,因为大多数情况下二叉查找树的平均查找速度比顺序查找要快。

平衡二叉树

由于二叉查找树可以任意构造,同样的值,可以构造出如图 ② 的二叉查找树,显然这棵二叉树的查询效率和顺序查找差不多。若想二叉查找数的查询性能最高,需要这棵二叉查找树是平衡的,也即平衡二叉树(AVL 树)。平衡二叉树首先需要符合二叉查找树的定义,其次必须满足任何节点的两个子树的高度差不能大于 1。显然图 ② 不满足平衡二叉树的定义,而图 ① 是一课平衡二叉树。平衡二叉树的查找性能是比较高的(性能最好的是最优二叉树),查询性能越好,维护的成本就越大。比如图 ① 的平衡二叉树,当用户需要插入一个新的值 9 的节点时,就需要做出如下变动。

二叉树

通过一次左旋操作就将插入后的树重新变为平衡二叉树是最简单的情况了,实际应用场景中可能需要旋转多次。至此我们可以考虑一个问题,平衡二叉树的查找效率还不错,实现也非常简单,相应的维护成本还能接受,为什么 MySQL 索引不直接使用平衡二叉树?随着数据库中数据的增加,索引本身大小随之增加,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘 I/O 消耗,相对于内存存取,I/O 存取的消耗要高几个数量级。可以想象一下一棵几百万节点的二叉树的深度是多少?如果将这么大深度的一颗二叉树放磁盘上,每读取一个节点,需要一次磁盘的 I/O 读取,整个查找的耗时显然是不能够接受的。那么如何减少查找过程中的 I/O 存取次数?

一种行之有效的解决方法是减少树的深度,将二叉树变为 m 叉树(多路搜索树),而 B+Tree 就是一种多路搜索树。理解 B+Tree 时,只需要理解其最重要的两个特征即可:第一,所有的关键字(可以理解为数据)都存储在叶子节点(Leaf Page),非叶子节点(Index Page)并不存储真正的数据,所有记录节点都是按键值大小顺序存放在同一层叶子节点上。其次,所有的叶子节点由指针连接。如下图为高度为 2 的简化了的 B+Tree。

简化后的二叉树

怎么理解这两个特征?MySQL 将每个节点的大小设置为一个页的整数倍(原因下文会介绍),也就是在节点空间大小一定的情况下,每个节点可以存储更多的内结点,这样每个结点能索引的范围更大更精确。所有的叶子节点使用指针链接的好处是可以进行区间访问,比如上图中,如果查找大于 20 而小于 30 的记录,只需要找到节点 20,就可以遍历指针依次找到 25、30。如果没有链接指针的话,就无法进行区间查找。这也是 MySQL 使用 B+Tree 作为索引存储结构的重要原因。

MySQL 为何将节点大小设置为页的整数倍,这就需要理解磁盘的存储原理。磁盘本身存取就比主存慢很多,在加上机械运动损耗(特别是普通的机械硬盘),磁盘的存取速度往往是主存的几百万分之一,为了尽量减少磁盘 I/O,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存,预读的长度一般为页的整数倍。

页是计算机管理存储器的逻辑块,硬件及 OS 往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(许多 OS 中,页的大小通常为 4K)。主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。

MySQL 巧妙利用了磁盘预读原理,将一个节点的大小设为等于一个页,这样每个节点只需要一次 I/O 就可以完全载入。为了达到这个目的,每次新建节点时,直接申请一个页的空间,这样就保证一个节点物理上也存储在一个页里,加之计算机存储分配都是按页对齐的,就实现了读取一个节点只需一次 I/O。假设 B+Tree 的高度为 h,一次检索最多需要 h-1 I/O(根节点常驻内存),复杂度 $O(h) = O(\log_{M}N)$。实际应用场景中,M 通常较大,常常超过 100,因此树的高度一般都比较小,通常不超过 3。

最后简单了解下 B+Tree 节点的操作,在整体上对索引的维护有一个大概的了解,虽然索引可以大大提高查询效率,但维护索引仍要花费很大的代价,因此合理的创建索引也就尤为重要。仍以上面的树为例,我们假设每个节点只能存储 4 个内节点。首先要插入第一个节点 28,如下图所示。

插入 28 节点

接着插入下一个节点 70,在 Index Page 中查询后得知应该插入到 50 - 70 之间的叶子节点,但叶子节点已满,这时候就需要进行也分裂的操作,当前的叶子节点起点为 50,所以根据中间值来拆分叶子节点,如下图所示。

叶子节点分裂

最后插入一个节点 95,这时候 Index Page 和 Leaf Page 都满了,就需要做两次拆分,如下图所示。

两次拆分

拆分后最终形成了这样一颗树。

两次拆分后形成的树

B+Tree 为了保持平衡,对于新插入的值需要做大量的拆分页操作,而页的拆分需要 I/O 操作,为了尽可能的减少页的拆分操作,B+Tree 也提供了类似于平衡二叉树的旋转功能。当 Leaf Page 已满但其左右兄弟节点没有满的情况下,B+Tree 并不急于去做拆分操作,而是将记录移到当前所在页的兄弟节点上。通常情况下,左兄弟会被先检查用来做旋转操作。就比如上面第二个示例,当插入 70 的时候,并不会去做页拆分,而是左旋操作。

左旋操作

通过旋转操作可以最大限度的减少页分裂,从而减少索引维护过程中的磁盘的 I/O 操作,也提高索引维护效率。需要注意的是,删除节点跟插入节点类型,仍然需要旋转和拆分操作,这里就不再说明。

索引高性能策略

通过上文,相信你对 B+Tree 的数据结构已经有了大致的了解,但 MySQL 中索引是如何组织数据的存储呢?以一个简单的示例来说明,假如有如下数据表:

CREATE TABLE People(
    last_name varchar(50) not null,
    first_name varchar(50) not null,
    dob date not null,
    gender enum(`m`,`f`) not null,t
    key(last_name,first_name,dob)
);

对于表中每一行数据,索引中包含了 last_name、first_name、dob 列的值,下图展示了索引是如何组织数据存储的。

索引查询

1、MySQL 不会使用索引的情况:非独立的列

“独立的列”是指索引列不能是表达式的一部分,也不能是函数的参数。比如:

select * from where id + 1 = 5

我们很容易看出其等价于 id = 4,但是 MySQL 无法自动解析这个表达式,使用函数是同样的道理。

2、前缀索引

如果列很长,通常可以索引开始的部分字符,这样可以有效节约索引空间,从而提高索引效率。

3、多列索引和索引顺序

在多数情况下,在多个列上建立独立的索引并不能提高查询性能。理由非常简单,MySQL 不知道选择哪个索引的查询效率更好,所以在老版本,比如 MySQL5.0 之前就会随便选择一个列的索引,而新的版本会采用合并索引的策略。举个简单的例子,在一张电影演员表中,在 actor_id 和 film_id 两个列上都建立了独立的索引,然后有如下查询:

select film_id,actor_id from film_actor where actor_id = 1 or film_id = 1

老版本的 MySQL 会随机选择一个索引,但新版本做如下的优化:

select film_id,actor_id from film_actor where actor_id = 1
union all
select film_id,actor_id from film_actor where film_id = 1 and actor_id <> 1

当出现多个索引做相交操作时(多个 AND 条件),通常来说一个包含所有相关列的索引要优于多个独立索引。

  • 当出现多个索引做联合操作时(多个 OR 条件),对结果集的合并、排序等操作需要耗费大量的 CPU 和内存资源,特别是当其中的某些索引的选择性不高,需要返回合并大量数据时,查询成本更高。所以这种情况下还不如走全表扫描。
  • 因此 explain 时如果发现有索引合并(Extra 字段出现 Using union),应该好好检查一下查询和表结构是不是已经是最优的,如果查询和表都没有问题,那只能说明索引建的非常糟糕,应当慎重考虑索引是否合适,有可能一个包含所有相关列的多列索引更适合。

前面我们提到过索引如何组织数据存储的,从图中可以看到多列索引时,索引的顺序对于查询是至关重要的,很明显应该把选择性更高的字段放到索引的前面,这样通过第一个字段就可以过滤掉大多数不符合条件的数据。索引选择性是指不重复的索引值和数据表的总记录数的比值,选择性越高查询效率越高,因为选择性越高的索引可以让 MySQL 在查询时过滤掉更多的行。唯一索引的选择性是 1,这时最好的索引选择性,性能也是最好的。

理解索引选择性的概念后,就不难确定哪个字段的选择性较高了,查一下就知道了,比如:

SELECT * FROM payment where staff_id = 2 and customer_id = 584

是应该创建(staff_id,customer_id)的索引还是应该颠倒一下顺序?执行下面的查询,哪个字段的选择性更接近 1 就把哪个字段索引前面就好。

select count(distinct staff_id)/count(*) as staff_id_selectivity,
       count(distinct customer_id)/count(*) as customer_id_selectivity,
       count(*) from payment

多数情况下使用这个原则没有任何问题,但仍然注意你的数据中是否存在一些特殊情况。举个简单的例子,比如要查询某个用户组下有过交易的用户信息:

select user_id from trade where user_group_id = 1 and trade_amount > 0

MySQL 为这个查询选择了索引(user_group_id,trade_amount),如果不考虑特殊情况,这看起来没有任何问题,但实际情况是这张表的大多数数据都是从老系统中迁移过来的,由于新老系统的数据不兼容,所以就给老系统迁移过来的数据赋予了一个默认的用户组。这种情况下,通过索引扫描的行数跟全表扫描基本没什么区别,索引也就起不到任何作用。推广开来说,经验法则和推论在多数情况下是有用的,可以指导我们开发和设计,但实际情况往往会更复杂,实际业务场景下的某些特殊情况可能会摧毁你的整个设计。

4、避免多个范围条件

实际开发中,我们会经常使用多个范围条件,比如想查询某个时间段内登录过的用户:

select user.* from user where login_time > '2017-04-01' and age between 18 and 30;

这个查询有一个问题:它有两个范围条件,login_time 列和 age 列,MySQL 可以使用 login_time 列的索引或者 age 列的索引,但无法同时使用它们。

5、覆盖索引

如果一个索引包含或者说覆盖所有需要查询的字段的值,那么就没有必要再回表查询,这就称为覆盖索引。覆盖索引是非常有用的工具,可以极大的提高性能,因为查询只需要扫描索引会带来许多好处:

  • 索引条目远小于数据行大小,如果只读取索引,极大减少数据访问量
  • 索引是有按照列值顺序存储的,对于 I/O 密集型的范围查询要比随机从磁盘读取每一行数据的 IO 要少的多

6、使用索引扫描来排序

MySQL 有两种方式可以生产有序的结果集,其一是对结果集进行排序的操作,其二是按照索引顺序扫描得出的结果自然是有序的。如果 explain 的结果中 type 列的值为 index 表示使用了索引扫描来做排序。扫描索引本身很快,因为只需要从一条索引记录移动到相邻的下一条记录。但如果索引本身不能覆盖所有需要查询的列,那么就不得不每扫描一条索引记录就回表查询一次对应的行。这个读取操作基本上是随机 I/O,因此按照索引顺序读取数据的速度通常要比顺序地全表扫描要慢。

在设计索引时,如果一个索引既能够满足排序,有满足查询,是最好的。只有当索引的列顺序和 ORDER BY 子句的顺序完全一致,并且所有列的排序方向也一样时,才能够使用索引来对结果做排序。如果查询需要关联多张表,则只有 ORDER BY 子句引用的字段全部为第一张表时,才能使用索引做排序。ORDER BY 子句和查询的限制是一样的,都要满足最左前缀的要求(有一种情况例外,就是最左的列被指定为常数,下面是一个简单的示例),其他情况下都需要执行排序操作,而无法利用索引排序。

-- 最左列为常数,索引:(date,staff_id,customer_id)
select  staff_id,customer_id from demo where date = '2015-06-01' order by staff_id,customer_id

7、冗余和重复索引

冗余索引是指在相同的列上按照相同的顺序创建的相同类型的索引,应当尽量避免这种索引,发现后立即删除。比如有一个索引(A,B),再创建索引(A)就是冗余索引。冗余索引经常发生在为表添加新索引时,比如有人新建了索引(A,B),但这个索引不是扩展已有的索引(A)。大多数情况下都应该尽量扩展已有的索引而不是创建新索引。但有极少情况下出现性能方面的考虑需要冗余索引,比如扩展已有索引而导致其变得过大,从而影响到其他使用该索引的查询。

8、删除长期未使用的索引

定期删除一些长时间未使用过的索引是一个非常好的习惯。关于索引这个话题打算就此打住,最后要说一句,索引并不总是最好的工具,只有当索引帮助提高查询速度带来的好处大于其带来的额外工作时,索引才是有效的。对于非常小的表,简单的全表扫描更高效。对于中到大型的表,索引就非常有效。对于超大型的表,建立和维护索引的代价随之增长,这时候其他技术也许更有效,比如分区表。最后的最后,explain 后再提测是一种美德。

特定类型查询优化

优化 COUNT()查询

COUNT()可能是被大家误解最多的函数了,它有两种不同的作用,其一是统计某个列值的数量,其二是统计行数。统计列值时,要求列值是非空的,它不会统计 NULL。如果确认括号中的表达式不可能为空时,实际上就是在统计行数。最简单的就是当使用 COUNT(*)时,并不是我们所想象的那样扩展成所有的列,实际上,它会忽略所有的列而直接统计所有的行数。我们最常见的误解也就在这儿,在括号内指定了一列却希望统计结果是行数,而且还常常误以为前者的性能会更好。但实际并非这样,如果要统计行数,直接使用 COUNT(*),意义清晰,且性能更好。

有时候某些业务场景并不需要完全精确的 COUNT 值,可以用近似值来代替,EXPLAIN 出来的行数就是一个不错的近似值,而且执行 EXPLAIN 并不需要真正地去执行查询,所以成本非常低。通常来说,执行 COUNT()都需要扫描大量的行才能获取到精确的数据,因此很难优化,MySQL 层面还能做得也就只有覆盖索引了。如果不还能解决问题,只有从架构层面解决了,比如添加汇总表,或者使用 redis 这样的外部缓存系统。

优化关联查询

在大数据场景下,表与表之间通过一个冗余字段来关联,要比直接使用 JOIN 有更好的性能。如果确实需要使用关联查询的情况下,需要特别注意的是:

  • 确保 ON 和 USING 字句中的列上有索引。在创建索引的时候就要考虑到关联的顺序。当表 A 和表 B 用列 c 关联的时候,如果优化器关联的顺序是 A、B,那么就不需要在 A 表的对应列上创建索引。没有用到的索引会带来额外的负担,一般来说,除非有其他理由,只需要在关联顺序中的第二张表的相应列上创建索引(具体原因下文分析)。

  • 确保任何的 GROUP BY 和 ORDER BY 中的表达式只涉及到一个表中的列,这样 MySQL 才有可能使用索引来优化。

要理解优化关联查询的第一个技巧,就需要理解 MySQL 是如何执行关联查询的。当前 MySQL 关联执行的策略非常简单,它对任何的关联都执行嵌套循环关联操作,即先在一个表中循环取出单条数据,然后在嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表中匹配的行为为止。然后根据各个表匹配的行,返回查询中需要的各个列。太抽象了?以上面的示例来说明,比如有这样的一个查询:

SELECT A.xx,B.yy
FROM A INNER JOIN B USING(c)
WHERE A.xx IN (5,6)

假设 MySQL 按照查询中的关联顺序 A、B 来进行关联操作,那么可以用下面的伪代码表示 MySQL 如何完成这个查询:

outer_iterator = SELECT A.xx,A.c FROM A WHERE A.xx IN (5,6);
outer_row = outer_iterator.next;
while(outer_row) {
    inner_iterator = SELECT B.yy FROM B WHERE B.c = outer_row.c;
    inner_row = inner_iterator.next;
    while(inner_row) {
        output[inner_row.yy,outer_row.xx];
        inner_row = inner_iterator.next;
    }
    outer_row = outer_iterator.next;
}

可以看到,最外层的查询是根据 A.xx 列来查询的,A.c 上如果有索引的话,整个关联查询也不会使用。再看内层的查询,很明显 B.c 上如果有索引的话,能够加速查询,因此只需要在关联顺序中的第二张表的相应列上创建索引即可。

优化 LIMIT 分页

当需要分页操作时,通常会使用 LIMIT 加上偏移量的办法实现,同时加上合适的 ORDER BY 字句。如果有对应的索引,通常效率会不错,否则,MySQL 需要做大量的文件排序操作。一个常见的问题是当偏移量非常大的时候,比如:LIMIT 10000 20 这样的查询,MySQL 需要查询 10020 条记录然后只返回 20 条记录,前面的 10000 条都将被抛弃,这样的代价非常高。

优化这种查询一个最简单的办法就是尽可能的使用覆盖索引扫描,而不是查询所有的列。然后根据需要做一次关联查询再返回所有的列。对于偏移量很大时,这样做的效率会提升非常大。考虑下面的查询:

SELECT film_id,description FROM film ORDER BY title LIMIT 50,5;

如果这张表非常大,那么这个查询最好改成下面的样子:

SELECT film.film_id,film.description
FROM film INNER JOIN (
    SELECT film_id FROM film ORDER BY title LIMIT 50,5
) AS tmp USING(film_id);

这里的延迟关联将大大提升查询效率,让 MySQL 扫描尽可能少的页面,获取需要访问的记录后在根据关联列回原表查询所需要的列。有时候如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用 OFFSET,比如下面的查询:

SELECT id FROM t LIMIT 10000, 10;
-- 改为:
SELECT id FROM t WHERE id > 10000 LIMIT 10;

其他优化的办法还包括使用预先计算的汇总表,或者关联到一个冗余表,冗余表中只包含主键列和需要做排序的列。

优化 UNION

MySQL 处理 UNION 的策略是先创建临时表,然后再把各个查询结果插入到临时表中,最后再来做查询。因此很多优化策略在 UNION 查询中都没有办法很好的时候。经常需要手动将 WHERE、LIMIT、ORDER BY 等字句“下推”到各个子查询中,以便优化器可以充分利用这些条件先优化。

除非确实需要服务器去重,否则就一定要使用 UNION ALL,如果没有 ALL 关键字,MySQL 会给临时表加上 DISTINCT 选项,这会导致整个临时表的数据做唯一性检查,这样做的代价非常高。当然即使使用 ALL 关键字,MySQL 总是将结果放入临时表,然后再读出,再返回给客户端。虽然很多时候没有这个必要,比如有时候可以直接把每个子查询的结果返回给客户端。

分区表

合理的使用索引可以极大提升 MySQL 的查询性能,但如果单表数据量达到一定的程度,索引就无法起作用,因为在数据量超大的情况下,除非覆盖索引,因回表查询会产生大量的随机 I/O,数据库的响应时间可能会达到不可接受的程度。而且索引维护(磁盘空间、I/O 操作)的代价也会非常大。因此,当单表数据量达到一定程度时(在 MySQL4.x 时代,MyISAM 存储引擎业内公认的性能拐点是 500W 行,MySQL5.x 时代的性能拐点则为 1KW ~ 2KW 行级别,具体需根据实际情况测试),为了提升性能,最为常用的方法就是分表。分表的策略可以是垂直拆分(比如:不同订单状态的订单拆分到不同的表),也可以是水平拆分(比如:按月将订单拆分到不同表)。但总的来说,分表可以看作是从业务角度来解决大数据量问题,它在一定程度上可以提升性能,但也大大提升了编码的复杂度,有过这种经历的同学可能深有体会。

在业务层分表大大增加了编码的复杂程度,而且处理数据库的相关代码会大量散落在应用各处,维护困难。那是否可以将分表的逻辑抽象出来,统一处理,这样业务层就不用关心底层是否分表,只需要专注在业务即可。答案当然是肯定的,目前有非常多的数据库中间件都可以屏蔽分表后的细节,让业务层像查询单表一样查询分表后的数据。如果再将抽象的逻辑下移到数据库的服务层,就是我们今天要讲的分区表。分区可以看作是从技术层面解决大数据问题的有效方法,简单的理解,可以认为是 MySQL 底层帮我们实现分表,分区表是一个独立的逻辑表,底层由多个物理子表组成。存储引擎管理分区的各个底层表和管理普通表一样(所有底层表必须使用相同的存储引擎),分区表的索引也是在各个底层表上各自加上一个完全相同的索引。从存储引擎的角度来看,底层表和普通表没有任何不同,存储引擎也无须知道。在执行查询时,优化器会根据分区的定义过滤那些没有我们需要数据的分区,这样查询就无需扫描所有分区,只需要查找包含需要数据的分区就可以了。

更好的理解分区表,我们从一个示例入手:一张订单表,数据量大概有 10TB,如何设计才能使性能达到最优?

首先可以肯定的是,因为数据量巨大,肯定不能走全表扫描。使用索引的话,你会发现数据并不是按照想要的方式聚集,而且会产生大量的碎片,最终会导致一个查询产生成千上万的随机 I/O,应用随之僵死。所以需要选择一些更粗粒度并且消耗更少的方式来检索数据。比如先根据索引找到一大块数据,然后再在这块数据上顺序扫描。这正是分区要做的事情,理解分区时还可以将其当作索引的最初形态,以代价非常小的方式定位到需要的数据在哪一片“区域”,在这片“区域”中,你可以顺序扫描,可以建索引,还可以将数据都缓存在内存中。因为分区无须额外的数据结构记录每个分区有哪些数据,所以其代价非常低。只需要一个简单的表达式就可以表达每个分区存放的是什么数据。

对表分区,可以在创建表时,使用如下语句:

CREATE TABLE sales {
    order_date DATETIME NOT NULL
    -- other columns
} ENGINE=InnoDB PARTITION BY RANGE(YEAR(order_date)) (
    PARTITION p_2014 VALUES LESS THAN (2014),
    PARTITION p_2015 VALUES LESS THAN (2015)
    PARTITION p_2016 VALUES LESS THAN (2016)
    PARTITION p_2017 VALUES LESS THAN (2017)
    PARTITION p_catchall VALUES LESS THAN MAXVALUE
)

分区子句中可以使用各种函数,但表达式的返回值必须是一个确定的整数,且不能是一个常数。MySQL 还支持一些其他分区,比如键值、哈希、列表分区,但在生产环境中很少见到。在 MySQL5.5 以后可以使用 RANGE COLUMNS 类型分区,这样即使是基于时间分区,也无需再将其转化成一个整数。

接下来简单看下分区表上的各种操作逻辑:

  • SELECT:当查询一个分区表时,分区层先打开并锁住所有的底层表,优化器先判断是否可以过滤部分分区,然后在调用对应的存储引擎接口访问各个分区的数据
  • INSERT:当插入一条记录时,分区层先打开并锁住所有的底层表,然后确定哪个分区接收这条记录,再将记录写入对应的底层表,DELETE 操作与其类似
  • UPDATE:当更新一条数据时,分区层先打开并锁住所有的底层表,然后确定数据对应的分区,然后取出数据并更新,再判断更新后的数据应该存放到哪个分区,最后对底层表进行写入操作,并对原数据所在的底层表进行删除操作

有些操作是支持条件过滤的。例如,当删除一条记录时,MySQL 需要先找到这条记录,如果 WHERE 条件恰好和分区表达式匹配,就可以将所有不包含这条记录的分区都过滤掉,这对 UPDATE 语句同样有效。如果是 INSERT 操作,本身就只命中一个分区,其他分区都会被过滤。虽然每个操作都会 “先打开并锁住所有的底层表”,但这并不是说分区表在处理过程中是锁住全表的。如果存储引擎能够自己实现行级锁,例如 InnoDB,则会在分区层释放对应表锁。这个加锁和解锁的操作过程与普通 InnoDB 上的查询类似。

在使用分区表时,为了保证大数据量的可扩展性,一般有两个策略:

  • 全量扫描数据,不用索引。即只要能够根据 WHERE 条件将需要查询的数据限制在少数分区中,效率是不错的
  • 索引数据,分离热点。如果数据有明显的“热点”,而且除了这部分数据,其他数据很少被访问到,那么可以将这部分热点数据单独存放在一个分区中,让这个分区的数据能够有机会都缓存在内存中。这样查询就可以值访问一个很小的分区表,能够使用索引,也能够有效的利用缓存。

分区表的优点是优化器可以根据分区函数来过滤一些分区,但很重要的一点是要在 WHERE 条件中带入分区列,有时候即使看似多余的也要带上,这样就可以让优化器能够过滤掉无须访问的分区,如果没有这些条件,MySQL 就需要让对应的存储引擎访问这个表的所有分区,如果表非常大的话,就可能会非常慢。

上面两个分区策略基于两个非常重要的前提:查询都能够过滤掉很多额外的分区、分区本身并不会带来很多额外的代价。而这两个前提在某些场景下是有问题的,比如:

1、NULL 值会使分区过滤无效

假设按照 PARTITION BY RANGE YEAR(order_date)分区,那么所有 order_date 为 NULL 或者非法值时,记录都会被存放到第一个分区。所以 WHERE order_date BETWEEN ‘2017-05-01’ AND ‘2017-05-31’,这个查询会检查两个分区,而不是我们认为的 2017 年这个分区(会额外的检查第一个分区),是因为 YEAR()在接收非法值时会返回 NULL。如果第一个分区的数据量非常大,而且使用全表扫描的策略时,代价会非常大。为了解决这个问题,我们可以创建一个无用的分区,比如:PARTITION p_null values less than (0)。如果插入的数据都是有效的话,第一个分区就是空的。

在 MySQL5.5 以后就不需要这个技巧了,因为可以直接使用列本身而不是基于列的函数进行分区:PARTITION BY RANGE COLUMNS(order_date)。直接使用这个语法可避免这个问题。

2、分区列和索引列不匹配

当分区列和索引列不匹配时,可能会导致查询无法进行分区过滤,除非每个查询条件中都包含分区列。假设在列 a 上定义了索引,而在列 b 上进行分区。因为每个分区都有其独立的索引,所以在扫描列 b 上的索引就需要扫描每一个分区内对应的索引,当然这种速度不会太慢,但是能够跳过不匹配的分区肯定会更好。这个问题看起来很容易避免,但需要注意一种情况就是,关联查询。如果分区表是关联顺序的第 2 张表,并且关联使用的索引与分区条件并不匹配,那么关联时对第一张表中符合条件的每一行都需要访问并搜索第二张表的所有分区(关联查询原理,请参考前一篇文章)

3、选择分区的成本可能很高

分区有很多种类型,不同类型的分区实现方式也不同,所以它们的性能也不尽相同,尤其是范围分区,在确认这一行属于哪个分区时会扫描所有的分区定义,这样的线性扫描效率并不高,所以随着分区数的增长,成本会越来越高。特别是在批量插入数据时,由于每条记录在插入前,都需要确认其属于哪一个分区,如果分区数太大,会造成插入性能的急剧下降。因此有必要限制分区数量,但也不用太过担心,对于大多数系统,100 个左右的分区是没有问题的。

4、打开并锁住所有底层表的成本在某些时候会很高

前面说过,打开并锁住所有底层表并不会对性能有太大的影响,但在某些情况下,比如只需要查询主键,那么锁住的成本相对于主键的查询来说,成本就略高。

5、维护分区的成本可能会很高

新增和删除分区的速度都很快,但是修改分区会造成数据的复制,这与 ALTER TABLE 的原理类似,需要先创建一个历史分区,然后将数据复制到其中,最后删除原分区。因此,设计数据库时,考虑业务的增长需要,合理的创建分区表是一个非常好的习惯。在 MySQL5.6 以后的版本可以使用 ALTER TABLE EXCHAGE PARTITION 语句来修改分区,其性能会有很大提升。

分区表还有一些其他限制,比如所有的底层表必须使用相同的存储引擎,某些存储引擎也不支持分区。分区一般应用于一台服务器上,但一台服务器的物理资源总是有限的,当数据达到这个极限时,即使分区,性能也可能会很低,所以这个时候分库是必须的。但不管是分区、分库还是分表,它们的思想都是一样的,大家可以好好体会下。

视图

对于一些关联表的复杂查询,使用视图有时候会大大简化问题,因此在许多场合下都可以看到视图的身影,但视图真如我们所想那样简单吗?它和直接使用 JOIN 的 SQL 语句有何区别?视图背后的原理又了解多少?

视图本身是一个虚拟表,不存放任何数据,查询视图的数据集由其他表生成。MySQL 底层通过两种算法来实现视图:临时表算法(TEMPTABLE)和合并算法(MERGE)。所谓临时表算法就是将 SELECT 语句的结果存放到临时表中,当需要访问视图的时候,直接访问这个临时表即可。而合并算法则是重写包含视图的查询,将视图定义的 SQL 直接包含进查询 SQL 中。通过两个简单的示例来体会两个算法的差异,创建如下视图:

// 视图的作用是查询未支付订单
CREATE VIEW unpay_order AS
SELECT * FROM sales WHERE status = 'new'
WITH CHECK OPTION;   // 其作用下文会讲

现要从未支付订单中查询购买者为 csc 的订单,可以使用如下查询:

-- 查询购买者为csc且未支付的订单
SELECT order_id,order_amount,buyer FROM unpay_order WHERE buyer = 'csc';

使用临时表来模拟视图:

CREATE TEMPORARY TABLE tmp_order_unpay AS SELECT * FROM sales WHERE status = 'new';
SELECT order_id,order_amount,buyer FROM tmp_order_unpay WHERE buyer = 'csc';

使用合并算法将视图定义的 SQL 合并进查询 SQL 后的样子:

SELECT order_id,order_amount,buyer FROM sales WHERE status = 'new' AND buyer = 'csc';

MySQL 可以嵌套定义视图,即在一个视图上在定义另一个视图,可以在 EXPLAN EXTENDED 之后使用 SHOW WARNINGS 来查看使用视图的查询重写后的结果。如果采用临时表算法实现的视图,EXPLAIN 中会显示为派生表(DERIVED),注意 EXPLAIN 时需要实际执行并产生临时表,所以有可能会很慢。明显地,临时表上没有任何索引,而且优化器也很难优化临时表上的查询,因此,如有可能,尽量使用合并算法会有更好的性能。那么问题来了:合并算法(类似于直接查询)有更好的性能,为什么还要使用视图?

首先视图可以简化应用上层的操作,让应用更专注于其所关心的数据。其次,视图能够对敏感数据提供安全保护,比如:对不同的用户定义不同的视图,可以使敏感数据不出现在不应该看到这些数据的用户视图上;也可以使用视图实现基于列的权限控制,而不需要真正的在数据库中创建列权限。再者,视图可以方便系统运维,比如:在重构 schema 的时候使用视图,使得在修改视图底层表结构的时候,应用代码还可以继续运行不报错。

基于此,使用视图其实更多的是基于业务或者维护成本上的考虑,其本身并不会对性能提升有多大作用(注意:此处只是基于 MySQL 考虑,其他关系性数据库中视图可能会有更好的性能,比如 ORACLE 和 MS SQL SERVER 都支持物化视图,它们都比 MySQL 视图有更好的性能)。而且使用临时表算法实现的视图,在某些时候性能可能会非常糟糕,比如:

// 视图的作用是统计每日支出金额,DATE('2017-06-15 12:00:23') = 2017-06-15
CREATE VIEW cost_per_day AS
SELECT DATE(create_time) AS date,SUM(cost) AS cost FROM costs GROUP BY date;

现要统计每日的收入与支出,有类似于上面的收入表,可以使用如下 SQL:

SELECT c.date,c.cost,s.amount
FROM cost_per_day AS c
JOIN sale_per_day AS s USING(date)
WHERE date BETWEEN '2017-06-01' AND '2017-06-30'

这个查询中,MySQL 先执行视图的 SQL,生成临时表,然后再将 sale_per_day 表和临时表进行关联。这里 WHERE 字句中的 BETWEEN 条件并不能下推到视图中,因而视图在创建时,会将所有的数据放到临时表中,而不是一个月数据,并且这个临时表也不会有索引。

当然这个示例中的临时表数据不会太大,毕竟日期的数量不会太多,但仍然要考虑生成临时表的性能(如果 costs 表数据过大,GROUP BY 有可能会比较慢)。而且本示例中索引也不是问题,通过上一篇我们知道,如果 MySQL 将临时表作为关联顺序中的第一张表,仍然可以使用 sale_per_day 中的索引。但如果是对两个视图做关联的话,优化器就没有任何索引可以使用,这时就需要严格测试应用的性能是否满足需求。

我们很少会在实际业务场景中去更新视图,因此印象中,视图是不能更新的。但实际上,在某些情况下,视图是可以更新的。可更新视图是指通过更新这个视图来更新视图涉及的相关表,只要指定了合适的条件,就可以更新、删除甚至是向视图中插入数据。通过上文的了解,不难推断出:更新视图的实质就是更新视图关联的表,将创建视图的 WHERE 子句转化为 UPDATE 语句的 WHERE 子句,只有使用合并算法的视图才能更新,并且更新的列必须来自同一个表中。回顾上文创建视图的 SQL 语句,其中有一句:WITH CHECK OPTION,其作用就是表示通过视图更新的行,都必须符合视图本身的 WHERE 条件定义,不能更新视图定义列以外的列,否则就会抛出 check option failed 错误。

视图还有一个容易造成误解的地方:“对于一些简单的查询,视图会使用合并算法,而对于一些比较复杂的查询,视图就会使用临时表算法”。但实际上,视图的实现算法是视图本身的属性决定的,跟作用在视图上的 SQL 没有任何关系。那什么时候视图采用临时表算法,什么时候采用合并算法呢?一般来说,只要原表记录和视图中的记录无法建立一一映射的关系时,MySQL 都将使用临时表算法来实现视图。比如创建视图的 SQL 中包含 GROUP BY、DISTINCT、UNION、聚合函数、子查询的时候,视图都将采用临时表算法(这些规则在以后的版本中,可能会发生改变,具体请参考官方手册)。

相比于其它关系型数据库的视图,MySQL 的视图在功能上会弱很多,比如 ORACLE 和 MS SQL SERVER 都支持物化视图。物化视图是指将视图结果数据存放在一个可以查询的表中,并定期从原始表中刷新数据到这张表中,这张表和普通物理表一样,可以创建索引、主键约束等等,性能相比于临时表会有质的提升。但遗憾的是 MySQL 目前并不支持物化视图,当然 MySQL 也不支持在视图中创建索引。

存储过程与触发器

回到第二个问题,有非常多的人在分享时都会抛出这样一个观点:尽可能不要使用存储过程,存储过程非常不容易维护,也会增加使用成本,应该把业务逻辑放到客户端。既然客户端都能干这些事,那为什么还要存储过程?

如果有深入了解过存储过程,就会发现存储过程并没有大家描述的那么不堪。我曾经经历过一些重度使用存储过程的产品,依赖到什么程度呢?就这么说吧,上层的应用基本上只处理交互与动效的逻辑,所有的业务逻辑,甚至是参数的校验均在存储过程中实现。曾经有出现过一个超大的存储过程,其文件大小达到惊人的 80K,可想而知,其业务逻辑有多么复杂。在大多数人眼中,这样的技术架构简直有点不可理喻,但实际上这款产品非常成功。

其成功的原因在一定程度上得益于存储过程的优点,由于业务层代码没有任何侵入业务的代码,在不改变前端展示效果的同时,可以非常快速的修复 BUG、开发新功能。由于这款产品需要部署在客户的私有环境上,快速响应客户的需求就变得尤为重要,正是得益于这种架构,可以在客户出现问题或者提出新需求时,快速响应,极端情况下,我们可以在 1 小时内修复客户遇到的问题。正是这种快速响应机制,让我们获得大量的客户。

当然存储过程还有其他的优点,比如,可以非常方便的加密存储过程代码,而不用担心应用部署到私有环境造成源代码泄露、可以像调试其他应用程序一样调试存储过程、可以设定存储过程的使用权限来保证数据安全等等。一切都非常美好,但我们的产品是基于 MS SQL SERVER 实现的,其可以通过 T-SQL 非常方便的实现复杂的业务逻辑。你可以把 T-SQL 看做是一门编程语言,其包含 SQL 的所有功能,还具备流程控制、批处理、定时任务等能力,你甚至可以用其来解析 XML 数据。关于 T-SQL 的更多信息可以参考 MSDN,主流的关系型数据库目前只有 MS SQL SERVER 支持 T-SQL,因此,MySQL 并不具备上文描述的一些能力,比如,MySQL 的存储过程调试非常不方便(当然可以通过付费软件来获得很好的支持)。

除此之外,MySQL 存储过程还有一些其他的限制:

  • 优化器无法评估存储过程的执行成本
  • 每个连接都有独立的存储过程执行计划缓存,如果有多个连接需要调用同一个存储过程,将会浪费缓存空间来缓存相同的执行计划

因此,在 MySQL 中使用存储过程并不是一个太好策略,特别是在一些大数据、高并发的场景下,将复杂的逻辑交给上层应用实现,可以非常方便的扩展已有资源以便获得更高的计算能力。而且对于熟悉的编程语言,其可读性会比存储过程更好一些,也更加灵活。不过,在某些场景下,如果存储过程比其他实现会快很多,并且是一些较小的操作,可以适当考虑使用存储过程。

和存储过程类似的,还有触发器,触发器可以让你在执行 INSERT、UPDATE 和 DELETE 时,执行一些特定的操作。在 MySQL 中可以选择在 SQL 执行之前触发还是在 SQL 执行后触发。触发器一般用于实现一些强制的限制,这些限制如果在应用程序中实现会让业务代码变得非常复杂,而且它也可以减少客户端与服务器之间的通信。MySQL 触发器的实现非常简单,所以功能非常有限,如果你在其他数据库产品中已经重度依赖触发器,那么在使用 MySQL 触发器时候需要注意,因为 MySQL 触发器的表现和预想的不一致。

首先对一张表的每一个事件,最多只能定义一个触发器,而且它只支持“基于行的触发”,也就是触发器始终是针对一条记录的,而不是针对整个 SQL 语句。如果是批量更新的话,效率可能会很低。其次,触发器可以掩盖服务器本质工作,一个简单的 SQL 语句背后,因为触发器,可能包含了很多看不见的工作。再者,触发器出现问题时很难排查。最后,触发器并不一定能保证原子性,比如 MyISAM 引擎下触发器执行失败了,也不能回滚。在 InnoDB 表上的触发器是在同一个事务中执行完成的,所以她们的执行是原子的,原操作和触发器操作会同时失败或者成功。

虽然触发器有这么多限制,但它仍有适用的场景,比如,当你需要记录 MySQL 数据的变更日志,这时触发器就非常方便了。

外键约束

目前在大多数互联网项目,特别是在大数据的场景下,已经不建议使用外键了,主要是考虑到外键的使用成本:

  • 外键通常要求每次修改数据时都要在另外一张表中执行一次查找操作。在 InnoDB 存储引擎中会强制外键使用索引,但在大数据的情况下,仍然不能忽略外键检查带来的开销,特别是当外键的选择性很低时,会导致一个非常大且选择性低的索引。
  • 如果向子表中插入一条记录,外键约束会让 InnoDB 检查对应的父表的记录,也就需要对父表对应记录进行加锁操作,来确保这条记录不会在这个事务完成之时就被删除了。这会导致额外的锁等待,甚至会导致一些死锁。
  • 高并发场景下,数据库很容易成为性能瓶颈,自然而然的就希望数据库可以水平扩展,这时就需要把数据的一致性控制放到应用层,也就是让应用服务器可以承担压力,这种情况下,数据库层面就不能使用外键。

因此,当不用过多考虑数据库的性问题时,比如一些内部项目或传统行业项目(其使用人数有限,而且数据量一般不会太大),使用外键是一个不错的选择,毕竟想要确保相关表始终有一致的数据,使用外键要比在应用程序中检查一致性方便简单许多,此外,外键在相关数据的删除和更新操作上也会比在应用中要高效。

绑定变量

可能大家看到“绑定变量”这个词时,会有一点陌生,换个说法可能会熟悉一些:prepared statement。绑定变量的 SQL,使用问号标记可以接收参数的位置,当真正需要执行具体查询的时候,则使用具体的数值代替这些问号,比如:

SELECT order_no, order_amount FROM sales WHERE order_status = ? and buyer = ?

为什么要使用绑定变量?总所周知的原因是可以预先编译,减少 SQL 注入的风险,除了这些呢?

当创建一个绑定变量 SQL 时,客户端向服务器发送了一个 SQL 语句原型,服务器收到这个 SQL 语句的框架后,解析并存储这个 SQL 语句的部分执行计划,返回给客户端一个 SQL 语句处理句柄,从此以后,客户端通过向服务器发送各个问号的取值和这个句柄来执行一个具体查询,这样就可以更高效地执行大量重复语句,因为:

  • 服务器只需要解析一次 SQL 语句
  • 服务器某些优化器的优化工作也只需要做一次,因为 MySQL 会缓存部分执行计划
  • 通信中仅仅发送的是参数,而不是整个语句,网络开销也会更小,而且以二进制发送参数和句柄要比发送 ASCII 文本的效率更高

需要注意的是,MySQL 并不是总能缓存执行计划,如果某些执行计划需要根据参入的参数来计算时,MySQL 就无法缓存这部分执行计划。使用绑定变量的最大陷阱是:你知道其原理,但不知道它是如何实现的。有时候,很难解释如下 3 种绑定变量类型之间的区别:

  1. 客户端模拟的绑定变量:客户端的驱动程序接收一个带参数的 SQL,再将参数的值带入其中,最后将完整的查询发送到服务器。
  2. 服务器绑定变量:客户端使用特殊的二进制协议将带参数的 SQL 语句发送到服务器端,然后使用二进制协议将具体的参数值发送给服务器并执行。
  3. SQL 接口的绑定变量:客户端先发送一个带参数的 SQL 语句到服务器端,这类似于使用 prepared 的 SQL 语句,然后发送设置的参数,最后在发送 execute 指令来执行 SQL,所有这些都是用普通的文本传输协议。

比如某些不支持预编译的 JDBC 驱动,在调用 connection.prepareStatement(sql)时,并不会把 SQL 语句发送给数据库做预处理,而是等到调用 executeQuery 方法时才把整个语句发送到服务器,这种方式就类似于第 1 种情况。因此,在程序中使用绑定变量时,理解你使用的驱动通过哪种方式来实现就显得很有必要。延伸开来说,对于自己使用的框架、开源工具,不应仅仅停留在会使用这个层面,有时间可以深入了解其原理和实现,不然有可能被骗了都不知道哦。

用户自定义函数

MySQL 本身内置了非常多的函数,比如 SUM、COUNT、AVG 等等,可实际应用中,我们常常需要更多。大多数情况下,更强大的功能都是在应用层面实现,但实际上 MySQL 也提供了机会让我们可以去扩展 MySQL 函数,这就是用户自定义函数(user-defined function),也称为:UDF。需要注意 UDF 与存储过程和通过 SQL 创建函数的区别,存储过程只能使用 SQL 来编写,而 UDF 没有这个限制,可以使用支持 C 语言调用约定的任何编程语言来实现。

UDF 必须事先编译好并动态链接到服务器上,这种平台相关性使得 UDF 在很多方面都很强大,UDF 速度非常快,而且可以访问大量操作系统功能,还可以使用大量库函数。如果需要一个 MySQL 不支持的统计聚合函数,并且无法使用存储过程来实现,而且还想不同的语言都可以调用,那么 UDF 是不错的选择,至少不需要每种语言都来实现相同的逻辑。

所谓能力越大,责任也就越大,UDF 中的一个错误可能直接让服务器崩溃,甚至扰乱服务器的内存和数据,因此,使用时需要注意其潜在的风险。在 MySQL 版本升级时也需要注意,因为你可能需要重新编译或者修改这些 UDF,以便让它们能在新版本中工作。

这里有一个简单的示例来展示如何创建 UDF:将结果集转化为 JSON

// 1、首先使用c语言实现功能
// 2、编译
// 这里省略第12步,实现并编译成.so
// 3、使用SQL创建函数
drop function json_array;
create function json_array returns string soname 'lib_mysqludf_json.so';
// 4、使用函数
select json_array(
           customer_id
       ,   first_name
       ,   last_name
       ,   last_update
       ) as customer
from   customer
where  customer_id =1;

-- 5、得到的结果如下:
+------------------------------------------+
| customer                                 |
+------------------------------------------+
| [1,"MARY","SMITH","2006-02-15 04:57:20"] |
+------------------------------------------+

字符集

关于字符集大多数人的第一印象可能就是:数据库字符集尽量使用 UTF8,因为 UTF8 字符集是目前最适合于实现多种不同字符集之间的转换的字符集,可以最大程度上避免乱码问题,也可以方便以后的数据迁移。But why?

字符集是指一种从二进制编码到某类字符符号的映射,可以参考如何使用一个字节来表示英文字母。校对规则是指一组用于某个字符集的排序规则,即采用何种规则对某类字符进行排序。MySQL 每一类编码字符都有其对应的字符集和校对规则。MySQL 对各种字符集的支持都非常完善,但同时也带来一些复杂性,某些场景下甚至会有一些性能牺牲。

一种字符集可能对应多种校对规则,且都有一个默认校对规则,那在 MySQL 中是如何使用字符集的?在 MySQL 中可以通过两种方式设置字符集:创建对象时设置默认值、客户端与服务器通信时显式设置。

MySQL 采用“阶梯”式的方式来设定字符集默认值,每个数据库,每张表都有自己的默认值,它们逐层继承,最终最靠底层的默认设置将影响你创建的对象。比如,创建数据库时,将根据服务器上的 character_set_server 来设置数据库的默认字符集,同样的道理,根据 database 的字符集来指定库中所有表的字符集……不管是对数据库,还是表和列,只有当它们没有显式指定字符集时,默认字符集才会起作用。

当客户端与服务器通信时,它们可以使用不同的字符集,这时候服务器将进行必要的转换工作。当客户端向服务器发送请求时,数据以 character_set_client 设置的字符集进行编码;而当服务器收到客户端的 SQL 或者数据时,会按照 character_set_connection 设置的字符集进行转换;当服务器将要进行增删改查等操作前会再次将数据转换成 character_set_database(数据库采用的字符集,没有单独配置即使用默认配置,具体参考上文),最后当服务器返回数据或者错误信息时,则将数据按 character_set_result 设置的字符集进行编码。服务器端可以使用 SET CHARACTER SET 来改变上面的配置,客户端也可以根据对应的 API 来改变字符集配置。客户端和服务器端都使用正确的字符集才能避免在通信中出现问题。

那如何选择字符集?

在考虑使用何种字符集时,最主要的衡量因素是存储的内容,在能够满足存储内容的前提下,尽量使用较小的字符集。因为更小的字符集意味着更少空间占用、以及更高的网络传输效率,也间接提高了系统的性能。如果存储的内容是英文字符等拉丁语系字符的话,那么使用默认的 latin1 字符集完全没有问题,如果需要存储汉字、俄文、阿拉伯语等非拉丁语系字符,则建议使用 UTF8 字符集。当然不同字符在使用 UTF8 字符集所占用的空间是不同的,比如英文字符在 UTF8 字符集中只使用一个字节,而一个汉字则占用 3 个字节。

除了字符集,校对规则也是我们需要考虑的问题。对于校对规则,一般来说只需要考虑是否以大小写敏感的方式比较字符串或者是否用字符串编码的二进制来比较大小,其对应的校对规则的后缀分别是_cs、_ci和_bin。大小写敏感和二进制校对规则的不同之处在于,二进制校对规则直接使用字符的字节进行比较,而大小写敏感的校对规则在多字节字符集时,如德语,有更复杂的比较规则。举个简单的例子,UTF8 字符集对应校对规则有三种:

  • utf8_bin 将字符串中的每一个字符用二进制数据存储,区分大小写
  • utf8_general_ci 不区分大小写,ci 为 case insensitive 的缩写,即大小写不敏感
  • utf8_general_cs 区分大小写,cs 为 case sensitive 的缩写,即大小写敏感

比如,创建一张表,使用 UTF8 编码,且大小写敏感时,可以使用如下语句:

CREATE TABLE sales (
    order_no VARCHAR(32) NOT NULL PRIMARY KEY,
    order_amount INT NOT NULL DEFAULT 0,
    ......
) ENGINE=InnoDB COLLATE=utf8_general_cs;

因此,在项目中直接使用 UTF8 字符集是完全没有问题的,但需要记住的是不要在一个数据库中使用多个不同的字符集,不同字符集之间的不兼容问题很难缠。有时候,看起来一切正常,但是当某个特殊字符出现时,一切操作都会出错,而且你很难发现错误的原因。

字符集对数据库的性能有影响吗?

某些字符集和校对规则可能会需要多个的 CPU 操作,可能会消耗更多的内存和存储空间,这点在前文已经说过。特别是在同一个数据库中使用不同的字符集,造成的影响可能会更大。

不同字符集和校对规则之间的转换可能会带来额外的系统开销,比如,数据表 sales 在 buyer 字段上有索引,则可以加速下面的 ORDER BY 操作:

SELECT order_no,order_amount FROM sales ORDER BY buyer;

只有当 SQL 查询中排序要求的字符集与服务器数据的字符集相同时,才能使用索引进行排序。你可能会说,这不是废话吗?其实不然,MySQL 是可以单独指定排序时使用的校对规则的,比如:

// 你说,这不是吃饱了撑的吗?我觉得也是,也许会有其适用的场景吧
// 这时候就不能使用索引排序呢,只能使用文件排序
SELECT order_no,order_amount FROM sales ORDER BY buyer COLLATE utf8_bin;

当使用两个字符集不同的列来关联两张表时,MySQL 会尝试转换其中一个列的字符集。这和在数据列外面封装一个函数一样,会让 MySQL 无法使用这个列上的索引。关于 MySQL 字符集还有一些坑,但在实际应用场景中遇到的字符集问题,其实不是特别的多,所以就此打住。

MySQL 配置

MySQL 配置的工作原理

MySQL 从哪儿获得配置信息:命令行参数和配置文件。类 Unix 系统中,配置文件一般位于 /etc/my.cnf 或者 /etc/mysql/my.cnf。在启动时,可以通过命令行参数指定配置文件的位置,当然命令行中也可以指定其它参数,服务器会读取配置文件的内容,删除所有注释和换行,然后和命令行选项一起处理。

任何打算长期使用的配置项都应该写入配置文件,而不是在命令行中指定。一定要清楚的知道 MySQL 使用的配置文件位置,在修改时不能想当然,比如,修改了/etc/my.cnf 的配置项,但 MySQL 实际并未使用这个配置文件。如果你不知道当前使用的配置文件路径,可以尝试:

root@msc3:~# which mysqld
/usr/sbin/mysqld
root@msc3:~# /usr/sbin/mysqld --verbose --help |grep -A 1 'Default options'
Default options are read from the following files in the given order:
/etc/my.cnf /etc/mysql/my.cnf ~/.my.cnf

一个典型的配置文件包含多个部分,每个部分的开头是一个方括号括起来的分段名称。MySQL 程序通常读取跟它同名的分段部分,比如,许多客户端程序读取 client 部分。服务器通常读取 mysqld 这一段,一定要确认配置项放在了文件正确的分段中,否则配置是不会生效的。MySQL 每一个配置项均使用小写,单词之间用下划线或者横线隔开,虽然我们常用的分隔符是下划线,但如果在命令行或者配置文件中见到如下配置,你要知道,它们其实是等价的:

# 配置文件
max_connections=5000
max-connections=5000
# 命令行
/usr/sbin/mysqld --max_connections=5000
/usr/sbin/mysqld --max-connections=5000

配置项可以有多个作用域:全局作用域、会话作用域(每个连接作用不同)、对象作用域。很多会话级配置项跟全局配置相等,可以认为是默认值,如果改变会话级配置项,它只影响改动的当前连接,当连接关闭时,所有的参数变更都会失效。下面有几个示例配置项:

  • query-cache-size 全局配置项
  • sort-buffer-size 默认全局相同,但每个线程里也可以设置
  • join-buffer-size 默认全局,且每个线程也可以设置。但若一个查询中关联多张表,可以为每个关联分配一个关联缓存( join-buffer),所以一个查询可能有多个关联缓冲。

配置文件中的变量(配置项)有很多(但不是所有)可以在服务器运行时修改,MySQL 把这些归为动态配置变量:

# 设置全局变量,GLOBAL和@@global作用是一样的
set   GLOBAL   sort-buffer-size  = <value>
set   @@global.sort-buffer-size := <value>

# 设置会话级变量,下面6种方式作用是一样的
# 即:没有修饰符、SESSION、LOCAL等修饰符作用是一致的
set  SESSION   sort-buffer-size  = <value>
set  @@session.sort-buffer-size := <value>
set          @@sort-buffer-size  = <value>
set  LOCAL     sort-buffer-size  = <value>
set     @@ocal.sort-buffer-size := <value>
set            sort-buffer-size  = <value>

# set命令可以同时设置多个变量,但其中只要有一个变量设置失败,所有的变量都未生效
SET GLOBAL sort-buffer-size = 100, SESSION sort-buffer-size = 1000;
SET GLOBAL max-connections = 1000, sort-buffer-size = 1000000;

动态的设置变量,MySQL 关闭时这些变量都会失效。如果在服务器运行时修改了变量的全局值,这个值对当前会话和其他任何已经存在的会话都不起效果,这是因为会话的变量值是在连接创建时从全局值初始化而来的。注意,在配置修改后,需要确认是否修改成功。你可能注意到,上面的示例中,有些使用“=”,有些使用“:=”。对于 set 命令本身来说,两种赋值运算符没有任何区别,在命令行中使用任一运算符符,均可以生效。而在其他语句中,赋值运算符必须是“:=”,因为在非 set 语句中“=”被视为比较运算符。具体可以参考如下示例:

// @exp 表示用户变量,上面的示例均是系统变量
// 错误
set @user = 123456;
set @group = select GROUP from USER where User = @user;
select * from USER where GROUP = @group;
// 正确
SET @user := 123456;
SELECT @group := `group` FROM user WHERE user = @user;
SELECT * FROM user WHERE `group` = @group;

有一些配置使用了不同的单位,比如 table-cache 变量指定表可以被缓存的数量,而不是表可以被缓存的字节数。而 key-buffer-size 则是以字节为单位。还有一些配置可以指定后缀单位,比如 1M=1024*1024 字节,但需要注意的是,这只能在配置文件或者作为命令行参数时有效。当使用 SQL 的 SET 命令时,必须使用数字值 1048576 或者 1024*1024 这样的表达式,但在配置文件中不能使用表达式。

小心翼翼的配置 MySQL

好习惯 1:不要通过配置项的名称来推断一个变量的作用

不要通过配置项的名称来推断一个变量的作用,因为它可能跟你想象的完全不一样。比如:

  • read-buffer-size:当 MySQL 需要顺序读取数据时,如无法使用索引,其将进行全表扫描或者全索引扫描。这时,MySQL 按照数据的存储顺序依次读取数据块,每次读取的数据块首先会暂存在缓存中,当缓存空间被写满或者全部数据读取结束后,再将缓存中的数据返回给上层调用者,以提高效率。
  • read-rnd-buffer-size:和顺序读取相对应,当 MySQL 进行非顺序读取(随机读取)数据块的时候,会利用这个缓冲区暂存读取的数据。比如:根据索引信息读取表数据、根据排序后的结果集与表进行 Join 等等。总的来说,就是当数据块的读取需要满足一定的顺序的情况下,MySQL 就需要产生随机读取,进而使用到 read-rnd-buffer-size参数所设置的内存缓冲区。

这两个配置都是在扫描 MyISAM 表时有效,且 MySQL 会为每个线程分配内存。对于前者,MySQL 只会在查询需要使用时才会为该缓存分配内存,并且一次性分配该参数指定大小的全部内存,而后者同样是需要时才分配内存,但只分配需要的内存大小而不是参数指定的数值, max-read-rnd-buffer-size(实际上没有这个配置项)这个名字更能表达这个变量的实际含义。

好习惯 2:不要轻易在全局修改会话级别的配置

对于某些会话级别的设置,不要轻易的在全局增加它们的值,除非你确认这样做是对的。比如: sort-buffer-size,该参数控制排序操作的缓存大小,MySQL 只会在查询需要做排序操作时才会为该缓冲分配内存,一旦需要排序,就会一次性分配指定大小的内存,即使是非常小的排序操作。因此在配置文件中应该配置的小一些,然后在某些查询需要排序时,再在连接中把它调大。比如:

SET @@seession.sort-buffer-size := <value>
-- 执行查询的sql
SET @@seession.sort-buffer-size := DEFAULT #恢复默认值
# 可以将类似的代码封装在函数中方便使用。

好习惯 3:配置变量时,并不是值越大越好

配置变量时,并不是值越大越好,而且如果设置的值太高,可能更容易导致内存问题。在修改完成后,应该通过监控来确认变量的修改对服务器整体性能的影响。

好习惯 4:规范注释,版本控制

在配置文件中写好注释,可能会节省自己和同事大量的工作,一个更好的习惯是把配置文件置于版本控制之下。

说完了好习惯,再来说说不好的习惯。

坏习惯 1:根据一些“比率”来调优

一个经典的按“比率”调优的经验法则是,缓存的命中率应该高于某个百分比,如果命中率过低,则应该增加缓存的大小。这是非常错误的意见,大家可以仔细思考一下:缓存的命中率跟缓存大小有必然联系吗?(分母变大,值就变大了?)除非确实是缓存太小了。关于 MyISAM 键缓冲命中率,下文会详细说明。

坏习惯 2:随便使用调优脚本

尽量不要使用调优脚本!不同的业务场景、不同的硬件环境对 MySQL 的性能要求是不一样的。比如有些业务对数据的完整性要求较高,那么就一定要保证数据不丢失,出现故障后可恢复数据,而有些业务却对数据的完整性要求没那么高,但对性能要求更高。因此,即使是同一个变量,在这两个不同场景下,其配置的值也应该是不同的。那你还能放心的使用网上找到的脚本吗 ?

给你一个基本的 MySQL 配置

前面已经说到,MySQL 可配置性太强,看起来需要花很多时间在配置上,但其实大多数配置的默认值已经是最佳的,最好不要轻易改动太多的配置,你甚至不需要知道某些配置的存在。这里有一个最小的示例配置文件,可以作为服务器配置文件的一个起点,其中有一些配置项是必须的。本节将为你详细剖析每个配置有何作用?为什么要配置它?怎么确定合适的值?

[mysql]
# CLIENT #
port                           = 3306
socket                         = /var/lib/mysql/mysql.sock
[mysqld]
# GENERAL #
user                           = mysql
port                           = 3306
default-storage-engine         = InnoDB
socket                         = /var/lib/mysql/mysql.sock
pid-file                       = /var/lib/mysql/mysql.pid
# DATA STORAGE #
datadir                        = /var/lib/mysql/
# MyISAM #
key-buffer-size                = 32M
myisam-recover                 = FORCE,BACKUP
# SAFETY #
max-allowed-packet             = 16M
max-connect-errors             = 1000000
# BINARY LOGGING #
log-bin                        = /var/lib/mysql/mysql-bin
expire-logs-days               = 14
sync-binlog                    = 1
# LOGGING #
log-error                      = /var/lib/mysql/mysql-error.log
log-queries-not-using-indexes  = 1
slow-query-log                 = 1
slow-query-log-file            = /var/lib/mysql/mysql-slow.log
# CACHES AND LIMITS #
tmp-table-size                 = 32M
max-heap-table-size            = 32M
query-cache-type               = 0
query-cache-size               = 0
max-connections                = 500
thread-cache-size              = 50
open-files-limit               = 65535
table-definition-cache         = 4096
table-open-cache               = 10240
# INNODB #
innodb-flush-method            = O_DIRECT
innodb-log-files-in-group      = 2
innodb-log-file-size           = 256M
innodb-flush-log-at-trx-commit = 1
innodb-file-per-table          = 1
innodb-buffer-pool-size        = 12G

分段

MySQL 配置文件的格式为集中式,通常会分成好几部分,可以为多个程序提供配置,如[client]、[mysqld]、[mysql]等等。MySQL 程序通常是读取与它同名的分段部分。

  • [client] 客户端默认设置内容
  • [mysql] 使用 mysql 命令登录 mysql 数据库时的默认设置
  • [mysqld] 数据库本身的默认设置

例如服务器 mysqld 通常读取[mysqld]分段下的相关配置项。如果配置项位置不正确,该配置是不会生效的。

GENERAL

首先创建一个用户 mysql 来运行 mysqld 进程,请确保这个用户拥有操作数据目录的权限。设置默认端口为 3306,有时为了安全,可能会修改一下。默认选择 Innodb 存储引擎,在大多数情况下是最好的选择。但如果默认是 InnoDB,却需要使用 MyISAM 存储引擎,请显式地进行配置。许多用户认为其数据库使用了某种存储引擎但实际上却使用的是另外一种,就是因为默认配置的问题。接着设置数据文件的位置,这里把 pid 文件和 socket 文件放到相同的位置,当然也可以选择其它位置,但要注意的是不要将 socket 文件和 pid 文件放到 MySQL 编译的默认位置,因为不同版本的 MySQL,这两个文件的默认路径可能会不一致,最好明确地设置这些文件的位置,以免版本升级时出现问题。

在类 UNIX 系统下本地连接 MySQL 可以采用 UNIX 域套接字方式,这种方式需要一个套接字(socket)文件,即配置中的 mysql.sock 文件。当 MySQL 实例启动时,会将自己的进程 ID 写入一个文件中——该文件即为 pid 文件。该文件可由参数 pid-file 控制,默认位于数据库目录下,文件名为主机名.pid

DATA STORAGE

datadir 用于配置数据文件的存储位置,没有什么好说的。

为缓存分配内存

接下来有许多涉及到缓存的配置项,缓存设置多大,最直接的因素肯定是服务器内存的大小。如果服务器只运行 MySQL,所有不需要为 OS 以及查询处理保留的内存都可以用在 MySQL 缓存。为 MySQL 缓存分配更多内存,可以有效的避免磁盘访问,提升数据库性能。大部分情况来说最为重要的缓存:

  • InnoDB 缓冲池
  • InnoDB 日志文件和 MyISAM 数据的操作系统缓存(MyISAM 依赖于 OS 缓存数据)
  • MyISAM 键缓存
  • 查询缓存
  • 无法配置的缓存,比如:bin-log 或者表定义文件的 OS 缓存

还有一些其他缓存,但它们通常不会使用太多内存。关于查询缓存,前面文章(参考本系列的第一篇)已有介绍,大多数情况下我们不建议开启查询缓存,因此上文的配置中 query-cache-type=0表示禁用了查询缓存,相应的查询缓存大小 query-cache-size=0。除开查询缓存,剩下关于 InnoDB 和 MyISAM 的相关缓存,在接下来会做详细介绍。

如果只使用单一存储引擎,配置服务器就会简单许多。如果只使用 MyISAM 表,就可以完全关闭 InnoDB,而如果只使用 InnoDB,就只需要分配最少的资源给 MyISAM(MySQL 内部系统表使用 MyISAM 引擎)。但如果是混合使用各种存储引擎,就很难在他们之间找到恰当的平衡,因此只能根据业务做一个猜测,然后在运行中观察服务器运行状况后做出调整。

MyISAM

key-buffer-size

key-buffer-size 用于配置 MyISAM 键缓存大小,默认只有一个键缓存,但是可以创建多个。MyISAM 自身只缓存索引,不缓存数据(依赖 OS 缓存数据)。如果大部分表都是 MyISAM,那么应该为键缓存设置较多的内存。但如何确定该设置多大?假设整个数据库中表的索引大小为 X,肯定不需要把缓存设置得比 X 还大,所以当前的索引大小就成为这个配置项的重要依据。可以通过下面两种方式来查询当前索引的大小:

// 1.通过SQL语句查询
SELECT SUM(INDEX_LENGTH) FROM INFORMATION_SCHEMA.TABLES WHERE ENGINE = 'MYISAM'
// 2.统计索引文件的大小
$ du -sch `find /path/to/mysql/data/directory/ -name "*.MYI"`
比如:
root@dev-msc3:# du -sch `find /var/lib/mysql -name "*.MYI"`
72K        /var/lib/mysql/static/t_global_region.MYI
40K        /var/lib/mysql/mysql/db.MYI
12K        /var/lib/mysql/mysql/proxies_priv.MYI
12K        /var/lib/mysql/mysql/tables_priv.MYI
4.0K       /var/lib/mysql/mysql/func.MYI
4.0K       /var/lib/mysql/mysql/columns_priv.MYI
4.0K       /var/lib/mysql/mysql/proc.MYI
4.0K       /var/lib/mysql/mysql/event.MYI
4.0K       /var/lib/mysql/mysql/user.MYI
4.0K       /var/lib/mysql/mysql/procs_priv.MYI
4.0K       /var/lib/mysql/mysql/ndb_binlog_index.MYI
164K       total

你可能会问,刚创建好的数据库,根本就没什么数据,索引文件大小为 0,那如何配置键缓存大小?这时候只能根据经验值:不超过为操作系统缓存保留内存的 25% ~ 50%。设置一个基本值,等运行一段时间后,根据运行情况来调整键缓存大小。总结来说,索引大小与 OS 缓存的 25%~50%两者间取小者。当然还可以计算键缓存的使用情况,如果一段时间后还是没有使用完所有的键缓存,就可以把缓冲区调小一点,计算缓存区的使用率可以通过以下公式:

// key_blocks_unused的值可以通过 SHOW STATUS获取
// key_cache_block_size的值可以通过 SHOW VARIABLES获取
(key_blocks_unused * key_cache_block_size) / key_buffer_size

键缓存块大小是一个比较重要的值,因为它影响 MyISAM、OS 缓存以及文件系统之间的交互。如果缓存块太小,可能会碰到写时读取(OS 在写数据之前必须先从磁盘上读取一些数据),关于写时读取的相关知识,大家可以自行查阅。

关于缓存命中率,这里再说一点。缓存命中率有什么意义?其实这个数字没太大的作用。比如 99%和 99.9%之间看起来差距很小,但实际上代表了 10 倍的差距。缓存命中率的实际意义与应用也有很大关系,有些应用可以在命中率 99%下良好的工作,有些 I/O 密集型应用,可能需要 99.99%。所以从经验上来说,每秒未命中次数这个指标实际上会更有用一些。比如每秒 5 次未命中可能不会导致 IO 繁忙,但每秒 100 次缓存未命中则可能出现问题。

MyISAM 键缓存的每秒未命中次数可以通过如下命令监控:

# 计算每隔10s缓存未命中次数的增量
# 使用此命令时请带上用户和密码参数:mysqladmin -uroot -pxxx extended-status -r -i 10 | grep Key_reads
$ mysqladmin extended-status -r -i 10 | grep Key_reads

最后,即使没有使用任何 MyISAM 表,依然需要将 key-buffer-size设置为较小值,比如 32M,因为 MySQL 内部会使用 MyISAM 表,比如 GROUP BY 语句可能会创建 MyISAM 临时表。

myisam-recover

myisam-recover选项用于配置 MyISAM 怎样寻找和修复错误。打开这个选项会通知 MySQL 在打开表时,检查表是否损坏,并在找到问题时进行修复,它可以设置如下值:

  • DEFAULT:表示不设置,会尝试修复崩溃或者未完全关闭的表,但在恢复数据时不会执行其它动作
  • BACKUP:将数据文件备份到.bak 文件,以便随后进行检查
  • FORCE:即使.myd 文件中丢失的数据超过 1 行,也让恢复动作继续执行
  • QUICK:除非有删除块,否则跳过恢复

可以设置多个值,每个值用逗号隔开,比如配置文件中的 BACKUP,FORCE会强制恢复并且创建备份,这样配置在只有一些小的 MyISAM 表时有用,因为服务器运行着一些损坏的 MyISAM 表是非常危险的,它们有时可能会导致更多数据损坏,甚至服务器崩溃。然而如果有很大的表,它会导致服务器打开所有的 MyISAM 表时都检查和修复,大表的检查和修复可能会耗费大量时间,且在这段时间里,MySQL 会阻止这个连接做其它任何操作,这显然是不切实际的。

因此,在默认使用 InnoDB 存储引擎时,数据库中只有非常小的 MyISAM 表时,只需要配置 key-buffe-size于一个很小的值(32M)以及 myisam-recover=BACKUP,FORCE。当数据库中大部分表为 MyISAM 表时,请根据上文的公式合理配置 key-buffer-size,而 myisam-recover则可以关闭,在启动后使用 CHECK TABLESREPAIR TABLES命令来做检查和修复,这样对服务器的影响比较小。

SAFETY

基本配置设置到位后,MySQL 已经比较安全了,这里仅仅列出两个需要注意的配置项,如果需要启用一些使服务器更安全和可靠的设置,可以参考 MySQL 官方手册,但需要注意的是,它们其中的一些选项可能会影响性能,毕竟保证安全和可靠需要付出一些代价。

max-allowed-packet

max-allowed-packet 防止服务器发送太大的数据包,也控制服务器可以接收多大的包。默认值 4M,可能会比较小。如果设置太小,有时复制上会出问题,表现为从库不能接收主库发过来的复制数据。如果表中有 Blob 或者 Text 字段,且数据量较大的话,要小心,如果数据量超过这个变量的大小,它们可能被截断或者置为 NULL,这里建议设置为 16M。

max-connect-errors

这个变量是一个 MySQL 中与安全相关的计数器值,它主要防止客户端暴力破解密码。如果某一个客户端尝试连接 MySQL 服务器失败超过 n 次,则 MySQL 会无条件强制阻止此客户端连接,直到再次刷新主机缓存或者重启 MySQL 服务器。

这个值默认为 10,太小了,有时候网络抽风或者应用配置出现错误导致短时间内不断尝试重连服务器,客户端就会被列入黑名单,导致无法连接。如果在内网环境,可以确认没有安全问题可以把这个值设置的大一点,默认值太容易导致问题。

LOGGING

接下来看下日志的配置,对于 MySQL 来说,慢日志和 bin-log 是非常重要的两种日志,前者可以帮助应用程序监控性能问题,后者在数据同步、备份等方面发挥着非常重要的作用。

关于 bin-log 的 3 个配置, log-bin 用于配置文件存放路径, expire_logs_days 让服务器在指定天数之后清理旧的日志,即配置保留最近多少天的日志。除非有运维手动备份清理 bin-log,否则强烈建议打开此配置,如果不启用,服务器空间最终将会被耗尽,导致服务器卡住或者崩溃。

sync-binlog

sync-binlog控制当事务提交之后,MySQL 是否将 bin-log 刷新到磁盘。如果其值等于 0 或者大于 1 时,当事务提交之后,MySQL 不会将 bin-log 刷新到磁盘,其性能最高,但存在的风险也是最大的,因为一旦系统崩溃,bin-log 将会丢失。而当其值等于 1 时,是最安全的,这时候即使系统崩溃,最多也就丢失本次未完成的事务,对实际的数据没有实质性的影响,但性能较差。

需要注意的是,在 5.7.7 之前的版本,这个选择的默认值为 0,而后默认值为 1,也就是最安全的策略。对于高并发的性能,需要关注这一点,防止版本升级后出现性能问题。

剩下的 4 个配置项就没太多要说的。

  • log-error:用于配置错误日志的存放目录
  • slow-query-log:打开慢日志,默认关闭
  • slow-query-log-file:配置慢日志的存放目录
  • log-queries-not-using-indexes:如果该 sql 没有使用索引,会将其写入到慢日志,但是否真的执行很慢,需要区分,默认关闭。

CACHES AND LIMITS

tmp-table-size && max-heap-table-size

这两个配置控制使用 Memory 引擎的内存临时表可以使用多大的内存。如果隐式内存临时表的大小超过这两个值,将会被转为磁盘 MyISAM 表(隐式临时表由服务器创建,用户保存执行中的查询的中间结果)。

如果查询语句没有创建庞大的临时表(通过合理的索引和查询设计来避免),可以把这个值设大一点,以免需要把内存临时表转换为磁盘临时表。但要谨防这个值设置得过大,如果查询确实会创建很大的临时表,那么还是使用磁盘比较好,毕竟并发数一起来,所需要的内存就会急剧增长。

应该简单的把这两个变量设为同样的值,这里选择了 32M,可以通过仔细检查 created-tmp-disk-tables 和 created-tmp-tables 两个变量来指导你设置,这两个变量的值将展示临时表的创建有多频繁。

max-connections

用于设置用户的最大连接数,保证服务器不会应为应用程序激增的连接而不堪重负。如果应用程序有问题,或者服务器遇到连接延迟问题,会创建很多新连接。但如果这些连接不能执行查询,那打开一个连接没什么好处,所以被“太多的连接”错误拒绝是一种快速而且代价小的失败方式。

在服务器资源允许的情况下,可以把 max-connections 设置的足够大,以容纳正常可能达到的负载。若认为正常情况将有 300 或者更多连接,可以设置为 500 或者更多(应对高峰期)。默认值是 100,太小了,这里设置为 500,但并不意味着其是一个合理的值,应该监控应用有多少连接,然后根据监控值(观察 max_used_connections 随时间的变化)来设置。

thread-cache-size

线程缓存保存那些当前没有与连接关联但是准备为后面新连接服务的线程。当一个新的连接创建时,如果缓存中有线程存在,MySQL 则从缓存中删除一个线程,并且把它分配给这个新连接。当连接关闭时,如果线程缓存还有空间的话,MySQL 又会把线程放回缓存。如果没有空间的话,MySQL 会销毁这个线程。只要 MySQL 在缓存里还有空闲的线程,它就可以迅速响应连接请求,因为这样就不用为每个连接创建新线程。thread-cache-size 指定 MySQL 可以保存在缓存中的线程数量。如果服务器没有很多的连接请求,一般不需要配置这个值。

如何判断这个值该设置多大?

观察 threads-connected 变量,如果 threads-connected 在 100-120,那么 thread-cache-size 设置为 20。如果它保持在 500-700,200 的线程缓存应该足够大了。可以这么理解:当同时有 700 个连接时,可能缓存中没有线程。在 500 个连接时,有 200 个缓存的线程准备为负载再次增加到 700 个连接时使用。

open-files-limit

在类 Uinux 系统上我们把它设置得尽可能大。现代 OS 中打开句柄开销都很小,如果此参数设置过小,可能会遇到“打开的文件太多( too many open files)”错误。

tablecachesize

表缓存跟线程缓存类似,但存储的对象是表,其包含表.frm 文件的解析结果和一些其他数据。准确的说,缓存的数据依赖于存储引擎,比如,对于 MyISAM,缓存表的数据和索引的文件描述符。表缓存对 InnoDB 的存储引擎来说,重要性会小很多,因为 InnoDB 不依赖它来做那么多的事。

从 5.1 版本及以后,表缓存就被分为两个部分:打开表缓存和定义表缓存,分别通过 table-open-cache-size 和 table-definition-cache-size 变量来配置。通常可以把 table-definition-cache-size 设置得足够高,以缓存所有的表定义,因为大部分存储引擎都能从 table-definition-cache 获益。

INNODB

InnoDB 应该是使用最广泛的存储引擎,最重要的配置选项是下面这两个: innodb-buffer-pool-size 与 innodb-log-file-size,解决这两个配置基本上就解决了真实场景下的大部分配置问题。

innodb-buffer-pool-size

如果大部分是 InnoDB 表,那么 InnoDB 缓冲池或许比其他任何东西都更需要内存,InnoDB 缓冲池缓冲的数据:索引、行数据、自适应哈希索引、插入缓冲、锁以及其他内部数据结构。InnoDB 还使用缓冲池来帮助延迟写入,这样就可以合并多个写入操作,然后一起顺序写入,提升性能。总之,InnoDB 严重依赖缓冲池,必须为其分配足够的内存。

当然,如果数据量不大且不会快速增长,就没有必要为缓冲池分配过多的内存,把缓冲池配置得比需要缓存的表和索引还要大很多,实际上也没有什么意义。很大的缓冲池也会带来一些挑战,例如,预热和关闭都会花费很长的时间。如果有很多脏页在缓冲池里,InnoDB 关闭时可能会花很长时间来把脏页写回数据文件。虽然可以快速关闭,但是在启动时需要做更多的恢复工作,也就是说我们无法同时加速关闭和重启两个操作。当有一个很大的缓冲池,重启服务需要花费很长时间(几小时或者几天)来预热,尤其是磁盘很慢的时候,如果想加快预热时间,可以在重启后立刻进行全表扫描或者索引扫描,把索引载入缓冲池。

可以看到示例的配置文件中把这个值配置为 12G,这不是一个标准配置,需要根据具体的硬件来估算。那如何估算?前面的小节,我们说到,MySQL 中最重要的缓存有 5 种,可以简单的使用下面的公式计算:

InnoDB缓冲池 = 服务器总内存 - OS预留 - 服务器上的其他应用占用内存 - MySQL自身需要的内存 - InnoDB日志文件占用内存 - 其它内存(MyISAM键缓存、查询缓存等)

具体来看,至少需要为 OS 保留 1~2G 内存,如果机器内存大的话可以预留多一些,建议 2GB 和总内存的 5%为基准,以较大者为准,如果机器上还运行着一些内存密集型任务,比如,备份任务,那么可以为 OS 再预留多一些内存。不要为 OS 缓存增加任何内存,因为 OS 通常会利用所有剩下的内存来做文件缓存。

一般来说,运行 MySQL 的服务器很少会运行其他应用程序,但如果有的话,请为这些应用程序预留足够多的内存。MySQL 自身运行还需要一些内存,但通常都不会太大。需要考虑 MySQL 每个连接需要的内存,虽然每个连接需要的内存都很少,但它还要求一个基本量的内存来执行任何给定的查询,而且查询过程中还需要为排序、GROUP BY 等操作分配临时表内存,因此需要为高峰期执行大量的查询预留足够的内存。这个内存有多大?只能在运行过程中监控。

如果大部分表都是 InnoDB,MyISAM 键缓存配置一个很小值足矣,查询缓存也建议关闭。公式中就剩下 InnoDB 日志文件了,这就是我们接下来要说的。

innodb-log-file-size && innodb-log-files-in-group

如果对 InnoDB 数据表有大量的写入操作,那么选择合适的 innodb-log-file-size 值对提升 MySQL 性能很重要。InnoDB 使用日志来减少提交事务时的开销。日志中记录了事务,就无须在每个事务提交时把缓冲池的脏块(缓存中与磁盘上数据不一致的页)刷新到磁盘。事务修改的数据和索引通常会映射到表空间的随机位置,所以刷新这些变更到磁盘需要很多随机 I/O。一旦日志安全的写入磁盘,事务就持久化了,即使变更还没有写到数据文件,在一些意外情况发生时(比如断电了),InnoDB 可以重放日志并且恢复已经提交的事务。

InnoDB 使用一个后台线程智能地刷新这些变更到数据文件。实际上,事务日志把数据文件的随机 I/O 转换为几乎顺序地日志文件和数据文件 I/O,让刷新操作在后台可以更快的完成,并且缓存 I/O 压力。

整体的日志文件大小受控于 innodb-log-file-size 和 innodb-log-files-in-group 两个参数,这对写性能非常重要。日志文件的总大小是每个文件的大小之和。默认情况下,只有两个 5M 的文件,总共 10M,对高性能工作来说太小了,至少需要几百 M 或者上 G 的日志文件。这里要注意 innodb-log-files-in-group 这个参数,它控制日志文件的数量,从名字上看好似配置一个日志组有几个文件,实际上, loggroup 表示一个重做日志的文件集合,没有参数也没有必要配置有多少个日志组。

修改日志文件的大小,需要完全关闭 MySQL,然后将旧的日志文件迁移到其他地方,重新配置参数,然后重启。重启时需要将旧的日志迁移回来,然后等待 MySQL 恢复数据后,再删除旧的日志文件,请一定要查看错误日志,确认 MySQL 重启成功后再删除旧的日志文件。

想要确定理想的日志文件大小,需要权衡正常数据变更的开销,以及崩溃时恢复需要的时间。如果日志太小,InnoDB 将必须要做更多的检查点,导致更多的日志写,在极个别情况下,写语句还会被拖累,在日志没有空间继续写入前,必须等待变更被刷新到数据文件。另一方面,如果日志太大,在崩溃时恢复就得做大量的工作,这可能增大恢复时间。InnoDB 会采用 checkpoint 机制来刷新和恢复数据,这会加快恢复数据的时间。

innodb-flush-log-at-trx-commit

前面讨论了很多缓存,InnoDB 日志也是有缓存的。当 InnoDB 变更任何数据时,会写一条变更记录到日志缓存区。在缓冲慢的时候、事务提交的时候,或者每一秒钟,InnoDB 都会将缓冲区的日志刷新到磁盘的日志文件。如果有大事务,增加日志缓冲区大小可以帮助减少 I/O,变量 innodb-log-buffer-size 可以控制日志缓冲区的大小。通常不需要把日志缓冲区设置的非常大,毕竟上述 3 个条件,任一条件先触发都会把缓冲区的内容刷新到磁盘,所以缓冲区的数据肯定不会太多,出入你的数据中有很多相当大的 BLOB 记录。通常来说,配置 1M~8M 即可。

既然存在缓冲区,怎样刷新日志缓冲就是我们需要关注的问题。日志缓冲必须刷新到磁盘,以确保提交的事务完全被持久化。如果和持久化相比,更在乎性能,可以修改 innodb-flush-log-at-trx-commit 变量来控制日志缓冲刷新的频率。

  • 0:每 1 秒钟将日志缓冲写到日志文件并刷新到磁盘,事务提交时不做任何处理
  • 1:每次事务提交时,将日志缓冲写到日志文件并刷新到磁盘
  • 2:每次事务提交时,将日志缓冲写到日志文件,然后每秒刷新一次到磁盘

1 是最安全的设置,保证不会丢失任何已经提交的事务,这也是默认的设置。0 和 2 最主要的区别是,如果 MySQL 挂了,2 不会丢失事务,但 0 有可能,2 在每次事务提交时,至少将日志缓冲刷新到操作系统的缓存,而 0 则不会。如果整个服务器挂了或者断电了,则还是可能会丢失一些事务。

innodb-flush-method

前面都在讨论使用什么样的策略刷新、以及何时刷新日志或者数据,那 InnoDB 具体是怎样刷新数据的?使用 innodb-flush-method 选项可以配置 InnoDB 如何跟文件系统相互作用。从名字上看,会以为只能影响 InnoDB 怎么写数据,实际上还影响了 InnoDB 怎么读数据。windows 和非 Windows 操作系统下这个选项的值是互斥的,也就是说有些值只能 Windows 下使用,有些只能在非 Windows 下使用,其中 Windows 下可取值: async_unbuffered、 unbuffered、 normal、 Nosync 与 littlesync,非 Windows 取值: fdatasync、 0_DIRECT、 0_DSYNC。

这个选项既会影响日志文件,也会影响数据文件,而且有时候对不同类型的文件的处理也不一样,导致这个选项有些难以理解。如果有一个选项来配置日志文件,一个选项来配置数据文件,应该会更好,但实际上它们混合在同一个配置项中。这里只介绍类 Unix 操作系统下的选项。

fdatasync

InnoDB 调用 fsync()fdatasync()函数来刷新数据和日志文件,其中 fdatasync()只刷文件的数据,但不包含元数据(比如:访问权限、文件拥有者、最后修改时间等描述文件特征的系统数据),因此 fsync()相比 fdatasync()会产生更多的 I/O,但在某些场景下 fdatasync()会导致数据损坏,因此 InnoDB 开发者决定用 fsync()来代替 fdatasync()

fsync()的缺点是操作系统会在自己的缓存中缓冲一些数据,理论上双重缓冲是浪费的,因为 InnoDB 自己会管理缓冲,而且比操作系统更加智能。但如果文件系统能有更智能的 I/O 调度和批量操作,双重缓冲也并不一定是坏事:

  • 有的文件系统和 os 可以累积写操作后合并执行,通过对 I/O 的重排序来提升效率、或者并发写入多个设备
  • 有的还可以做预读优化,比如连续请求几个顺序的块,它会通知硬盘预读下一个块

这些优化在特定的场景下才会起作用, fdatasyncinnodb-flush-method的默认值。

0_DIRCET

这个设置不影响日志文件并且不是所有的类 Unix 系统都有效,但至少在 Linux、FreeBSD 以及 Solaris 是支持的。这个设置依然使用 fsync 来刷新文件到磁盘,但是它完全关闭了操作系统缓存,并且是所有的读和写都直接通过存储设置,避免了双重缓冲。如果存储设备支持写缓冲或预读,那么这个选项并不会影响到设备的设置,比如 RAID 卡。

0_DSYNC

这个选项使得所有的写同步,即只有数据写到磁盘后写操作才返回,但它只影响日志文件,而不影响数据文件。

说完了每个配置的作用,最后是一些建议:如果使用类 Unix 操作系统并且 RAID 控制器带有电池保护的写缓存,建议使用 0DIRECT,如果不是,默认值或者 0DIRECT 都可能是最好的选择。

innodb-file-per-table

最后一个配置,说说 InnoDB 表空间,InnoDB 把数据保存在表空间内,它本质上是一个由一个或者多个磁盘文件组成的虚拟文件系统。InnoDB 表空间并不只是存储表和索引,它还保存了回滚日志、插入缓冲、双写缓冲以及其他内部数据结构,除此之外,表空间还实现了很多其它的功能。可以通过 innodb-data-file-path 配置项定制表空间文件, innodb-data-home-dir 配置表空间文件存放的位置,比如:

innodb-data-home-dir = /var/lib/mysql
innodb-data-file-path = ibdata1:1G;ibdata2:1G;ibdata3:1G

这里在 3 个文件中创建了 3G 表空间,为了允许表空间在超过了分配的空间时还能增长,可以像这样配置最后一个文件自动扩展

innodb-data-file-path =ibdata1:1G;ibdata2:1G;ibdata3:1G:autoextend

innodb-file-per-table 选项让 InnoDB 为每张表使用一个文件,这使得在删除一张表时回收空间容易很多,而且特别容易管理,并且可以通过查看文件大小来确定表大小,所以这里建议打开这个配置。

下一页