做数据库性能优化这些年,我总结出一个规律:大多数SQL突然变慢的线上问题,最后都能追溯到一个低调的源头——统计信息(Statistics)。很多人遇到慢SQL,第一反应是加索引、改SQL、调参数,折腾半天才发现,优化器手里的“地图”是过期的,它压根不知道表里现在有多少行数据。统计信息就是数据库用来估算执行计划成本的元数据,它本身不是数据,而是数据的高度抽象摘要。
这篇文章结合我多年的数据库运维与调优实践,专门聊统计信息的查看与收集:去哪看、字段怎么读、什么时候该收、参数怎么定,以及那些文档里不会写但很容易坑到人的细节。无论你正在做数据库日常运维、应用性能优化,还是基础架构支持,看完之后基本可以直接拿去用。
1. 统计信息到底是什么,为什么它比索引还重要
简单说,现代关系型数据库普遍基于代价来选择执行计划,统计信息就是代价估算的核心输入。以一条最简单的查询为例,优化器要决定“全表扫”还是“走索引”,它不可能真的把整张表先扫一遍试试,只能靠表统计信息里的行数、块数、平均行长去估算两种方案的代价。行数估算差一个数量级,代价估算就会完全失真,选出来的执行计划往往就是一场灾难。
更直白地讲,索引是数据库的“路标”,统计信息是数据库的“地图”。路上有再多路标,地图版本是旧的,导航照样会给你推荐一条烂路。所以,“索引建好了SQL还是慢”的时候,先别急着怀疑索引,很可能问题出在统计信息上。
1.1 优化器靠统计信息算“成本账”
优化器在做执行计划时,需要三类统计信息配合使用:
- 表级统计信息:表的行数、块数、平均行长。行数决定扫描多少数据,块数决定全表扫描需要读多少物理块,平均行长影响IO与缓冲评估。
- 列级统计信息:某列的不重复值数量(NDV)、空值数量、数据分布直方图。优化器用它们估算过滤后的行数,也就是“选择性”。
- 索引级统计信息:索引的叶子块数量、B树层级(blevel)、聚类因子(clustering factor)。这些直接决定索引扫描加回表的成本估算。
三类信息各有各的作用,缺一个都可能让执行计划跑偏。比如表统计信息中行数正确,但列统计信息里NDV严重偏小,优化器会认为该列过滤性极好,于是走索引回表,实际却要回上千万行,性能直接崩掉。
1.2 统计信息不准,问题会怎么爆发
统计信息不准的症状往往是“突然变慢”,而不是“一直慢”。最常见的两个场景:
第一个场景是批量任务后没来得及收集。某个业务表每天晚上做大批量数据清理或导入,行数从十万级别涨到千万级别,但统计信息还停留在十万。第二天早高峰查询一来,优化器觉得表很小,选择了全表扫描或错误的索引访问路径,接口直接超时。这类问题在“新手村”里几乎人手一次。
第二个场景是数据分布严重倾斜但缺少直方图。比如订单状态字段只有0和1两种取值,0占99.9%,1占0.1%。没有直方图时,优化器默认两种值基本等概率分布,于是按状态筛选时对行数估算严重失真。结果该走索引的走了全表,该走全表的选择了索引回表,两边都难受。
这些症状有一个共同点:SQL本身没变,索引也在,硬件也没问题,纯粹是优化器拿到的数据摘要和真实数据对不上。
2. 统计信息的查看:不改一行SQL,先看懂数据库的内部地图
很多同学问“统计信息去哪看”,其实主流数据库都提供了数据字典视图或系统目录视图。难点不在看不看得到,而在“看到了字段但不知道它在说什么”。下面我按表级、列级、索引级三类展开,给出通用查询方式和字段解读。
2.1 表级统计:行数、块数、平均行长怎么看
先看商业库最常见的数据字典视图查询方式。代码以这套结构为例,在主流商业数据库中基本通用:
SELECT table_name, num_rows, blocks, avg_row_len, last_analyzed FROM user_tables ORDER BY last_analyzed DESC;开源数据库也有类似统计视图,可以从系统活跃统计表中查询,字段名称略有差异,但逻辑一致:
SELECT relname AS table_name, n_live_tup AS estimated_rows, last_vacuum, last_analyze FROM pg_stat_user_tables ORDER BY last_analyze DESC;查询结果里几个核心字段要重点理解:
- num_rows:优化器认为这张表有多少行。注意这通常是估算值,不是精确值。怀疑它不准时,直接
SELECT COUNT(*)和它对比。数量级差一个以上,基本可以断定统计信息过期。 - blocks:表占用了多少个数据块。全表扫描的成本几乎由 blocks 直接决定。如果实际表已经膨胀到几千个块,统计信息还停留在几百个块,优化器会把全表扫描想得过于便宜。
- avg_row_len:平均行长度,影响IO和缓存评估。一般变化不大,但如果表结构变更后长期没收集,这个值也会误导优化器。
- last_analyzed:最后一次收集时间。这是判断统计信息“新鲜不新鲜”的最直接依据。如果业务表每天千万级变化,而 last_analyzed 还是三个月前,问题基本锁定。
我判断统计信息是否过期通常用“三看”原则:一看时间,last_analyzed 是否早于最近一次大规模数据变更;二看行数,估算行数与真实行数偏差是否超过10%~20%;三看计划,同一SQL在数据量变化前后执行计划是否发生劣化。三条里面中两条,就需要补收统计信息了。
2.2 列级统计与直方图:过滤性判断的关键
列级统计信息决定了优化器对过滤条件“选择性”的估算。查询示例:
SELECT table_name, column_name, num_distinct, num_nulls, avg_col_len, histogram_type FROM user_tab_col_statistics WHERE table_name = 'TRANS_RECORD';几个关键字段的含义:
- num_distinct:不重复值数量,也就是NDV。它决定一个等值条件过滤后大约剩多少行。如果某列实际有百万级不重复值,统计信息里只有几百,优化器会以为这个条件过滤性极好,结果走索引后回表无数次。
- num_nulls:空值数量。查询条件里出现
IS NULL或IS NOT NULL时,这个值会直接影响行数估算。 - avg_col_len:平均列长度,影响排序和哈希操作的估算。
- histogram_type:直方图类型。没有直方图时,优化器假设列值均匀分布;有直方图说明系统记录了列值的分布特征。
直方图是最容易被低估的一项。举个例子:员工状态字段有“在职”和“离职”两个值,离职占95%。没有直方图时,优化器认为“离职”过滤后还剩50%的数据,索引扫描加回表的成本可能被正确识别;但如果真实分布是99%对1%,没有直方图就会严重误判。遇到这种高度倾斜的列,别省那一点点收集成本,给列加直方图能让优化器看清现实。
此外,直方图有频率直方图、等高直方图等不同类型,简单理解就是“用不同方式记录数据分布的直方图”。日常使用不需要死记细节,但要记住一条经验:不要把每张表每个列都无脑加直方图,否则收集时间直线上升,而且对高更新频率的表,收完很快又失真。更合理的做法是只对查询过滤字段、等值连接字段、分布不均匀的字段定向收集。
2.3 索引统计与聚类因子:回表成本怎么估
索引统计信息经常被人忽略,但它直接影响“走索引是否划算”。查询示例:
SELECT index_name, blevel, leaf_blocks, distinct_keys, clustering_factor, status, last_analyzed FROM user_ind_statistics WHERE table_name = 'TRANS_RECORD';三个关键指标:
- blevel:B树层级。层级越高,定位到叶子块需要的IO次数越多。一般2~3层都很正常,突然变深说明索引可能严重分裂。
- leaf_blocks:叶子块数量。索引变大一倍,扫描索引的成本也会上升。
- clustering_factor:聚类因子。这是最值得看的指标。它表示按照索引顺序去访问表时,物理块“跳来跳去”的程度。如果表物理顺序和索引顺序高度一致,聚类因子接近表的块数;如果物理顺序非常乱,聚类因子会接近表的行数。
为什么聚类因子重要?同一个索引,聚类因子从一万涨到一千万,优化器估算的回表成本会天差地别。比如一张表按自增ID插入,ID索引的聚类因子通常很漂亮,回表成本低,优化器愿意走索引;如果表被反复更新、大量中间物理删除,顺序打乱,聚类因子飙升,优化器会越来越倾向于全表扫描。遇到聚类因子异常增长且业务层无法改变数据写入方式时,考虑表重组或重建,往往比调整SQL更有效。
3. 统计信息的收集:参数、时机与策略
查看统计信息只是诊断,真正动手收集时才是踩坑重灾区。先说结论:统计信息不是收得越频繁、越精确越好,而是在“够用”和“代价可接受”之间取平衡。下面从自动任务、参数设置、场景策略三个方面展开。
3.1 自动收集任务:省心,但别盲信
主流数据库默认都有自动统计信息维护任务。商业库通常有固定的维护窗口,开源库一般靠后台自动清理/分析机制。这套机制在日常场景下很省心,基本不用管。但自动任务有四个明显盲区:
第一是批量数据加载窗口错位。自动任务可能在每天晚上10点跑,但业务侧凌晨两点才导入完数据,统计信息要等到第二天晚上才会更新,早高峰恰恰是最需要准确统计信息的时候。
第二是高频小表更新。某些配置表、状态表每秒都在被更新,自动任务收集完后几小时,数据分布可能又变了,直方图失真很快。
第三是测试环境。测试库如果也开着自动任务,可能出现“白天做测试、半夜被自动收集”的尴尬情况,影响测试结果的稳定性。
第四是临时表和外部表。很多自动任务默认不覆盖临时表,但某些业务逻辑把临时表当常规表用,这时优化器只能用默认值猜,自然容易出错。
所以自动任务可以当作“保底”,但关键业务表必须在重要数据变更后主动触发一次手动收集。手动收集不可怕,就怕你只会用默认参数。
3.2 手动收集的核心参数怎么定
手动收集统计信息时,最需要关注的四个参数是采样率、并行度、直方图策略、级联模式。它们之间相互影响,需要配合使用。参数对照如下:
| 参数 | 常用经验值 | 影响 | 注意事项 |
|---|---|---|---|
| 采样率 | 10%~30% | 采样率越高越接近真实数据,但耗时和资源消耗也越大 | 超大表不建议100%采样,收益极低 |
| 并行度 | CPU核数的一半左右 | 并行太高容易打满IO和CPU | 业务高峰时段务必调低或串行 |
| 直方图策略 | AUTO模式或只对倾斜列生成 | 决定哪些列记录分布特征 | 不要无脑为所有列生成直方图 |
| 级联 | TRUE | 是否连带收集索引统计信息 | 手动收表时建议开启,避免遗漏索引统计 |
采样率是很多人纠结的点。我直接给个经验:千万行级别的表用20%左右够用,亿级大表用10%甚至更低。某次我为了提高统计精度,把一张大表采样率调到100%,并行度拉满,结果收集任务跑了几十分钟,把系统IO干到接近饱和。事实上优化器并不需要精确到每一行,它需要的是正确数量级。10%和100%的采样结果在价格估算上往往差别不大,但资源消耗相差数倍。
并行度也要克制。并行收集能缩短收集时间,但如果顶层还有业务流量,并行开太高会抢掉业务资源。我常用的策略是:低峰期可以开到CPU核数一半,高峰期顶多开2~4,甚至串行。说到底,统计信息收集是在“抢系统资源”和“优化未来查询”之间做交换,别把交换做成亏本生意。
直方图策略上,推荐先使用AUTO模式让系统自己判断哪些列需要直方图。如果发现某列数据高度倾斜但AUTO没生成,再手动指定该列强制生成直方图。这里给一个常规手动收集的示例:
-- 商业库手动收集单表统计信息 EXEC DBMS_STATS.GATHER_TABLE_STATS(USER, 'TRANS_RECORD', estimate_percent => 20, degree => 4, method_opt => 'FOR ALL COLUMNS SIZE AUTO', cascade => TRUE);-- 开源库手动收集表统计信息 ANALYZE TABLE trans_record;命令看上去简单,但参数背后都对应着“为什么”。比如 cascade=>TRUE,就是防止你管好了表统计信息却漏掉索引统计信息;method_opt选AUTO,就是为了避免对每一列都建立直方图导致过度收集。参数不是摆设,每一个都是实战经验的浓缩。
3.3 不同场景下收集策略对照表
不同业务场景对统计信息的需求差异很大,一张对照表能帮你很快确定策略:
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 普通OLTP中小表 | 依赖自动任务,偶尔手动补收 | 成本低,自动任务基本够用 |
| 超大表 | 低采样率+低并行度,分区分批收集 | 避免长时间锁资源、IO打满 |
| 批量加载/迁移后 | 作业末尾强制执行一次收集 | 填补自动任务窗口错位的空窗期 |
| 高频DML的表 | 缩短收集周期,或只收集关键列 | 统计信息过期速度快,需要更高频小成本收集 |
| 分区表 | 开启增量收集或全局统计 | 避免只收到部分分区,导致全局估算失效 |
| 测试/预发环境 | 采用更高采样率全量收集 | 让优化器行为接近生产,减少环境差异干扰 |
分区表是个特别容易翻车的点。很多分区表只有个别分区被频繁读写,如果统计信息只收了最新分区,而没有维护全局统计,优化器做跨分区查询时行数估算会严重不正常。分区表的统计信息策略建议单独梳理,不要用普通表那一套直接套。
还会有人问“统计信息多久收一次”。没有标准答案,但有一条经验法则:当DML影响的行数超过表总行数的10%~15%时,就该考虑重新收集了。对于在线交易系统,可以把这个阈值收紧到5%;对于数据仓库,通常每天窗口任务一次即可。关键是“及时”而不是“频繁”,过度收集同样会拖垮白天业务。
4. 一次完整的统计信息排障实录
前面讲了很多原理,下面用一个最容易复现的“迁移后SQL变慢”案例,完整走一遍定位和处理过程。这套排查思路,基本可以直接迁移到大多数类似事故上。
4.1 现象:查询从20毫秒变成4秒
某天同事跑来说,一个对账接口从平均20毫秒直接变成4秒,线上用户体验明显受影响。我的第一反应不是看SQL,而是先问“这个表最近有没有做过结构变更、批量导入或数据迁移”。得到回复是:当天白天刚做了一次历史数据归档迁移,插入了三千多万行到流水表trans_record里。
历史经验告诉我,这类“今天导入了大量数据”的任务,十有八九会留下统计信息缺失或过期的问题。所以我没有急着改SQL,直接去核对统计信息。
4.2 定位:执行计划翻车,根因在统计信息
先看表统计信息:
SELECT table_name, num_rows, blocks, avg_row_len, last_analyzed FROM user_tables WHERE table_name = 'TRANS_RECORD';结果显示 num_rows 还停留在几年前的三十万行,last_analyzed 也是很久之前。而实际SELECT COUNT(*)已经三千多万行,相差两个数量级。再看执行计划,优化器选的是全表扫描,因为没有走索引,耗时直接爆炸。
这里有一个关键逻辑:为什么索引明明存在,优化器却不走?因为优化器认为这张表只有三十万行,全表扫描的成本非常低;而走索引要估算回表三十万次,它觉得不划算。实际上回表次数是三千多万次,远超全表扫描成本。优化器不是“不想走索引”,而是它的地图里根本没有那多出来的三千万行。
这类问题特征太明显:SQL没变、执行计划突然劣化、同时当天有大批量数据变更,三种线索叠加,基本可以直接锁定统计信息问题。
4.3 处理与后续预防
处理方式很直接,重新收集trans_record的统计信息。考虑到表已经比较大,我设置采样率20%、并行度4,避免给正在运行的业务造成压力:
EXEC DBMS_STATS.GATHER_TABLE_STATS(USER, 'TRANS_RECORD', estimate_percent => 20, degree => 4, method_opt => 'FOR ALL COLUMNS SIZE AUTO', cascade => TRUE);收集过程不到一分钟。再查执行计划,优化器已经正确选择索引范围扫描,接口耗时回落到150毫秒左右。问题解决了,但这类事故如果不做预防,迟早会换个表重新来一遍。
所以当时立了两条“军规”:第一,任何批量导入、迁移、归档作业的最后一步,必须触发相关表的统计信息收集,不收集不允许发布;第二,对核心表做一个统计信息监控脚本,每天比对估算行数与真实行数,偏差超过30%就告警。第三,迁移工具如果会附带旧环境的统计信息,导入后一定要先看last_analyzed和num_rows是否合法,再决定要不要全员重收。
5. 常见问题与避坑经验
最后整理一份高频问题速查表和几条实操心得。这些问题我在不同项目里反复见过,每个都踩过或看别人踩过。
5.1 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决建议 |
|---|---|---|---|
| SQL突然变慢但索引完好 | 统计信息过期或缺失 | 对比last_analyzed与实际数据量 | 手动收集关键表统计信息 |
| 收集后执行计划反而变差 | 采样率不足或直方图策略不合适 | 检查method_opt与列数据分布 | 定向收集倾斜列或提高采样率 |
| 收集统计信息时CPU/IO飙升 | 并行度设置过高,或与业务高峰重叠 | 查看活跃会话与等待事件 | 调低并行度,错峰执行 |
| 分区表统计信息看着不对 | 增量/全局统计没开启 | 查看表级行数与分区级行数 | 开启增量收集模式 |
| 跨环境迁移后性能下降 | 统计信息未被迁移或迁移的是旧数据 | 查last_analyzed是否为空或陈旧 | 迁移完成后重新全员收集 |
| 等值查询估算偏差严重 | 缺少直方图或直方图失真 | 查histogram_type与NDV | 对关键列定向生成直方图 |
| 每次相同SQL时长都有抖动 | 自动任务窗口与业务高峰冲突 | 查看任务执行历史 | 调整维护窗口或错开时间 |
| 统计信息一直显示为空/N/A | 表从未被收集过 | 查last_analyzed是否为默认值 | 立即执行一次收集 |
这张表的目的不是让你背答案,而是帮你建立一条思路:遇到性能问题,先把“统计信息”这个变量排除掉,再往下深挖。很多看似复杂的执行计划问题,最后都是统计信息这一层出了问题。
5.2 几条来自实操的避坑建议
第一,收集统计信息没你想的那么轻量。别为了“更准”就无限提高采样率、拉满并行度。我见过有人对全库执行收集所有表,并行度调到最高,直接在业务高峰把生产干到接近瘫痪。收统计信息之前,先看一眼当前会话数和IO负载,心里有数再动手。
第二,收集完统计信息后执行计划变差,不要急着加固定提示或固化执行计划。先回答三个问题:数据本身是不是又变了?相关列是否有直方图?SQL里的绑定变量是否导致窥视问题?执行计划固化是最后手段,它只会让数据库在业务数据变化之后继续沿用老计划,等于把问题从“选错”变成“永远错”。
第三,迁移后的第一件大事,永远是核对统计信息。很多数据迁移只搬了数据,没搬统计信息;更糟的是连旧环境统计信息一起搬过来,行数差异巨大。让优化器“重新认识数据”,比你花一个下午调一条SQL快得多。
第四,搭建一个统计信息巡检脚本非常值。不用复杂,每天输出核心表的last_analyzed、估算行数、真实行数、偏差比,超过阈值就提醒。这个脚本能让你在用户报障之前就发现隐患,属于性价比极高的运维投资。
最后分享一点我个人的操作习惯:每次完成一个大版本升级或数据迁移,我不会急着去优化SQL,第一步永远是花十分钟把所有核心表的统计信息看一遍。统计信息这种“看不见的元数据”,平时没人注意,但恰恰最值得优先维护。你多花一点时间维护它,优化器就会少给你制造一个深夜告警。