接手一个从旧版本迁移到 MySQL 8.0 的存量项目时,我第一天的建表操作就翻了好几个跟头:表名大小写不一致导致连不上、用 CTAS 复制出来的新表一个索引都没有、写了 CHECK 约束之后数据死活插不进去——而这些差异,全都集中在 CREATE TABLE 这条被不少人当成“入门语法”的语句里。MySQL 8 是近几个大版本里 SQL 行为变化最明显的一个,CREATE TABLE 作为一切数据的起点,确实值得单独拿出来过一遍。这篇文章不打算逐条翻译官方文档,而是按我实际使用中踩过的坑和验证过的做法,把标题里表名、临时表、表克隆与复制、列数据类型和属性这四条主线逐一说透。
1. 建表语句的全局观:语法骨架与MySQL 8的关键改动
1.1 一段语法骨架里的四个入口
CREATE TABLE 的完整语法看起来很长,但拆开来看,其实就是“要建什么 + 怎么建 + 建成什么样”。MySQL 8 下最基本的骨架是:
CREATE [TEMPORARY] TABLE [IF NOT EXISTS] tbl_name (create_definition,...) [table_options] [partition_options]create_definition 是列定义、主键、索引、约束这些“表内零件”;table_options 是引擎、字符集、行格式这类“表的全局配置”;partition_options 是分区相关;而 TEMPORARY 关键字决定了这是一张会话级临时表。还有一个容易忽略的点:CREATE TABLE 后面可以跟 LIKE 或 AS SELECT 两种简写形式,分别对应结构克隆和带数据的快速复制,这个后面细说。
在实际工作中,我习惯把“IF NOT EXISTS”写进每一个建表脚本。因为 MySQL 8 里,如果表已经存在,不带 IF NOT EXISTS 的 CREATE TABLE 会直接报错中断整个脚本;加了之后,配合 SHOW WARNINGS 可以清楚看到“表已存在、语句被忽略”的提示:
CREATE TABLE IF NOT EXISTS demo_user ( id INT PRIMARY KEY ) ENGINE=InnoDB; SHOW WARNINGS;1.2 8.0 里那些“看起来一样,实际不一样”的版本差异
从 5.7 升到 8.0,CREATE TABLE 的语法主体没变,但底层行为和默认规则变了不少。我挑几个影响最大的列出来:
| 改动点 | MySQL 5.7 及以前 | MySQL 8.0 |
|---|---|---|
| 数据字典 | 每个表有独立的 .frm 元数据文件 | 统一存储在数据字典中,DDL 具备原子性 |
| 默认字符集 | latin1 / utf8mb3 混用 | utf8mb4,默认排序规则 utf8mb4_0900_ai_ci |
| CHECK 约束 | 解析但默认不强制执行 | 从 8.0.16 开始强制执行 |
| 列默认值表达式 | 只允许常量或 CURRENT_TIMESTAMP | 8.0.13 起支持任意表达式,写法带括号 |
| 整数显示宽度 | INT(11) 这类写法可影响显示 | 8.0.17 起 INTEGER 显示宽度废弃 |
| 表创建失败 | 可能留下半成品文件 | 原子 DDL,失败自动回滚不残留 |
我记得最清楚的是一次模拟演练:在一张业务明细表上加一个意外中断的 CREATE TABLE ... SELECT,5.7 环境下偶尔会留下一个空表壳子,而 8.0 里整个语句会完整回滚,不需要手动去清理残留对象。这个特性对生产环境非常友好,但也意味着执行长事务式的建表语句时,对锁和资源消耗要有更谨慎的预期。
1.3 一个综合示例的逐段拆解
下面这条语句基本覆盖了日常建表会用到的核心元素,我习惯用它作为团队内部“标准建表模板”的底稿:
CREATE TABLE IF NOT EXISTS order_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', order_no VARCHAR(32) NOT NULL COMMENT '订单号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待处理 1-已完成 2-已取消', amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '金额', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', amount_times_two DECIMAL(12,2) GENERATED ALWAYS AS (amount * 2) VIRTUAL COMMENT '生成列示例', PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单明细表';注意几个现代建表的习惯:主键用 BIGINT 自增而不是 INT,给未来留余量;所有字段都带 COMMENT,避免半年后没人知道某个字段是做什么的;时间字段显式写明 DEFAULT CURRENT_TIMESTAMP,不依赖隐式规则;订单号这种业务上必须唯一的列单独建立唯一索引。这段里 DEFAULT CHARSET 和 COLLATE 我推荐每次建表都写清楚,宁可冗余,不要模棱两可地把“默认值”交给环境决定,否则同一套脚本在不同实例上建出来的字符集可能都不一样。
2. 表名规则:从命名规范到大小写敏感的版本之坑
2.1 标识符的“硬条件”与团队约定
MySQL 8 中表名的长度上限是 64 个字符,允许的字符包括字母、数字、下划线、$,以及 U+0080 到 U+FFFF 范围内的 Unicode 字符。数字可以作为表名开头,但不能只由数字组成,除非你用反引号包住它。纯数字表名虽然技术上可行,但每次查询都要加反引号,SQL 里也可能被当成数字字面量产生歧义,属于典型的“能跑但别这么干”。
团队规范方面,我参与过的几个项目最终都收敛到同一套规则:表名全小写、单词间用下划线分隔、不使用保留字、前缀按业务模块划分,比如 order_、user_、pay_。这样做的最大好处不是“好看”,而是避免跨平台大小写问题。比如某次同事在 macOS 上建了一张 UserOrder 表,代码里写的是 userorder,开发环境跑得很欢,一上 Linux 生产环境直接报表不存在,排查半天才发现只是大小写问题。
2.2 lower_case_table_names:跨平台迁移必踩的坑
MySQL 对表名大小写的敏感度由 lower_case_table_names 控制,取值有三档:
| 取值 | 行为 | 常见平台 |
|---|---|---|
| 0 | 区分大小写,按原名存储 | Linux 默认 |
| 1 | 不区分大小写,存储时转为小写 | Windows 默认 |
| 2 | 不区分大小写,按原样存储 | macOS 默认 |
MySQL 8.0 里这个变量只能在实例初始化时确定,运行后不能直接修改。也就是说,你要在配置文件里提前想清楚,而不是等建了一堆表之后再改。从 Windows 迁 Linux 时,如果库里有混合大小写的表名,在 Linux 上查询会直接报 Table 'xxx' doesn't exist。我处理过的一次事故就是 Windows 开发机导出 SQL 里的表名是驼峰式,生产 Linux 库又是全小写,数据导过去后一堆代码报错。
为了避免这类问题,我现在的做法很朴素:初始化集群时统一把 Linux 上的 lower_case_table_names 设置为 0,同时在规范层面强制所有表名、列名都小写。这样即使跨平台迁移,只要命名本身没有大小写混用,两边行为就是一致的。
2.3 保留字、反引号与表名引用的最佳习惯
MySQL 8.0 的关键字列表比 5.7 又长了一截,像 CUBE、GROUPING、JSON_TABLE、WINDOW 等新功能相关的词都成了保留字。这意味着以前能用的表名或列名,在升级后可能突然变成非法标识符。有一个很实用的排查方法,直接用 SQL 查系统字典:
SELECT WORD, RESERVED FROM INFORMATION_SCHEMA.KEYWORDS WHERE RESERVED = 1 ORDER BY WORD;第 3 章 临时表:会呼吸的会话级数据工作台
3.1 临时表的生命周期与连接池风险
CREATE TEMPORARY TABLE 创建的表只对当前会话可见,会话结束自动删除。换句话说,它是“一次会话内的私密工作台”,其他连接既看不到也碰不到。这个特性在复杂查询拆解、分批处理数据、保存中间结果时非常实用:
CREATE TEMPORARY TABLE tmp_sales_summary AS SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE create_time >= '2024-01-01' GROUP BY user_id; ALTER TABLE tmp_sales_summary ADD INDEX idx_user_id (user_id); SELECT u.name, s.total_amount FROM users u JOIN tmp_sales_summary s ON u.id = s.user_id WHERE s.total_amount > 1000;临时表上可以加索引,这点比 CTE 或派生表灵活得多。派生表在优化器眼里是一个无法直接加索引的物化结果,而临时表由用户手动控制结构,数据量大了之后性能差距非常明显。
但连接池环境里有个隐蔽的坑:连接池为了复用连接,不一定真的关闭底层连接。如果业务代码里创建了临时表但没显式 DROP,临时表会一直挂在那个连接上,直到连接被真正销毁。更麻烦的是,同一个连接之后被其他线程复用时,可能读到上一个请求留下的临时表数据,造成“串数据”的诡异现象。
所以我的习惯是:临时表用完后立刻执行 DROP TEMPORARY TABLE IF EXISTS,并且把创建、使用、删除放在同一个连接的生命周期里,不跨连接传递任何临时表的期望。Python 的 pymysql、Java 的 JDBC 连接池场景都一样适用,临时表天然不是为跨会话设计的。
3.2 MySQL 8 中临时表的存储引擎与落盘规则
MySQL 8.0 里临时表的内存存储引擎由 internal_tmp_mem_storage_engine 控制,默认是 TempTable,也可以配置成 MEMORY。TempTable 引擎能把 VARCHAR 这类变长字段更高效地存在内存里,并且支持 BLOB、TEXT、JSON 类型(超过内存上限才会落盘)。而 MEMORY 引擎是定长行存储,遇到 TEXT/BLOB 这种类型就没办法,只能直接落盘。
临时表内存大小不是无限的,超过阈值后会自动转换为磁盘上的 InnoDB 临时表,控制参数是 tmp_table_size(或 MEMORY 引擎下的 max_heap_table_size)。落盘瞬间会有一次明显的性能抖动,我在大批量会话内聚合时遇到过临时表落盘后慢查询时间从几十毫秒涨到几秒钟。如果定位到慢 SQL 的 EXPLAIN 里有 Using temporary,而且临时表确实很大,优先调整 SQL 逻辑减少中间结果集,而不是盲目调大 tmp_table_size。
还有一个从 8.0.13 才出现的硬性变化:创建临时表时不允许再显式指定 ENGINE 子句。老脚本里常见的 CREATE TEMPORARY TABLE ... ENGINE=MyISAM 在 8.0.13 之后会直接报错。因为我这边接触过从 5.7 迁移上来的项目,这类脚本通常隐藏在存储过程里,排查起来会多花一些时间。
3.3 临时表与普通表同名时的遮蔽规则
临时表允许和普通表同名。在同一会话内,临时表会“遮蔽”同名普通表,所有对该表名的引用都指向临时表;会话结束临时表消失,普通表恢复可见。下面这个例子可以很直观地验证:
CREATE TABLE t_demo (id INT); INSERT INTO t_demo VALUES (1); CREATE TEMPORARY TABLE t_demo (id INT); INSERT INTO t_demo VALUES (2); SELECT * FROM t_demo; -- 返回 2 DROP TEMPORARY TABLE t_demo; SELECT * FROM t_demo; -- 返回 1这个特性用得好可以做“会话级覆盖”,比如在报表模块里临时替换一张配置表的逻辑。但用得不好就是事故:曾经有个同事在存储过程里建了和业务表同名的临时表,忘了 DROP,后续逻辑全部读到了临时表里的少量脏数据,排查了很久才发现是遮蔽问题。我的建议很简单,临时表命名统一加 tmp_ 前缀,并且不跟业务表同名,从源头消灭这类问题。
4. 表克隆与复制的两条路径及组合拳
4.1 CREATE TABLE ... LIKE:复制结构的精细范围
CREATE TABLE new_table LIKE old_table 是复制表结构最直接的方式。它会完整复制列定义、索引、约束(包括 8.0.16 之后强制的 CHECK 约束)、表选项,以及普通表的分区定义。但有一个重要的边界:外键不会被复制,数据也不会被复制。
这个边界在实际工作中经常让人意外。某次我需要给一张大表做归档,先用 LIKE 复制出一张结构相同的空表,然后 INSERT INTO ... SELECT 把数据搬过去,结果插入时直接报外键失败。查了半天才反应过来,LIKE 是“复制结构但不复制外键”,归档表缺了外键约束,又正好撞上有脏数据。正确做法是先 LIKE 复制结构,再单独 ALTER TABLE 把外键补回来,或者干脆用更完整的数据导出导入方式。
另外,LIKE 会复制 AUTO_INCREMENT 的当前值。如果希望新表从 1 开始自增,复制后要显式执行 ALTER TABLE new_table AUTO_INCREMENT = 1。这个细节容易漏,等发现时新表的主键已经延续了旧表的序列。
CREATE TABLE order_item_bak LIKE order_item; ALTER TABLE order_item_bak AUTO_INCREMENT = 1; INSERT INTO order_item_bak SELECT * FROM order_item;上面这套组合拳是我做“同库快速备份”时的标准操作:结构完整、索引完整、自增值可控、数据完整,且全程不依赖 mysqldump。
4.2 CREATE TABLE ... AS SELECT:数据也带走的结构简化版
另一种克隆方式是 CREATE TABLE new_table AS SELECT,通常简写为 CTAS。它的特点是列定义和数据一起复制,但列定义是按查询结果的“结果集类型”推导出来的,不是完整复制原表:
CREATE TABLE order_item_copy AS SELECT * FROM order_item;这条语句执行后,order_item_copy 里能看到同样的列和全部数据,但索引、外键、触发器、默认值、注释、AUTO_INCREMENT 全部丢失。原因不难理解:CTAS 的语义是基于“查询返回什么,就建一张什么表”,它没有能力也无义务去考证每一列来自哪张源表、拥有哪些附加属性。
需要注意,CTAS 生成的列类型可能和原表的原始类型存在细微差异。比如 SELECT 的结果经过函数计算、联合查询、聚合等操作后,新表的列可能是表达式推导出的类型,而不是源表的精确类型。最稳妥的做法是在 CTAS 之后立刻用 SHOW CREATE TABLE 检查一遍实际结构,再决定是否需要 ALTER TABLE 修正。
MySQL 8.0 里,CREATE TABLE ... AS SELECT 使用的是原子 DDL 语义:如果数据插入过程中失败,表和已插入的行都会回滚,不会留下空表残骸。这一点在 5.7 年代很难保证,算是个实打实的体验提升。
4.3 按需选择:结构、数据还是两者都要
| 对比维度 | CREATE TABLE ... LIKE | CREATE TABLE ... AS SELECT | LIKE + INSERT INTO ... SELECT |
|---|---|---|---|
| 列定义 | 完整复制 | 按结果推导 | 完整复制 |
| 数据 | 不复制 | 复制 | 复制 |
| 索引 | 完整复制 | 不复制 | 完整复制 |
| 自增属性 | 复制当前值 | 不复制 | 复制,可手动重置 |
| 外键 | 不复制 | 不复制 | 不复制 |
| 分区定义 | 复制 | 不复制 | 复制 |
| 默认值/注释 | 复制 | 不复制 | 复制 |
| 典型用途 | 快速建同构空表 | 生成报表、临时结果表 | 数据归档、同库备份 |
三者的取舍说起来不复杂:只要结构不要数据,用 LIKE;只要数据和大概结构、之后会专门补索引,用 CTAS;结构和数据都要且要保留索引,用 LIKE 然后 INSERT INTO ... SELECT。生产环境里我最常用的是第三种,因为很多归档场景必须保留查询索引才能保证后续访问不慢。
CTAS 还有一个容易被忽视的用法:它可以结合 WHERE 条件只复制部分数据,完成一张表的“水平拆分”:
CREATE TABLE orders_2024 AS SELECT * FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31';拆分完成后,再把新表的主键、索引、外键通过 ALTER TABLE 补齐。整个过程比 mysqldump 按条件导出再导入要轻量得多,适合在同一个实例上做数据分层。
5. 列数据类型与属性:一套选型决策清单
5.1 数值类型:范围边界与显示宽度陷阱
MySQL 8 的整数类型还是 TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT 五种,每种都有带符号和无符号两套范围。给业务字段选型时,我一般先算“这个数字最大能到多少”,再倒推用哪种类型:
| 类型 | 带符号范围 | 无符号范围 | 占用空间 |
|---|---|---|---|
| TINYINT | -128 ~ 127 | 0 ~ 255 | 1 字节 |
| SMALLINT | -32768 ~ 32767 | 0 ~ 65535 | 2 字节 |
| MEDIUMINT | -8388608 ~ 8388607 | 0 ~ 16777215 | 3 字节 |
| INT | -2147483648 ~ 2147483647 | 0 ~ 4294967295 | 4 字节 |
| BIGINT | -9223372036854775808 ~ 9223372036854775807 | 0 ~ 18446744073709551615 | 8 字节 |
常见的“状态位”用 TINYINT 就够,是/否、0/1 这类标志位别用 INT,纯属浪费。订单金额我始终坚持用 DECIMAL,而不是 FLOAT 或 DOUBLE。浮点数在计算机里是二进制近似存储,0.1 + 0.2 可能等于 0.30000000000000004,做累计求和会出现分毫误差。DECIMAL 是定点数,按十进制精确存储,DECIMAL(12,2) 表示最长 12 位有效数字、小数点后 2 位,用来存金额既直观又准确。
MySQL 8.0.17 之后,整数类型的显示宽度语法如 INT(11)、TINYINT(4) 已经被标记为废弃、不再具备任何实际用途。旧脚本里写了也不报错,但没必要在新表里再写这种形式了。
5.2 字符串与日期类型:最容易出问题的两个大类
CHAR 和 VARCHAR 的选择,核心是“定长”还是“变长”。CHAR(n) 最多 255 个字符,存满固定长度,适合长度固定且使用频繁的字段,比如 MD5 哈希值(32 位)、手机号,读取时没有长度计算开销;VARCHAR(n) 按实际内容存储,额外记录内容长度,适合大多数业务字段,比如姓名、地址、备注。
VARCHAR 的最大长度不是简单的“65535 字符”,而是受整行最大字节数限制。在 utf8mb4 字符集下,一个字符最多占 4 字节,65535 字节去掉行内其他开销后,单列能申请的上限大约是 16383 个字符,但如果你还有别的字段,实际可用空间会更小。建表时遇到“Column length too big”错误的同学,多半就是没把行总字节数算进去。
日期和时间类型的选择上,最常被拿来比较的是 DATETIME 和 TIMESTAMP:
| 类型 | 范围 | 时区处理 | 占用空间 |
|---|---|---|---|
| DATE | 1000-01-01 ~ 9999-12-31 | 无时区概念 | 3 字节 |
| DATETIME | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 | 无时区概念 | 8 字节 |
| TIMESTAMP | 1970-01-01 00:00:01 ~ 2038-01-19 03:14:07 | 自动转换时区 | 4 字节 |
| TIME | 负 838 小时 ~ 正 838 小时 | 无时区概念 | 3 字节 |
TIMESTAMP 的范围到 2038 年就截止了,存储长期日期(出生日期、合同到期日)不要用它。MySQL 8.0 里 explicit_defaults_for_timestamp 默认开启,意味着 TIMESTAMP 列不会再像旧版本那样自动填充当前时间,必须显式写 DEFAULT CURRENT_TIMESTAMP。如果是从 5.7 迁移上来的老项目,可能有些表依赖旧行为,迁移后行为会变,需要重点核对建表语句与数据默认值。
MySQL 8.0.13 开始,默认值支持使用表达式了,这是一个非常有用的能力。比如给 UUID 主键写默认值:
CREATE TABLE demo_uuid ( id CHAR(32) NOT NULL DEFAULT (UUID_TO_BIN(UUID(), 1)) PRIMARY KEY, ... );注意这个写法里默认值必须用括号包起来,这是 8.0 表达式默认值的语法要求。需要注意的是,BLOB、TEXT、JSON 类型依然不能直接设置默认值,即使有了表达式默认值机制也不支持,这是官方明确的限制。
5.3 约束与生成列:CHECK 强制执行的版本变化
NOT NULL 和 DEFAULT 是一对相辅相成的属性。我的原则是:业务字段能 NOT NULL 就 NOT NULL,并提供一个合理默认值。这样做的价值在数据写入阶段就显现了——应用层偶尔漏传字段时,数据库不会报错,也不会写入 NULL 导致后续聚合函数统计结果与预期不符。只有像“补充说明”“备注”这类明确允许缺失的字段,才使用 NULL。
CHECK 约束在 MySQL 8.0.16 之前是一个大坑:它被解析进表结构,但不实际校验数据。有些在 5.7 里建的“带 CHECK 的表”,里面已经存了违反约束的数据。升级到 8.0.16 之后,CHECK 真正开始强制执行,新的写操作会被拦住,旧数据却还在表里,形成一种“存量明明非法、增量进不来”的尴尬状态。因此迁移到 MySQL 8 时,建议把带 CHECK 的表都抽查一遍存量数据:
CREATE TABLE user_check ( id INT PRIMARY KEY, age INT CHECK (age >= 0 AND age <= 120) ); INSERT INTO user_check VALUES (1, -5); -- MySQL 8.0.16 起直接报错生成列是 MySQL 5.7 引入、8.0 继续增强的一个特性,通常用 VIRTUAL 或 STORED 两种方式:
- VIRTUAL:不占用物理存储,读取时实时计算,适合计算开销低、查询频繁的列。
- STORED:计算后存到磁盘,占用空间,但后续查询不需要重新计算。
举一个实际例子,订单表里存了单价和数量,希望直接查询小计金额:
CREATE TABLE order_line ( id INT PRIMARY KEY, unit_price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, line_total DECIMAL(10,2) GENERATED ALWAYS AS (unit_price * quantity) STORED );InnoDB 在 MySQL 8.0 里支持在 VIRTUAL 生成列上建二级索引,这一点比 5.7 更好用。如果某个过滤条件经常落在生成列上,直接建索引就能加速,不用转换成 STORED 列,省了存储成本。
5.4 字符集、排序规则与默认值的变化
MySQL 8.0 的默认字符集是 utf8mb4,默认排序规则是 utf8mb4_0900_ai_ci。这里有一个历史遗留陷阱:5.7 时代常见的“utf8”其实是 utf8mb3,只能存最多 3 字节的字符,遇到 emoji 或某些生僻汉字会直接报错或乱码。所以新库新表一律用 utf8mb4,不要图省事写 utf8。
排序规则(COLLATE)也会影响查询行为。utf8mb4_0900_ai_ci 中 ai 表示 accent insensitive(不区分重音),ci 表示 case insensitive(不区分大小写)。如果你的业务需要大小写敏感的精确匹配,就要显式指定 utf8mb4_0900_bin 或 utf8mb4_bin。我一个搜索模块就因为表里排序规则不统一,导致同样一个查询在两张表上返回结果数量不一样,最后排查到索引字段的 COLLATE 不一致,查询优化器判断字符集不同而放弃走索引。
建表时我建议把字符集和排序规则写成完整语句:
CREATE TABLE product ( id INT PRIMARY KEY, sku VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, name VARCHAR(128) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;这样无论实例的默认配置怎么变,表的行为都稳定可控。如果表里所有字段都没有特殊需求,统一表级 COLLATE 就够;只有个别字段需要大小写敏感匹配时,才用列级 CHARACTER SET / COLLATE 单独指定。
6. 建表实战避坑清单:三个事故与一套自查习惯
6.1 事故一:CTAS 克隆出的新表没有索引
某次我做线上表的年度归档,用 CREATE TABLE orders_2024 AS SELECT * FROM orders WHERE ... 把数据复制出来,然后直接让报表模块切换查询。结果报表查询从原来的几十毫秒变成了十几秒,打爆了数据库 CPU。SHOW CREATE TABLE 一看,新表除了列定义和数据,主键、索引、自增属性全没了。报表模块每次查询都在全表扫。
从那之后我给自己定了一条铁律:任何 CTAS 创建的表,运行任何业务查询之前,必须先检查 SHOW CREATE TABLE,确认索引、自增、默认值是否满足需要,不满足就立刻 ALTER TABLE 补上。
6.2 事故二:旧临时表脚本在 8.0.13 后直接报错
一个调度任务里有一段创建临时表给后续存储过程使用的逻辑,迁移到 MySQL 8.0.16 之后突然执行失败。报错信息指向 CREATE TEMPORARY TABLE ... ENGINE=MyISAM 部分。查了版本说明才发现,8.0.13 起临时表创建不再允许显式指定引擎。解决办法很简单,去掉 ENGINE 子句,让 InnoDB 按默认规则管理临时表;内存临时表则依赖 TempTable 引擎自动决定是否落盘。
这个坑藏的深是因为正常建表时写 ENGINE=InnoDB 完全没问题,唯独临时表的语法限制变了。把老脚本迁到 8.0,我建议 grep 一遍所有 CREATE TEMPORARY TABLE 语句,看有没有 ENGINE 关键字,有就批量去掉。
6.3 事故三:CHECK 约束“突然生效”导致写入失败
一次版本升级后,某个活动模块开始大量上报“数据插入失败”的告警。查日志发现 INSERT 语句往 age 字段写了一个超出 0~120 范围的值,而表结构里 CHECK 约束是早就存在的。问题在于:这个表是在 5.7 时代建的,CHECK 当时只解析不执行,应用层也没做校验,于是脏数据一直能进去。升级到 MySQL 8.0.16 之后,约束突然开始工作,写入就被拦住了。
这个案例提醒我,做版本升级前,除了看新功能,还要关注“旧功能行为变化”。带 CHECK 约束的表,最好在升级前先用一条 SQL 扫描存量数据里有多少违反约束的行,提前决定是清理数据还是移除约束。
6.4 一套建表前自查习惯
我把 CREATE TABLE 的常见问题整理成了一张自查清单,每次写建表脚本都逐条过一遍:
- 表名:全小写、下划线分隔、长度不超过 64 字符、不用保留字,SQL 里统一反引号包裹。
- 字符集:表级显式指定 utf8mb4,排序规则按业务需要选择 ai_ci 还是 bin。
- 主键:InnoDB 表尽量有显式主键,优先 BIGINT 自增或 UUID 转换后的定长字符串。
- 字段属性:能 NOT NULL 就 NOT NULL,枚举状态字段给默认值,金额用 DECIMAL。
- 时间字段:DATETIME 显式 DEFAULT CURRENT_TIMESTAMP,需要更新时间就用 ON UPDATE CURRENT_TIMESTAMP。
- 索引:唯一约束用 UNIQUE KEY,高频查询走二级索引,避免索引冗余。
- 克隆场景:明确选 LIKE、CTAS,还是 LIKE + INSERT INTO ... SELECT,并在克隆后检查 SHOW CREATE TABLE。
- 临时表:命名带 tmp_ 前缀,用完立即 DROP,不跨连接复用,8.0.13 后不写 ENGINE 子句。
这套习惯看起来琐碎,但每一条背后都有对应的事故。建表语句是数据库设计里最前置的一环,它定下的每个属性都会在后续的查询性能、迁移成本、运维体验里被无限放大。我现在的做法是:不管多小的表,建表脚本都写成完整可重复执行的版本,字段带注释、索引显式命名、字符集和引擎写清楚,然后把这套脚本放进项目代码库统一管理。这样无论谁来维护,看到的都是同一个标准,踩坑的概率自然就低很多。