一条原本在测试环境返回毫秒级的SQL,上线后突然要跑几十秒,查看执行计划会发现优化器预估只返回几行,实际却处理了上百万行。这个现象通常不是索引缺失,而是列数据倾斜导致统计信息失真。优化器的成本模型默认列值均匀分布,如果某一列的真实数据高度集中,基数估计就会严重偏离,进而影响扫描路径、连接顺序和内存分配。

以订单表 orders 为例,status 字段只有 0、1、2 三个取值,但 98% 的记录都是 1。当执行 WHERE status=1 时,优化器如果不知道真实分布,可能估算返回三分之一的数据行。若该表还要与另一个大表连接,错误的基数估计会让优化器选择嵌套循环而不是哈希连接,或者低估内存需求导致磁盘溢出,SQL 性能就会断崖式下跌。
一、数据倾斜为什么让执行计划走偏
数据库优化器在生成执行计划时,主要依赖统计信息估算各个操作返回的行数,也就是基数。这些统计信息通常包含表的总行数、列的非重复值数量、空值比例以及列值的分布情况。如果只收集了列的非重复值数量而没有直方图,优化器默认列值按均匀分布计算。例如一个列有 100 个非重复值,表总行数为 100 万,优化器会认为每个值平均对应 1 万行。这种假设在数据均匀时接近真实,一旦出现数据倾斜,估计结果就会严重失真。
数据倾斜不只是影响单表过滤,还会沿着执行计划向上传导。以 orders 表和 order_items 表连接为例,orders.status 列 98% 的记录都是 1,优化器如果按均匀分布估计 WHERE orders.status=1 只返回 1 万行,它可能选择 orders 作为驱动表,并用嵌套循环连接 order_items。实际上过滤后返回 98 万行,嵌套循环需要访问 order_items 接近百万次,执行时间自然从秒级变成分钟级。如果优化器知道真实基数,它大概率会选择哈希连接,先全表扫描 orders 并构建哈希表,再一趟扫描 order_items 完成连接。
所以数据倾斜引发性能骤降的根因,不是优化器本身不够聪明,而是它拿到的信息不完整。要解决这个问题,第一步就是给优化器提供列值分布信息,也就是直方图。
二、直方图如何收集和利用
直方图是把列值分布情况存储为统计信息的一种结构。Oracle 中常见的直方图类型包括频率直方图、等高直方图和混合直方图。当列的非重复值数量小于或等于直方图桶数时,数据库会创建频率直方图,每个非重复值都有独立的频次,适合枚举值较少的场景。当非重复值较多时,Oracle 可能使用等高直方图或混合直方图,用桶来记录值分布范围。
在 Oracle 中收集直方图可以让优化器自动选择方法,也可以对指定列显式收集。下面的语句对 orders 表的 status 列收集直方图,桶数设置为 254,这是Oracle常用的上限。
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'SCOTT',
tabname => 'ORDERS',
method_opt => 'FOR COLUMNS SIZE 254 STATUS',
cascade => TRUE
);
END;
执行后可以通过数据字典确认直方图是否生成。HISTOGRAM 字段会显示 FREQUENCY、HEIGHT BALANCED 或 HYBRID,NUM_BUCKETS 表示桶数,SAMPLE_SIZE 反映采样行数。
SELECT COLUMN_NAME, HISTOGRAM, NUM_BUCKETS, NUM_DISTINCT,
SAMPLE_SIZE
FROM DBA_TAB_COL_STATISTICS
WHERE TABLE_NAME='ORDERS' AND COLUMN_NAME='STATUS';
PostgreSQL 和 MySQL 也提供类似能力。PostgreSQL 在 ANALYZE 时默认会根据数据分布生成频次数组或直方图边界,查询 pg_stats 即可看到 most_common_vals 和 histogram_bounds。MySQL 8.0 之后可以通过 ANALYZE TABLE 收集直方图,并在 information_schema.column_statistics 中查看。无论哪种数据库,直方图的核心作用都是让优化器知道热点值和冷门值的真实占比,而不是用平均值代替一切。
三、查询条件分散:让热点值不再绑架执行计划
有了直方图之后,优化器对固定条件值的估算会准确很多,但实际系统里还存在另一个问题:绑定变量窥探。例如 WHERE status=:1 这样的语句,当第一次执行时传入冷门值 2,优化器基于直方图选择索引范围扫描并把计划缓存起来。后续再传入热点值 1,由于计划已经被固定,数据库很可能继续使用索引范围扫描,回表数量巨大,仍然可能变慢。Oracle 11g 之后引入自适应游标共享,对于绑定变量引起的计划不稳定有改善,但并非所有数据库都具备类似机制。
一个更稳妥的做法是查询条件分散,也就是把热点值和冷门值从同一个谓词中拆开,让优化器分别为它们选择适合自己的执行路径。比如原来的 SQL 是 WHERE status IN (1,2),可以改写为:
SELECT * FROM orders WHERE status = 1 UNION ALL SELECT * FROM orders WHERE status = 2;
这样 UNION ALL 的两个分支会被分别优化,status=1 的分支因为数据量大可能走全表扫描,status=2 的分支因为数据量小可能走索引范围扫描。再加上直方图的帮助,两个分支的基数估计都会更准确,整体执行时间反而比一个统一计划更短。
查询条件分散也适用于 OR 条件。例如 WHERE status=1 OR status=2,如果优化器扩展后仍使用同一个计划,可以改成 UNION ALL;如果数据倾斜非常严重,还可以把热点值单独封装成一个视图或物化视图,避免与其他条件相互拖累。分散的核心不是把SQL写得复杂,而是让优化器在每个局部都能获得准确的输入信息。
四、从慢SQL到稳定计划的调优步骤
处理数据倾斜问题时,不要一上来就加提示或改参数,先按顺序定位。第一步用实际执行计划确认预估行数与实际行数的差异,Oracle 中可查看 A-Rows 和 E-Rows,PostgreSQL 中可启用 EXPLAIN ANALYZE。第二步执行分组统计,找出倾斜列的真实分布。下面的SQL可以看到每个status的占比。
SELECT status, COUNT(*) AS cnt,
ROUND(COUNT(*)/SUM(COUNT(*)) OVER() * 100, 2) AS pct
FROM orders
GROUP BY status
ORDER BY cnt DESC;
第三步检查统计信息是否过期,尤其是大量数据插入或更新之后。如果统计信息还是旧的,直方图也无法反映当前分布,需要先重新收集。第四步对倾斜列收集直方图,并再次查看执行计划,确认优化器的基数估计是否已经接近真实值。第五步如果绑定变量导致计划不稳定,再考虑查询条件分散、自适应游标共享或SQL基线等手段。
举例来说,某订单系统里 status 字段 99% 是已完成状态,一个报表查询按 status 过滤后与订单明细连接,优化器最初估算返回 8000 行,实际返回 120 万行,执行计划选择嵌套循环导致耗时 45 秒。收集直方图后,优化器估算返回 118 万行,改用哈希连接,执行时间降到 1.2 秒。对剩余冷门状态保留索引路径,整体性能恢复稳定。
数据倾斜引发的性能问题,本质上是一个信息不对称问题。直方图补全了列值分布信息,查询条件分散则进一步降低了单一执行计划被热点值绑架的风险。两者配合使用,往往能让一条看似无解的慢 SQL 回到正常的响应水平,也能减少后续数据增长带来的执行计划抖动。