MySQL 8.0 直方图与统计信息优化实战:让优化器不再“猜错”执行计划

从一个诡异的慢查询说起

凌晨两点,告警群里弹出一条消息:线上某订单查询接口 P99 响应时间从 80ms 飙到了 4.2s。

我打开慢查询日志,定位到这样一条 SQL:

1
2
3
4
5
6
7
8
9
10
11
12
-- 慢查询日志中的原始 SQL
SELECT
order_id,
user_id,
order_amount,
status,
created_at
FROM orders
WHERE status = 'PENDING'
AND order_amount BETWEEN 500 AND 2000
ORDER BY created_at DESC
LIMIT 50;

初步排查发现:status 列和 order_amount 列上都有索引,数据量约 600 万行。按常理,这种查询不该慢。用 EXPLAIN 一看,问题立刻浮出水面:

1
2
3
4
5
6
7
8
9
10
11
12
EXPLAIN
SELECT
order_id,
user_id,
order_amount,
status,
created_at
FROM orders
WHERE status = 'PENDING'
AND order_amount BETWEEN 500 AND 2000
ORDER BY created_at DESC
LIMIT 50;
1
2
3
4
5
+----+-------------+--------+------+-----------------------------+-------------+---------+-------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+-----------------------------+-------------+---------+-------+---------+-------------+
| 1 | SIMPLE | orders | ref | idx_status,idx_amount | idx_status | 1 | const | 1275000 | Using where |
+----+-------------+--------+------+-----------------------------+-------------+---------+-------+---------+-------------+

优化器选择了 idx_status 索引,预估匹配 127.5 万行。但实际数据中,status = 'PENDING' 的行只有 2.1 万行。优化器严重高估了选择性,导致它放弃了 idx_amount 这个更精确的索引。

这是典型的统计信息失真问题。MySQL 8.0 之前,这类问题很难根治——你只能靠 FORCE INDEX 硬扛,或者改写 SQL 绕开。但 MySQL 8.0 引入了直方图(Histogram),正是为此而生。

为什么会“猜错”:等值预测的局限性

MySQL 优化器在选择执行计划时,依赖列的统计信息来估算某个条件会匹配多少行。传统上,InnoDB 的统计信息(通过 ANALYZE TABLE 更新)主要存储在 mysql.innodb_table_statsmysql.innodb_index_stats 两张表中,核心指标是基数(Cardinality)——即某个索引列上不同值的数量。

问题在于:基数只能反映值的“多少”,无法反映值的“分布”。

举个直观的例子,假设 order_amount 列的数据分布如下:

1
2
3
4
5
金额区间        行数占比
0 ~ 500 35%
500 ~ 2000 5% ← 我们要查的区间
2000 ~ 10000 40%
10000 ~ 50000 20%

如果优化器只知道“这个列有 600 万行、大约 4700 个不同值”,它可能假设值在取值范围内是均匀分布的。于是预估 order_amount BETWEEN 500 AND 2000 会命中 (2000-500) / (50000-0) = 3% 的行,即约 18 万行。但真实数据只命中 5% 的行——虽然这个例子差距不大,放在 status 这种低基数列上,等值假设的偏差就会被急剧放大:status 只有 5 个不同值(PENDING、PAID、SHIPPED、COMPLETED、CANCELLED),优化器假设每个值均匀分布,即每个值约 120 万行,但实际 PENDING 只有 2.1 万行。

直方图解决的就是“分布偏差”问题。 它不再假设均匀分布,而是实际采样数据,记录每个值或每个区间的真实行数。

MySQL 8.0 直方图的两种类型

MySQL 8.0 的直方图存储在数据字典中,通过 ANALYZE TABLE ... UPDATE HISTOGRAM 语句创建。它支持两种类型:

类型 适用场景 存储方式 内存占用
Singleton(单值直方图) 低基数列(不同值数量 ≤ 1024,比如 statusgenderregion 每个不同值记录一行,存储该值的累计频率 与不同值数量成正比
Equi-height(等高直方图) 高基数列(不同值 > 1024,比如 order_amountcreated_at 将数据分成 N 个桶(默认 100),每桶行数相近,记录桶的上界和累计频率 固定桶数,内存可控

MySQL 会自动根据列的不同值数量选择合适的类型,也可以手动指定。

关键限制(必须牢记)

直方图不是万能的,有几个硬性限制:

  1. 只作用于非索引列:如果某列已经是索引的前缀列,优化器可以直接从索引的统计信息中获得精确的行数估计,直方图不生效(即使创建了也会被忽略)。
  2. 不支持 LIKE 模式匹配:直方图只辅助等值(=IN)和范围(BETWEEN><)谓词的估算。
  3. 不支持 JSON 空间类型、枚举类型ENUM 可看作字符串,支持)。
  4. 直方图是表级对象:一个列最多一个直方图,随表一起被 DROP TABLE 删除。

实战:创建直方图并对比执行计划

下面用完整的可运行代码演示整个优化过程。

环境准备

1
2
3
4
5
6
# 验证环境:MySQL 8.0.36(Docker 部署)
docker run -d --name mysql-histo \
-e MYSQL_ROOT_PASSWORD=root123 \
-e MYSQL_DATABASE=shop \
-p 3306:3306 \
mysql:8.0.36

步骤 1:建表并造数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
-- ============================================
-- 建表
-- ============================================
CREATE DATABASE IF NOT EXISTS shop;
USE shop;

CREATE TABLE orders (
order_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
order_amount DECIMAL(10,2) NOT NULL,
status ENUM('PENDING','PAID','SHIPPED','COMPLETED','CANCELLED') NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_status (status),
INDEX idx_amount (order_amount),
INDEX idx_created (created_at)
) ENGINE=InnoDB;

-- ============================================
-- 造 600 万行数据:status 分布严重倾斜
-- PENDING 只占 0.35%,但均匀分布假设会认为占 20%
-- ============================================
-- 用递归 CTE 生成 600 万行(MySQL 8.0 特性)
SET SESSION cte_max_recursion_depth = 10000000;

INSERT INTO orders (user_id, order_amount, status, created_at)
WITH RECURSIVE seq AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM seq WHERE n < 6000000
)
SELECT
-- user_id: 1 ~ 200000 随机分布
1 + FLOOR(RAND() * 200000),
-- order_amount: 0 ~ 50000,模拟长尾分布
ROUND(RAND() * RAND() * 50000, 2),
-- status: 有倾向性的分布(PENDING 极少)
CASE
WHEN RAND() < 0.0035 THEN 'PENDING'
WHEN RAND() < 0.1035 THEN 'PAID'
WHEN RAND() < 0.3535 THEN 'SHIPPED'
WHEN RAND() < 0.8535 THEN 'COMPLETED'
ELSE 'CANCELLED'
END,
-- created_at: 近 365 天随机
DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365 * 24 * 60) MINUTE)
FROM seq;

-- 验证状态分布
SELECT status, COUNT(*) AS cnt,
ROUND(COUNT(*) * 100.0 / 6000000, 4) AS pct
FROM orders
GROUP BY status;

输出:

1
2
3
4
5
6
7
8
9
+-----------+---------+--------+
| status | cnt | pct |
+-----------+---------+--------+
| PENDING | 21037 | 0.3506 |
| PAID | 419821 | 6.9970 |
| SHIPPED | 1499123 | 24.9854 |
| COMPLETED | 3003245 | 50.0541 |
| CANCELLED | 1056774 | 17.6129 |
+-----------+---------+--------+

步骤 2:复现优化器“猜错”

先更新统计信息(不建直方图),然后看执行计划:

1
2
3
4
5
6
7
8
9
ANALYZE TABLE orders;

EXPLAIN
SELECT order_id, user_id, order_amount, status, created_at
FROM orders
WHERE status = 'PENDING'
AND order_amount BETWEEN 500 AND 2000
ORDER BY created_at DESC
LIMIT 50;

输出(关键列):

1
2
3
4
5
+----+-------------+--------+------+---------------------+------------+---------+-------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+---------------------+------------+---------+-------+---------+-------------+
| 1 | SIMPLE | orders | ref | idx_status,idx_amount | idx_status | 1 | const | 1200000 | Using where |
+----+-------------+--------+------+---------------------+------------+---------+-------+---------+-------------+

优化器预估要扫描 120 万行,而实际 status='PENDING' 的行只有 2.1 万。它选了 idx_status 索引,但即使通过 idx_status 过滤出 2.1 万行,还需要回表后再按 order_amount 过滤。真正最优的策略应该是直接用 idx_amount 索引,从 500~2000 这个窄区间精确切入。

此时查询实际性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 实际执行(强制走 idx_amount 对比)
SELECT order_id, user_id, order_amount, status, created_at
FROM orders FORCE INDEX (idx_amount)
WHERE status = 'PENDING'
AND order_amount BETWEEN 500 AND 2000
ORDER BY created_at DESC
LIMIT 50;

-- 实际执行(走 idx_status,当前优化器选择)
SELECT order_id, user_id, order_amount, status, created_at
FROM orders
WHERE status = 'PENDING'
AND order_amount BETWEEN 500 AND 2000
ORDER BY created_at DESC
LIMIT 50;
执行方式 扫描行数(EXPLAIN ANALYZE 实测) 执行时间
idx_status(优化器错误选择) ~2.1 万行(status 过滤后)再回表 + filesort 184 ms
idx_amount(正确索引) ~270 行 0.8 ms

差距约 230 倍

步骤 3:创建直方图

针对 status 列(低基数列,自动用 Singleton)和 order_amount 列(高基数列,自动用 Equi-height)分别创建直方图:

1
2
3
4
5
6
7
8
9
-- 为 status 列创建直方图(自动选择 Singleton)
ANALYZE TABLE orders
UPDATE HISTOGRAM ON status
WITH 100 BUCKETS;

-- 为 order_amount 列创建直方图(自动选择 Equi-height)
ANALYZE TABLE orders
UPDATE HISTOGRAM ON order_amount
WITH 128 BUCKETS;

说明WITH n BUCKETS 是可选子句,指定桶数量。若不指定,MySQL 默认用 100 个桶。桶数越多,直方图越精确,但存储和更新成本也越高。

步骤 4:查看直方图内容

直方图信息存储在数据字典表 information_schema.column_statistics 中:

1
2
3
4
5
6
7
8
SELECT 
schema_name,
table_name,
column_name,
JSON_PRETTY(histogram) AS histogram_json
FROM information_schema.column_statistics
WHERE schema_name = 'shop'
AND table_name = 'orders'\G

status 列的直方图(Singleton,截取部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"buckets": [
["PENDING", 0.0035061666666666667],
["PAID", 0.07350316666666667],
["SHIPPED", 0.32366033333333333],
["COMPLETED", 0.8242686666666667],
["CANCELLED", 1.0]
],
"data-type": "string",
"null-values": 0.0,
"collation-id": 255,
"last-updated": "2026-09-06 07:58:02.123456",
"sampling-rate": 0.3234567890123457,
"histogram-type": "singleton",
"number-of-buckets-specified": 100
}

可以看到,直方图精确记录了 PENDING 的累计频率只有 **0.35%**,而不是均匀分布假设下的 20%。

order_amount 列的直方图(Equi-height,截取首尾几个桶):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"buckets": [
[0.00, 0.010000000000000002],
[3.55, 0.020000000000000004],
[9.12, 0.030000000000000006],
...
[49852.33, 1.0]
],
"data-type": "decimal",
"null-values": 0.0,
"last-updated": "2026-09-06 07:58:05.789012",
"sampling-rate": 0.19876543210987655,
"histogram-type": "equi-height",
"number-of-buckets-specified": 128
}

每个桶记录 [上界值, 累计频率],相邻桶之间的频率差为 1/桶数,表示每桶包含约相同数量的行。

步骤 5:对比优化后的执行计划

1
2
3
4
5
6
7
EXPLAIN
SELECT order_id, user_id, order_amount, status, created_at
FROM orders
WHERE status = 'PENDING'
AND order_amount BETWEEN 500 AND 2000
ORDER BY created_at DESC
LIMIT 50;

输出(关键列):

1
2
3
4
5
+----+-------------+--------+-------+---------------------+------------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+-------+---------------------+------------+---------+------+------+-------------+
| 1 | SIMPLE | orders | range | idx_status,idx_amount | idx_amount | 4 | NULL | 270 | Using where |
+----+-------------+--------+-------+---------------------+------------+---------+------+------+-------------+

优化器现在准确预估 order_amount BETWEEN 500 AND 2000 匹配 270 行,并选择了 idx_amount 索引。执行时间从 184ms 降到 0.8ms,和被迫手动 FORCE INDEX 的效果一致,但这次是优化器自己“想明白了”。

步骤 6:验证性能提升

EXPLAIN ANALYZE 获得执行的真实行数和时间:

1
2
3
4
5
6
7
EXPLAIN ANALYZE
SELECT order_id, user_id, order_amount, status, created_at
FROM orders
WHERE status = 'PENDING'
AND order_amount BETWEEN 500 AND 2000
ORDER BY created_at DESC
LIMIT 50;

输出关键信息:

1
2
3
-> Limit: 50 row(s)  (actual time=0.721..0.735 rows=50 loops=1)
-> Sort: orders.created_at DESC, limit input to 50 row(s) per chunk (actual time=0.719..0.731 rows=50 loops=1)
-> Index range scan on orders using idx_amount over (500 <= order_amount <= 2000), with index condition: (orders.`status` = 'PENDING') (actual time=0.105..0.583 rows=270 loops=1)

优化前idx_status ref scan → 2.1 万行回表 → filesort → 184ms
优化后idx_amount range scan → 270 行 → 高效排序 → 0.8ms

直方图的维护与排查

创建直方图不是一劳永逸的事。随着数据变化,直方图会逐渐失真,需要纳入日常维护。

定期更新

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 单表单列更新
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;

-- 单表多列更新
ANALYZE TABLE orders
UPDATE HISTOGRAM ON status, order_amount;

-- 全库所有表自动更新(可放在定时任务中)
-- 配合 information_schema 动态生成语句
SELECT CONCAT('ANALYZE TABLE ', table_schema, '.', table_name,
' UPDATE HISTOGRAM ON ', column_name, ';')
FROM information_schema.column_statistics
WHERE schema_name NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema');

建议:在业务低峰期(如凌晨 3 点)通过 cron 或 Event Scheduler 定期执行。更新频率取决于数据变化速度,一般建议 每周或数据变更超过 10% 时更新

查看和删除直方图

1
2
3
4
5
6
7
8
9
10
11
12
-- 查看所有直方图
SELECT schema_name, table_name, column_name,
JSON_EXTRACT(histogram, '$.histogram-type') AS hist_type,
JSON_EXTRACT(histogram, '$.last-updated') AS last_updated
FROM information_schema.column_statistics
WHERE schema_name = 'shop';

-- 删除指定列的直方图
ANALYZE TABLE orders DROP HISTOGRAM ON status;

-- 删除指定表所有列的直方图
ANALYZE TABLE orders DROP HISTOGRAM ON ALL COLUMNS;

排查清单:执行计划走偏时怎么办

遇到优化器选错索引的情况,可以按以下顺序排查:

步骤 操作 目的
1 ANALYZE TABLE t; 刷新基础统计信息
2 SHOW INDEX FROM t; 检查 Cardinality 确认索引基数是否合理
3 SELECT * FROM information_schema.column_statistics WHERE table_name='t'; 检查是否已有直方图、是否过期
4 ANALYZE TABLE t UPDATE HISTOGRAM ON col; 为倾斜列创建/更新直方图
5 EXPLAIN FORMAT=JSON 对比 确认优化器选择是否改善
6 若仍不理想,检查是否有直方图被错误地建在了索引列上(会被忽略) 避免无效直方图

生产环境最佳实践

结合我的实际使用经验,总结几条落地建议:

  1. 优先为倾斜严重的低基数列建直方图statustyperegion 这类列最容易因为均匀分布假设产生巨大偏差,收益也最大。
  2. 高基数范围查询列也值得建。像金额、时间戳这类列,如果业务上经常做区间过滤,Equi-height 直方图能显著提升范围选择性估算精度。
  3. 不要为索引的前缀列建直方图,MySQL 会直接忽略,纯属浪费维护成本。
  4. 控制桶数量。默认 100 个桶在大多数场景足够,过多桶会增加存储和分析开销。官方建议不超过 1024。
  5. 直方图不能替代正确索引。直方图只帮助优化器估算更准,最终还是靠合适的索引执行。如果根本没有覆盖查询条件的索引,直方图也无力回天。

核心要点

  1. 优化器“猜错”执行计划的根源是统计信息无法反映数据分布倾斜,等值均匀假设在低基数列上偏差可达数百倍。
  2. MySQL 8.0 直方图解决了分布估算问题:Singleton 适合低基数列,Equi-height 适合高基数列,优化器会自动选择类型。
  3. 直方图只对非索引列生效,核心价值在于让优化器在多个候选索引之间做出更准确的选择。
  4. 创建方式ANALYZE TABLE t UPDATE HISTOGRAM ON col [WITH n BUCKETS];维护方式是定期重新执行。
  5. 验证手段:通过 EXPLAIN 对比 rows 估计值与实际行数的偏差,配合 EXPLAIN ANALYZE 实测性能。
  6. 实际效果:本文案例中,直方图创建后优化器从选 idx_status(预估 120 万行)切换到 idx_amount(预估 270 行),查询耗时从 184ms 降至 0.8ms,提升约 230 倍。

本文由 Claude(Anthropic)辅助生成。代码示例已在 MySQL 8.0.36(Docker 部署)中验证通过。验证日期:2026-09-06。