MySQL如何有效地创建基于 INNODB 引擎的表

2016-05-27 赵伟 数据库开发者

有用户问我们为什么下面这个建表语句会执行失败,报错是 "Row size too large ...."
下面我就以这个例子出发讲一讲使用mysql如何有效地建表。本文使用的图片是从网络搜索到的,不是我自己制作的,在此感谢图片的作者和拥有者。

CREATE TABLE bad_table (
  col1 varchar(20) NOT NULL DEFAULT '000001',
  col2 varchar(100) DEFAULT NULL,
  col3 varchar(200) DEFAULT NULL,
  col4 varchar(100) DEFAULT NULL,
  col5 varchar(200) DEFAULT NULL,
  col6 varchar(6) DEFAULT NULL,
  col7 varchar(12) DEFAULT NULL,
  col8 varchar(10) DEFAULT NULL,
  col9 varchar(9) DEFAULT NULL,
  col10 varchar(3) DEFAULT NULL,
  col11 varchar(10) DEFAULT NULL,
  col12 varchar(2) DEFAULT NULL,
  col13 varchar(5) DEFAULT NULL,
  col14 varchar(1) DEFAULT NULL,
  col15 varchar(3) DEFAULT NULL,
  col16 decimal(24,6) DEFAULT NULL,
  col17 varchar(3) DEFAULT NULL,
  col18 decimal(24,6) DEFAULT NULL,
  col19 varchar(50) DEFAULT NULL,
  col20 varchar(12) DEFAULT NULL,
  col21 varchar(12) DEFAULT NULL,
  col22 varchar(1) DEFAULT NULL,
  col23 varchar(3) DEFAULT NULL,
  col24 varchar(3) DEFAULT NULL,
  col25 varchar(400) DEFAULT NULL,
  col26 int(11) DEFAULT NULL,
  col27 varchar(10) DEFAULT NULL,
  col28 varchar(1) DEFAULT NULL,
  col29 varchar(32) DEFAULT NULL,
  col30 varchar(400) DEFAULT NULL,
  col31 varchar(400) DEFAULT NULL,
  col32 varchar(400) DEFAULT NULL,
  col33 decimal(24,6) DEFAULT NULL,
  col34 varchar(1) DEFAULT NULL,
  col35 varchar(100) DEFAULT NULL,
  col36 varchar(32) DEFAULT NULL,
  col37 varchar(20) DEFAULT NULL,
  col38 varchar(32) DEFAULT NULL,
  col39 varchar(32) DEFAULT NULL,
  col40 varchar(32) DEFAULT NULL,
  col41 varchar(20) DEFAULT NULL,
  col42 varchar(3) DEFAULT NULL,
  col43 varchar(50) DEFAULT NULL,
  col44 varchar(3) DEFAULT NULL,
  col45 varchar(50) DEFAULT NULL,
  col46 varchar(300) DEFAULT NULL,
  col47 varchar(8) DEFAULT NULL,
  col48 varchar(8) DEFAULT NULL,
  col49 varchar(16) DEFAULT NULL,
  col50 varchar(2) DEFAULT NULL,
  col51 varchar(2) DEFAULT NULL,
  col52 varchar(2) DEFAULT NULL,
  col53 varchar(2) DEFAULT NULL,
  col54 varchar(2) DEFAULT NULL,
  col55 varchar(2) DEFAULT NULL,
  col56 varchar(3) DEFAULT NULL,
  col57 varchar(2) DEFAULT NULL,
  col58 varchar(2) DEFAULT NULL,
  col59 varchar(3) DEFAULT NULL,
  col60 varchar(4) DEFAULT NULL,
  col61 varchar(4) DEFAULT NULL,
  col62 varchar(2) DEFAULT NULL,
  col63 varchar(20) DEFAULT NULL,
  col64 varchar(32) DEFAULT NULL,
  col65 varchar(32) DEFAULT NULL,
  col66 varchar(200) DEFAULT NULL,
  col67 varchar(50) DEFAULT NULL,
  col68 varchar(2) DEFAULT NULL,
  col69 varchar(2) DEFAULT NULL,
  col70 varchar(2) DEFAULT NULL,
  col71 varchar(2) DEFAULT NULL,
  col72 varchar(20) DEFAULT NULL,
  col73 varchar(1) DEFAULT NULL,
  col74 varchar(20) DEFAULT NULL,
  col75 varchar(20) DEFAULT NULL,
  col76 varchar(2) DEFAULT NULL,
  col77 varchar(10) DEFAULT NULL,
  col78 varchar(32) DEFAULT NULL,
  col79 varchar(4) DEFAULT NULL,
  col80 varchar(1) DEFAULT NULL,
  col81 varchar(1) DEFAULT NULL,
  col82 varchar(32) DEFAULT NULL,
  PRIMARY KEY (col1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;

这是一个来自于真实的客户的真实的应用中的表,这里隐去了客户的所有信息,表名和列名也都经过改造,去掉了关键信息,而且去掉了所有的注释信息。这个表是一个定义不良的表,它有很多问题,本文就分析一下它的问题以及如何才能更好地建表。
这个表直观看上去问题包括
1. 过多的列
2. 很多列是最大长度在8以内的varchar类型
3. 有一些最大长度在几百的varchar列

首先,过多的列导致一行特别长,但是通常并不是每个字段都会频繁更新,这样那些频繁更新的字段的更新开销就会比较大,因为首先过大的行导致一个页上面存储的行很少,即使更改一行的一个字节,也需要完整地读取它所在的这整个页面。那么读取一个页面能够命中的行就会比较少,就可能需要读取更多的页面来更新多个行,并且也需要写盘更多的页面。同时,还意味着记录binlog和事务日志的代价也会增大(可能通过优化可以部分地降低这部分代价)。并且任何一行的buffer命中率也会严重降低,对查询和更新的性能都会降低。

同时,对于innodb来说这还意味着当表有很多行(假设是几千万行)的时候,b+树的高度会比较高,导致搜索时间变长。这里作一个简单的计算。假设这个表有1亿行,innodb_page_size=4k, 由于innodb要求一页最少存储2行数据,那么主表的b+树最多会有5千万个叶节点页面,假设平均每个内部节点页面可以存储200条记录(每条记录存储 {索引key,下级节点指针} ),那么也就是 200^h1 >= 5*10^7,可以知道树的高度h1=4(单个根节点的高度是0),同时主表一共有大约5800万个页面。这里假设innodb的b+树有较好的自平衡机制,不会产生任何一个左右子树高度差大于1的子树。如果表的设计合理每个叶节点页面可以存储一百行的话,那么一共只有100万个叶节点页面,这样200^h2 >= 10^6,可以知道h2=3,同时主表一共有大约104万个页面。由此可见行的密度和命中率大幅增大,而且h2的树的搜索路径会导致每次搜索都平均多一次页面读取。

再做一个基于极端假设的计算:假设主键很长,以至于内节点页面只能存储10条记录,那么h1将会变成8,这个主表的b+树将会有大约1000万个内节点页面,共约6000万个页面;h2会变成6,这个主表的b+树将会有大约100万个内节点页面,共约200万个页面。后者的查询和更新的效率会比前者高出很多。也就是说,任何一个索引的key都最好别太长,否则那个索引的搜索路径会比较长,而且buffer的效率(命中率)会降低。对于secondary index道理也类似,而且由于它们的索引行会存储主键,所以主键的size也会影响到secondary index的效率。

最后,innodb对于索引key的存储有一定的优化,包括合并同一个索引记录上面重复的字段,以及相邻索引记录中相同字段不重复存储等,会一定程度改进上面计算的结果,但是并不会有本质的改进。innodb的b+树结构如下图,本文不在此展开赘述。MySQL如何有效地创建基于 INNODB 引擎的表

2. 根据原SQL语句的注释可以知道,这些短的varchar字段中,有下面这些是可以用更合适的类型的。
从存储的空间效率来讲,每个varchar短字段(长度小于257)还需要额外1个字节的空间,同时在mysqld内部的查询处理阶段,数值类型的字段的计算和处理也会比字符串类型更高效一些。不过这些基本是微不足道的,差别不大。更重要的是使用的方便性方面,使用合适的类型在后续的开发工作中会有各种方便,这些数值类型的存在肯定是有原因的,应该在合适的场合使用它们。

长度在4以内的这些字段,属于这么几种:
a. 用于存储bool值,也就是存储(二元状态,是/否)。这种 应该使用bool 类型
b. 用于存储类型值,也就是存储(类型,分类,种类,级别,等级)等。 这种应该使用enum类型。有人担心enum预先无法预知所有可能的枚举值,但其实在alter table中可以on line方式(不需要copy table)修改列定义的方式增加更多的枚举值的,而且在表存储中枚举值是作为数字来存储的,每个字段最多2个字节。特别是在查询,比较,匹配的时候是数字比较而不是字符串,因而效率会提升很多。

c. 长度在12以内的字段
不合理的字段主要是用于存储日期时间。应该使用datetime,确保方便和正确的计算和查询日期和时间,以及正确地索引。特别是在支持时区的情况下,如果不使用date time类型简直很难正确地计算。

3. 对于那些最大长度在几百的varchar列,在dynamic格式下它们都是存储在off page的,也就是不与数据行存储在同一页,而是存储在特定的其他页面中,这样读取这些字段还需要访问更多的数据页。原理如下图,这里不展开细讲了。MySQL如何有效地创建基于 INNODB 引擎的表

事实上dynamic格式下,对于长度大于40字节的字段都是这样存储的。如果查询要求这些列的时候,这类字段越多,查询执行效率越低,所以业务的查询语句不要总是select*,特别是在表的列中有很多这种大字段(varchar,blob,text)的时候,而是针对性地选择需要的列,这样可以避免innodb从无谓地取出这些字段,要知道取出这些字段每个行都可能需要增加多次磁盘IO操作,特别是它们的页面存储效率本来就比较低。在实际存储一行的时候并不是所有这样的字段都会offpage存储,而是从长到短依次取出做off page存储,直到行的长度达标(也就是一页存至少两行)。这样的话,行的长度还是可能会比较长,如果最大长度在40以上的varchar列比较多的话。innodb的页与行的大致结构如下图:MySQL如何有效地创建基于 INNODB 引擎的表

本例中用户遇到的问题是由于innodb_page_size=4k,所以这个表的最大可能的行长度超出了4k页面允许的最大长度(略少于2k)。而解决的办法包括:
1. 拆分表,以业务需求为基础,按照ER模型的定义来拆分,同时最好把基本静态的列放在一个表中,经常被更新的放在另一个表中。
2. 如果业务需求要求必须在这个表中定义这么多列,那么把最大长度少于40字节的(本例正好使用的是UTF8)列挑一些可以缩短的,缩短直到可以成功建表为止。

上一篇:15.3、mysql之InnoDB和MyISAM表空间详解


下一篇:MySQL InnoDB 索引组织表 & 主键作用