MySQL支持的数据类型非常多,选择正确的数据类型对于获得高性能至关重要。不管存储哪种类型的数据,下面几个简单的原则都有助于做出更好的选择:
在为列选择数据类型时,第一步需要确定适合的大类型,如:数字、字符串、时间等。然后,才选择具体的类型,比如数字类型有: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
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
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 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语句变得很慢:
如果存储UUID值,则应该移除"-"符号;或者更好的做法是,用UNHEX()函数转换UUID值为16字节的数字,并且存储在一个BINARY(16)列中。检索时可以通过HEX()函数来格式化为十六进制格式。
UUID()生成的值与加密散列函数例如SHA1()生成的值不同的特征:UUID值虽然分布也不均匀,但还是有一定的顺序的。尽管如此,但还是不如递增的整数好用。
某些类型的数据并不直接与内置类型一致。低于秒级精度的时间戳就是一个例子;
另一个例子是IPv4地址。人们通常使用VARCHAR(15)来存储IP地址。然而,它们实际是32位无符号整数,不是字符串。用小数点将字段分割成四段是为了阅读方便。所以应该用无符号整数存储IP地址。MySQL提供INET_ATON()和INET_NTOA()函数在这两种表示方法之间转换。
太多的列
MySQL的存储引擎API工作时需要在服务器层和存储引擎层之间通过行缓冲格式拷贝数据,然后在服务器层将缓冲内容解码成各个列,这个转换过程的代价是非常高的。MyISAM的定长结构实际上与服务器层的行结构正好匹配,所以不需要转换。但是MyISAM和变长结构和InnoDB的行结构则总需要转换。转换的代价依赖于列的数量。如果列太多而实际使用的列又很少的话,有可能会导致CPU占用过高。
太多的关联
一个粗略的经验法则,如果希望查询执行得快速且并发性好,单个查询最好在12 个表以内做关联。
NULL值
我们之前写了避免使用NULL的好处,并且建议尽可能地考虑替代方案。在需要存储NULL到表时,尽可能的考虑能否用0、某个特殊值、或者空字符串来代替。但也不要因此而走极端,如果我们要存储一些自然数,那么没有任何数字可以代替NULL值,所以这种情况下,请大胆使用NULL值。并且,如果用某些特殊值来代替NULL值会使得系统或者业务变得更复杂,也不要吝啬那点性能,请大胆使用NULL值。
第一范式:原子性 字段不可再分,否则就不是关系数据库。所谓第一范式(1NF)是指数据库表的每一列都是不可分割的基本数据项,同一列中不能有多个值,即实体中的某个属性不能有多个值或者不能有重复的属性。
第二范式:唯一性 一个表只说明一个事物。第二范式需要确保数据库表中的每一列都和主键相关,而不能只与主键的某一部分相关(主要针对联合主键而言)。也就是说在一个数据库表中,一个表中只能保存一种数据,不可以把多种数据保存在同一张数据库表中。这样设计,在很大程度上减小了数据库的冗余。
第三范式:每列都与主键有直接关系,不存在传递依赖;第三范式(3NF)要求一个数据库表中不包含已在其它表中已包含的非主关键字信息。也就是属性不依赖于其它非主属性。
范式的优点
范式的缺点
通常需要关联。稍微复杂一些的查询语句在符合范式的schema上都可能需要至少一次关联,或者更多。这不但代价昂贵,而可能使一些索引策略无效。例如,范式化可能将列存放在不同的表中,而这些列如果在一个表中本可以属于同一个索引。
反范式的优点
反范式的缺点
最常见的反范式化数据的方法是复制或者缓存,在不同的表中存储相同特定的列。
提升性能最好的方式是在同一张表中保存衍生的冗余数据。有时候,也需要创建一张完全独立的汇总表或缓存表。建立汇总表的原因就在于:无论是通过粗略的统计计算还是严格的计数,都比计算原始表的所有行有效的多。建立缓存表的原因则是优化检索和查询语句,这些查询语句经常需要特殊的表和索引,跟普通的OLTP操作表有一些区别。
可能会需要很多不同的索引组合来加速各种类型的查询,可以根据业务的需求,对缓存表使用不同的存储引擎,甚至可以把整个表导出,插入到专门的搜索系统中获得更高的搜索效率(例如Lucene或者Sphinx)。在使用缓存表和汇总表时,必须决定是实时维护还是定期重建。当重建汇总表的时候,需要保证数据在操作时依然可以使用,这就需要通过影子表来进行实现。(重命名替换)。
物化视图实际上是预先计算并且存储在磁盘上的表,可以通过各种各样的策略刷新和更新。对比与传统的缓存表和汇总表的方法,物化视图通过提取对源表的更改,可以增量式的重新计算物化视图的内容。
为了提升读查询速度,经常会需要建一些额外的索引,增加冗余列,甚至是创建缓存表和汇总表,这些方法会增加查询的负担,也需要额外的维护任务,但在设计高性能数据库时,这些都是常见的技巧:虽然写操作变得更慢了,但是显著地提高了读操作的性能。然而,写操作变慢并不是读操作变快所付出的唯一代价,还可能同时增加了读操作和写操作的开发难度。
如果应用在表中保存计数器,则在更新计数器时可能碰到并发的问题。计数器表在Web应用中很常见。可以用这种表缓存一个用户的朋友数、文件下载次数等。创建一个独立的表存储计数器通常是一个好办法。
单行记录的计数器表
CREATE TABLE `hit_counter` (
`cnt` int(10) unsigned NOT NULL
) ENGINE=InnoDB
网站的每次点击都会导致对计数器进行更新:
update hit_counter set cnt = cnt + 1;
多行记录的计数器表
问题在于,对于多个进程想更新这行记录时,这条记录上都有一个全局的互斥锁。这会使得这些事务操作只能串行执行。 如果想要获得更高的并发更新性能,也可以将计数器保存在多行中,每次随机选择一行进行更新。这样做需要对计数器表进行如下修改:
CREATE TABLE `hit_counter` (
`slot` tinyint(3) unsigned NOT NULL,
`cnt` int(10) unsigned NOT NULL,
PRIMARY KEY (`slot`)
) ENGINE=InnoDB
然后预先在这张表中增加100行数据。每次选择一个随机的槽(slot)进行更新:
update hit_counter set cnt = cnt + 1 where slot = rand() * 100;
使用下面的方式获取位于min和max之间的随机整数
select ROUND(RAND()*(max-min)+min);
所以,每次更新执行执行的语句是,其中min和max分别是区间的最小值和最大值:
update hit_counter set cnt = cnt + 1 where slot = ROUND(RAND()*(max-min)+min);
要获得统计结果,需要使用下面的语句聚合查询:
select sum(cnt) from hit_counter;
按天计数
一个常见的需求是每隔一段时间开始一个新的计数器(例如,每天一个)。如果需要这样,则需要修改表结构:
CREATE TABLE `daily_hit_counter` (
`day` date NOT NULL,
`slot` tinyint(3) unsigned NOT NULL,
`cnt` int(10) unsigned NOT NULL,
PRIMARY KEY (`day`,`slot`)
) ENGINE=InnoDB
在这个场景中,可以不用前面的例子预先生成行,而用on duplicate key update代替。
insert into daily_hit_counter(day, slot, cnt) values(current_date, ROUND(RAND()*(10-1)+1), 1)
on duplicate key update cnt = cnt + 1;
乐观锁
使用乐观锁的概念,不对表记录进行加锁,更新的时候进行判断。
CREATE TABLE `daily_hit_counter` (
`cnt` int(10) unsigned NOT NULL,
PRIMARY KEY (`day`,`slot`)
) ENGINE=InnoDB
select cnt from daily_hit_counter;
update daily_hit_counter set cnt = cnt + 1 where cnt = xxx;
悲观锁
使用悲观锁的概念,对表记录进行加锁。
CREATE TABLE `daily_hit_counter` (
`cnt` int(10) unsigned NOT NULL,
PRIMARY KEY (`day`,`slot`)
) ENGINE=InnoDB
select cnt from daily_hit_counter for update [wait xxx];
update daily_hit_counter set cnt = cnt + 1;
MySql的ALTER TABLE 操作的性能对大表来说是个大问题。MySQL执行大部分修改表结构操作的方法是用新的结构创建一个空表,从旧表中查出所有数据插入新表,然后删除旧表。这样操作肯呢个需要花费很长时间,如果内存不足而表又很大,而且还有很多索引的情况下尤其如此。许多人都有这样的经验。ALTER TABLE操作需要花费数个小时甚至数天才能完成。
MySQL5.1以及更新版本包含一些类型的“在线”操作的支持,这些功能不需要在整个操作过程中锁表。最近版本的InnoDB也支持通过排序来建索引,这使得建索引更快并且有一个紧凑的布局。
一般而言,大部分ALTER TABLE操作将导致MySQL服务中断。我们会展示一些在DDL操作时使用的技巧,但这是针对一些特殊场景而言的。对常见的场景,能使用的技巧只有两种:一种是先在一台不提供服务的机器上执行ALTER TABLE操作,然后和提供服务的主库进行切换;另外一种技巧是“影子拷贝”。影子拷贝的技巧是用要求的表结构创建一张和源表无关的新表,然后通过重命名和删表操作交换两张表。
不是所有的ALTER TABLE操作都会引起表重建。例如,有两种方法可以改变或者删除一个列的默认值(一种方法很快,另外一种则很慢)。假如要修改电影的默认租赁期限,从三天到五天。下面是很慢的方式:
mysql> ALTER TABLE sakila.film
->MODIFY COLUMN rental_duration TINYINT(3) NOT NULL DEFAULT 5;
SHOW STATUS 显示这个语句做了1000次读和1000次插入操作。换句话说,它拷贝了整张表到一张新表,甚至列的类型,大小和可否为NULL属性都没改变。
理论上,MySQL可以跳过创建新表的步骤。列的默认值实际上存在表的.frm文件中,所以可以直接修改这个文件而不需要改动表本身。然而MySQL还没有采用这种优化的方法,所有的MODIFY COLUMN操作都将导致表重建。
另外一种方法是通过ALTER COLUMN操作来改变列的默认值:
mysql>ALTER TABLE sakila.film
->ALTER COLUMN rental_duration SET DEFAULT 5;
这个语句会直接修改.frm文件而不涉及表数据。所以,这个操作是非常快的。
快速创建MYISAM索引
通常为了快速载入MYISAM表数据,可以先disable keys,再装载完数据后,再reenable keys.
不过disables keys仅仅是禁用了非唯一键.(MYISAM存储唯一索引在内存中,然后每行数据导入进行检查唯一性,如果内存不够,会很慢)
mysql> ALTER TABLE test.load_data DISABLE KEYS;
mysql> ALTER TABLE test.load_data ENABLE KEYS;
那么如何跳过这个唯一键的检查呢?由上alter table仅仅更改.frm的方式,我们也可以冒点风险这样做.一定要先备份,这不是官网宣称支持.
适用于装载备份数据,因为我们明知道这些数据是有效的,而且满足唯一性检查.
具体步骤如下,这对于大表很有效:
良好的schema设计原则是普遍适用的,但MySQL有它自己的实现细节要注意。概括来说,尽可能保持任何东西小而简单总是好的。MySQL喜欢简单,需要使用数据库的人应该也同样会喜欢简单的原则:
范式是好的,但是反范式(大多数情况下意味着重复数据)有时也是必需的,并且能带来好处。第5章我们将看到更多的例子。预先计算、缓存或生成汇总表也可能获得很大的好处。Justin Swanhart的Flexviews工具可以帮助维护汇总表。
最后,ALTER TABLE 是让人痛苦的操作,因为在大部分情况下,它都会锁表并且重建整张表。我们展示了一些特殊的场景可以使用骇客方法;但是对大部分场景,必须使用其他更常规的方法,例如在备机执行ALTER并在完成后把它切换为主库。本书后续章节会有更多关于这方面的内容。