本文由云 + 社区发表

一、从常见的报错说起

​ 故事的开头我们先来看一个常见的 sql 报错信息:

img

​ 相信对于这类报错大家一定遇到过很多次了,特别对于 OMG 这种已内容生产为主要工作核心的 BG,在内容线的存储中,数据大一定是个绕不开的话题。这里的数据 “大”,远不止存储空间占用多,其中也包括了单个(表)字段存储多、大,数据留存时间长,数据冗余多,冷热数据不明显导致的体量大,访问峰值随着热点变化明显,逻辑处理复杂导致数据存储压力放大等等。回到这个报错的问题上来,我们先来看一下这个表的结构:

img

看到这里,我相信大家会有不同的处理方式了,这里就不对各种处理方式的优劣做比较了,仅仅叙述使用频率较高的两种处理方式。

​ 这两种的处理方式也各有优缺点,把字段改成 text 或者 blob,不仅增大了数据存储的容量,对这个字段的索引页只能采用前缀或者全文索引了,如果业务侧存储的是 json 格式的数据,5.7 支持 json 数据类型是个不错的选择,可以针对单个子类进行查询和输出。同样如果缩小和拆分的话就比较依赖业务的场景和逻辑需求了,业务使用的逻辑上需要修改,工程量也需要评估。

二、深入探索

​ 接着我们再来深入分析下关于限制大小 “65535” 的一些容易混淆的概念。

1、“65535” 不是单个 varchar(N) 中 N 的最大限制,而是整个表非大字段类型的字段的 bytes 总合。

---------------------------------------------------------------------------------------------

Every table (regardless of storage engine) has a maximum row size of 65,535 bytes. Storage engines may place additional constraints on this limit, reducing the effective maximum row size.

---------------------------------------------------------------------------------------------

2、不同的字符集对字段可存储的 max 会有影响,例如,UTF8 字符需要 3 个字节存储,对于 VARCHAR(255)CHARACTER SET UTF8 列,会占用 255×3 =765 的字节。故该表不能包含超过 65,535/765=85 这样的列。GBK 是双字节的以此类推。

3、可变长度列在评估字段大小时还要考虑存储列实际长度的字节数。例如,VARCHAR(255)CHARACTER SET UTF8 列需要额外的两个字节来存储值长度信息,所以该列需要多达 767 个字节存储,其实最大可以存储 65533 字节,剩余两个字节存储长度信息。

4、BLOB、TEXT、JSON 列不同于 varchar、char 等字段,列长度信息独立于行长存储,可以达到 65535 字节真实存储

5、定义 NULL 列会降低允许的最大列数。

​ row length = 1 + (sum of column lengths) + (number of NULL columns + delete_flag + 7)/8 + (number of variable-length columns)

6、对于 InnoDB 表,NULL 和 NOT NULL 列存储大小是一样

7、InnoDB 允许单表最多 1000 个列

8、varchar 主键只支持不超过 767 个字节或者 768/2=384 个双字节 或者 767/3=255 个三字节的字段 而 GBK 是双字节的,UTF8 是三字节的

9、不用的引擎对索引的限制有区别

三、真正的故障

​ 下面来说下今天遇到的业务故障,线上业出现了大量的如下报错,导致程序无法写入数据:

img

按照提示和正常的思路,我们先第一反应认为业务存在如下的问题:

​ 接着查看了业务的库表结构,如下:

img

​ 很快排除了第一个原因,因为首先业务的报错不是在建立表的时候出现的,如果是表中非大字段之和 65535,在建表的时候就会出错,而业务是在写入的时候才报错的,而且通过库表结构也能发现大量的都是 mediumblob 类型字段,非大字段加起来远小于 65535。

​ 接着根据业务提供的具体 SQL,appversion、datadata、elt_stamp、id 这几个非大字段,也并没有超过限制,mediumblob 类型字段最大可存储 16M,业务的数据远远没有达到这个量级。按照报错的提示把 appversion、datadata、elt_stamp、id 这几个非大字段均改成 blob 类型,还是无法解决。(根据之前的分析,必然不是问题的根源)。

​ 冷静下来后,发现其实还有个细节被忽略掉了,业务的失败率不是 100%,说明还是有成功的请求,通过对比成功和失败的 sql,发现果然数据量差异的还是 mediumblob 类型字段。那么现在第一个想到的就是,max_allowed_packet 这个参数,是不是调小了,是的单个请求超过大小被拒绝了,查了下配置的值(如下图),配置的大小 1G,sql 的数据长度远没有这么大,这个原因也排除了。

img

​ 查到这里基本上排除了常见几个问题,接着再看一下另一个参数的限制:innodb_page_size,这个的默认值是 16K,每个 page 两行数据,所以每行最大 8k 数据。

查看了下数据表 Row_format 是 Compact,那么我们可以推断问题的原因应该就是innodb 默认的 approach 存储格式会把每个 blob 字段的前 864 个字节存储在 page 里,所以 blob 超过一定数量的话,单行大小就会超过 8k,所以就报错了。通过对比业务写成功和失败的 SQL 也应征了这个推论,那么现在要怎么解决这个问题?

由于业务单表的存储条数并不大,而且业务逻辑不适合拆分,所以我们要在 Row_format 上来解决这个问题。

​ Barracuda 文件格式下拥有两种新的行记录格式 Compressed 和 Dynamic 两种,新的两种格式对于存放 BLOB 的数据采用了完全的行溢出的方式,在数据页中只存放 20 个字节的指针,实际的数据都存放在 BLOB Page 中。Compressed 行记录格式的另一个功能就是存储在其中的数据会以 zlib 的算法进行压缩。

相关的变更操作就相对简单了:

1、 修改 MySQL 全局变量:

SET GLOBAL innodb_file_format='Barracuda';

2、平滑变更原表的属性:

ROW_FORMAT=COMPRESSED

四、继续学习

​ 通过这个案例我们可以从中提炼出两个值得深入研究一下的点:

1、关于 innodb_page_size

​ 从 MySQL5.6 开始,innodb_page_size 可以设置 Innodb 数据页为 8K,4K,默认为 16K。这个参数在一开始初始化时就要加入 my.cnf 里,如果已经创建了表,再修改,启动 MySQL 会报错。

那么在 5.6 的版本之前要修改这个值,怎么办?那只能是在源码上做点文章了,然后重新 rebuild 一下 MySQL。

img

​ UNIV_PAGE_SIZE 是数据页大小,默认的是 16K,该值是可以设置必须为 2 的次方。对于该值可以设置成 4k、8k、16k、32K、64K。同时更改了 UNIV_PAGE_SIZE 后需要更改 UNIV_PAGE_SIZE_SHIFT 该值是 2 的多少次方为 UNIV_PAGE_SIZE,所以设置数据页分别情况如下:

img

​ 接着再来说一下 innodb_page_size 设置成不同值的对于 mysql 性能上的影响,测试的表含有 1 亿条记录,文件大小 30G。

​ ①读写场景(50% 读 50% 写)

​ 16K,对 CPU 压力较小,平均在 20%

​ 8K,CPU 压力为 30%~40%,但 select 吞吐量要高于 16K

​ ②读场景(100% 读)

​ 16K 和 8K 差别不明显

InnoDB Buffer Pool 管理页面本身也有代价,Page 数越多,那么相同大小下,管理链表就越长。因此当我们的数据行本身就比较长(大块插入),更大的页面更有利于提升速度,因为一个页面可以放入更多的行,每个 IO 写的大小更大,可以更少的 IOPS 写更多的数据。 当行长超过 8K 的时候,如果是 16K 的页面,就会强制转换一些字符串类型为 TEXT,把字符串主体转移到扩展页中,会导致读取列需要多一个 IO,更大的页面也就支持了更大的行长,64K 页面可以支持近似 32K 的行长而不用使用扩展页。 但是如果是短小行长的随机读取和写入,则不适合使用这么大的页面,这会导致 IO 效率下降,大 IO 只能读取到小部分。

2、关于 Row_format

​ Innodb 存储引擎保存记录,是以行的形式存放的。在 InnoDB 1.0.x 版本之前,InnoDB 存储引擎提供了 Compact 和 Redundant 两种格式来存放行记录数据。MySQL 5.1 中的 innodb_plugin 引入了新的文件格式:Barracuda,该文件格式拥有新的两种行格式:compressed 和 dynamic。并且把 compact 和 redundant 合称为 Antelope。可以通过命令 SHOW TABLE STATUS LIKE 'table_name';来查看当前表使用的行格式,其中 row_format 列表示当前所使用的行记录结构类型。

​ MySQL 5.6 版本中,默认 Compact ,msyql 5.7.9 及以后版本,默认行格式由 innodb_default_row_format 变量决定,默认值是 DYNAMIC,也可以在 create table 的时候指定 ROW_FORMAT=DYNAMIC(通过这个可动态调整表的存储格式)。如果要修改现有表的行模式为 compressed 或 dynamic,必须先将文件格式设置成 Barracuda(set global innodb_file_format=Barracuda;)。再用 ALTER TABLE tablename ROW_FORMAT=COMPRESSED;去修改才能生效,否则修改无效却无提示。

①compact

如果 blob 列值长度 <= 768 bytes,不会发生行溢出 (page overflow),内容都在数据页 (B-tree Node);如果列值长度 > 768 字节,那么前 768 字节依然在数据页,而剩余的则放在溢出页 (off-page),如下图:

img

​ 上面讲的 blob 或变长大字段类型包括 blob、text、varchar,其中 varchar 列值长度大于某数 N 时也会存溢出页,在 latin1 字符集下 N 值可以这样计算:innodb 的块大小默认为 16kb,由于 innodb 存储引擎表为索引组织表,树底层的叶子节点为一双向链表,因此每个页中至少应该有两行记录,这就决定了 innodb 在存储一行数据的时候不能够超过 8k,减去其它列值所占字节数,约等于 N。

②compressed 或 dynamic

对 blob 采用完全行溢出,即聚集索引记录(数据页)只保留 20 字节的指针,指向真实存放它的溢出段地址:

img

​ dynamic 行格式,列存储是否放到 off-page 页,主要取决于行大小,它会把行中最长的那一列放到 off-page,直到数据页能存放下两行。TEXT/BLOB 列 <=40 bytes 时总是存放于数据页。可以避免 compact 那样把太多的大列值放到 B-tree Node,因为 dynamic 格式认为,只要大列值有部分数据放在 off-page,那把整个值放入都放入 off-page 更有效。

​ compressed 物理结构上与 dynamic 类似,但是对表的数据行使用 zlib 算法进行了压缩存储。在 long blob 列类型比较多的情况下用,可以降低 off-page 的使用,减少存储空间(50% 左右,可参见之前 “【数据库评测报告】第三期:innodb、tokudb 压缩性能” 报告中的测试结果),但要求更高的 CPU,buffer pool 里面可能会同时存储数据的压缩版和非压缩版,所以也多占用部分内存。

​ 最后参考了《高性能 MySQL》,给出一些使用 BLOB 这类变长大字段类型的建议:

​ ①大字段在 InnoDB 里可能浪费大量空间。例如,若存储字段值只是比行的要求多了一个字节,也会使用整个页面来存储剩下的字节,浪费了页面的大部分空间。同样的,如果有一个值只是稍微超过了 32 个页的大小,实际上就需要使用 96 个页面。

​ ②太长的值可能使得在查询中作为 WHERE 条件不能使用索引,因而执行很慢。在应用 WHERE 条件之前,MySQL 需要把所有的列读出来,所以可能导致 MySQL 要求 InnoDB 读取很多扩展存储,然后检查 WHERE 条件,丢弃所有不需要的数据。

​ ③一张表里有很多大字段,最好组合起来单独存到一个列里面。让所有的大字段共享一个扩展存储空间,比每个字段用自己的页要好。

​ ④把大字段用 COMPRESS() 压缩后再存为 BLOB,或者在发送到 MySQL 前在应用程序中进行压缩,可以获得显著的空间优势和性能收益。

​ ⑤扩展存储禁用了自适应哈希,因为需要完整的比较列的整个长度,才能发现是不是正确的数据。

此文已由作者授权腾讯云 + 社区发布



↙↙↙阅读原文可查看相关链接,并与作者交流