暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

MySQL 建表失败 ERROR 1118 的两个报错 65535 与 16318

一次 SAP 大宽表迁移引发的血案:200+ 列的表结构搬到 MySQL 8.0,建表直接报错。这篇文章把 ERROR 1118 的两道计算口径、复现实验、严格模式的坑、以及三种解法一次讲透。

0、结论先行

  1. MySQL 创建表报"行大小超出限制(ERROR 1118)",实际是两道独立的关卡,报错文案不同,解法完全不同:
-- 第一关:Server 层声明总长,上限 65535,与页大小无关
ERROR 1118 (42000): Row size too large. The maximum row size for the used
table type, not counting BLOBs, is 65535. This includes storage overhead,
check the manual. You have to change some columns to TEXT or BLOBs

-- 第二关:InnoDB 页内上限,16K 页是 8128,32K 页是 16318
Row size too large (> 16318). Changing some columns to TEXT or BLOB may help.
In current row format, BLOB prefix of 0 bytes is stored inline
  1. 第一关(65535)是 Server 层硬限制,没有任何参数能绕,只能开发改表结构:减列、改小列值或改 TEXT/BLOB。

  2. 第二关(16318)有三条路:

    • innodb_page_size 从 16K 扩到 32K(需重建实例;64K 只比 32K 多 65 字节,意义不大)
    • 关闭 innodb_strict_mode(建表从报错降级为报警,但有后患,下文详述)
    • 改表结构:删无用列,或把 VARCHAR 改成 TEXT
  3. 实验发现:TEXT 比 VARCHAR 多一个"溢出"属性——VARCHAR 插入时严格遵守行上限,TEXT 能绕过限制把数据写进去。

1、问题背景

某 SAP 迁移项目,一张 200+ 列的凭证行项目表(大量 VARCHAR(60)、DECIMAL(38,6))要落到 MySQL。建表直接报 ERROR 1118。

测试环境画像:

mysql> SELECT @@version, @@innodb_page_size, @@innodb_default_row_format,
              @@innodb_strict_mode, @@character_set_server, @@collation_server\G
*************************** 1. row ***************************
               @@version: 8.0.36
       @@innodb_page_size: 32768
@@innodb_default_row_format: dynamic
     @@innodb_strict_mode: 1
  @@character_set_server: utf8mb4
      @@collation_server: utf8mb4_bin

MySQL 8.0.36、32K 页、DYNAMIC 行格式、严格模式 ON、utf8mb4——这是信创/新项目很典型的配置,也是最容易撞上这两道红线的配置(utf8mb4 每字符按 4 字节算,声明长度直接 ×4)。

2、两道关卡的计算口径(判断方式)

2.1 第一关:Server 层声明总长(上限 65535)

先校验,与页大小无关。公式:

server_decl_bytes = Σ 每列声明字节 + ceil(可空列数 / 8)

各类型声明字节速查表:

类型 声明字节(utf8mb4)
VARCHAR(n) 4n≤255 → 4n+1;4n>255 → 4n+2
CHAR(n) 4n
TEXT / BLOB / JSON 只计 ~12 字节(这就是报错里说 "not counting BLOBs" 的原因)
BIGINT 8
INT 4
MEDIUMINT / SMALLINT / TINYINT 3 / 2 / 1
DATE / YEAR 3 / 1
DATETIME(n) 5 + ceil(n/2)
TIMESTAMP(n) 4 + ceil(n/2)
TIME(n) 3 + ceil(n/2)
DECIMAL(m,d) ≈ ceil((m-d)/9)×4 + ceil(d/9)×4
BIT(b) ceil(b/8)
FLOAT / DOUBLE 4 / 8

2.2 第二关:InnoDB 页内最坏占用(上限 = 页可用空间一半)

后校验。公式:

rec_max_size = 5(行头)
             + 2 × (用户列数 + 2)      ← +2 是两个隐藏系统列
             + ceil(可空列数 / 8)
             + Σ 定长列全额 + 13        ← 13 = TRX_ID(6) + ROLL_PTR(7)
             + Σ 变长列折算             ← >40 字节 → 按 41 计;≤40 → 实价+1

各页大小对应的行上限:

innodb_page_size 单行页内上限
4K ≈ 2000
8K ≈ 4000
16K(默认) ≈ 8128
32K ≈ 16318
64K ≈ 16383(只比 32K 多 65 字节)

算个例子:1 个 BIGINT 主键 + 9 个 VARCHAR(2000) 的表:

5 + 2×(10+2) + ceil(9/8) + (8+13) + 41×9 = 5 + 24 + 2 + 21 + 369 = 421 字节

离 16318 还很远——所以关键是列数多才致命,不是单列长。

2.3 两个必须搞懂的概念

Q1:什么叫定长列、变长列?

一句话:看类型,不看数据。

  • 定长列:每行占的字节雷打不动。BIGINT 存 1 还是存 999999999 都是 8 字节;DATETIME(3) 永远 7;DECIMAL(10,2) 永远 5。没有任何折算余地,实价全算。
  • 变长列:每行占的字节跟着内容走。VARCHAR(15) 存个 'a' 占 2 字节,存 15 个汉字占 46 字节。正因为可伸可缩,InnoDB 才敢在第二关用"41 字节折算"这种估计价,也才有后来的"溢出页"机制。

判断口诀:数值、日期时间、DECIMAL、BIT = 定长;带 VAR 的、TEXT/BLOB/JSON = 变长。

一个坑:utf8mb4 的 CHAR 也是变长(每字符 1~4 字节不定),latin1 的 CHAR 才是定长——看见 CHAR 先想字符集。

Q2:公式里的 "+2" 是哪来的?

是 InnoDB 藏在每一行里的 2 个系统列,建表时看不见,物理上每条记录都有:

隐藏列 字节 干什么
DB_TRX_ID 6 最近一次修改这行的事务 ID
DB_ROLL_PTR 7 回滚指针,指向 undo log 里这行的旧版本

这俩是 MVCC 的基础设施——你事务里能"看到旧版本数据"、能 ROLLBACK,全靠它们。

判断规则一句话:看表有没有主键。

  • 有 PRIMARY KEY → +2,定长部分 +13
  • 没有主键 → InnoDB 再偷加第 3 个隐藏列 DB_ROW_ID(6 字节自增行号)当聚簇键 → 目录费 +3,定长 +19

3、动手复现:400 列的表,两种报错都打出来

用 WITH RECURSIVE + GROUP_CONCAT 批量拼 DDL(先把 group_concat 上限调大):

SET SESSION group_concat_max_len = 102400;

-- 实验 A:400 个 VARCHAR(60) → 撞第一关 65535
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 400)
SELECT CONCAT(
  'CREATE TABLE lab_t400_v60 (id BIGINT UNSIGNED PRIMARY KEY, ',
  GROUP_CONCAT(CONCAT('c', n, ' VARCHAR(60) NOT NULL DEFAULT \'\'') ORDER BY n SEPARATOR ', '),
  ') ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;'
) AS ddl FROM seq;

执行结果:

ERROR 1118 (42000): Row size too large. The maximum row size for the used
table type, not counting BLOBs, is 65535. ...
-- 实验 B:400 个 VARCHAR(15) → 撞第二关 16318
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 400)
SELECT CONCAT(
  'CREATE TABLE lab_t400_v15 (id BIGINT UNSIGNED PRIMARY KEY, ',
  GROUP_CONCAT(CONCAT('c', n, ' VARCHAR(15) NOT NULL DEFAULT \'\'') ORDER BY n SEPARATOR ', '),
  ') ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;'
) INTO @ddl FROM seq;

PREPARE stmt FROM @ddl;
EXECUTE stmt;

执行结果:

ERROR 1118 (42000): Row size too large (> 16318). Changing some columns to
TEXT or BLOB may help. In current row format, BLOB prefix of 0 bytes is
stored inline.

一个有意思的细节:set global innodb_strict_mode = 0 之后,需要退出重新登录才生效——已建立的会话拿不到新值。

4、关闭严格模式的代价:从"建不出来"变成"插不进去"

关掉 innodb_strict_mode 后,建表从报错降级为 warning,表能建出来。但风险转移到了插入阶段,且失败时机不可预测。

实验 C:往 lab_t400_v15(400 个 VARCHAR(15))插数据。

-- 每列塞 15 个汉字(46 字节/列),单行直接超限
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 400)
SELECT GROUP_CONCAT('REPEAT(\'汉\',15)' ORDER BY n SEPARATOR ', ') INTO @big FROM seq;

SELECT CONCAT('INSERT INTO lab_t400_v15 VALUES (1,', @big, ');') INTO @ins1;
PREPARE stmt FROM @ins1;
EXECUTE stmt;

结果:

ERROR 1118 (42000): Row size too large (> 16318). ...

更要命的是多行插入——一条 SQL 里只要有一行超限,整条 SQL 的所有行一起失败:

-- 5 行数据,只有第 3 行(id=4)超限
SELECT CONCAT('INSERT INTO lab_t400_v15 VALUES (2,', @small, '),(3,', @small,
              '),(4,', @big, '),(5,', @small, '),(6,', @small, ');') INTO @ins2;
PREPARE stmt FROM @ins2;
EXECUTE stmt;   -- ERROR 1118,5 行一行都进不去

这就意味着:应用侧必须自己甄别"到底是哪条数据超长",排查成本全部后移。

5、TEXT 的"溢出"后门:同一份数据,VARCHAR 进不去,TEXT 能进

实验 D:建一张 400 个 TEXT 列的对照表。

WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n < 400)
SELECT CONCAT(
  'CREATE TABLE lab_t400_text (id BIGINT UNSIGNED PRIMARY KEY, ',
  GROUP_CONCAT(CONCAT('c', n, ' TEXT') ORDER BY n SEPARATOR ', '),
  ') ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;'
) INTO @ddl2 FROM seq;

先在严格模式 ON 下建——**TEXT 表照样报 ERROR 1118 (> 16318)**,和 VARCHAR(15) 一模一样(因为 DYNAMIC 格式下 400 列的目录费 + 隐藏列照样超页内上限)。

关 SESSION 严格模式,建出来(Warning 139),再恢复:

SET SESSION innodb_strict_mode = OFF;
EXECUTE stmt;          -- Query OK, 1 warning (139)
SHOW WARNINGS;
SET SESSION innodb_strict_mode = ON;

然后做对照插入——把刚才插 lab_t400_v15 时报错的那同一份数据,原样插进 TEXT 表:

SELECT CONCAT('INSERT INTO lab_t400_text VALUES (1,', @big, ');') INTO @ins_text;
PREPARE stmt FROM @ins_text;
EXECUTE stmt;          -- Query OK, 1 row affected

SELECT COUNT(*) AS varchar表行数 FROM lab_t400_v15;   -- 0
SELECT COUNT(*) AS text表行数   FROM lab_t400_text;   -- 1

0 vs 1。 结论:TEXT 列在 DYNAMIC 行格式下走溢出页(off-page)存储,行内只留指针,天然绕过页内行上限;VARCHAR 没有这条路。这也是第一关报错官方建议 "change some columns to TEXT or BLOBs" 的底层原因。

6、解决方案与取舍

方案 操作成本 风险 适用场景
改表结构:减列 / VARCHAR 改 TEXT 开发配合 低 首选,彻底根治
innodb_page_size 16K → 32K 重建实例 中(停机窗口) 列确实多且不能改结构
关闭 innodb_strict_mode 重启/动态参数 高:建表变警告,插入期才暴雷,多行插入一行超限全单失败 应急过渡,不建议长期

关于"关严格模式算不算数据丢失",团队里有过一次定义之争,值得写出来:

  • 定义 A(原子化视角):成功就是成功落库,失败就是立即报错、立刻有人处理。只要不存在"插入成功但库里查不到",就不算丢数据。按这个定义,关严格模式可以接受——插不进去会报错,开发/实施去找超长的那条数据即可。
  • 定义 B(全局一致视角):成功应该是"在任何场景都成功",包括初始化、备份恢复演练。这种独一份的库,申请资源都要特殊指定,行上限问题埋着,后面每次演练都要多一道坎。

两种定义没有绝对对错,但决策权要交给对后果负责的人。我的建议是:表结构整改完毕后,把严格模式重新打开,彻底杜绝"插入期暴雷"这条不确定性。多行合并插入时尤其注意——一行超限,整条 SQL 连坐失败。

7、一键体检:找出库里所有"埋雷表"

贴一段可以直接用的诊断 SQL,基于 information_schema 按上面两套口径静态估算(32K 页示例;16K 页把 @half_page 改成 8128):

SET @half_page := 16318;  -- 32K 页;16K 页改 8128

SELECT t.*,
  CASE
    WHEN t.server_decl_bytes > 65535       THEN 'P0-撞65535'
    WHEN t.innodb_worst_bytes > @half_page THEN 'P0-撞16318-埋雷表'
    ELSE 'P1-逼近红线(>80%)'
  END AS risk
FROM (
  SELECT
    c.TABLE_SCHEMA, c.TABLE_NAME, COUNT(*) AS col_cnt,
    SUM(CASE
      WHEN c.DATA_TYPE IN ('tinytext','text','mediumtext','longtext',
                           'tinyblob','blob','mediumblob','longblob','json') THEN 12
      WHEN c.DATA_TYPE = 'varchar' THEN c.CHARACTER_MAXIMUM_LENGTH * IFNULL(cs.maxlen,1)
           + IF(c.CHARACTER_MAXIMUM_LENGTH * IFNULL(cs.maxlen,1) > 255, 2, 1)
      WHEN c.DATA_TYPE = 'char'    THEN c.CHARACTER_MAXIMUM_LENGTH * IFNULL(cs.maxlen,1)
      WHEN c.DATA_TYPE = 'bigint'  THEN 8
      WHEN c.DATA_TYPE IN ('int','integer') THEN 4
      WHEN c.DATA_TYPE = 'mediumint' THEN 3
      WHEN c.DATA_TYPE = 'smallint'  THEN 2
      WHEN c.DATA_TYPE = 'tinyint'   THEN 1
      WHEN c.DATA_TYPE = 'date' THEN 3
      WHEN c.DATA_TYPE = 'year' THEN 1
      WHEN c.DATA_TYPE = 'float'  THEN 4
      WHEN c.DATA_TYPE = 'double' THEN 8
      WHEN c.DATA_TYPE = 'datetime'  THEN 5 + CEIL(IFNULL(c.DATETIME_PRECISION,0)/2)
      WHEN c.DATA_TYPE = 'timestamp' THEN 4 + CEIL(IFNULL(c.DATETIME_PRECISION,0)/2)
      WHEN c.DATA_TYPE = 'time'      THEN 3 + CEIL(IFNULL(c.DATETIME_PRECISION,0)/2)
      WHEN c.DATA_TYPE = 'decimal'   THEN CEIL((c.NUMERIC_PRECISION-IFNULL(c.NUMERIC_SCALE,0))/9)*4
                                         + CEIL(IFNULL(c.NUMERIC_SCALE,0)/9)*4
      WHEN c.DATA_TYPE = 'bit' THEN CEIL(IFNULL(c.NUMERIC_PRECISION,1)/8)
      ELSE 0
    END) + CEIL(SUM(c.IS_NULLABLE='YES')/8) AS server_decl_bytes,
    5 + 2*(COUNT(*)+3) + CEIL(SUM(c.IS_NULLABLE='YES')/8)
    + SUM(CASE
      WHEN c.DATA_TYPE = 'bigint'  THEN 8
      WHEN c.DATA_TYPE IN ('int','integer') THEN 4
      WHEN c.DATA_TYPE = 'mediumint' THEN 3
      WHEN c.DATA_TYPE = 'smallint'  THEN 2
      WHEN c.DATA_TYPE = 'tinyint'   THEN 1
      WHEN c.DATA_TYPE = 'date' THEN 3
      WHEN c.DATA_TYPE = 'year' THEN 1
      WHEN c.DATA_TYPE = 'float'  THEN 4
      WHEN c.DATA_TYPE = 'double' THEN 8
      WHEN c.DATA_TYPE = 'datetime'  THEN 5 + CEIL(IFNULL(c.DATETIME_PRECISION,0)/2)
      WHEN c.DATA_TYPE = 'timestamp' THEN 4 + CEIL(IFNULL(c.DATETIME_PRECISION,0)/2)
      WHEN c.DATA_TYPE = 'time'      THEN 3 + CEIL(IFNULL(c.DATETIME_PRECISION,0)/2)
      WHEN c.DATA_TYPE = 'decimal'   THEN CEIL((c.NUMERIC_PRECISION-IFNULL(c.NUMERIC_SCALE,0))/9)*4
                                         + CEIL(IFNULL(c.NUMERIC_SCALE,0)/9)*4
      WHEN c.DATA_TYPE = 'bit' THEN CEIL(IFNULL(c.NUMERIC_PRECISION,1)/8)
      ELSE IF(IFNULL(c.CHARACTER_MAXIMUM_LENGTH,65535) * IFNULL(cs.maxlen,1) > 40,
              41, IFNULL(c.CHARACTER_MAXIMUM_LENGTH,0) * IFNULL(cs.maxlen,1) + 1)
    END) AS innodb_worst_bytes
  FROM information_schema.COLUMNS c
  LEFT JOIN information_schema.CHARACTER_SETS cs
    ON cs.CHARACTER_SET_NAME = c.CHARACTER_SET_NAME
  WHERE c.TABLE_SCHEMA NOT IN ('mysql','sys','information_schema','performance_schema')
  GROUP BY c.TABLE_SCHEMA, c.TABLE_NAME
) t
WHERE t.server_decl_bytes > 65535 * 0.8
   OR t.innodb_worst_bytes > @half_page * 0.8
ORDER BY t.innodb_worst_bytes DESC;

结果读法:

risk 含义 处置
P0-撞65535 声明总长超 Server 层 严格模式建不出,减列或改 TEXT
P0-撞16318-埋雷表 超页内上限但表已存在 当年是关严格模式建的,存在写不进去的行,优先整改
P1-逼近红线(>80%) 余量不足 加列空间将尽,纳入观察

查出来 0 行是好事。只想查一个库,把 WHERE 换成 c.TABLE_SCHEMA = '库名' 即可。

两点口径说明:① 这是声明长度静态估算(DDL 视角),与行里实际存了多少数据无关——正好用来找"埋雷表";② 只算了聚集索引(整行),宽联合二级索引是另一本账(变长列不折算、按全额),需要的话评论区说一声,我再补那段。

8、一句话记法

定长看类型、变长看内容;主键永不进 NULL 位图;每行背后都跟着两个 6+7 的隐藏跟班(没主键变三个)。65535 是 Server 的死线,16318 是页里的红线;TEXT 有溢出后门,VARCHAR 没有。


作者:Master 三石 | 公众号同名,专注 Oracle / MySQL / 国产库一线实战

最后修改时间:2026-10-09 10:38:36
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论