MySQL 扩展 VARCHAR 长度遭遇问题的总结

最近,业务反馈有个扩展 VARCHAR 改表需求失败多次,需要干预处理一下。

作者:莫善,某互联网公司高级 DBA。

爱可生开源社区出品,原创内容未经授权不得随意使用,转载请联系小编并注明来源。

本文约 3600 字,预计阅读需要 12 分钟。

背景介绍

最近,业务反馈有个扩展 VARCHAR 改表需求失败多次,需要干预处理一下。

经过排查分析得出,这是由于改表系统解析改表需求得出错误的改表方案导致,即这类改表可以满足快速改表操作(直接使用 ALTER TABLE),理论上任务下发后能马上改完,但是工单结果是执行触发 10 秒超时,最终工单失败。

原则上,VARCHAR 类型的扩展是可以满足快速改表的,我们的改表工单针对这类需求也是支持的,但是实际结果与预期不符,这到底是工单系统的 Bug?还是 MySQL 的坑呢?

本文就来总结一下 扩展 VARCHAR 长度可能会遇到的一些问题,以及我们给出的解决方案,仅供参考

仅讨论 MySQL 5.7 及以后的版本。

MySQL Online DDL

Operation Extending VARCHAR column size
In Place Yes
Rebuilds Table No
Permits Concurrent DML Yes
Only Modifies Metadata Yes

上表是 MySQL 官方文档中关于 Online DDL 章节中的一部分。可以看到关于 VARCHAR 类型的字段的扩展是可以原地改表,且仅仅改了元数据,理论上敲完回车就执行结束。

当然针对这种场景,还是有一些条件的,直接贴原话:

The number of length bytes required by a VARCHAR column must remain the same. For VARCHAR columns of 0 to 255 bytes in size, one length byte is required to encode the value. For VARCHAR columns of 256 bytes in size or more, two length bytes are required. As a result, in-place ALTER TABLE only supports increasing VARCHAR column size from 0 to 255 bytes, or from 256 bytes to a greater size. In-place ALTER TABLE does not support increasing the size of a VARCHAR column from less than 256 bytes to a size equal to or greater than 256 bytes. In this case, the number of required length bytes changes from 1 to 2, which is only supported by a table copy (ALGORITHM=COPY).

https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-ope...

VARCHAR 是变长类型,实际存储的内容不固定,需要 1 或者 2 个字节来表示实际长度,所以修改前和修改后,这个字节数要求是一致。

有了这个技术基础,我们的改表系统就针对这类需求做了优化,可以支持直接使用 ALTER TABLE 进行改表,如果是大表可以节省很多时间,提升效率,也因此遇到了很多问题,才有了这篇文章。

问题汇总

首先简单介绍一下我们的改表系统的处理逻辑,我们会根据业务的改表需求去选择最优的改表方案:

  1. 满足快速改表就直接使用 ALTER TABLE 进行操作。

    比如,删除索引,修改表名/列名,修改默认值/注释,扩展 VARCHAR 长度,小表添加唯一索引以及 8.0 快速加列等等。

  2. 不满足快速改表就优先选择 gh-ost 进行改表

    binlog format 不为 ROW 则不能使用 gh-ost,添加唯一索引必须使用 gh-ost

  3. 不满足 gh-ost 都会选择 pt-osc 进行改表。

    其中添加唯一索引会直接失败。

那么问题来了,我们是如何判断业务改表需求是不是扩展 VARCHAR?

其实思路也很简单,就是检查改表前后的 information_schema.columns 记录,用到的 SQL 如下:

select * from information_schema.columns where table_schema = 'db' and table_name = 'table' and column_name = 'col';

# 样例数据
*************************** 1. row ***************************
           TABLE_CATALOG: def
            TABLE_SCHEMA: information_schema
              TABLE_NAME: CHARACTER_SETS
             COLUMN_NAME: CHARACTER_SET_NAME
        ORDINAL_POSITION: 1
          COLUMN_DEFAULT: 
             IS_NULLABLE: NO
               DATA_TYPE: varchar
CHARACTER_MAXIMUM_LENGTH: 32
  CHARACTER_OCTET_LENGTH: 96
       NUMERIC_PRECISION: NULL
           NUMERIC_SCALE: NULL
      DATETIME_PRECISION: NULL
      CHARACTER_SET_NAME: utf8
          COLLATION_NAME: utf8_general_ci
             COLUMN_TYPE: varchar(32)
              COLUMN_KEY: 
                   EXTRA: 
              PRIVILEGES: select
          COLUMN_COMMENT: 
   GENERATION_EXPRESSION: 
  • DATA_TYPE 值是 VARCHAR
  • CHARACTER_MAXIMUM_LENGTH 的值,要求改表后要大于等于改表前的值
  • CHARACTER_OCTET_LENGTH 的值,要求改表前后这个值要么是都小于等于 255,要么是都大于 255
  • DATA_TYPE/COLUMN_TYPE/CHARACTER_MAXIMUM_LENGTH/CHARACTER_OCTET_LENGTH 字段外的其余字段要求改表前后保持一致

问题一:默认值问题

我们发现,如果还改了字段名、注释、默认值这种元数据信息,依旧是可以快速改表,于是乎就进行了优化,不再比较这三个属性 COLUMN_NAME|COLUMN_COMMENT|COLUMN_DEFAULT

关于默认值,看起来有点复杂,最开始也是想跑偏了,认为判断 COLUMN_DEFAULT 的值就行,比较这个值前后要么都是 null,要么都不是 null。都不是 null 的情况下可以是任意值,比如可以用下面的逻辑判断改表前后是一致即可。

if(COLUMN_DEFAULT is null ,null,"")

但是有个问题,如果一个字段从 允许为 null 默认值为 1 变成 不允许为null 默认值也是 1,该值改表前后也是一致的,具体测试如下:

CREATE TABLE `tb_test` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `rshost` varchar(20) DEFAULT '1' COMMENT '主机地址',
  `cpu_info` json DEFAULT NULL COMMENT 'cpu信息 json串',
  `mem_info` json DEFAULT NULL COMMENT 'mem信息 json串, 单位是GB',
  `io_info` json DEFAULT NULL COMMENT '磁盘io使用情况, 单位是KB',
  `net` json DEFAULT NULL COMMENT '网络使用情况, 单位是KB(speed单位是MB/S)',
  `a_time` datetime NOT NULL DEFAULT '2022-01-01 00:00:00',
  PRIMARY KEY (`id`),
  KEY `idx_a_time` (`a_time`),
  KEY `idx_rshost` (`rshost`)
) ENGINE=InnoDB AUTO_INCREMENT=623506728 DEFAULT CHARSET=utf8mb4
>select * from information_schema.columns where table_name = 'tb_test' and column_name = 'rshost'\G
*************************** 1. row ***************************
           TABLE_CATALOG: def
            TABLE_SCHEMA: dbzz_monitor
              TABLE_NAME: tb_test
             COLUMN_NAME: rshost
        ORDINAL_POSITION: 2
          COLUMN_DEFAULT: 1
             IS_NULLABLE: YES
               DATA_TYPE: varchar
CHARACTER_MAXIMUM_LENGTH: 30
  CHARACTER_OCTET_LENGTH: 120
       NUMERIC_PRECISION: NULL
           NUMERIC_SCALE: NULL
      DATETIME_PRECISION: NULL
      CHARACTER_SET_NAME: utf8mb4
          COLLATION_NAME: utf8mb4_general_ci
             COLUMN_TYPE: varchar(30)
              COLUMN_KEY: 
                   EXTRA: 
              PRIVILEGES: select,insert,update,references
          COLUMN_COMMENT: 主机地址
   GENERATION_EXPRESSION: 
1 row in set (0.00 sec)

>alter table tb_test modify `rshost` varchar(30) not null DEFAULT '1' COMMENT '主机地址';
Query OK, 1000000 rows affected (13.68 sec)
Records: 1000000  Duplicates: 0  Warnings: 0

>select * from information_schema.columns where table_name = 'tb_test' and column_name = 'rshost'\G
*************************** 1. row ***************************
           TABLE_CATALOG: def
            TABLE_SCHEMA: dbzz_monitor
              TABLE_NAME: tb_test
             COLUMN_NAME: rshost
        ORDINAL_POSITION: 2
          COLUMN_DEFAULT: 1
             IS_NULLABLE: NO
               DATA_TYPE: varchar
CHARACTER_MAXIMUM_LENGTH: 30
  CHARACTER_OCTET_LENGTH: 120
       NUMERIC_PRECISION: NULL
           NUMERIC_SCALE: NULL
      DATETIME_PRECISION: NULL
      CHARACTER_SET_NAME: utf8mb4
          COLLATION_NAME: utf8mb4_general_ci
             COLUMN_TYPE: varchar(30)
              COLUMN_KEY: 
                   EXTRA: 
              PRIVILEGES: select,insert,update,references
          COLUMN_COMMENT: 主机地址
   GENERATION_EXPRESSION: 
1 row in set (0.00 sec)

可以看到 COLUMN_DEFAULT 这个列的值是非 null 且不变,按照上面的判断逻辑会认为可以快速改表,但是我们知道实际上这个需求是需要 copy 数据的。

其实,关于默认值问题使用 IS_NULLABLE 的值就可以完美解决, 如果是 null 到 not null 这个值会从 yes 变成 no;如果是 not null 到 null,这个值会从 no变成 yes。

所以最终解决方案仅比较 IS_NULLABLE 即可,只要改表前后一致就认为默认值这个属性满足快速改表

在测试这个问题的时候发现一个现象:not null 到 null 可以使用 inplace 算法,但是需要 copy 数据;null 到 not null 不能使用 inplace,请看下面的用例:
-- not null --> null可以使用inplace
>alter table tb_test modify `rshost` varchar(30) DEFAULT '1' COMMENT '主机地址' ,ALGORITHM=INPLACE, LOCK=NONE;
Query OK, 0 rows affected (3.45 sec)
Records: 0  Duplicates: 0  Warnings: 0

-- null --> not null不可以使用inplace
>alter table tb_test modify `rshost` varchar(30) not null DEFAULT '1' COMMENT '主机地址' ,ALGORITHM=INPLACE, LOCK=NONE;
ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: cannot silently convert NULL values, as required in this SQL_MODE. Try ALGORITHM=COPY.
>

-- 可以使用下面的操作查看改表进度拷贝数据的情况,第一次使用需要开启此功能
-- UPDATE performance_schema.setup_instruments SET ENABLED = 'YES' WHERE NAME LIKE 'stage/innodb/alter%';
-- UPDATE performance_schema.setup_consumers SET ENABLED = 'YES' WHERE NAME LIKE '%stages%';

>SELECT EVENT_NAME, WORK_COMPLETED, WORK_ESTIMATED FROM performance_schema.events_stages_current;
+-----------------------------+----------------+----------------+
| EVENT_NAME                  | WORK_COMPLETED | WORK_ESTIMATED |
+-----------------------------+----------------+----------------+
| stage/sql/copy to tmp table |         272289 |         978903 |
+-----------------------------+----------------+----------------+
1 row in set (0.00 sec)

-- 为了避免测试干扰,检查events_stages_history表之前可以先清空,切记不要对线上环境做此操作。
-- TRUNCATE TABLE performance_schema.events_stages_history;
>SELECT EVENT_NAME, WORK_COMPLETED, WORK_ESTIMATED FROM performance_schema.events_stages_history;
+-----------------------------+----------------+----------------+
| EVENT_NAME                  | WORK_COMPLETED | WORK_ESTIMATED |
+-----------------------------+----------------+----------------+
| stage/sql/copy to tmp table |        1000000 |         978903 |
+-----------------------------+----------------+----------------+
1 row in set (0.00 sec)

问题二:索引字段问题

过了一段时间又发现第二个问题,部分工单会触发执行 10 秒超时失败。

工单系统判断用户的改表需求,满足直接使用 ALTER TABLE 进行操作会有个 10 秒超时的兜底策略,来避免因为解析错误导致方案选择错误最终影响主从延迟。

另外,也建议带上 ALGORITHM=INPLACE, LOCK=NONE ,避免因为不是使用 inplace 导致 DML 阻塞。

这个问题排查了很久都没什么眉目,反反复复的查阅文档及测试,始终都认为这个需求一定是满足快速改表的方案。实在是想不明白到底是哪里的问题,还一度认为是 MySQL 的 Bug。

下面是一张 100w 记录表的测试用例:

> show create table tb_test\G
*************************** 1. row ***************************
       Table: tb_test
Create Table: CREATE TABLE `tb_test` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `rshost` varchar(30) NOT NULL DEFAULT '1' COMMENT '主机地址',
  `cpu_info` json DEFAULT NULL COMMENT 'cpu信息 json串',
  `mem_info` json DEFAULT NULL COMMENT 'mem信息 json串, 单位是GB',
  `io_info` json DEFAULT NULL COMMENT '磁盘io使用情况, 单位是KB',
  `net` json DEFAULT NULL COMMENT '网络使用情况, 单位是KB(speed单位是MB/S)',
  `a_time` datetime NOT NULL DEFAULT '2022-01-01 00:00:00',
  PRIMARY KEY (`id`),
  KEY `idx_a_time` (`a_time`),
  KEY `idx_rshost` (`rshost`)
) ENGINE=InnoDB AUTO_INCREMENT=623506728 DEFAULT CHARSET=utf8mb4
1 row in set (0.00 sec)

> select count(*) from tb_test;
+----------+
| count(*) |
+----------+
|  1000000 |
+----------+
1 row in set (0.15 sec)

> alter table tb_test modify `rshost` varchar(31) NOT NULL DEFAULT '1' COMMENT '主机地址';
Query OK, 0 rows affected (3.61 sec)
Records: 0  Duplicates: 0  Warnings: 0

> alter table tb_test modify `rshost` varchar(32) NOT NULL DEFAULT '1' COMMENT '主机地址', ALGORITHM=INPLACE, LOCK=NONE;
Query OK, 0 rows affected (3.66 sec)
Records: 0  Duplicates: 0  Warnings: 0

> alter table tb_test drop index idx_rshost;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

> alter table tb_test modify `rshost` varchar(33) NOT NULL DEFAULT '1' COMMENT '主机地址', ALGORITHM=INPLACE, LOCK=NONE;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

> alter table tb_test modify `rshost` varchar(34) NOT NULL DEFAULT '1' COMMENT '主机地址', ALGORITHM=INPLACE, LOCK=NONE;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

> 

可以看到 rshost 字段有一个索引,在扩展字段的时候虽然支持 inplace,但是实际上很慢,内部应该是重建索引了,后来将索引删除后就秒改了。

针对这个场景,我们的解决方案是使用 gh-ost/pt-osc 进行改表,那么问题来了,我们应该怎么判断目标字段是否是被索引了呢?

请看下面的 SQL,information_schema.STATISTICS 记录了一个表的所有索引字段信息,可以很方便的判断某个字段是否被索引。

select * from information_schema.STATISTICS where table_schema = 'db' and table_name = 'table' and column_name = 'col';

其他问题

这个问题也是执行 10 秒超时,也就是文章开头提到的业务反馈的问题,其实跟 问题二 差不多同期,但在解决了 问题二 后还是一直找不到原因及解决方案。

关于这个问题甚至都没法复现,不像 问题二 可以方便复现,当时在业务的线上库做操作又能 100% 复现,但是将他们的表及数据单独导出来放在测试环境就不行。

在业务库上测试是选了一个从库,不记录 binlog 的方式(set sql_log_bin = 0)。虽然不建议这么做,但是实属迫不得已,在测试环境复现不出来。

后来实在找不到原因,就跳过快速改表的方案使用改表工具进行处理,后来这个事情就算不了了之了。直到前几天业务突然找我,说之前的那个表能快速改表了。我赶紧去查看了工单详情,发现确实如业务所述,这回我就更加郁闷了,难不成是见鬼了?这玩意还自带歇业窗口的嘛?

本着严谨的态度,又去测了一下。确实是可以满足快速改表了,但是原因还是找不到,这感觉真的很难受。

最后,静下来认真梳理了一下,发现了一些猫腻。下面是我的测试思路:

1. 将线上的表导出并导入到测试环境

因为表本身就几个 G,不算大就使用了 mysqldump 进行导出导入。这个操作并非 100% 复原线上的环境,有个隐藏的变量被修改了,那就是这个表被重建了,这个跟之前业务用改表工具进行修改后的操作有点类似,所以就猜想,会不会是因为这个表本身存在空洞导致的呢。

最后通过拉历史备份,还原了一个环境进行了测试,果不其然不能快速改表。为了印证了想法,就去查了一下这个表的空洞。十分遗憾,这个表并没有存在空洞(空洞只有几 MB)。这回又郁闷了,还以为要破案了,但是不管怎么样既然怀疑是重建表能解决,那就开搞。

2. 重建前的状态

业务从 varchar(300) 扩展到 varchar(500),其他属性没变更。

| 1170930999 | dba            | 192.168.1.100:47522  | dbzz_dbreport | Query            |      45 | altering table                                        | ALTER TABLE  t_recycle_express  MODIFY  address VARCHAR(500) NOT NULL DEFAULT '' COMMENT '地址', ALGORITHM=INPLACE, LOCK=NONE;             |

3. 重建后的状态

>ALTER TABLE t_recycle_express engine = innodb;
Query OK, 0 rows affected (18 min 52.60 sec)
Records: 0  Duplicates: 0  Warnings: 0

>ALTER TABLE
    ->   t_recycle_express
    -> MODIFY
    ->   address VARCHAR(500) NOT NULL DEFAULT '' COMMENT '地址';
Query OK, 0 rows affected (0.07 sec)
Records: 0  Duplicates: 0  Warnings: 0

活久见,还真是重建表后就能解决了!虽然很郁闷,终究是有一个解决方案了,后期我们决定对此做个优化,将满足快速改表的工单又触发十秒超时的改为使用 gh-ost/pt-osc 重新执行,以此避免业务反复提交工单,应该能大大提升好感度。

这个问题虽然知道解决方案,但是依旧不知道原因,我猜测可能是跟统计信息不准确有关系(或者约束),要是有大佬知道原因,请告知一下。

总结

MySQL Online DDL 特性给 DBA 带来了很多的便利,提升了工作效率,我们可以基于官方的理论作为指导去优化我们的系统。但是实际情况是理论知识很简单,线上环境十分复杂,可能会遇到各种意料之外的事情,任何线上的操作都要给自己留好后路做好兜底,这是十分必要的。

我们的系统,如果没有添加 10 秒超时的兜底,那势必会因为解析错误导致选了错误的改表方案,然后导致从库延迟,可能会影响线上业务,想想都有点心慌。

这里有个注意事项,针对执行超时不能简单的使用 timeout 等属性进行控制,还需要添加检查逻辑,要到数据库里面去查一下任务是否真的已经终止了。避免因为 timeout 异常导致终止信号没有给到 MySQL,这种可能会引发一系列问题,切记切记。

以上,仅供参考。

你可能感兴趣的:(mysql数据类型)