跳到主内容

从全表扫描到按天毫秒出数:MySQL 8.0 函数索引替代虚拟生成列实测 ​

问题背景:按天统计为什么容易扫全表 ​

业务中最常见的报表需求之一,是把 created_at 这类 DATETIME 或 TIMESTAMP 字段按天聚合:

sql
SELECT DATE(created_at) AS dt, COUNT(*) AS cnt
FROM orders
WHERE created_at >= '2026-01-01'
  AND created_at <  '2026-02-01'
GROUP BY DATE(created_at)
ORDER BY dt;

很多团队已经给 created_at 建了普通索引,但慢查询仍然存在。原因是这条 SQL 同时包含范围过滤、函数计算和分组:数据量较大时,优化器可能认为回表成本不高而选择索引范围扫描,也可能因为选择度不足直接全表扫描;即使走了时间索引,仍要对大量记录执行 DATE() 计算并临时聚合。

过去 MySQL 5.7 常见做法是增加一个虚拟生成列,再在生成列上建索引。MySQL 8.0.13 开始支持函数索引,也就是函数键部分,即 functional key parts,可以直接对表达式建索引,不必把表达式暴露成业务字段。

本文用一个可重复执行的订单表示例,对比三种落地方式:普通时间索引、虚拟生成列索引、MySQL 8.0 函数索引,并重点说明按天统计场景下如何稳定命中索引。

三种方案对比:普通索引、虚拟生成列与函数索引 ​

先给结论:如果数据库已经是 MySQL 8.0.13 或更高版本,且目标是加速固定表达式,如 DATE(created_at),优先考虑函数索引;如果表达式需要被业务查询、ORM、视图反复引用,或者数据库版本仍停留在 MySQL 5.7,则使用虚拟生成列更合适。

普通时间索引适合范围过滤虚拟生成列索引字段可见,兼容 5.7函数索引表达式直建索引按天统计优先验证表达式索引再结合过滤条件设计联合索引

三种方案的差异主要体现在四个方面:

方案表结构侵入索引表达式适用版本主要问题
普通索引 (created_at)低不保存 DATE() 结果所有版本聚合仍需计算,低选择度下可能全表扫描
虚拟生成列 + 索引中created_dateMySQL 5.7+多出字段,ORM 和权限模型需要适配
函数索引低(DATE(created_at))MySQL 8.0.13+查询表达式必须与索引表达式匹配

需要强调的是,函数索引不是“给函数加了索引就一切加速”。本质上它仍然是一棵 B+Tree,键值是表达式计算结果。如果查询返回表中大部分数据,或者过滤条件不能有效缩小范围,优化器照样可能放弃索引。

数据准备:构造可复现的按天统计表 ​

下面的示例使用 MySQL 8.0.13 及以上版本。先建表并写入 10 万级测试数据,时间跨度约为 180 天:

sql
DROP TABLE IF EXISTS orders;

CREATE TABLE orders (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_no VARCHAR(32) NOT NULL,
  status TINYINT NOT NULL DEFAULT 1,
  amount DECIMAL(10,2) NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id),
  KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 递归 CTE 生成约 18 万行测试数据,MySQL 8.0 可直接执行
INSERT INTO orders (order_no, status, amount, created_at)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 180000
)
SELECT
  CONCAT('O', LPAD(n, 9, '0')) AS order_no,
  CASE WHEN n % 20 = 0 THEN 0 ELSE 1 END AS status,
  ROUND(50 + RAND() * 450, 2) AS amount,
  TIMESTAMPADD(
    SECOND,
    FLOOR(RAND(n) * 180 * 24 * 3600),
    '2026-01-01 00:00:00'
  ) AS created_at
FROM seq;

先执行一次典型报表 SQL,并查看执行计划:

sql
EXPLAIN ANALYZE
SELECT DATE(created_at) AS dt, COUNT(*) AS cnt, SUM(amount) AS total_amount
FROM orders
WHERE created_at >= '2026-03-01'
  AND created_at <  '2026-03-08'
  AND status = 1
GROUP BY DATE(created_at)
ORDER BY dt;

在测试库中,普通 idx_created_at 是否被使用取决于一周数据占比和统计信息。你可能看到 type=range,也可能看到 type=ALL。即便使用范围扫描,Extra 中也常出现 Using where; Using temporary。

方案一:虚拟生成列如何支持按天聚合 ​

MySQL 5.7 可以新增一个 VIRTUAL 生成列。虚拟列本身不占普通数据页空间,但在二级索引中会实际存储键值:

sql
ALTER TABLE orders
  ADD COLUMN created_date DATE
  GENERATED ALWAYS AS (DATE(created_at)) VIRTUAL,
  ADD KEY idx_created_date (created_date);

随后把查询中的 DATE(created_at) 改成生成列:

sql
EXPLAIN
SELECT created_date AS dt, COUNT(*) AS cnt, SUM(amount) AS total_amount
FROM orders
WHERE created_date >= '2026-03-01'
  AND created_date <  '2026-03-08'
  AND status = 1
GROUP BY created_date
ORDER BY created_date;

这个方案的优点是直观,索引键就是一个 DATE 字段;缺点也明显:表结构多了一列,使用 SELECT * 会返回该字段,某些数据同步工具、ORM 映射、归档脚本可能需要改造。此外,生成列表达式修改并不轻松,通常需要删除列和索引后重新添加。

方案二:MySQL 8.0 函数索引直接落地 ​

函数索引不需要新增业务字段,可以直接对表达式建索引:

sql
ALTER TABLE orders
  ADD KEY idx_func_date ((DATE(created_at)));

注意语法中有两层括号:外层属于索引定义,内层表示表达式。少写一层会得到语法错误。

查询时仍然写原始表达式,优化器才能识别并匹配函数索引:

sql
EXPLAIN
SELECT DATE(created_at) AS dt, COUNT(*) AS cnt, SUM(amount) AS total_amount
FROM orders
WHERE DATE(created_at) >= '2026-03-01'
  AND DATE(created_at) <  '2026-03-08'
  AND status = 1
GROUP BY DATE(created_at)
ORDER BY dt;

重点观察执行计划:

sql
SHOW INDEX FROM orders WHERE Key_name = 'idx_func_date';

EXPLAIN FORMAT=TREE
SELECT DATE(created_at) AS dt, COUNT(*), SUM(amount)
FROM orders
WHERE DATE(created_at) >= '2026-03-01'
  AND DATE(created_at) <  '2026-03-08'
  AND status = 1
GROUP BY DATE(created_at);

命中时,key 应显示为 idx_func_date,访问类型通常是 range。如果查询一周数据,函数索引可以把扫描范围限制在 7 个日期键附近,而不是从 18 万行开始过滤。

但真实报表一般不止一个日期条件。例如还要过滤 status,只对 amount 聚合,可以考虑联合函数索引:

sql
ALTER TABLE orders
  ADD KEY idx_func_date_status_amount (
    (DATE(created_at)),
    status,
    amount
  );

这个索引的顺序含义是:先按日期表达式定位,再按状态过滤,最后利用索引中的金额减少回表。它更适合下面这种窄条件、聚合列固定的查询:

sql
EXPLAIN ANALYZE
SELECT DATE(created_at) AS dt, COUNT(*) AS cnt, SUM(amount) AS total_amount
FROM orders
WHERE DATE(created_at) >= '2026-03-01'
  AND DATE(created_at) <  '2026-03-08'
  AND status = 1
GROUP BY DATE(created_at)
ORDER BY dt;

不要盲目把所有过滤字段都塞进联合索引。索引越宽,写入放大越明显,而且 SUM(amount) 是否能完全覆盖,还取决于查询列是否都包含在索引中。对于 SELECT * 或查询列很多的场景,覆盖索引通常不划算。

函数索引与普通时间索引如何取舍 ​

看到函数索引有效后,有人会直接删除 idx_created_at,这是危险的。DATE(created_at) 索引适合按天等值或日期范围查询,但原始时间点查询仍然依赖 created_at:

sql
-- 更适合 idx_created_at
SELECT id, amount
FROM orders
WHERE created_at >= '2026-03-01 08:00:00'
  AND created_at <  '2026-03-01 09:00:00';

-- 更适合函数索引
SELECT DATE(created_at), COUNT(*)
FROM orders
WHERE DATE(created_at) = '2026-03-01'
GROUP BY DATE(created_at);

如果业务既有小时级精确查询,又有按天报表,通常需要保留两个索引,再通过慢查询和写入压力评估是否合并。不要只看一次 EXPLAIN:应使用 EXPLAIN ANALYZE 观察实际行数、实际时间和估算偏差,并在数据分布变化后执行:

sql
ANALYZE TABLE orders;

避坑总结:函数索引失效的常见原因 ​

1. 查询表达式和索引表达式不一致 ​

常见错误是索引建在 DATE(created_at) 上,查询却写成:

sql
-- 可能无法匹配 DATE(created_at) 函数索引
WHERE created_at >= '2026-03-01'
  AND created_at <  '2026-03-08'

或者使用了 CAST()、CONVERT_TZ()、DATE_FORMAT() 等不同表达式。MySQL 不会总是自动证明两个表达式完全等价。修正方法是保持谓词和分组表达式与索引定义完全一致;需要时区转换时,就把同一个时区转换表达式放进函数索引和 SQL 中。

2. 在 UTC 时间字段上按本地时区统计 ​

表字段如果存 UTC,报表却要求北京时间,直接写 DATE(created_at) 会把每天 00:00 到 08:00 的数据算到前一天。错误现象通常是“每日总数和业务后台差 8 小时边界的数据”。修正方式是统一表达式:

sql
ALTER TABLE orders
  ADD KEY idx_func_bj_date (
    (DATE(CONVERT_TZ(created_at, '+00:00', '+08:00')))
  );

SELECT DATE(CONVERT_TZ(created_at, '+00:00', '+08:00')) AS bj_date,
       COUNT(*)
FROM orders
WHERE DATE(CONVERT_TZ(created_at, '+00:00', '+08:00')) = '2026-03-01'
GROUP BY bj_date;

同时要确认 MySQL 时区表已正确加载,否则 CONVERT_TZ() 可能返回 NULL。

3. 低选择度条件导致优化器主动放弃索引 ​

如果查询一整年或全表数据,函数索引命中了也需要大量回表,优化器选择全表扫描并不一定是错。很多人看到 type=ALL 就强行加 FORCE INDEX,结果报表更慢。正确做法是缩小时间范围、增加高选择度条件、设计合适的联合索引,或对历史报表使用汇总表。

4. 只建索引不处理排序和分页 ​

按天统计通常还要 ORDER BY dt DESC、LIMIT。如果表达式索引顺序是升序,而查询只取最近几天,可以显式建立降序索引:

sql
ALTER TABLE orders
  ADD KEY idx_func_date_desc ((DATE(created_at)) DESC);

否则可能出现 Using filesort。当然,只有几天分组结果时,文件排序成本很小,不应为了执行计划“好看”而过度建索引。

5. 忽略函数索引对写入和 DDL 的成本 ​

函数索引在写入时需要计算表达式并维护额外 B+Tree,批量导入、更新时间字段时成本会上升。大表加索引应使用在线 DDL 评估锁与复制延迟,MySQL 8.0 可优先考虑 ALGORITHM=INPLACE, LOCK=NONE,但仍要在低峰期执行:

sql
ALTER TABLE orders
  ADD KEY idx_func_date ((DATE(created_at))),
  ALGORITHM=INPLACE,
  LOCK=NONE;

最终建议 ​

按天统计场景下,MySQL 8.0 函数索引是虚拟生成列的轻量替代方案:它不增加业务字段,表达式清晰,适合固定报表口径。落地时不要只验证“索引是否存在”,而要验证“SQL 表达式是否完全匹配、条件范围是否足够窄、联合索引顺序是否符合过滤和聚合路径”。

我的实践决策是:MySQL 5.7 继续使用虚拟生成列;MySQL 8.0 新系统优先函数索引;高频宽口径报表再引入日汇总表。这样既能让单日、单周统计从大范围扫描变成毫秒级范围定位,也能避免为了一条慢查询无节制地堆叠索引。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。