掘金 后端 ( ) • 2024-06-07 10:25

在 MySQL 中,如果向下面这张表插入这样一条记录,结果是什么呢?name varchar(32) 字段最多能够存储 32 个字符,而我们向其中插入了长度为 26 + 10 = 36 的字符串。

CREATE TABLE `long_string` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `role` tinyint(4) NOT NULL DEFAULT '1',
  `name` varchar(32) NOT NULL DEFAULT '',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

INSERT INTO long_string VALUES (null, 1, 'abcdefghijklmnopqrstuvwxyz0123456789', DEFAULT);

简短的回答是:这取决于 MySQL 的配置和 INSERT 语句的写法,可能成功也可能会报错

这个回答也太模糊了吧,那到底什么时候成功,什么情况又失败呢?

本文就试着通过分析与 INSERT 语句相关的源代码来回答这个问题。

分析 INSERT 语句相关的源代码

MySQL 是通过以 Sql_cmd_insert::mysql_insert() 方法为起点的一系列方法实现 INSERT 语句的,该函数的主要流程如下所示(以 5.7.44 版为例)。

Sql_cmd_insert::mysql_insert() 的主流程

// /home/vagrant/mysql-5.7.44/sql/sql_insert.cc:429
/**
  INSERT statement implementation
  ...
*/
bool Sql_cmd_insert::mysql_insert(THD *thd, TABLE_LIST *table_list)
{
  // 一些局部变量
  LEX *const lex= thd->lex;
  SELECT_LEX *const select_lex= lex->select_lex;
  TABLE      *insert_table= lex->insert_table_leaf->table;
  // ...
  
  // 创建 `VALUES` 子句,即 `VALUES (row1), (row2), ... (rowN)` ,的迭代器
  List_iterator_fast<List_item> its(insert_many_values);  // ①
  // ...
  
  // 遍历 `VALUES` 子句中待插入记录的列表
  its.rewind();
  // ...
  while ((values= its++))
  {
    // ...
    if (fill_record_n_invoke_before_triggers(...))     // ②
    {
      assert(thd->is_error());
      error= 1;
      break;
    }

    error= write_record(thd, insert_table, ...);      // ③
    if (error)
      break;
// ...
  }

  // ...
  DBUG_RETURN(FALSE);    // ④
  
exit_without_my_ok:
  // ...
  DBUG_RETURN(err);      // ⑤
}

这个方法先创建了一个迭代器 List_iterator_fast<List_item> its,用于遍历 VALUES 子句中待插入记录的列表①,该列表存储在 insert_many_values 变量中(定义在位于 sql/sql_insert.hSql_cmd_insert_base 类中)。

之所以强调是“待插入记录的列表”,是因为一条 INSERT 语句可以插入多条记录,例如,

INSERT INTO tbl_name (a,b,c)
    VALUES(1,2,3), (4,5,6), (7,8,9);

接下来将迭代器重置为初始位置(rewind()),并通过 while 循环开始遍历每一条待插入的记录。its++ 操作会使迭代器指向下一条待插入的记录。

fill_record_n_invoke_before_triggers() 用于将待插入记录中的一个个值赋给表中对应的字段②,如果此过程中出现异常,会返回非零值,表示有错误发生。

如果这一步没有错误,则调用 write_record() 将(字段名和取值已绑定好的)记录插入到表中③。具体插入方式则要交由所使用的存储引擎(如 InnoDB、MyISAM 等)处理。

如果所有记录都成功插入,则返回 FALSE 表示没有错误④。一旦发生了错误,就要通过 goto 语句跳转到 exit_without_my_ok,并返回相应的错误代码⑤。

这就是 mysql_insert() 方法的主流程。下面我们重点关注 fill_record_n_invoke_before_triggers() 的逻辑。

fill_record_n_invoke_before_triggers() 的主流程

fill_record_n_invoke_before_triggers() 中的重点是调用了 fill_record() 函数。

// https://github.com/mysql/mysql-server/blob/mysql-5.7.44/sql/sql_base.cc#L9755
/**
  Fill field buffer with values from Field list.
  ...
*/
bool fill_record(THD *thd, TABLE *table, Field **ptr, List<Item> &values, ...)
{
  // ...

  // 表中字段列表的迭代器
  Field *field;
  // 创建待插入记录 (item1, item2, ..., itemN) 的迭代器
  List_iterator_fast<Item> v(values);
  while ((field= *ptr++) && ! thd->is_error())
  {
    Item *const value= v++;
    // ...

    if (value->save_in_field(field, false) == TYPE_ERR_NULL_CONSTRAINT_VIOLATION)
      goto err;
  }

  // ...
  DBUG_RETURN(thd->is_error());

err:
  table->auto_increment_field_not_null= false;
  DBUG_RETURN(true);
}

该函数用于将待插入记录中的一个个值填充(fill)到表的各个字段中,并进行相应的检查和处理。我们可以对照着下面这张图来理解这段代码。

mysqlinsertfillrecordfieldsvalues

这里的 ptr 指向 Field* 的数组,每次迭代后,ptr 都会指向下一个 Field*(图中的虚线表示曾经指向的 Field*)。而存储在 field 变量中的 Field* 又指向具体 Field 类的对象。每一个字段都对应一个具体 Field 类的对象,所有具体的 Field 类都继承自 Field 这个父类。

values 变量对应值的列表,v 是值列表的迭代器,每次迭代后,v 都会指向下一个具体 Item 类的对象(存储在 value 变量中。图中的虚线表示曾经指向的具体 Item 类的对象)。每一个值都对应一个具体的 Item 类的对象,所有具体的 Item 类都继承自 Item 类这个父类。

这里值得注意的是,随着迭代(while 循环)的进行,value 变量和 field 变量的变化,特别是其实际类型的变化。例如,在处理第一个字段 id 时,field 变量指向 Field_long 类的对象,vaule 变量指向 Item_null 类的对象。而当处理到 name 字段时,field 变量指向了 Field_varstring 类的对象,vaule 变量则指向了 PTI_text_literal_text_string 类的对象。

每次迭代时都会执行 value->save_in_field(field, false) 这个方法,而“向 name varchar(32) 字段插入过长字符串会发生什么”的答案就在这里!

跟踪 value->save_in_field() 就找到了答案

field 变量指向 Field_varstring 类的对象后,若继续跟踪在 value->save_in_field() 方法中又调用了哪些方法(以及在这些方法中后续又调用了哪些方法),则会发现最终会调用 Field_longstr::report_if_important_data() 方法(Field_varstringField_longstr 的子类)。

/*
  Check if we lost any important data and send a truncation error/warning
*/
type_conversion_status
Field_longstr::report_if_important_data(const char *pstr, const char *end,
                                        bool count_spaces)
{
  if (pstr < end)       // String is truncated ①
  {
    if (test_if_important_data(field_charset, pstr, end))
    { // ②
      // if ...
        if (!table->in_use->lex->is_ignore() && table->in_use->is_strict_mode())
          set_warning(Sql_condition::SL_WARNING, ER_DATA_TOO_LONG, 1);    // ④
        else
          set_warning(Sql_condition::SL_WARNING, WARN_DATA_TRUNCATED, 1); // ⑤
      // } end if
      return TYPE_WARN_TRUNCATED;
    }
    else if (count_spaces)
    { /* If we lost only spaces then produce a NOTE, not a WARNING */ // ③
// if ...
      set_warning(Sql_condition::SL_NOTE, WARN_DATA_TRUNCATED, 1); 
      // } end if
      return TYPE_NOTE_TRUNCATED;
    }
  }
  return TYPE_OK;
}

该方法用于检查一个字符串在插入数据库时是否被截断,若发生了截断,还要根据一些条件生成不同级别的错误(警告或通知)。

首先,通过 pstr 是否小于 end①来判断字符串是否被截断。这里的 pstr 指向截断位置,而 end 指向字符串的结尾。对于 abcdefghijklmnopqrstuvwxyz0123456789 这个字符串而言,pstr 指向 6789 这个子串,因为 varchar(32) 的字段只能存储下 a~z 加上 0~5,这 26 + 6 = 32 个字符。如果 pstrend 之前,则说明字符串超过了字段的最大长度,被截断了。

接下来就是比较有意思的地方了。MySQL 竟然会针对不同情况——比如,被截断的部分是否仅包括空格?MySQL 是否运行在严格模式(Strict SQL Mode)下?INSERT 语句中是否带有 IGNORE ?——产生不同的结果。

下面,我们就对照着这段代码,构造不同的测试用例,看一看向 varchar(N) 字段插入长度大于 N 的字符串到底会有多少种结果吧。

对照着源代码做实验

首先来看被截断的部分仅包含空格的情况。这对应源代码中③的位置。

mysql> INSERT INTO long_string VALUES (null, 1, 'abcdefghijklmnopqrstuvwxyz          ', DEFAULT);
Query OK, 1 row affected, 1 warning (10.13 sec)

mysql> show warnings;
+-------+------+-------------------------------------------+
| Level | Code | Message                                   |
+-------+------+-------------------------------------------+
| Note  | 1265 | Data truncated for column 'name' at row 1 |
+-------+------+-------------------------------------------+
1 row in set (0.02 sec)

mysql> SELECT *, length(name) FROM long_string ORDER BY id DESC limit 1;
+----+------+----------------------------------+---------------------+--------------+
| id | role | name                             | created_at          | length(name) |
+----+------+----------------------------------+---------------------+--------------+
|  5 |    1 | abcdefghijklmnopqrstuvwxyz       | 2024-06-06 10:58:47 |           32 |
+----+------+----------------------------------+---------------------+--------------+
1 row in set (0.01 sec)

这里插入的 'abcdefghijklmnopqrstuvwxyz ' 长度为 36,最终只有前 32 个字符被插入到了表中,并产生了 1 条警告。

接下来,再来看看被截断的部分是 6789 的情况。

mysql> SELECT @@session.sql_mode\G
*************************** 1. row ***************************
@@session.sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
1 row in set (0.00 sec)

mysql> INSERT INTO long_string VALUES (null, 1, 'abcdefghijklmnopqrstuvwxyz0123456789', DEFAULT);
ERROR 1406 (22001): Data too long for column 'name' at row 1

mysql> SELECT *, length(name) FROM long_string ORDER BY id DESC limit 1;
+----+------+----------------------------------+---------------------+--------------+
| id | role | name                             | created_at          | length(name) |
+----+------+----------------------------------+---------------------+--------------+
|  5 |    1 | abcdefghijklmnopqrstuvwxyz       | 2024-06-06 10:58:47 |           32 |
+----+------+----------------------------------+---------------------+--------------+
1 row in set (0.00 sec)

mysql> INSERT IGNORE INTO long_string VALUES (null, 1, 'abcdefghijklmnopqrstuvwxyz0123456789', DEFAULT);
Query OK, 1 row affected, 1 warning (9.67 sec)

mysql> show warnings;
+---------+------+-------------------------------------------+
| Level   | Code | Message                                   |
+---------+------+-------------------------------------------+
| Warning | 1265 | Data truncated for column 'name' at row 1 |
+---------+------+-------------------------------------------+
1 row in set (0.00 sec)

mysql> SELECT *, length(name) FROM long_string ORDER BY id DESC limit 1;
+----+------+----------------------------------+---------------------+--------------+
| id | role | name                             | created_at          | length(name) |
+----+------+----------------------------------+---------------------+--------------+
|  6 |    1 | abcdefghijklmnopqrstuvwxyz012345 | 2024-06-06 11:04:12 |           32 |
+----+------+----------------------------------+---------------------+--------------+
1 row in set (0.00 sec)

sql_mode 包含 STRICT_TRANS_TABLES 时(MySQL 工作在严格模式下),除非使用 INSERT IGNORE,否则 MySQL 直接报错,拒绝插入记录。这对应源代码中④处的代码。

源代码⑤位置自然就对应关闭严格模式的情况,MySQL 此时会截断字符串,然后“偷偷”报一个警告。但 affected rows依然是1,这可就留下了一个大坑——巨大的 JSON 字符串很可能已经被截断导致再也无法解析,甚至无法恢复,而我们却认为插入成功了。

好了,实验先暂时做到这里吧。


本文通过分析与 INSERT 语句相关的源代码回答了“向 varchar(N) 字段插入长度大于 N 的字符串会发生什么”这个问题。情况是不是比预想的答案——要么拒绝,要么最多存储前 N 个字符,这还取决于配置——要复杂呢。

若你也想通过 gdb 动手调试 MySQL,可以参考这篇文章 📃 《如何搭建用于 gdb 调试的 MySQL》

胡译胡说 软件工程师、技术图书译者。译有《图解云计算架构》《图解量子计算机》《图解TCP/IP(第6版)》《计算机是怎样跑起来的》《自制搜索引擎》等。