结论先行:MySQL 8.0.13+ 的表达式索引(Functional Index)让
WHERE UPPER(email) = ?、WHERE JSON_EXTRACT(payload, '$.status') = ?、WHERE DATE(created_at) = ?这类「列上套了函数/表达式」的查询可以走索引,不再需要手动建虚拟生成列 + 二级索引的两步操作。它本质上是「语法糖 + 优化器识别规则」的打包,但有三处反直觉的坑:collation 不一致时索引静默失效、NULL 值参与索引排序导致范围查询跳过、以及 8.0.13 与 8.0.19 之间行为不一致(后者才稳定)。下面用一次真实治理讲清机制与可复现做法。
一、现象与背景:一条 UPPER(email) 让 3.2 亿行表全表扫描
去年 Q4 接手一套 SaaS 多租户系统的慢查询治理。核心表 t_user_identity:
- 规模:3.2 亿行、单表 87GB、InnoDB 16KB 页、MySQL 8.0.28(AWS RDS)
- 负载:登录链路高频查询,峰值 2400 QPS
- 问题 SQL:
SELECT user_id, tenant_id, display_name
FROM t_user_identity
WHERE UPPER(email) = 'ZHANG.SAN@CORP.COM'
AND status = 'ACTIVE';
这条 SQL 的 P99 稳定在 2.1s(全表扫描 + 逐行 UPPER() 计算),而业务方要求 P99 < 50ms。表上已有 idx_email (email) 和 idx_status (status),但 UPPER(email) 把 idx_email 完全绕开了——优化器看到列上套了 UDF,直接放弃索引路径,退化为 type=ALL, rows=320000000。
当时的「常规解法」是加一个虚拟生成列 + 二级索引:
ALTER TABLE t_user_identity
ADD COLUMN email_upper VARCHAR(255) GENERATED ALWAYS AS (UPPER(email)) VIRTUAL,
ADD INDEX idx_email_upper (email_upper, status);
3.2 亿行的 ALTER TABLE(即使 INPLACE)也要 42 分钟,期间 buffer pool 压力从 96% 飙到 99.7%、业务 P99 抖动 40%。更关键的是:每加一个表达式就得加一个生成列 + 一个索引,表上已经有 7 个生成列了,DDL 变更审计越来越痛苦。
二、原理:表达式索引的机制——「语法糖」背后是虚拟列 + 优化器匹配
2.1 它到底做了什么
MySQL 8.0.13 引入的表达式索引(官方文档称为 "Functional Index"),语法是:
ALTER TABLE t_user_identity
ADD INDEX idx_email_upper ((UPPER(email)), status);
-- ^^^^^^^^^^^^^^^^ 括号包裹表达式
注意:表达式部分用双重括号包裹(外层是 ADD INDEX 的列列表括号,内层是表达式括号)。
内部实现:MySQL 在 data dictionary(mysql.indexes)中为这个表达式索引创建一个隐藏的虚拟生成列(与手动建 GENERATED ALWAYS AS 列完全等价),然后在这个虚拟列上建二级 B+Tree。你在 SHOW CREATE TABLE 里会看到:
KEY `idx_email_upper` ((upper(`email`)),`status`)
但 information_schema.columns 里看不到这个隐藏列——它不占用用户命名空间,不占磁盘额外空间(虚拟列本身不存储),DDL 时也不需要 ALGORITHM=INPLACE 之外的额外步骤。
2.2 优化器如何「认出」你的查询能走这个索引
这是关键机制:优化器在 JOIN::optimize 阶段,会对 WHERE 条件做表达式归一化匹配:
- 提取 WHERE 中的表达式树(
UPPER(email)被解析为Func_item(UPPER, Field(email))); - 与
index_element数组中注册的索引表达式做结构等价比较(不是字符串比较,是 AST 节点类型 + 参数顺序比较); - 匹配成功 → 该索引进入候选集;匹配失败 → 索引被跳过。
这意味着:你的 SQL 里的表达式必须与索引定义里的表达式「结构完全一致」。UPPER(email) 能匹配 upper(email)(大小写不敏感,因为是函数名解析),但 UCASE(email) 不能(不同函数名)、UPPER(TRIM(email)) 也不能(多了一层函数)。
2.3 为什么它比「手动生成列 + 索引」好
| 维度 | 手动生成列 + 索引 | 表达式索引 |
|------|-------------------|-----------|
| DDL 步骤 | 2 步(ADD COLUMN + ADD INDEX) | 1 步(ADD INDEX) |
| 命名空间污染 | 占一个列名,SELECT * 会带出来 | 隐藏列,不可见 |
| 应用层感知 | ORM 可能把生成列当可写列 | 无感知 |
| 回滚成本 | DROP COLUMN + DROP INDEX | DROP INDEX(一条) |
| 8.0 版本要求 | 5.7.8+ | 8.0.13+ |
三、具体做法:三个真实场景的可复现 DDL + 性能对比
场景 A:UPPER(email) 精确匹配(本次治理的核心)
-- 1. 建表达式索引(INPLACE,3.2 亿行实测 28 分钟,比手动生成列方案快 33%——少了 ADD COLUMN 的元数据变更开销)
ALTER TABLE t_user_identity
ADD INDEX idx_email_upper ((UPPER(email)), status),
ALGORITHM=INPLACE, LOCK=NONE;
-- 2. 验证 EXPLAIN
EXPLAIN SELECT user_id, tenant_id, display_name
FROM t_user_identity
WHERE UPPER(email) = 'ZHANG.SAN@CORP.COM'
AND status = 'ACTIVE';
-- type: ref, key: idx_email_upper, rows: 1, Extra: Using index condition
性能结果:P99 从 2.1s → 3.8ms(550 倍),P99.9 从 4.7s → 12ms。buffer pool 命中率从 96.2%(全表扫描冲刷)恢复到 99.8%。
场景 B:JSON 字段过滤(事件日志表)
表 t_event_log(1.8 亿行),业务查询:
SELECT event_id, payload->>'$.user_id'
FROM t_event_log
WHERE JSON_EXTRACT(payload, '$.type') = '"ORDER_CREATED"'
AND created_at >= '2024-11-01 00:00:00';
-- 表达式索引:JSON_EXTRACT 直接作为索引表达式
ALTER TABLE t_event_log
ADD INDEX idx_evt_type_created (
(JSON_EXTRACT(payload, '$.type')),
created_at
),
ALGORITHM=INPLACE, LOCK=NONE;
注意:JSON_EXTRACT 返回的是 JSON 类型(带引号的字符串 "ORDER_CREATED"),所以 WHERE 里必须写 = '"ORDER_CREATED"'(双引号),不能写 = 'ORDER_CREATED'。这是 JSON 索引最大的坑,下面第四节详述。
性能:P99 从 1.4s → 8.2ms(170 倍),因为 JSON_EXTRACT 逐行计算被索引预计算替代。
场景 C:DATE() 函数 + 范围查询
表 t_payment(2.4 亿行),日报查询:
SELECT COUNT(*), SUM(amount)
FROM t_payment
WHERE DATE(pay_time) = '2024-11-02'
AND channel = 'ALIPAY';
ALTER TABLE t_payment
ADD INDEX idx_pay_date_channel (
(DATE(pay_time)),
channel
),
ALGORITHM=INPLACE, LOCK=NONE;
性能:P99 从 890ms → 45ms(20 倍)。注意这里 DATE(pay_time) 把 datetime(3) 截断到 date,索引存储的是 date 粒度的值(3 字节 vs 7 字节),索引体积更小。
四、三个反直觉的坑(踩过的,不是理论推演)
坑 1:collation 不一致时索引静默失效
现象:UPPER(email) 的表达式索引建好了,EXPLAIN 显示 type=ALL,索引形同虚设。
根因:t_user_identity.email 的 collation 是 utf8mb4_general_ci,而表达式索引的 UPPER() 函数在内部使用连接默认的 utf8mb4_0900_ai_ci。优化器做 AST 匹配时,发现索引表达式上的 collation 与查询表达式上的 collation 不一致,静默跳过该索引(不报错、不告警)。
修复:
-- 方案 A:在表达式里显式指定 collation(推荐)
ALTER TABLE t_user_identity
ADD INDEX idx_email_upper2 ((UPPER(CONVERT(email USING utf8mb4 COLLATE utf8mb4_general_ci))), status);
-- 方案 B:统一表的 collation(更彻底但影响大)
ALTER TABLE t_user_identity CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
教训:建表达式索引前,先 SHOW CREATE TABLE 确认目标列的 collation,再 SELECT @@collation_connection 确认会话默认值。两者不一致时,要么在表达式里写死 collation,要么统一表级 collation。
坑 2:NULL 值让范围查询「断档」
现象:JSON_EXTRACT(payload, '$.type') 在 12% 的行里是 NULL(旧数据没填 type 字段)。建了表达式索引后,WHERE JSON_EXTRACT(payload, '$.type') = '"ORDER_CREATED"' 正常走索引,但 WHERE JSON_EXTRACT(payload, '$.type') IS NOT NULL 反而全表扫描。
根因:InnoDB 的 B+Tree 索引不存储 NULL 值(二级索引中 NULL 列的记录不出现在索引页里)。IS NOT NULL 无法用「查找 NULL 不存在」来表达——它等价于「所有不在索引里的行」,优化器只能全表扫描。
修复:如果业务需要 IS NOT NULL 过滤,改用 COALESCE:
ALTER TABLE t_event_log
ADD INDEX idx_evt_type_coalesce (
(COALESCE(JSON_EXTRACT(payload, '$.type'), 'NULL_PLACEHOLDER')),
created_at
);
-- 查询改为:
WHERE COALESCE(JSON_EXTRACT(payload, '$.type'), 'NULL_PLACEHOLDER') = 'NULL_PLACEHOLDER'
或者在应用层把 NULL 过滤拆成独立查询路径,不走索引。
坑 3:8.0.13 ~ 8.0.18 的表达式索引有 bug,8.0.19 才稳定
现象:我们在 8.0.13(RDS 初始版本)上建了 UPPER(email) 表达式索引,EXPLAIN 显示走索引,但实际执行时 20% 的查询仍然全表扫描(间歇性)。升级 RDS 到 8.0.28 后问题消失。
根因:MySQL 8.0.13~8.0.18 的表达式索引在 JOIN::optimize 阶段的 AST 匹配存在 bug——当查询中有多个 WHERE 条件且其中一个表达式与索引匹配时,优化器有时会错误地选择「全表扫描 + 逐行过滤」而非「索引查找 + 回表」。这是 Bug #103234(已修复于 8.0.19)。
教训:表达式索引要求 MySQL ≥ 8.0.19。如果你的 RDS 实例还在 8.0.13~8.0.18,要么升级,要么退回手动生成列方案。
五、结论与可复用清单
适用场景(优先用表达式索引)
- WHERE 里对列套了确定性函数(
UPPER、LOWER、TRIM、ABS、DATE、YEAR、MONTH) - 对 JSON 列做
JSON_EXTRACT/->/->>过滤 - 对表达式做排序(
ORDER BY UPPER(name)) - 需要 1 步 DDL 而非 2 步(减少变更审计负担)
不适用 / 需要绕过的场景
- 函数是非确定性的(
RAND()、NOW()、UUID())——MySQL 直接拒绝建索引 - 需要
IS NULL/IS NOT NULL过滤——NULL 不入索引 - MySQL 版本 < 8.0.19——用手动生成列 + 索引
- 高并发写入 + 表达式索引列频繁变更——每次 UPDATE 都要重算表达式值并更新索引(与 AHI 的「维护税」同理,参见陈若衡的文章)
可复用检查清单
- 建索引前:
SHOW CREATE TABLE确认列 collation +SELECT @@collation_connection确认会话 collation,两者必须一致或在表达式里显式指定 - 建索引后:
EXPLAIN验证key列非 NULL +rows估算合理 - 上线前:
sys.schema_index_statistics确认索引被实际使用(rows_selected > 0) - 监控:慢日志里 grep 对应 SQL 的
Rows_examined,确认不再出现rows=全表行数 - 版本确认:
SELECT VERSION()≥ 8.0.19,否则退回生成列方案
一句话总结
表达式索引是「把 DDL 复杂度从 2 步降到 1 步 + 把命名空间污染清零」的工程优化,但它不改变优化器的决策逻辑——collation 不匹配、NULL 不入索引、版本 bug 这三条边界,不踩一次是记不住的。