《高性能MySQL》——Schema与数据类型优化(笔记)
Posted yjx23332
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《高性能MySQL》——Schema与数据类型优化(笔记)相关的知识,希望对你有一定的参考价值。
文章目录
四、Schema与数据类型优化
4.1 选择优化的数据类型
mysql支持的数据类型非常多,选择正确的数据类型对于获得高性能至关重要。
不管存储哪种类型的数据,下面几个简单的原则都有助于做出更好的选择。
更小的通常更好
一般情况下,应该尽量使用可以正确存储数据的最小数据类型。更小的数据类型通常更快,因为它们占用更少的磁盘、内存和CPU缓存,并且处理时需要的CPU周期也更少。
但是要确保没有低估需要存储的值的范围,因为在schema中的多个地方增加数据类型的范围是一个非常耗时和痛苦的操作。
如果无法确定哪个数据类型是最好的,就选择你认为不会超过范围的最小类型。(如果系统不是很忙或者存储的数据量不多,或者是在可以轻易修改设计的早期阶段,那之后修改数据类型也比较容易)。
简单就好
简单数据类型的操作通常需要更少的CPU周期。例如,整型比字符操作代价更低,因为字符集和校对规则(排序规则)使字符比较比整型比较更复杂。
比如:
-
一个是应该使用MySQL内建的类型而不是字符串来存储日期和时间
-
另外一个是应该用整型存储IP地址
尽量避免NULL
很多表都包含可为NULL (空值)的列,即使应用程序并不需要保存NULL也是如此,这是因为可为NULL是列的默认属性。通常情况下最好指定列为NOT NULL,除非真的需要存储NULL值。
如果查询中包含可为NULL的列,对MySQL来说更难优化,因为可为NULL的列使得索引、索引统计和值比较都更复杂。
- 可为NULL的列会使用更多的存储空间,在MySQL里也需要特殊处理。
- 当可为NULL的列被索引时,每个索引记录需要一个额外的字节,在MyISAM里甚至还可能导致固定大小的索引(例如只有一个整数列的索引)变成可变大小的索引。
通常把可为NULL的列改为NOT NULL 带来的性能提升比较小,所以(调优时)没有必要首先在现有schema中查找并修改掉这种情况,除非确定这会导致问题。
但是,如果计划在列上建索引,就应该尽量避免设计成可为NULL的列。
当然也有例外,例如值得一提的是, InnoDB 使用单独的位(bit) 存储NULL值,所以对于稀疏数据生4有很好的空间效率。但这一点不适用于MyISAM。
在为列选择数据类型时,第一步需要确定合适的大类型:数字、字符串、时间等。
下一步是选择具体类型。
很多MySQL的数据类型可以存储相同类型的数据,只是存储的长度和范围不一样、允许的精度不同,或者需要的物理空间(磁盘和内存空间)不同。
相同大类型的不同子类型数据有时也有一些特殊的行为和属性。
例如,DATETIME 和TIMESAMP列都可以存储相同类型的数据:时间和日期,精确到秒。
然而TIMESTAMP只使用DATETIME一半的存储空间,并且会根据时区变化,具有特殊的自动更新能力。
另一方面,TIMESTAMP允许的时间范围要小得多,有时候它的特殊能力会
成为障碍。
本章只讨论基本的数据类型。
MySQL为了兼容性支持很多别名,例如INTEGER、BOOL、以及NUMERIC。它们都只是别名。这些别名可能令人不解,但不会影响性能。
如果建表时采用数据类型的别名,然后用SHOW CREATE TABLE 检查,会发现MySQL报告的是基本类型,而不是别名。
4.1.1 整数类型
有两种类型的数字:
- 整数(whole number)
- 实数(real number)
如果存储整数,可以使用这几种整数类型: TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT。 分别使用8,16,24, 32, 64 位存储空间。它们可以存储的值的范围从 − 2 ( N − 1 ) -2^(N-1) −2(N−1)到 2 ( N − 1 ) − 1 2^(N-1)-1 2(N−1)−1,其中N是存储空间的位数。
整数类型有可选的UNSIGNED属性,表示不允许负值,这大致可以使正数的上限提高一倍。
例如TINYINT UNSIGNED可以存储的范围是0 ~ 255,而TINYINT的存储范围是-128 ~127。
有符号和无符号类型使用相同的存储空间,并具有相同的性能,因此可以根据实际情况选择合适的类型。
你的选择决定MySQL是怎么在内存和磁盘中保存数据的。然而,整数计算一般使用64位的BIGINT整数,即使在32位环境也是如此。(一些聚合函数是例外,它们使用DECIMAL或DOUBLE进行计算)。
MySQL可以为整数类型指定宽度,例如INT(11),对大多数应用这是没有意义的:它不会限制值的合法范围,只是规定了MySQL的一些交互工具(例如MySQL命令行客户端)用来显示字符的个数。对于存储和计算来说,INT(1) 和INT(20)是相同的。
4.1.2 实数类型
实数是带有小数部分的数字。然而,它们不只是为了存储小数部分;也可以使用DECIMAL存储比BIGINT还大的整数。
MySQL既支持精确类型,也支持不精确类型。
FLOAT和DOUBLE类型支持使用标准的浮点运算进行近似计算。如果需要知道浮点运算是怎么计算的,则需要研究所使用的平台的浮点数的具体实现。
DECIMAL类型用于存储精确的小数。
在MySQL5.0和更高版本,DECIMAL类型支持精确计算。MySQL4.1以及更早版本则使用浮点运算来实现DECIAML的计算,这样做会因为精度损失导致一些奇怪的结果。
在这些版本的MySQL中,DECIMAL只是一个“存储类型”。
因为CPU不支持对DECIMAL的直接计算,所以在MySQL 5.0以及更高版本中,MySQL服务器自身实现了DECIMAL的高精度计算。相对而言,CPU直接支持原生浮点计算,所以浮点运算明显更快。
浮点和DECIMAL类型都可以指定精度。对于DECIMAL列,可以指定小数点前后所允许的最大位数。这会影响列的空间消耗。MySQL 5.0 和更高版本将数字打包保存到一个二进制字符串中(每 4个字节存9个数字)。例如,DECIMAL(18,9)小数点两边将各存储9个数字,一共使用9个字节:小数点前的数字用4个字节,小数点后的数字用4个字节,小数点本身占1个字节。
MySQL 5.0和更高版本中的DECIMAL类型允许最多65个数字。而早期的MySQL版本中这个限制是254个数字,并且保存为未压缩的字符串(每个数字一个字节)。然而,这些(早期)版本实际上并不能在计算中使用这么大的数字,因为DECIMAL只是一种存储格式;在计算中DECIMAL会转换为DOUBLE类型。
有多种方法可以指定浮点列所需要的精度,这会使得MySQL悄悄选择不同的数据类型,或者在存储时对值进行取舍。这些精度定义是非标准的,所以我们建议只指定数据类型,不指定精度。
浮点类型在存储同样范围的值时,通常比DECIMAL使用更少的空间。FLOAT使用4个字节存储。DOUBLE占用8个字节,相比FLOAT有更高的精度和更大的范围。和整数类型一样,能选择的只是存储类型; MySQL使用DOUBLE作为内部浮点计算的类型。
因为需要额外的空间和计算开销,所以应该尽量只在对小数进行精确计算时才使用DECIMAL一例如存储财务数据。但在数据量比较大的时候,可以考虑使用BIGINT代替DECIMAL,将需要存储的货币单位根据小数的位数乘以相应的倍数即可。假设要存储财务数据精确到万分之一分,则可以把所有金额乘以一百万,然后将结果存储在BIGINT里,这样可以同时避免浮点存储计算不精确和DECIMAL精确计算代价高的问题。
4.1.3 字符串类型
MySQL支持多种字符串类型,每种类型还有很多变种。
从MySQL 4.1开始,每个字符串列可以定义自己的字符集和排序规则,或者说校对 规则(collation) 。
VARCHAR和CHAR类型VARCHAR和CHAR是两种最主要的字符串类型。不幸的是,很难精确地解释这些值是怎么存储在磁盘和内存中的,因为这跟存储引擎的具体实现有关。
下面的描述假设使用的存储引擎是InnoDB和/或者MyISAM。如果使用的不是这两种存储引擎,请参考所使用的存储引擎的文档。
先看看VARCHAR和CHAR值通常在磁盘上怎么存储。请注意,存储引擎存储CHAR或者VARCHAR值的方式在内存中和在磁盘上可能不一样,所以MySQL服务器从存储引擎读出的值可能需要转换为另一种存储格式。
VARCHAR
VARCHAR类型用于存储可变长字符串,是最常见的字符串数据类型。它比定长类型更节省空间,因为它仅使用必要的空间(例如,越短的字符串使用越少的空间)。
有一种情况例外,如果MySQL表使用ROW_FORMAT=FIXED 创建的话,每一行都会使用定长存储,这会很浪费空间。
VARCHAR需要使用1或2个额外字节记录字符串的长度:如果列的最大长度小于或等于255字节,则只使用1个字节表示,否则使用2个字节。
假设采用latin1字符集,一个VARCHAR(10)的列需要11个字节的存储空间。VARCHAR(1000) 的列则需要1002个字节,因为需要2个字节存储长度信息。
VARCHAR节省了存储空间,所以对性能也有帮助。但是,由于行是变长的,在UPDATE时可能使行变得比原来更长,这就导致需要做额外的工作。
如果一个行占用的空间增长,并且在页内没有更多的空间可以存储,在这种情况下,不同的存储引擎的处理方式是不一样的。
- 例如,MyISAM会将行拆成不同的片段存储,InnoDB则需要分裂页来使行可以放进页内。其他一些存储引擎也许从不在原数据位置更新数据。
下面这些情况下使用VARCHAR是合适的:
- 字符串列的最大长度比平均长度大很多
- 列的更新很少,所以碎片不是问题
- 使用了像UTF-8这样复杂的字符集,每个字符都使用不同的字节数进行存储。
在5.0或者更高版本,MySQL在存储和检索时会保留末尾空格。但在4.1或更老的版本,MySQL会剔除末尾空格。
InnoDB则更灵活,它可以把过长的VARCHAR存储为BLOB。
CHAR
CHAR类型是定长的:
- MySQL总是根据定义的字符串长度分配足够的空间。
当存储CHAR值时,MySQL会 删除所有的末尾空格 (在MySQL 4.1和更老版本中VARCHAR也是这样实现的一也就是说这 些版本中CHAR和VARCHAR在逻辑.上是一样的,区别只是在存储格式上)。
CHAR值会根据需要 采用空格进行填充 以方便比较。
-
CHAR适合存储很短的字符串,或者所有值都接近同一个长度。
例如,CHAR非常适合存储密码的MD5值,因为这是一个定长的值。 -
对于经常变更的数据,CHAR也比VARCHAR更好,因为定长的CHAR类型不容易产生碎片。
-
对于非常短的列,CHAR比VARCHAR在存储空间上也更有效率。
例如用CHAR(1)来存储只有Y和N的值,如果采用单字节字符集只需要一个字节, 但是VARCHAR(1)却需要两个字节,因为还有一个记录长度的额外字节。
首先,我们创建一张只有一一个CHAR(10)字段的表并且往里面插入一些值:
mysql> CREATE TABLE char_test( char_col CHAR(10));
mysql> INSERT INTO char_test(char_col) VALUES ('string1'), (' string2'), (' string3 ');
当检索这些值的时候,会发现string3末尾的空格被截断了。
数据如何存储取 决于存储引擎,并非所有的存储引擎都会按照相同的方式处理定长和变长的字符串。Memory引擎只支持定长的行,即使有变长字段也会根据最大长度分配最大空间。
不过,填充和截取空格的行为在不同存储引擎都是一样的,因为这是在MySQL服务器层进行处理的。
与CHAR和VARCHAR类似的类型还有BINARY和VARBINARY,它们存储的是二进制字符串。
二进制字符串跟常规字符串非常相似,但是二进制字符串存储的是字节码而不是字符。
填充也不一样: MySQL填充BINARY采用的是\\0 (零字节)而不是空格,在检索时也不会去掉填充值。
当需要存储二进制数据,并且希望MySQL使用字节码而不是字符进行比较时,这些类型是非常有用的。二进制比较的优势并不仅仅体现在大小写敏感上。
MySQL比较BINARY字符串时,每次按一个字节,并且根据该字节的数值进行比较。因此,二进制比较比字符比较简单很多,所以也就更快。
使用VARCHAR(5)和VARCHAR(200)存储’hello’ 的空间开销是一样的。那么使用更短的列有什么优势吗?
事实证明有很大的优势。更长的列会消耗更多的内存,因为MySQL通常会分配固定大小的内存块来保存内部值。尤其是使用内存临时表进行排序或操作时会特别糟糕。
在利用磁盘临时表进行排序时也同样糟糕。所以最好的策略是只分配真正需要的空间。
BLOB和TEXT类型
BLOB和TEXT都是为存储很大的数据而设计的字符串数据类型,分别采用二进制和字符方式存储。
实际上,它们分别属于两组不同的数据类型家族:
- 字符类型是TINYTEXT,SMALLTEXT,TEXT,MEDIUMTEXT, LONGTEXT
- 对应的二进制类型是TINYBLOB,SMALLBLOB,BLOB,MEDIUMBLOB,LONGBLOB。
BLOB是SMALLBLOB的同义词,TEXT是SMALLTEXT的同义词。
与其他类型不同,MySQL把每个BLOB和TEXT值当作一个独立的对象处理。存储引擎在存储时通常会做特殊处理。
当BLOB和TEXT值太大时,InnoDB会使用专门的“外部”存储区域来进行存储,此时每个值在行内需要1 ~ 4个字节存储一个指针,然后在外部存储区域存储实际的值。
BLOB和TEXT家族之间仅有的不同是BLOB类型存储的是二进制数据,没有排序规则或字符集,而TEXT类型有字符集和排序规则。
MySQL对BLOB和TEXT列进行排序与其他类型是不同的:
- 它只对每个列的最前max_sort_ length 字节而不是整个字符串做排序。
如果只需要排序前面一小部分字符,则可以减小max_sort_length的配置,或者使用ORDER BY SUSTRING(column, length)。
MySQL不能将BLOB和TEXT列全部长度的字符串进行索引,也不能使用这些索引消除排序。
磁盘临时表和文件排序
因为Memory引擎不支持BLOB和TEXT类型,所以,如果查询使用了BLOB或TEXT列并且需要使用 隐式临时表 ,将不得不使用MyISAM磁盘临时表,即使只有几行数据也是如此(Percona Server的Memory引擎支持BLOB和TEXT类型,但直到书的写作之际,同样的场景下还是需要使用磁盘临时表)。
这会导致严重的性能开销。
即使配置MySQL将临时表存储在内存块设备上(RAM Disk),依然需要许多昂贵的系统调用。
最好的解决方案是尽量避免使用BLOB和TEXT类型。如果实在无法避免,有一个技巧是在所有用到BLOB字段的地方都使用SUBSTRING(column, length) 将列值转换为字符串(在ORDER BY子句中也适用),这样就可以使用内存临时表了。
但是要确保截取的子字符串足够短,不会使临时表的大小超过max_ heap_ table_ size或tmp_table_ size,超过以后MySQL会将内存临时表转换为MyISAM磁盘临时表。
最坏情况下的长度分配对于排序的时候也是一样的,所以这一招对于内存中创建大临时表和文件排序,以及在磁盘上创建大临时表和文件排序这两种情况都很有帮助。
例如,假设有一个1000 万行的表,占用几个GB的磁盘空间。其中有一个utf8字符集的VARCHAR(1000)列。每个字符最多使用3个字节,最坏情况下需要3000字节的空间。如果在ORDER BY 中用到这个列,并且查询扫描整个表,为了排序就需要超过30GB的临时表。
如果EXPLAIN执行计划的Extra列包含”Using temporary",则说明这个查询使用了隐式临时表。
使用枚举(ENUM) 代替字符串类型
有时候可以使用枚举列代替常用的字符串类型。枚举列可以把一些不重复的字符串存储成一个预定义的集合。
MySQL在存储枚举时非常紧凑,会根据列表值的数量压缩到一个或者两个字节中。
MySQL在内部会将每个值在列表中的位置保存为整数,并且在表的.frm文件中保存“数字-字符串”映射关系的“查找表”。下面有一个例子:
mysq1> CREATE TABLE enum_test(
-> e ENUM('fish', 'apple', 'dog') NOT NULL
-> );
mysql> INSERT INTO enum_test(e) VALUES('fish'), ('dog'), ( 'apple' );
这三行数据实际存储为整数,而不是字符串。可以通过在数字上下文环境检索看到这个双重属性:
mysql> SELECT e + 0 FROM enum_ test;
如果使用数字作为ENUM枚举常量,这种双重性很容易导致混乱,例如ENUM( ‘1’, ‘2’, ‘3’)。 建议尽量避免这么做。
另外一个让人吃惊的地方是,枚举字段是按照内部存储的整数而不是定义的字符串进行排序的:
mysql> SELECT e FROM enum test ORDER BY e;
一种绕过这种限制的方式是按照需要的顺序来定义枚举列。
另外也可以在查询中使用FIELD()函数显式地指定排序顺序,但这会导致MySQL无法利用索引消除排序。
使用order by 的时候,通常会在查出来值以后,再进行排序。我们可以使用索引来消除这一行为,而避免临时表的产生。
mysql> SELECT e FROM enum_test ORDER BY FIELD(e, ' apple' ,' dog', 'fish'); .
如果在定义时就是按照字母的顺序,就没有必要这么做了。
枚举最不好的地方是,字符串列表是固定的,添加或删除字符串 必须使用 ALTER TABLE。
因此,对于一系列未来可能会改变的字符串,使用枚举不是一个好主意,除非能接受只在列表末尾添加元素,这样在MySQL5.1中就可以不用重建整个表来完成修改。
由于MySQL把每个枚举值保存为整数,并且必须进行查找才能转换为字符串,所以枚举列有一些开销。
通常枚举的列表都比较小,所以开销还可以控制,但也不能保证一直如此。
在特定情况下,把CHAR/VARCHAR列与枚举列进行关联可能会比直接关联CHAR/VARCHAR列更慢。
下图为书中基准测试后的结果,具体可自行翻阅。
从上面的结果可以看到,当把列都转换成ENUM以后,关联变得很快。但是当VARCHAR列和ENUM列进行关联时则慢很多。
在本例中,如果不是必须和VARCHAR列进行关联,那么转换这些列为ENUM就是个好主意。这是一个通用的设计实践,在“查找表”时采用整数主键而避免采用基于字符串的值进行关联。
转换列为枚举型还有另一个好处。把这列转换为ENUM可以让表的大小缩小。在某些情况下,即使可能出现ENUM和VARCHAR进行关联的情况,这也是值得的。
同样,转换后主键也只有原来的一半大小了。因为这是InnoDB表,如果表上有其他索引,减小主键大小会使非主键索引也变得更小。(此处书中例子中的主键是多个字段作为一个主键,其中的一个字段就是Enum。又因为InnoDB非聚簇索引上会保留主键用来返表,因此主键减小队非聚簇索引有好处)
4.1.4 日期和时间类型
MySQL可以使用许多类型来保存日期和时间值,例如YEAR和DATE。
MySQL能存储的最小时间粒度为秒(MariaDB支持微秒级别的时间类型)。但是MySQL也可以使用微秒级的粒度进行临时运算。
大部分时间类型都没有替代品,因此没有什么是最佳选择的问题。唯一的问题是保存日期和时间的时候需要做什么。
MySQL提供两种相似的日期类:
- DATETIME
- TIMESTAMP
对于很多应用程序,它们都能工作,但是在某些场景,一个比另一个工作得好。
DATETIME
这个类型能保存大范围的值,从1001年到9999年,精度为秒。
它把日期和时间封装到格式为YYYYMMDDHHMMSS的整数中,与时区无关。使用8个字节的存储空间。
默认情况下,MySQL以一种 可排序的、无歧义 的格式显示DATETIME值,例如“2008-01-16 22:37:08”。这是ANSI标准定义的日期和时间表示方法。
TIMESTAMP
就像它的名字一样,TIMETAMP类型保存了从1970年1月1日午夜(格林尼治标准时间)以来的秒数,它和UNIX时间戳相同。
TIMESTAMP 只使用4个字节的存储空间,因此它的范围比DATETIME小得多:只能表示从1970年到2038年。MySQL提供了FROM_ UNIXTIME()函数把Unix时间戳转换为日期,并提供了UNIX_ TIMESTAMP()函数把8期转换为Unix时间戳。
MySQL 4.1以及更新的版本按照DATETIME的方式格式化TIMESTAMP的值,但是MySQL 4.0以及更老的版本不会在各个部分之间显示任何标点符号。这仅仅是显示格式上的区别,TIMESTAMP 的存储格式在各个版本都是一样的。
TIMESTAMP显示的值 依赖于时区 。MySQL服务器、操作系统,以及客户端连接都有时区设置。
因此,存储值为0的TIMESTAMP在美国东部时区显示为“1969-12-31 19:00:00”, 与格林尼治时间差5个小时。
有必要强调一下这个区别:
- 如果在多个时区存储或访问数据,TIMESTAMP和DATETIME的行为将很不-样。前者提供的值与时区有关系,后者则保留文本表示的日期和时间。
TIMESTAMP也有DATETIME没有的特殊属性。
- 默认情况下,如果插入时没有指定第一个TIMESTAMP列的值, MySQL则设置这个列的值为当前时间。
- 在插入一行记录时,MySQL默认也会更新第一个TIMESTAMP列的值(除非在UPDATE语句中明确指定了值)。你可以配置任何TIMESTAMP列的插入和更新行为。
- 最后,TIMESTAMP列默认为NOT NULL,这也和其他的数据类型不一样。
除了特殊行为之外,通常也应该尽量使用TIMESTAMP,因为它比DATETIME空间效率更高。
有时候人们会将Unix时间截存储为整数值,但这不会带来任何收益。用整数保存时间截的格式通常不方便处理,所以我们不推荐这样做。
如果需要存储比秒更小粒度的日期和时间值怎么办?
MySQL目前没有提供合适的数据类型,但是可以使用自己的存储格式:
- 可以使用BIGINT类型存储微秒级别的时间截
- 或者使用DOUBLE存储秒之后的小数部分。
- 这两种方式都可以,或者也可以使用MariaDB替代MySQL。
4.1.5 位数据类型
MySQL有少数几种存储类型使用紧凑的位存储数据。所有这些位类型,不管底层存储格式和处理方式如何,从技术上来说都是字符串类型。
BIT
- 在MySQL 5.0之前,BIT是TINYINT的同义词。
- 但是在MySQL 5.0以及更新版本,这是一个特性完全不同的数据类型。
可以使用BIT列在一列中存储一个或多个true/false值。BIT(1) 定义一个包含单个位的字段,BIT(2) 存储2个位,依此类推。BIT 列的最大长度是64个位。
BIT的行为因存储引擎而异:
-
MyISAM会打包存储所有的BIT列,所以17个单独的BIT列只需要17个位存储(假设没有可为NULL的列),这样MyISAM只使用3个字节就能存储这17个BIT列。
-
其他存储引擎例如Memory和InnoDB,为每个BIT列使用一个足够存储的最小整数类型来存放,所以不能节省存储空间。
MySQL把BIT当作字符串类型,而不是数字类型。当检索BIT(1)的值时,结果是一个包含二进制0或1值的字符串,而不是ASCII码的“0”或“1”。
然而,在数字上下文的场景中检索时,结果将是位字符串转换成的数字。如果需要和另外的值比较结果,一定要记得这一点。
例如,如果存储一个值b’00111001’ (二进制值等于57)到BIT(8)的列并且检索它,得到的内容是字符码为57的字符串。也就是说得到ASCII码为57的字符“9”。
但是在数字上下文场景中,得到的是数字57 :
mysql> CREATE TABLE bittest(a bit(8));
mysql> INSERT INTO bittest VALUES(b'00111001');
mysql> SELECT a, a + 0 FROM bittest;
这是相当令人费解的,所以我们认为应该谨慎使用BIT类型。对于大部分应用,最好 避免使用 这种类型。
如果想在一个bit的存储空间中存储一个true/false值,另一个方法是创建一个可以为空的CHAR(0)列。
该列可以保存空值(NULL) 或者长度为零的字符串(空字符串)。
SET
如果需要保存很多true/false值,可以考虑合并这些列到一个SET数据类型,它在MySQL内部是以一系列打包的位的集合来表示的。
这样就有效地利用了存储空间,并且MySQL有像FIND_ IN_ SET() 和FIELD()这样的函数,方便地在查询中使用。
它的主要缺点是改变列的定义的代价较高: 需要ALTER TABLE 。
在整数列上进行按位操作
一种替代SET的方式是使用一个整数包装一系列的位
。例如,可以把8个位包装到一个TINYINT中,并且按位操作来使用。可以在应用中为每个位定义名称常量来简化这个工作。
比起SET,这种办法主要的好处在于可以不使用ALTERTABLE改变字段代表的“枚举”值,缺点是查询语句更难写,并且更难理解(当第5个bit位被设置时是什么意思? )。
一些人非常适应这种方式,也有一些人不适应,所以是否采用这种技术取决于个人的偏好。
一个包装位的应用的例子是保存权限的访问控制列表(ACL)。 每个位或者SET元素代表一个值,例如CAN_READ、CAN_WRITE,或者CAN_DELETE。
如果使用SET列,可以让MySQL在列定义里存储位到值的映射关系;如果使用整数列,则可以在应用代码里存储这个对应关系。这是使用SET列时的查询:
mysq1> CREATE TABLE acl (
-> perms SET('CAN_ READ', 'CAN WRITE', 'CAN DELETE') NOT NULL
-> );
mysq1> INSERT INTO acl(perms) VALUES ('CAN READ,CAN DELETE');
mysq1> SELECT perms FROM acl WHERE FIND_ IN_ SET('AN_ READ' , perms);
mysql> SET @CAN_READ := 1 << 0,
->@CAN_WRITE := 1 << 1,
->@CAN_DELETE := 1 << 2;
mysq1> CREATE TABLE acl (
-> perms TINYINT UNSIGNED NOT NULL DEFAULT 0
-> );
mysq1> INSERT INTO acl(perms) VALUES(@CAN_READ + @CAN_DELETE);
mysql> SELECT perms FROM acl WHERE perms & @CAN_READ;
sql变量这部分的代码移到业务层更好。
4.1.6 选择标识符(identifier)
为标识列(identifier column) 选择合适的数据类型非常重要。
一般来说更有可能用标识列与其他值进行比较(例如,在关联操作中),或者通过标识列寻找其他列。标识列也可能在另外的表中作为外键使用,所以为标识列选择数据类型时,应该选择跟关联表中的对应列一样的类型(正如我们在本章早些时候所论述的一样,在相关的表中使用相同的数据类型是个好主意,因为这些列很可能在关联中使用)。
当选择标识列的类型时,不仅仅需要考虑存储类型,还需要考虑MySQL对这种类型怎么执行计算和比较。
例如,MySQL在内部使用整数存储ENUM和SET类型,然后在做比较操作时转换为字符串。
一旦选定了一种类型,要确保在所有关联表中都使用同样的类型。类型之间需要精确匹配,包括像UNSIGNED这样的属性。
混用不同数据类型可能导致性能问题,即使没有性能影响,在比较操作时隐式类型转换也可能导致很难发现的错误。这种错误可能会很久以后才突然出现,那时候可能都已经忘记是在比较不同的数据类型。在可以满足值的范围的需求,并且预留未来增长空间的前提下,应该选择最小的数据类型。
例如有一个state_ id 列存储美国各州的名字生12,就不需要几千或几百万个值,所以不需要使用INT。TINYINT 足够存储,而且比INT少了3个字节。
如果用这个值作为其他表的外键,3个字节可能导致很大的性能差异。下面是一些小技巧。
- 整数类型
整数通常是标识列最好的选择,因为它们很快并且可以使用AUTO_ INCREMENT。
- ENUM和SET类型
对于标识列来说,EMUM和SET类型通常是一个糟糕的选择,尽管对某些只包含固定状态或者类型的静态“定义表”来说可能是没有问题的。
ENUM和SET列适合存储固定信息,例如有序的状态、产品类型、人的性别。
举个例子,如果使用枚举字段来定义产品类型,也许会设计一张以这个枚举字段为主键的查找表(可以在查找表中增加一些列来保存描述性质的文本,这样就能够生成一个术语表,或者为网站的下拉菜单提供有意义的标签)。
这时,使用枚举类型作为标识列是可行的,但是大部分情况下都要避免这么做。
- 字符串类型
如果可能,应该避免使用字符串类型作为标识列,因为它们很消耗空间,并且通常比数字类型慢。尤其是在MyISAM表里使用字符串作为标识列时要特别小心。
MyISAM默认对字符串使用压缩索引,这会导致查询慢得多。
对于完全“随机”的字符串也需要多加注意,例如MD5()、SHA1() 或者UUID()产生的字符串。这些函数生成的新值会任意分布在很大的空间内,这会导致INSERT以及一些SELECT语句变得很慢
- 因为插入值 会随机地写到索引的不同位置,所以使得INSERT语句更慢。这会导致页分裂、磁盘随机访问,以及对于聚簇存储引擎产生聚簇索引碎片。
- SELECT语句会变得更慢,因为逻辑上相邻的行会分布在磁盘和内存的不同地方。
- 随机值导致缓存对所有类型的查询语句效果都很差,因为会使得缓存赖以工作的访问局部性原理失效。如果整个数据集都一样的“热”,那么缓存任何一部分特定数据到内存都没有好处;如果工作集比内存大,缓存将会有很多刷新和不
命中。
如果存储UUID值,则应该移除“-” 符号;或者更好的做法是,用UNHEX()函数转换UUID值为16字节的数字,并且存储在一个BINARY(16)列中。检索时可以通过HEX()函数来格式化为十六进制格式。
UUID()生成的值与加密散列函数例如SHA1()生成的值有不同的特征:
- UUID值虽然分布也不均匀,但还是有一定顺序的。尽管如此,但还是不如递增的整数好用。
4.1.7 当心自动生成的schema
写得很烂的schema迁移程序,或者自动生成schema的程序,都会导致严重的性能问题。
有些程序存储任何东西都会使用很大的VARCHAR列,或者对需要在关联时比较的列使用不同的数据类型。
如果schema是自动生成的,一定要反复检查确认没有问题。
对象关系映射(ORM)系统(以及使用它们的“框架”)是另一种常见的性能噩梦。
一些ORM系统会存储任意类型的数据到任意类型的后端数据存储中,这通常意味着其没有设计使用更优的数据类型来存储。
有时会为每个对象的每个属性使用单独的行,甚至使用基于时间戳的版本控制,导致单个属性会有多个版本存在。
这种设计对开发者很有吸引力,因为这使得他们可以用面向对象的方式工作,不需要考虑数据是怎么存储的。然而,“对开发者隐藏复杂性”的应用通常不能很好地扩展。
建议在用性能交换开发人员的效率之前仔细考虑,并且总是在真实大小的数据集上做测试,这样就不会太晚才发现性能问题。
4.1.8 特殊类型数据;
某些类型的数据并不直接与内置类型一致。低于秒级精度的时间戳就是一个例子;
另一个例子是一个IPv4地址。人们经常使用VARCHAR(15)列来存储IP地址。然而,它们实际上是32位无符号整数,不是字符串。
用小数点将地址分成四段的表示方法只是为了让人们阅读容易。所以应该用无符号整数存储IP地址。MySQL提供INET_ ATON()和INET_ NT0A() 函数在这两种表示方法之间转换。
4.2 MySQL schema设计中的陷阱
虽然有一些普遍的好或坏的设计原则,但也有一些问题是由MySQL的实现机制导致的,这意味着有可能犯一些只在MySQL下发生的特定错误。
4.2.1 太多的列
MySQL的存储引擎API工作时需要在服务器层和存储引擎层之间通过行缓冲格式拷贝数据,然后在服务器层将缓冲内容解码成各个列。从行缓冲中将编码过的列转换成行数据结构的操作代价是非常高的。
MyISAM的定长行结构实际上与服务器层的行结构正好匹配,所以不需要转换。然而,MyISAM的变长行结构和InnoDB的行结构则总是需要转换。转换的代价依赖于列的数量。
当我们研究一个CPU占用非常高的案例时,发现客户使用了非常宽的表(数千个字段),然而只有一小部分列会实际用到,这时转换的代价就非常高。如果计划使用数千个字段,必须意识到服务器的性能运行特征会有一些不同。
4.2.2 太多的关联
所谓的 “实体-属性-值”(EAV) 设计模式是一个常见的糟糕设计模式,尤其是在MySQL下不能靠谱地工作。
MySQL限制了每个关联操作最多只能有61张表,但是EAV数据库需要许多自关联。我们见过不少EAV数据库最后超过了这个限制。
事实.上在许多关联少于61张表的情况下,解析和优化查询的代价也会成为MySQL的问题。
一个粗略的经验法则,如果希望查询执行得快速且并发性好,单个查询最
好在12个表以内做关联。
4.2.3 全能的枚举
注意防止过度使用枚举(ENUM)。
CREATE TABLE ...(
country enum(','0','1',2'...'31')
这种模式的schema设计非常凌乱。这么使用枚举值类型也许在任何支持枚举类型的数据库都是一个有问题的设计方案,这里应该用整数作为外键关联到字典表或者查找表来查找具体值。但是在MySQL中,当需要在枚举列表中增加一个新的国家时就要做一次ALTER TABLE操作。
在MySQL 5.0以及更早的版本中ALTER TABLE 是一种阻塞操作;即使在5.1和更新版本中,如果不是在列表的末尾增加值也会一样需要ALTER TABLE (我们将展示一些骇客式的方法来避免阻塞操作,但是这只是骇客的玩法,别轻易用在生产环境中)。
4.2.4 变相的枚举
枚举(ENUM) 列允许在列中存储一组定义值中的单个值,集合(SET) 列则允许在列中存储一组定义值中的一个或多个值。
有时候这可能比较容易导致混乱。这是一个例子:
CREATE TABLE ...(
is_default set('Y','N') NOT NULL default 'N'
Y与N不会同时出现,这时候可以用枚举
4.2.5 非此发明(Not Invent Here)的NULL
我们之前写了避免使用NULL的好处,并且建议尽可能地考虑替代方案。
即使需要存储一个事实上的“空值”到表中时,也不一定非得使用NULL。也许可以使用0、某个特殊值,或者空字符串作为代替。
但是遵循这个原则也不要走极端。当确实需要表示未知值时也不要害怕使用NULL。
在一些场景中,使用NULL可能会比某个神奇常数更好。从特定类型的值域中选择一个不可能的值,例如用-1代表一个未知的整数,可能导致代码复杂很多,并容易引入bug,还可能会让事情变得一团糟。
处理NULL确实不容易,但有时候会比它的替代方案更好。
下面是一个我们经常看到的例子:
CREATE TABLE ... (
dt DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00'
伪造的全0值可能导致很多问题(可以配置MySQL的SQL_MODE来禁止不可能的日期,对于新应用这是个非常好的实践经验,它不会让创建的数据库里充满不可能的值)。
值得一提的是,MySQL会在索引中存储NULL值,而Oracle则不会。
4.3 范式和反范式
- 在范式化的数据库中,每个事实数据会出现并且只出现一次。
- 在反范式化的数据库中,信息是冗余的,可能会存储在多个地方。
可参考
完全的范式化和完全的反范式化schema都是实验室里才有的东西,在真实世界中很少会这么极端地使用。
在实际应用中经常需要混用,可能使用部分范式化的schema、缓存表,以及其他技巧。
最常见的反范式化数据的方法是复制或者缓存,在不同的表中存储相同的特定列。
在MySQL 5.0和更新版本中,可以使用触发器更新缓存值,这使得实现这样的方案变得更简单。(一般也不会在数据库层使用,因为不方便后续迁移,这类维护一般放置业务层)
4.4 缓存表和汇总表
有时提升性能最好的方法是在同一张表中保存衍生的冗余数据。
然而,有时也需要创建一张完全独立的汇总表或缓存表(特别是为满足检索的需求时)。
如果能容许少量的脏数据,这是非常好的方法,但是有时确实没有选择的余地(例如,需要避免复杂、昂贵的实时更新操作)。
术语“缓存表”和“汇总表”没有标准的含义。
-
“缓存表”来表示存储那些可以比较简单地从schema其他表获取(但是每次获取的速度比较慢)数据的表(例如,逻辑上冗余的数据)。
-
“汇总表"时,则保存的是使用GROUPBY语句聚合数据的表(例如,数据不是逻辑上冗余的)。
-
也有人使用术语 “累积表(Roll-Up Table)” 称呼这些表。因为这些数据被“累积”了。
仍然以网站为例,假设需要计算之前24小时内发送的消息数。在一个很繁忙的网站不可能维护一个实时精确的计数器。作为替代方案,可以每小时生成一张汇总表。这样也许一条简单的查询就可以做到,并且比实时维护计数器要高效得多。缺点是计数器并不是100%精确。
如果必须获得过去24小时准确的消息发送数量(没有遗漏),有另外一种选择。以每小时汇总表为基础,把前23个完整的小时的统计表中的计数全部加起来,最后再加上开始阶段和结束阶段不完整的小时内的计数。
假设统计表叫作msg_per_ hr并且这样定义:
CREATE TABLE msg_per_hr (
hr DATETIME NOT NULL,
cnt INT UNSIGNED NOT NULL,
PRIMARY KEY(hr)
);
可以通过把下面的三个语句的结果加起来,得到过去24小时发送消息的总数。我们使用LEFT(NOW(),14)来获得当前的日期和时间最接近的小时:
mysql> SELECT SUM(cnt) FROM msg_per_hr
-> WHERE hr BETNEEN
-> CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 23 HOUR
-> AND CONCAT(LEFT(NOW(), 14), '00:00') - INTERVAL 1 HOUR;
mysql> SELECT COUNT(*) FROM message
-> WHERE posted阳NOW() - INTERVAL 24 HOUR
-> AND posted < CONCAT(LEFT(NOW(), 14), '0:00') - INTERVAL 23 HOUR;
mysql> SELECT COUNT(*) FROM message
-> WHERE posted >= CONCAT(LEFT(MySQL之Schema与数据类型优化
选择优化的数据类型
MySQL支持的数据类型非常多,选择正确的数据类型对于获得高性能至关重要。不管存储哪种类型的数据,下面几个简单的原则都有助于做出更好的选择:
更小的通常更好
一般情况下,应该尽量使用可以正确存储数据的最小数据类型。更小的数据类型通常更快。因为它们占用更少的磁盘、内存和CPU缓存,并且处理时需要的CPU周期也更少。但是要确保没有低估需要存储的值的范围,因为在schema中的增加数据类型的范围是一个非常耗时和痛苦的操作。如果无法确定哪个数据类型是最好的,就选择不会超过范围的最小类型。
简单就好
简单数据类型的操作通常需要更少的CPU周期。例如,整型比字符操作代价更低,因为字符集和校对规则(排序规则)使字符比较比整型比较更复杂。这里有两个例子:一个是应该使用MySQL内建的类型而不是字符串来存储时间和日期,另一个是应该用整型存储IP地址。
尽量避免NULL
很多表都包含可为NULL的列,即使应用程序并不需要保存NULL也是如此,这是因为可为NULL是列的默认属性(TIMESTAMP除外),然而通常情况下最好指定列为NOT NULL,除非真的需要存储NULL值。
如果查询中包含可为NULL的列,对MySQL来说更难优化,因为可为NULL的列使得索引统计和值比较更加复杂。可为NULL的列会使用更多的存储空间,在MySQL里也需要特殊的处理。当可为NULL的字段被索引时,每个索引记录需要一个额外的字节,在MyASIM里甚至还可能导致固定大小的索引(例如只有一个整数列的索引)变成可变大小的索引。
通常把可为NULL的列改为NOT NULL 带来的性能提升比较小,所以(调优时)没有必要首先在现有schema中查找并修改这种情况,除非确定这会导致问题。但是,如果计划在列上建索引,就应该尽量避免设计为NULL的列。当然也有一些例外,例如值得一提的是,InnoDB使用单独的位(bit)存储NULL值,所以对于稀疏数据(很多值为NULL,只有少数行是非NULL)有很好的空间效率。但这一点不适用于MyISAM。
在为列选择数据类型时,第一步需要确定适合的大类型,如:数字、字符串、时间等。然后,才选择具体的类型,比如数字类型有:TINYINT、INT、BIGINT,字符串类型有:VARCHAR、CHAR,时间类型有:DATETIME、TIMESTAMP。如果我们要保存年龄,年龄是数字类型的,INT和BIGINT都可以保存,但通常情况下,INT就已经绰绰有余了。
很多MySQL的数据类型可以存储相同类型的数据,只是存储的长度和范围不一样、允许的精度不同,或者需要的物理空间(磁盘和内存空间)不同。相同大类型的不同子类数据类型有时也有一些特殊的行为和属性。
例如,DATETIME和TIMESTAMP列都可以存储相同类型的数据:时间和日期,精确到秒。然而,TIMESTAMP只使用DATETIME一半的存储空间,并且会根据时区变化,具有特殊的自动更新能力。另一方面,TIMESTAMP允许的时间范围要小得多,有时候它的特殊能力会成为障碍。
整数类型
有两种类型的数字:整数和实数。如果存储整数,可以使用这几种整数类型:TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT。分别使用8、16、24、32、64位存储空间。它们可以存储的值的范围从-2^(N-1)到2^(N-1) -1,其中N是存储空间的位数。
整数类型有可选的UNSIGNED属性,表示不允许有负值,这大致可以使正数的上限提高一倍。例如TINYINT UNSIGNED可以存储的范围时0~255。而TINYINT的存储范围是-128~127。
有符号和无符号类型使用相同的存储空间,并具有相同的性能,因此可以根据实际情况选择合适的类型。
MySQL可以为整数类型指定宽度,例如INT(11),对大多数应用这是没有意义的:它不会限制值的合法范围,只是规定了MySQL的一些交互工具用来显示字符的个数。对于存储和计算来说,INT(1)和INT(20)是相同的。
实数类型
实数是带有小数部分的数字。然而,它们不只是为了存储小数部分;也可以使用DEMICAL存储比BIGINT还要大的整数。MySQL既支持精确类型,也支持不精确类型。浮点数类型在存储同样范围的值时,通常比DEMICAL使用更少的空间。FLOAT使用4个字节,DOUBLE使用8个字节,DOUBLE比FLOAT有更高的精度和更大的范围。和整数类型一样,能选择的只是存储类型,MySQL使用DOUBLE作为内部浮点计算的类型。
因为需要额外的空间和计算开销,所以应该尽量只在对小数进行精确计算时才使用DEMICAL——例如财务数据。但在数据量比较大的时候,可以考虑使用BIGINT代替DEMICAL,将需要存储的货币单位根据小数的位数乘以相应的倍数即可。假设要存储财务数据精确到万分之一分,则可以把所有的金额乘以一万,然后将结果存储在BIGINT中,这样可以同时避免浮点存储计算不精确和DEMICAL精确计算代价高的问题。
字符串类型
MySQL支持多种字符串类型,每种类型还有很多变种。VARCHAR和CHAR是两种最主要的字符串类型,但很难解释这些值是如何存储在磁盘和内存中,因为这跟存储引擎的实现有关。但我们可以介绍如果在存储引擎是InnoDB或MyISAM对字符串的存储。
VARCHAR类型用于存储可变长字符串,是最常见的字符串数据类型。它比定长类型更节省空间,因为它仅适用必要的空间(例如,越短的字符串使用越少的空间)。有一种情况例外,如果MySQL表使用ROW_FORMAT=FIXED创建的话,每一行都会使用定长存储,这会很浪费空间。
VARCHAR需要使用1或2个额外字节记录字符串的长度:如果列的最大长度小于或等于255字节,则只使用1个字节表示,否则使用2个字节。假设采用latin1字符集,一个VARCHAR(10)的列需要11个字节的存储空间。VARCHAR(1000)的列则需要1002个字节,因为需要2个字节存储长度信息。
VARCHAR节省了存储空间,所以对性能也有帮助。但是,由于行是变长,在UPDATE时可能使行变得比原来更长,这就导致需要做额外的工作。如果一个行占用的空间增长,并且在页内没有更多的空间可以存储,在这种情况下,不同的存储引擎的处理方式是不一样的。例如,MyISAM会将行拆成不同的片段存储,InnoDB则需要分裂页使行可以放进页内。其他一些存储引擎也许从不在原数据位置更新数据。
下面这些情况使用VARCHAR是合适的:字符串列的最大长度比平均长度大很多;列的更新很少,所以碎片不是问题;使用了像UTF-8这样复杂的字符集,每个字符都使用不同的字节数进行存储。
CHAR类型是定长的:MySQL总是根据定义的字符串长度分配足够的空间。当存储CHAR值时,MySQL会删除所有的末尾空格。CHAR值会根据需要采用空格进行填充以方便比较。CHAR适合存储很短的字符串,或者所有值都接近同一个长度。例如,CHAR非常适合存储密码的MD5值,因为这是一个定长的值。对于经常变更的数据,CHAR也比VARCHAR更好,因为定长的CHAR类型不容易产生碎片。对于非常短的列,CHAR比VARCHAR在存储空间上也更有效率。例如用CHAR(1)来存储只有Y和N的值,如果采用单个字节字符集只需要一个字节,但VARCHAR(1)却需要两个字节,因为还有一个记录长度的额外字节。
如果觉得CHAR类型的行为有些难以理解,我们可以通过一些例子来说明。首先我们创建一张只有一个CHAR(10)字段的表,并往里面插入一些值:
mysql> create table test1(age tinyint(1));
Query OK, 0 rows affected (0.09 sec)
mysql> INSERT INTO char_test(char_col) VALUES(‘string1‘), (‘ string2‘), (‘string3 ‘);
Query OK, 3 rows affected (0.03 sec)
Records: 3 Duplicates: 0 Warnings: 0
当检索这些值的时候,会发现sring3末尾的空格被截断了。
mysql> SELECT CONCAT("‘", char_col, "‘") FROM char_test;
+----------------------------+
| CONCAT("‘", char_col, "‘") |
+----------------------------+
| ‘string1‘ |
| ‘ string2‘ |
| ‘string3‘ |
+----------------------------+
3 rows in set (0.01 sec)
如果用VARCHAR(10)字段存储相同的值,可以得到如下结果:
mysql> CREATE TABLE varchar_test(varchar_col VARCHAR(10));
Query OK, 0 rows affected (0.04 sec)
mysql> INSERT INTO varchar_test(varchar_col) VALUES(‘string1‘), (‘ string2‘), (‘string3 ‘);
Query OK, 3 rows affected (0.09 sec)
Records: 3 Duplicates: 0 Warnings: 0
mysql> SELECT CONCAT("‘", varchar_col, "‘") FROM varchar_test;
+-------------------------------+
| CONCAT("‘", varchar_col, "‘") |
+-------------------------------+
| ‘string1‘ |
| ‘ string2‘ |
| ‘string3 ‘ |
+-------------------------------+
3 rows in set (0.00 sec)
数据如何存储取决于引擎,并非所有的存储引擎都会按照相同的方式处理定长和变长的字符串。Memory引擎只支持定长的行,即使有变长字符安也会根据最大长度分配最大空间。不过,填充和截取空格的行为在不同存储引擎都是一样的,因为这是在MySQL服务层进行处理的。
与CHAR和VARCHAR类似的类型还有BINARY和VARBINARY,它们存储的是二进制字符串。二进制字符串跟常规字符串非常类似,但是二进制字符串存储的是字节码而不是字符。填充也不一样,MySQL填充BINARY采用的是`(零字节)而不是空格,在检索时也不会去掉填充值。
当需要存储二进制数据,并且希望MySQL使用字节码而不是字符进行比较时,这些类型是非常有用的。二进制比较的优势并不仅仅体现在大小写敏感上。MySQL比较BINARY字符串时,每次按一个字节,并且根据该字节的数值进行比较。因此,二进制比较比字符比较简单多了,所以也就更快。
使用VARCHAR(5)和VARCHAR(200)存储‘hello‘的空间开销是一样的,那么使用更短的列有什么优势吗?事实证明有很大的优势。更长的列会消耗更多的内存,因为MySQL通常会分配固定大小的内存块来保存内部值。尤其是使用内存临时表进行排序或操作时会特别糟糕。在利用磁盘临时表进行排序时也同样糟糕。所以,最好的策略是只分配真正需要的空间。
BLOB和TEXT类型
BLOB和TEXT都是为了存储很大的数据而设计的字符串数据类型,分别采用二进制和字符方式存储。实际上,它们分别属于两个不同的数据类型家族:字符类型是TINYTEXT、SMALLTEXT、TEXT、MEDIUMTEXT、LONGTEXT;对应的二进制类型是TINYBLOB、SMALLBLOB、BLOB、MEDIUMBLOB、LONGBLOB。BLOG是SMALLBLOB的同义词,TEXT是SMALLTEXT的同义词。
与其他类型不同,MySQL把每个BLOB和TEXT值当作一个独立的对象处理。存储引擎在存储时通常会做特殊处理。当BLOB和TEXT值太大时,InnoDB会使用专门的“外部”存储区域来进行存储,此时每个值在行内需要1~4个字节存储一个指针,然后在外部存储区域存储实际的值。
BLOB和TEXT家族之间仅有的不同是BLOB类型存储的是二进制数据,没有排序规则或字符集,而TEXT类型有字符集和排序规则。
MySQL对BLOB和TEXT列进行排序与其他类型是不同的:它只对每个列的最前max_sort_length字节而不是整个字符串做排序。如果只需要排序前面一小部分字符,则可以减小max_sort_length的配置,或者用ORDER BY SUSTRING(column, length)
MySQL不能将BLOB和TEXT列全部长度的字符串进行索引,也不能使用这些索引消除排序。
磁盘临时表和文件排序
因为memory引擎不支持BLOB和TEXT类型,所以,如果查询使用了BLOB或TEXT列并且需要使用隐式临时表,将不得不使用MyISAM磁盘临时表,即使只有几行数据也是如此。
这会导致严重的性能开销。即使配置MySQL将临时表存储在内存块设备上(RAM Disk),依然需要许多昂贵的系统调用。
最好的解决方案是尽量避免使用BLOB和TEXT类型。如果实在无法避免,有一个技巧是在所有用到BLOB字段的地方都使用SUBSTRING(column,length)将列值转换为字符串(在ORDER BY子句中也适用),这样就可以使用内存临时表了。但是要确保截取的子字符串足够短,不会使临时表的大小超过max_heap_table_size或tmp_table_size,超过以后MySQL会将内存临时表转换为MyISAM磁盘临时表。
最坏的情况下的长度分配对于排序的时候也是一样的,所以这一招对于内存中创建大临时表和文件排序,以及在磁盘上创建大临时表和文件排序这两种情况都很有帮助。
例如,假设有一个1000万行的表,占用几个GB的磁盘空间。其中有一个utf8字符集的VARCHAR(1000)列。每个字符最多使用3个字节,最坏情况下需要3000字节的空间。如果在ORDER BY中用到这个列,并且查询扫描整个表,为了排序就需要超过30GB(1000万行 * 3000字节)的临时表。 如果EXPLAIN执行计划的Extra列包含“Using temporary”,则说明这个查询使用了隐式临时表。
使用枚举(ENUM)代替字符串类型
有时候可以使用枚举列代替常用的字符串类型。枚举列可以把一些不重复的字符串存储成一个预定义的集合。MySQL在存储枚举时非常紧凑,会根据列表值的数量压缩到一个或者两个字节中。MySQL在内部会将每个值在列表中的位置保存为整数,并且在表的.fm文件中保存“数字——字符串”映射关系的“查找表”。下面有一个例子:
mysql> CREATE TABLE enum_test(e ENUM(‘fish‘, ‘apple‘, ‘dog‘) NOT NULL);
Query OK, 0 rows affected (0.11 sec)
mysql> INSERT INTO enum_test(e) VALUES(‘fish‘) ,(‘dog‘) ,(‘apple‘);
Query OK, 3 rows affected (0.05 sec)
Records: 3 Duplicates: 0 Warnings: 0
这三行数据实际存储的是整数,而不是字符串。可以通过在数字上下文环境检索看到这个双重属性:
mysql> SELECT e + 0 FROM enum_test;
+-------+
| e + 0 |
+-------+
| 1 |
| 3 |
| 2 |
+-------+
3 rows in set (0.01 sec)
如果使用数字作为ENUM枚举常量,这种双重性很容易导致混乱,例如ENUM(‘1‘, ‘2‘, ‘3‘)。建议尽量避免这样做。
另外一个让人吃惊的地方是,枚举字段是按照内部存储的整数来排序而非定义的字符串:
mysql> SELECT e FROM enum_test ORDER BY e;
+-------+
| e |
+-------+
| fish |
| apple |
| dog |
+-------+
3 rows in set (0.00 sec)
一种绕过这种限制的方式是按照需要的顺序来定义枚举列。另外也可以在查询中使用FIELD()函数显示地指定排序顺序,但这会导致MySQL无法利用索引消除排序。
mysql> SELECT e FROM enum_test ORDER BY FIELD(e, ‘apple‘, ‘dog‘, ‘fish‘);
+-------+
| e |
+-------+
| apple |
| dog |
| fish |
+-------+
3 rows in set (0.01 sec)
如果在定义时就按照字母的顺序,就没有必要这么做了。
枚举最不好的地方是,字符串列表是固定的,添加或删除字符串必须使用ALTER TABLE。因此,对于一系列未来可能会改变的字符串,使用枚举不是一个好主意,除非能接受只在列表末尾添加元素。
由于MySQL把每个枚举值保存为整数,并且必须进行查找才能转换成字符串,所以枚举列有些开销。通常枚举列的列表都比较小,所以开销还可以控制,但也不能保证一直如此。在特定情况下,把CHAR/VARCHAR列与枚举列进行关联可能会比直接关联CHAR/VARCHAR列更慢,但是使用枚举列会比使用CHAR/VARCHAR占用很少的磁盘容量。
日期和时间类型
MySQL可以使用许多类型来保存日期和时间值,例如YEAR和DATE。MySQL能存储的最小时间粒度为秒(MariaDB支持微秒级别的时间类型)。但是MySQL也可以使用微秒级的粒度进行临时计算,后面会展示怎么绕开这种存储限制。
大部分时间类型都没有替代品,因此没有什么是最佳选择的问题。唯一的问题是保存日期和时间的时候都需要做什么。MySQL提供两种相似的日期类型:DATETIME和TIMESTAMP。对于很多应用程序,他们都能工作,但是某些场景,一个比另一个工作得好。让我们来看一下。
DATETIME
这个类型能保存大范围的值,从1001年到9999年,精度为妙。它把日期和时间封装到格式为YYYYMMDDHHMMSS的整数中,与时区无关。使用8个字节的存储空间。默认情况下,MySQL以一种可排序的、无歧义的格式显示DATETIME值,例如“2008-01-16 22:37:08”。这是ANSI标准定义的日期和时间表示方法。
TIMESTAMP
就像它的名字一样,TIMESTAMP类型保存了从1970年1月1日午夜(格林尼治标准时间)以来的秒数,它和UNIX时间戳相同。TIMESTAMP只能使用4个字节的存储空间,因此它的范围比DATETIME小得多:只能表示从1970年到2038年。MySQL提供了FROM_UNIXTIME()函数把Unix时间戳转换为日期,并提供了UNIX_TIMESTAMP()函数把日期转换为Unix时间戳。
MySQL4.1以及更新的版本按照DATETIME的方式格式化TIMESTAMP的值,但是MySQL4.0以及更老的版本不会在各个部分之间显示任何标点符号。这仅仅是显示格式上的区别,TIMESTAMP的存储格式在各个版本都是一样的。
TIMESTAMP显示的值也依赖于时区。MySQL服务器、操作系统,以及客户端连接都有时区设置。
因此,存储值为0的TIMESTAMP在美国东部时区显示为“1969-12-31 19:00:00”,与格林尼治时间差5个小时。有必要强调一下这个区别:如果在多个时区存储或访问数据,TIMESTAMP和DATETIME的行为将很不一样。前者提供的值与时区有关系,后者则保留文本表示的日期和时间。
TIMESTAMP也有DATETIME没有的特殊属性。默认情况下,如果插入时没有指定第一个TIMESTAMP列的值,MySQL则设置这个列的值为当前时间(除非在UPDATE语句中明确指定了值)。你可以配置任何TIMESTAMP列的插入和更新行为。最后,TIMESTAMP列默认为NOT NULL,这也和其他的数据类型不一样。
除了特殊行为之外,通常也应该尽量使用TIMPESTAMP,因为它比DATETIME空间效率更高。有时候人们会将Unix时间戳存储为整数值,但这不会带来任何收益。用整数保存时间戳的格式通常不方处理,所以我们不推荐这样做。
如果需要存储比秒更小粒度的日期和时间值怎么办?MySQL目前没有提供合适的数据类型,但是可以使用自己的存储格式:可以使用BIGINT类型存储微妙级别的时间戳,或者使用DOUBLE存储秒之后的小数部分。这两种方式都可以,或者也可以使用MariaDB替代MySQL。
位数据类型
MySQL有少数几种存储类型使用紧凑的位存储数据。所有这些位类型,不管底层存储格式和处理方式如何,从技术上来说都是字符串类型。
BIT
在MySQL5.0之前,BIT是TINYINT的同义词。但是在MySQL5.0以及更新之后,这是一个特性完全不同的数据类型。可以使用BIT列存储ture/false 值。BIT(1)定义一个包含单个位字段,BIT(2)存储2个位,以此类推,BIT例最大长度是64个位。BIT的行为因存储引擎而异。MyISAM会打包存储所有的BIT列,所以17个单独的BIT列只需要17个BIT个位存储(假设没有可为NULL的列),这样MyISAM只使用3个字节就能存储这17个BIT列。其他存储引擎例如Memory和InnoDB,为每个BIT列使用一个足够存储的最小整数类型来存放,所以不能节省存储空间。
MySQL把BIT当做字符串类型,而不是数字类型。当检索bit(1)的值时,结果是一个包含二进制0或1的字符串,而不是ASCII码的"0"或"1"。然而,在数字上下文的场景中检索时,结果将是位字符串转换成数字。如果需要和另外的值比较结果,一定要记住这一点。例如:如果存储一个值b‘00111001‘(二进制的值为57)到BIT(8)的列并检索它,得到的内容是字符串码为57的字符串。也就是说,得到ASCII码为57的字符"9"。但是在数字上下文场景中,得到的数字是57:
mysql> CREATE TABLE bittest(a bit(8));
Query OK, 0 rows affected (0.07 sec)
mysql> INSERT INTO bittest VALUES(b‘00111001‘);
Query OK, 1 row affected (0.04 sec)
mysql> SELECT a, a + 0 FROM bittest;
+------+-------+
| a | a + 0 |
+------+-------+
| 9 | 57 |
+------+-------+
1 row in set (0.01 sec)
这是相当令人费解的,所以我们应该谨慎使用BIT类型。对于大部分应用,最好避免使用这种类型。如果想存储true/false值,另一个方法是创建一个可以为空的CHAR(0)列,该列可以保存空值(NULL)或者长度为零的字符串(空字符串)。
SET
如果需要保存很多true/false值,可以考虑合并这些列到一个SET数据类型,它在MySQL内部是以一系列打包的位的集合来表示的。这样就有效地利用了存储空间,并且MySQL 有像FIND_IN_SET()和FIELD()这样的函数,方便地在查询中使用。它的主要缺点是改变列的定义的代价较高:需要ALTER TABLE,这对大表来说是非常昂贵的操作。一般来说,也无法在SET 列上通过索引查找。
在整数列上进行按位操作:一种替代SET 的方式是使用一个整数包装一系列的位。例如,可以把8个位包装到一个TINYINT 中,并且按位操作来使用。可以在应用中为每个位定义名称常量来简化这个工作。比起SET,这种办法主要的好处在于可以不使用ALTER TABLE 改变字段代表的“枚举”值,缺点是查询语句更难写,并且更难理解(当第5个bit 位被设置时是什么意思?)。一些人非常适应这种方式,也有一些人不适应,所以是否采用这种技术取决于个人的偏好。
一个包装位的应用的例子是保存权限的访问控制列表(ACL)。每个位或者SET元素代表一个值,例如CAN_READ、CAN_WRITE,或者CAN_DELETE。如果使用SET列,可以让MySQL 在列定义里存储位到值的映射关系;如果使用整数列,则可以在应用代码里存储这个对应关系。这是使用SET 列时的查询:
mysql> CREATE TABLE acl(perms SET(‘CAN_READ‘, ‘CAN_WRITE‘, ‘CAN_DELETE‘) NOT NULL);
Query OK, 0 rows affected (0.12 sec)
mysql> INSERT INTO acl(perms) VALUES(‘CAN_READ,CAN_DELETE‘);
Query OK, 1 row affected (0.02 sec)
mysql> SELECT perms FROM acl WHERE FIND_IN_SET(‘CAN_READ‘, perms);
+---------------------+
| perms |
+---------------------+
| CAN_READ,CAN_DELETE |
+---------------------+
1 row in set (0.00 sec)
选择标识符(identifier)
为标识列(identifier column)选择合适的数据类型非常重要。一般来说更有可能用标识列与其他值进行比较(例如,在关联操作中),或者通过标识列找其他列。标识列也可能在另外的表中作为外键使用,所以为标识列选择数据类型时,应该选择跟关联表中的对应列一样的类型。
当选择标识列的类型时,不仅仅需要考虑存储类型,还需要考虑MySQL对这种类型怎么执行计算和比较。例如,MySQL在内部使用整数存储ENUM和SET类型,然后在做比较操作时转换为字符串。
一旦选定了一种类型,要确保在所有关联表中都使用同样的类型。类型之间需要精确匹配,包括像UNSIGNED这样的属性。混用不同数据类型可能导致性能问题,即使没有性能影响,在比较操作时隐式类型转换也可能导致很难发现的错误。这种错误可能会很久以后才突然出现,那时候可能都已经忘记是在比较不同的数据类型。在可以满足值的范围的需求,并且预留未来增长空间的前提下,应该选择最小的数据类型。例如有一个state_id列存储美国各州的名字,就不需要几千或几百万个值,所以不需要使用INT。TINYINT足够存储,而且比INT少了3个字节。如果用这个值作为其他表的外键,3个字节可能导致很大的性能差异。下面是一些小技巧。
整数类型
整数通常是标识列最好的选择,因为它们很快并且可以使用AUTO_INCREAMENT。
ENUM和SET类型
对于标识列来说,ENUM和SET类型通常是一个糟糕的选择,尽管对某些只包含固定状态或者类型的静态“定义表”来说可能是没有问题的。ENUM和SET列适合存储固定信息,例如有序的状态、产品类型、人的性别。
举个例子,如果使用枚举字段来定义产品类型,也许会设计一张以这个枚举字段为主键的查找表(可以在查找表中增加一些列来保存描述性质的文本,这样就能够生成一个术语表,或者为网站的下拉菜单提供有意义的标签)。这时,使用枚举类型作为标识列是可行的,但是大部分情况下都要避免这么做。
字符串类型
如果可能,应该避免使用字符串类型作为标识列,因为他们很消耗空间,并且通常比数字类型慢。尤其是在MyISAM表里使用字符串作为标识列时要特别小心。MyISAM默认对字符串使用压缩索引,这会导致查询慢很多。在我们的测试中,我们注意到最多有6倍的性能下降。
对于完全“随机”的字符串也需要多加注意,例如MD5()、SHA1()或者UUID()产生的字符串。这些函数生成的新值会任意分布在很大的空间内,这会导致INSERT以及一些SELECT语句变得很慢:
- 因为插入值会随机地写到索引的不同位置,所以使得INSERT语句更慢。这会导致页分裂、磁盘随机访问,以及对于聚簇存储引擎产生聚簇索引碎片。
- SELECT语句会变得更慢,因为逻辑上相邻的行会分布在磁盘和内存的不同地方。
- 随机值导致缓存对所有类型的查询语句效果都很差,因为会使得缓存赖以工作的访问局部性原理失效。如果这个数据集都一样的“热”,那么缓存任何一部分特定数据到内存都没有好处;如果工作集比内存大,缓存将会有很多刷新和不命中。
如果存储UUID值,则应该移除"-"符号;或者更好的做法是,用UNHEX()函数转换UUID值为16字节的数字,并且存储在一个BINARY(16)列中。检索时可以通过HEX()函数来格式化为十六进制格式。
UUID()生成的值与加密散列函数例如SHA1()生成的值不同的特征:UUID值虽然分布也不均匀,但还是有一定的顺序的。尽管如此,但还是不如递增的整数好用。
特殊类型数据
某些类型的数据并不直接与内置类型一致。低于秒级精度的时间戳就是一个例子;
另一个例子是IPv4地址。人们通常使用VARCHAR(15)来存储IP地址。然而,它们实际是32位无符号整数,不是字符串。用小数点将字段分割成四段是为了阅读方便。所以应该用无符号整数存储IP地址。MySQL提供INET_ATON()和INET_NTOA()函数在这两种表示方法之间转换。
以上是关于《高性能MySQL》——Schema与数据类型优化(笔记)的主要内容,如果未能解决你的问题,请参考以下文章