《高性能MySQL》- 04 Schema与数据类型优化
选择优化的数据类型
下面几个简单的原则有助于做出更好的选择:
- 更小的通常更好。一般情况下,尽可能使用可以正确存储数据的最小数据类型。它们通常更快,站更少的磁盘,内存和cpu缓存。但需要确保没有低估存储的值的范围
- 简单就好。简单数据类型通常也是需要更少的cpu周期。例如,整型比字符操作代价更低。有两个需要先记着,一个是使用MySQL的内建类型,而不是字符串来创建时间,第二个是用整型存储IP地址。之后会讨论
- 尽量避免NULL。通常情况下最好指定为NOT NULL,除非需要用到NULL。如果查询中包含可为NULL的列,对MySQL来说更难优化,因为可为NULL的列使得索引,索引统计和值比较都更复杂。当然,其实通常把NULL列改为NOT NULL的提升是比较小的,所以调优时,非优先项,除非确定这会出问题。
接着来说说MySQL中的时间和日期,DATETIME和TIMESAMP都可以存储相同类型的数据:时间和日期,精确到秒。然而TIMESAMP只用了DATETIME的一半存储空间,而且会根据时区变化,具有特殊的自动更新能力。另一方面,TIMESTAMP允许的时间范围要小的多,有时候特殊能力会成为障碍。
整数类型
有两种类型的数字:数字和实数。如果存储整数可以采用:TINYINT,SMALLINT,MEDIUMINT,INT,BIGINT。分别使用8,16,24,32,64位空间。
整数可以选UNSIGNED属性,表示不允许有负值,这大致可以使正数上限提高一倍。有符号和无符号的存储使用相同的空间,并且具有相同的性能,因此可以可以根据实际情况选择合适的类型。
MySQL 是可以为整数指定宽度,例如INT(11),但这其实是没有什么意义的:它不会限制值的合法范围,只是对于某些交互工具用来显示字符的个数。对于存储和计算来说,INT(1)和INT(20)没什么区别。
实数类型
实数是带有小数部分的数字。然而,它们不只是为了存储小数部分;也可以使用DECIMAL存储比BIGINT还大的整数。FLOAT和DOUBLE类型支持使用标准的浮点运算进行近似计算。
DECIMAL类型用于存储精确的小数。因为CPU不支持DECIMAL的计算,MySQL服务器自身实现了DECIMAL的计算。相对而言,CPU直接支持原生浮点计算,运算更快。
对于DECIMAL和浮点来说都是可以指定精度的。而且DECIMAL只是一种存储格式,在计算中DECIMAL会转换为DOUBLE类型。浮点类型在存储同样范围的值时,通常比DECIMAL使用更少的空间。
字符串类型
MySQL支持多种字符串类型,每种类型还有很多变种。这些数据类型在4.1和5.0版本发生了很大的变化,使得情况变得更加复杂。从MySQL4.1开始,每个字符串列可以定义自己的字符集和排序规则,或者说校对规则。这些东西很大程度上影响性能。
VARCHAR和CHAR,是两种主要的字符串类型。至于这些是怎么存储在磁盘上的,是和存储引擎有关系的。注意,存储引擎关于CHAR或者VARCHAR值的方式在内存中和在磁盘上可能不一样,所以MySQL服务器从存储引擎读出的值可能需要转换为另一种存储格式。
VARVHAR。用于变长字符串,是最常见的字符串类型,比定长节省空间,它仅使用必要的空间。VARCHAR需要使用1或者两个额外字节来存储字符串的长度。虽然它能节省空间,但未必经常都是好的,当需要UPDATE的时候,可能产生变长,使得原来行更长,这个时候就需要做额外的工作。不同存储引擎的存储方式不一样。这些情况使用VARCHAR是合理的:字符串列的最大长度比平均长度大很多;列的更新很少,所以碎片不是问题;使用了像UTF-8这样复杂的字符集,每个字符都使用不同的字节数进行存储。
CHAR。 定长,写了多少分配多少。CHAR适合存储那些很短的,而且几乎相同的长度的一些值,比方说MD5。而且对于经常变更的值也是CHAR更好一些。CHAR会删末尾空格。
与CHAR和VARCHAR相类似的还有BINARY和VARBINARY,是存储二进制的。
注意一点,尽管VARCHAR(5)和VARCHAR(100)的空间开销是一样的,但最好的策略仍是分配只需要的空间。
BLOB和TEXT类型
BLOB和TEXT都是为了存储很大的数据而设计的字符串类型,分别采用二进制和字符的方式存储。
实际上它们属于两组不同的数据类型家族:字符类型是TINYTEXT,SMALLTEXT,TEXT,MEDIUMTEXT,LONGTEXT;对应另一组TINYBLOB,SMALLBLOB,BLOB,MEDIUMBLOB,LONGBLOB。
与其他的类型不同,MySQL把每个BLOB和TEXT值当做一个独立的对象处理。当其过大的时候,MySQL会使用专门的“外部”存储来进行存储。MySQL对BLOB和TEXT列进行排序与其他类型是不同的:它只对每个列的最前max_sort_length字节而不是整个字符串排序。如果只需要排序前面一小部分字符,则可以减小max_sort_length的配置,或者使用ORDER BY SUSTRING。
使用枚举代替字符串类型
有时候可以使用枚举列替代常用的字符串类型。枚举可以把一些不重复的字符串存储成一个预定义的集合。枚举最不好的地方就是,字符串列表是固定的,添加或删除字符串必须用ALTER TABLE。对于一些列未来可能会改变的字符串,使用枚举不是一个好主意,除非能接受只在列表末尾添加元素。
MySQL把每个枚举保存为整数,并且必须进行查找才能转换为字符串,所以枚举列有一些开销。通常枚举的列表都比较小,所以开销还可以控制,但也不能保证一直如此,在特定的情况下,把CHAR/VARCHAR列与枚举列进行关联可能会比直接关联CHAR/VARCHAR列慢。
日期和时间类型
MySQL可以使用许多类型来保存日期和时间值,例如YEAR和DATE。MySQL能存储的最小时间粒度为秒。
DATETIME,这个类型能保存大范围的值,从1001年到9999年,精度为秒。它把日期和时间封装到格式为YYYYMMDDHHMMSS的整数中,与时区无关。使用8个字节的存储空间。
TIMESTAMP,保存了从1970年1月1日午夜(格林尼治标准时间)以来的秒数,它和UNIX时间戳相同。只使用了四个字节来存储。因此它比DATETIME小得多。只能表示从1970到2038年。MySQL提供了FROM_UNIXTIME()函数把Unix时间戳转换为日期。TIMESTAMP也有DATETIME没有的特殊属性。如果插入时没有指定第一个TIMESTAMP列的值,MySQL则设置这个列的值为当前时间。除了特殊行为之外,通常也应该尽量使用TIMESTAMP,因为它比DATETIME空间效率更高。
位数据类型
MySQL有少数几种存储类型使用紧凑的位存储数据。所有这些类型,不管底层存储格式和处理方式如何,从技术上来说都是字符串。
BIT。在5.0之前是和TINYINY同义,之后是一个特性完全不同的数据类型,可以在使用BIT列在一列中存储一个或多个true/false值。BIT(1)包含一个单位的字段,依次类推。MySQL把BIT当做字符串类型,而不是数字类型。
SET。如果需要保存很多true/false值,可以考虑合并这些列到一个SET数据类型。它在MySQL内部是以一些列打包的位的集合来表示的。这样可以有效利用空间。它的主要缺点是改变列的定义的代价比较高:需要ALTER TABLE。一般来说也无法在SET上通过索引查找。
一种替代SET的方式是使用一个整数包装一系列的位。例如可以把8个位包装到一个TINYINT里,比起SET,这种办法的好处在于可以不ALTER TABLE改变字段代表的“枚举”值,缺点是查询语句更难写,并且更难理解。一个包装位的应用的例子是保存权限的访问控制列表(ACL)。每个位或者SET元素代表一个值。
选择标识符
为标识列选择合适的数据类型非常重要。当选择标识列的类型时,不仅仅需要考虑存储类型,还需要考虑MySQL对这种类型怎么执行计算和比较。
一旦选定了一种类型,要确保在所有关联表中都使用同样的类型。类型之间需要精确匹配,包括像UNSIGNED这样的属性。
在可以满足值的范围的需求,并且预留未来增长空间的前提下,应该选择最小的数据类型。下面是一些小技巧:
- 整数类型,通常是标识列最好的选择,因为快而且可以使用AUTO_INCREMENT。
- ENUM和SET类型,对于标识列来说,ENUM和SET类型通常是一个糟糕的选择,它们只适合存储固定的信息。
- 字符串类型,如果可能应该尽可能的避免使用字符串做标识列,它们很消耗空间而且比数字类慢。
如果存储UUID值,则应该移出“-”符号;或者更好的做法是,用UNHEX()函数转换UUID值为16字节的数字,并且存储在一个BINARY(16)列中。
MySQL schema设计中的陷阱
我们主要讨论在设计MySQL schema的问题:
太多的列:MySQL的存储引擎API工作时需要在服务器层和存储引擎层之间通过行缓冲格式拷贝数据,然后在服务器层将缓冲内容解码成各个列。从行缓冲中将编码过的列转换成行数据结构的操作代价是非常高的。
太多的关联:所谓的“实体-属性-值”(EAV)设计模式是一个常见的糟糕的设计模式。MySQL限制了每个关联操作最多只有61张表,但是EAV数据库需要许多自关联。一个粗略的经验法则,如果希望查询执行得快速且并发性好,单个查询最好在12个表以内做关联。
全能的枚举:因为在枚举在增加时需要ALTER TABLE 所以除非完全不变,否则不要使用。
变相枚举:使用SET混乱定义,例如: CREATE TABLE ...( is_default set (‘Y‘, ‘N‘) NOT NULL default ‘N‘ 。
非此发明的NULL:之前说了尽量不要使用NULL,可以用其他替换一下,0或者空格,但是也不能走极端,确实需要的时候,就需要使用NULL。(其实还是之前的原则,除非必须使用,否则不用,但必须使用时就用),例:CREATE TABLE ...( dt DATETIME NOT NULL DEFAULT ‘0000-00-00 00:00:00 全0就会出现其他问题了。
范式和反范式
对于任何数据通常有很多表示方法,从完全的范式化到完全的反范式化,以及两者的折中。
从举个例子开始,经典的 (雇员,部门,部门领导) ,这样设计的问题是在修改时可能会发生不一致。有时需要修改很多行。所以我们拆成两张表,(雇员,部门) (部门,部门领导)。
范式的优点和缺点
范式通常能带来的好处:
- 范式化的更新通常比较快。
- 当数据较好地范式化的时候,就只有很少的或者没有重复数据,所以只需要修改更少的数据。
- 范式化的表通常很小,可以更好的放在内存里,所以执行操作会更快。
- 很少有多余的数据意味着检索列表数据时更少需要DISTINCT或者GROUP BY语句。
范式化的缺点是通常需要很多关联。
反范式化
反范式化能避免出现关联,所有数据都在一个表里。但是对于一些比较差的查询时,会扫描全表。
主要问题是关联,使得需要在一个索引中又排序又过滤。将两张表的字段合并一下,并且增加一个索引,有时候就能不关联写出这个查询。
混用范式化和反范式化
事实是,完全的范式化和反范式化只有实验室才有。真实的环境是很少这么极端的,在实际的应用中经常需要混用,可能使用部分范式化的schema,缓存表,以及其他技巧。最常见的反范式化就是复制或者缓存,在不同的表中存相同的特定的列。举个例子,在user表和message表里都可以存account_type字段,避免了完全反范式化的插入和删除。但这样的问题就是要更新的表变多了。另一个从父表冗余一些数据到子表的理由是排序的需要。
缓存表和汇总表
有时提升性能最好的方法是在同一张表中保存衍生的冗余数据。缓存表和汇总表没有标准的含义。我们用缓存表表示存储那些可以比较简单地从schema其他表获取数据的表。而术语汇总表则是保存使用GROUP BY语句聚合的表。
我们需要知道,实时的计算统计值是很昂贵的操作,因为要么需要扫描表中的大部分数据,要么查询语句只能在某些特定的索引上才有效运行,而这类特定的索引一般会对UPDATE操作有影响,所以一般不希望创建这样的索引。
在使用缓存表的时候,必须决定是实时维护还是定期重建。哪个更好依赖于程序和需要。当重建汇总表时,需要保证数据在操作时依然是可以用的。
物化视图
许多数据库管理系统,都提供了一个被称作物化视图的功能。物化视图实际上是预先计算并且存储在磁盘上的表,可以通过各种策略刷新和更新。MySQL不支持原生的物化视图。但是可以通过Flexviews来创建。
计数器表
如果应用在表中保存计数器,则在更新计数器时可能遇到并发问题。创建一张独立的表存储计数器通常是个好主意,这样可以使得计数器表小且快。
我们现在创建一个计数器表,只有一行数据:CREATE TABLE hit_counter ( cnt int unsigned not null) engine=InnoDB; 每次点击都会更新计数器。
问题在于,对于任何想更新这一行的事务来说,这记录上有个全局的互斥锁,会使得事务只能串行执行。为了获得更好的并发性能,我们可以创建100行数据,然后随机选一个槽进行更新;
更快地读,更慢地写。为了提升读查询的速度,经常会需要创建一些额外索引,增加冗余列,甚至是创建缓存表和汇总表。这些方法会增加写查询的负担,也需要额外的维护任务,但在设计高性能数据库时,这些都是常见的技巧:虽然写的更慢了,但是提高了读操作的性能。然而,写操作变慢不是读操作变快的唯一代价,还同时增加了读写操作的开发难度。
加快ALERT TABLE操作的速度
MySQL的ALERT TABLE对大表来说是个大问题。MySQL执行大部分修改表结构操作的方法是用新的结构创建一个空表,从旧表中查出所有数据插入新表,然后删除旧表。
常用的技巧是有两种,一种是先在另一台不提供服务的机器上执行ALTER TABLE操作,然后和提供服务的主库进行切换;另外一种技巧是“影子拷贝”。影子拷贝的技巧是用要求的表结构,创建一张和源表无关的新表,然后通过重命名和删除表的操作交换两张表。
其实理论上MySQL是可以跳过创建表的步骤。列的默认值实际上存在表的.frm文件中,所以可以直接修改这个文件而不需要改动表本身。
还有一种方法就是通过ALTER COLUMN来改变列的默认值。这个语句是直接修改.frm文件而不涉及表数据,所以是非常快的。
修改.frm文件
如果愿意冒一些风险,有时候可以不重建表修改某些类型,下面的这些操作是有可能不需要重建表的:
- 移出(不是增加)一个列的AUTO_INCREMENT属性。
- 增加,移出或者更改ENUM和SET常量。如果移出的是已经有行数据用到其值的常量,查询会返回一个空字串。
基本的技术是为想要的表结构创建一个新的.frm文件,然后用它替换掉已经存在的那个:
- 创建一张有相同结构的空表,并进行所需要的修改。
- 执行FLUSH TABLES WITH READ LOCK。这将会关闭所有正在使用的表,并且禁止任何表被打开。
- 交换.frm文件。
- 执行UNLOCK TABLES 来释放第2步的读锁。
快速创建MyISAM索引
为了高效地载入数据列到MyISAM表中,有一个常用的技巧是先禁用索引,载入数据,然后重启索引。这个技巧能够发挥作用,是因为构建索引的工作被延迟到数据完全载入后,这个时候已经可以通过排序来构建索引了。
对于InnoDB有个类似的技巧,这依赖于InnoDB的快速在线索引创建功能。这个技巧是,先删除所有的非唯一索引,然后增加新的列,最后重新创建删除掉的索引。Percona Server可以自动完成这些操作步骤。
下面是操作步骤:
- 用需要的表结构创建一张表,但是不包括索引。
- 载入数据到表中以构建 .MYD文件。
- 按照需要的结构创建另外一个空表,这次要包含索引。这会创建需要的.frm文件和.MYI文件。
- 获取读锁并刷新表。
- 重命名第二张表的.frm文件和.MYI文件,让MySQL认为是第一张表的文件。
- 释放读锁。
- 使用REPAIR TABLE来重新创建表的索引,该操作会通过排序来构建所有索引,包括唯一索引。
这个操作对于大表来说会快很多。