以下是配合 SQL 示例修改后的全文。第 3 节和第 4 节之间的 DDL 执行行为部分已按你的要求做了扩展。
生成列的基础知识
生成列(Generated Column)是 MySQL 5.7 引入的特性:列的值不由 INSERT 或 UPDATE 显式提供,而由列定义中的表达式自动计算。基本语法:
col_name data_type [GENERATED ALWAYS] AS (expr) [VIRTUAL | STORED] |
GENERATED ALWAYS 可选;VIRTUAL | STORED 不写时默认为 VIRTUAL。表达式只能使用确定性内置函数、字面量和运算符,不允许子查询、用户变量、存储函数或非确定性函数(如 NOW())。
VIRTUAL / STORED:区别、共同点与索引
核心区别
| 维度 | VIRTUAL | STORED |
|---|---|---|
| 计算时机 | 读取行时动态计算 | 写入时计算并落盘 |
| 是否占用数据存储 | 不占用 | 占用 |
| 读取开销 | 每次读取需计算 | 直接取值,无计算 |
| 索引支持 | InnoDB 5.7.8+ 支持二级索引 | 普遍支持 |
共同点
两者都由表达式自动维护,不允许在 DML 中直接赋值;都支持 NOT NULL、COMMENT;都可以引用普通列和位于其之前的生成列。
索引上的使用
InnoDB 支持在 VIRTUAL 生成列上创建二级索引,且添加或删除该二级索引是就地操作(in-place),不需要复制表。STORED 列的索引天然可用;对 STORED 列而言,索引变更也是就地完成的,但表达式变更不是就地操作。
ADD COLUMN ... GENERATED ALWAYS AS (...) 的用法
在已有表上添加生成列的标准写法:
ALTER TABLE t1 ADD COLUMN c2 INT GENERATED ALWAYS AS (c1 + 1) STORED; |
但 ADD COLUMN 的执行算法因存储类型而不同,这是实际操作中最需要区分的部分。
STORED 列的 ADD COLUMN:不是就地操作
对 STORED 列执行 ADD COLUMN 不是就地操作,因为服务器必须逐行计算表达式并物化写入磁盘,无法绕过全表数据重写。
-- 示例:在已有大表上添加 STORED 生成列 |
这条 ADD COLUMN 在 MySQL 8.0 中不能使用 ALGORITHM=INPLACE,实际会走 COPY 或重建表的方式。如果在有并发写入的生产表上执行,需要预留足够的 DDL 窗口。
VIRTUAL 列的 ADD COLUMN:非分区表上可以是就地操作
对 VIRTUAL 列,在非分区表上 ADD COLUMN 和 DROP COLUMN 是就地操作,因为 VIRTUAL 列不落盘,只需修改元数据即可完成。
-- 示例:在同样的表上添加 VIRTUAL 生成列 |
但有两个重要限制:
(1)不能与其他 ALTER TABLE 操作组合。如果一条 ALTER TABLE 中同时包含添加 VIRTUAL 列和其他操作(比如再加一个普通列或索引),整体就不再是就地操作。
-- 这样写会失去 in-place 特性: |
(2)分区表例外。对分区表,VIRTUAL 列的 ADD COLUMN / DROP COLUMN 不是就地操作。
VIRTUAL 列上加二级索引:索引操作本身是就地完成的
在已有 VIRTUAL 生成列上添加二级索引,索引操作本身是就地完成的,不需要重建表。这与“添加 VIRTUAL 列”是两个独立的 DDL 动作,可以分开执行以获得各自的最优算法:
-- 先加 VIRTUAL 列(in-place) |
反过来,如果希望一步完成“加 VIRTUAL 列 + 加索引”,组合操作会失去 VIRTUAL 列 ADD COLUMN 的 in-place 特性,但索引部分仍然可以就地完成。
WITHOUT VALIDATION / WITH VALIDATION:VIRTUAL 列的取值范围校验
默认行为:不校验
当添加或修改 VIRTUAL 生成列时,MySQL 默认不校验表达式计算结果是否超出列数据类型范围。这意味着,如果表达式的计算结果与声明的类型不匹配,DDL 本身可能成功,但后续读取时才报告警告或错误。
-- 示例:a + b 可能超过 TINYINT 的范围(TINYINT 上限 127) |
在这种情况下,ADD COLUMN 可能成功,但之后查询 total 列时,超出范围的行会返回警告或触发意外错误。
WITH VALIDATION:复制表并严格校验
如果显式加上 WITH VALIDATION,ALTER TABLE 会复制表并逐行校验生成列表达式的计算结果;一旦出现超出范围或任何其他错误,语句直接失败。
-- 使用 WITH VALIDATION:会复制表,严格校验 |
由于 a + b 对部分行超过 TINYINT 上限,这条语句会直接失败,而不是留下“脏数据”。代价是表复制带来的耗时和额外磁盘空间。
操作选择速查
| 写法 | 算法 | 校验 | 适用场景 |
|---|---|---|---|
| 默认(不写子句) | 尽量 in-place | 不校验 | 数据范围可控、追求 DDL 速度 |
WITH VALIDATION |
COPY(复制表) | 严格校验 | 对数据一致性有硬要求,可接受较慢 DDL |
WITHOUT VALIDATION 和 WITH VALIDATION 仅在 ADD COLUMN、CHANGE COLUMN、MODIFY COLUMN 中允许使用。
表达式、数据类型、列名与存储类型的调整
ALTER TABLE 对生成列允许的操作是 ADD、MODIFY、CHANGE。
表达式和数据类型可以同时修改:
ALTER TABLE t1 |
这条语句把数据类型从 INT 改为 TINYINT,表达式从 c1 + 1 改为 c1 + 5。
列名调整:如果生成列没有被其他列引用,可以使用 CHANGE 重命名,但必须完整重新指定列定义。
STORED 与 VIRTUAL 不能直接互转:MODIFY / CHANGE 无法把 VIRTUAL 改成 STORED,反之亦然;必须 DROP 后重新 ADD。
普通列与生成列的互转:普通列可以转为 STORED,但不能转为 VIRTUAL;STORED 生成列可以转为普通列(生成值保留为普通列值),VIRTUAL 生成列不能转为普通列。
可执行脚本示例
仅生成列上创建索引(不使用函数)
三张结构对应的表:t_no_gen(无生成列)、t_virtual(VIRTUAL + 索引)、t_stored(STORED + 索引)。
SET SESSION cte_max_recursion_depth = 10000; |
对比查询:
EXPLAIN SELECT * FROM t_no_gen WHERE a + b = 3000; -- type=ALL, key=NULL |
解读:t_no_gen 在 a + b 上没有索引,只能全表扫描;t_virtual 和 t_stored 在 InnoDB 下都能通过生成列上的二级索引把访问类型从 ALL 降为 ref。在查询加速上两者效果相同,差异主要在写入成本、磁盘占用和 DDL 算法(见第 3 节)。
查询普通表(不使用函数)
EXPLAIN SELECT * FROM t_no_gen WHERE a + b = 3000; |
VIRTUAL生成列
STORED 生成列
均建有索引(使用函数)
|
查询普通表(不使用函数)
EXPLAIN SELECT * FROM t_no_gen WHERE a + b = 3000; |
查询普通表(使用函数)
答疑:为什么两次查询普通表都使用索引
遇到的现象是 MySQL 优化器的一个典型行为:查询条件在索引列上进行了运算(甚至是函数包裹),EXPLAIN 依然显示了索引,但这里的 type 是 index,而不是更高效的 ref 或 range。
解读 EXPLAIN 结果:效率并不高的“使用索引”
两条 SQL 的 EXPLAIN 结果一致:type 为 index,key 为 idx_a-b,Extra 为 Using where。这组信息表明,索引并未用于快速定位数据。
type: index:这代表全索引扫描 (Full Index Scan)。MySQL 会遍历整个idx_a-b索引树来获取数据,而不是像ref或range那样利用索引进行快速查找。其效率通常比全表扫描 (ALL) 要好一些,但远低于ref或range。key: idx_a-b:这表明优化器选择使用idx_a-b这个联合索引。Extra: Using where:这表示在通过索引获取到数据后,MySQL 还需要在 Server 层进行额外的WHERE条件过滤。这意味着索引并没有完成所有的过滤工作。
综合来看,MySQL 在这里只是把 idx_a-b 当作一个更小的、有序的表来扫描,而不是用它来直接定位 a + b = 3000 对应的行。
为什么条件复杂仍“用”了索引?
核心原因在于查询所需的列 a 和 b 全部包含在 idx_a-b 这个联合索引中。
虽然 WHERE 条件 a + b = 3000 和 IFNULL(a,0) + IFNULL(b,0) = 3000 无法直接利用 B+ 树索引进行快速定位,但优化器意识到,与其回表读取整行数据,不如直接扫描这个体积更小、且已经包含了所需全部列 (a 和 b) 的索引树。这种技术被称为索引覆盖 (Covering Index)。扫描索引树所需的数据量通常远小于扫描整个表的数据量,因此优化器认为扫描整个索引的代价低于扫描全表。
关于 IFNULL 的特别说明:
一般情况下,在 WHERE 子句中对索引列使用函数会导致索引失效。但在你的查询中,IFNULL 包裹的是 a 和 b,而这两个列恰好都在联合索引 idx_a-b 中。因此,优化器依然可以采用全索引扫描的方式,先读取所有行的 a 和 b 值,再在 Server 层计算 IFNULL(a,0) + IFNULL(b,0) 并与 3000 进行比较。这并非“高效使用索引”,而是“在无法高效使用索引时,选择了一种代价相对较低的扫描方式”。
如何让索引真正“高效”工作?
要想让索引发挥 ref 或 range 级别的快速查找作用,WHERE 条件中的索引列必须是“裸露”的,即不能包裹任何函数或参与运算。
- 针对
a + b = 3000:如果业务中这类查询很频繁,最佳实践是使用生成列。可以添加一个STORED生成列total AS (a + b),并直接在total上建立索引。这样,查询WHERE total = 3000就能精准使用索引了。 - 针对
IFNULL:如果a和b列可能为NULL,且你希望将NULL视为 0 来处理,那么IFNULL是必要的。但在当前场景下,它阻止了索引进行快速定位。如果业务逻辑允许,可以考虑在数据库设计层面就处理好NULL值(例如使用DEFAULT 0),从而避免在查询中使用IFNULL,让WHERE a + b = 3000这样的条件有潜力用到索引(比如生成列索引)。
VIRTUAL生成列
STORED 生成列
直接操作生成列
插入t_stored生成列
执行时报错
INSERT INTO t_stored (id,a,b,total) VALUES(100000,1,2,3) |
更新t_stored生成列
执行时报错
-- INSERT INTO t_stored (id,a,b,total) VALUES(100000,1,2,5); |
插入t_virtual生成列
直接报错
INSERT INTO t_virtual (id,a,b,total) VALUES(100000,1,2,5) |
更新t_virtual生成列
直接报错
UPDATE t_virtual set total=3 where id=1 |