ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL统计信息失真导致慢查询:排查与修复实践

PostgreSQL统计信息失真导致慢查询:排查与修复实践

1. 一条慢查询背后:为什么问题偏偏出在统计信息上

1.1 复盘起点:一条执行计划跑偏的报表SQL

上个月我处理了一桩很典型的慢查询。某张订单表大约 3500 万行,之前一直跑得好好的报表统计SQL,某天突然从 2 秒多变成 3 分半。第一反应当然是查索引、看锁、看CPU,结果都没异常。把 EXPLAIN ANALYZE 拉出来一看,症状非常明显:优化器估算返回 800 行左右,实际却扫出来 480 万行,执行计划选择了嵌套循环,驱动表那边有 50 多万行,硬生生做了几十万次索引探测。

我把三天的排查记录交给了常用的 AI 辅助工具做归纳,它给出来的核心线索只有一条:去查统计信息是不是过期了。顺着这个方向检查,果然出问题的那张订单表,最近一次 ANALYZE 是 20 天前跑的,而中间正好有两天在做状态字段的批量更新。说白了,优化器手里拿的"路况数据"还是旧版的,它规划的路线当然不是最优的。这篇文章就是围绕这条主线展开的:PostgreSQL统计信息为什么能影响查询性能、失真以后会造成什么后果、我们又该怎么检查和修复。适合后端开发、自己维护数据库的团队以及刚接触执行计划的同学阅读。

1.2 统计信息在查询优化链路里的位置

一条 SQL 从客户端发出到返回结果,PG 内部经历的链路大致是:词法语法解析、查询重写、生成计划、执行。真正决定"怎么跑"的是生成计划这一步,也就是优化器(planner)。优化器不是靠猜的,它会针对每一种可能的执行方式(全表扫描、索引扫描、哈希连接、嵌套循环、排序归并等)估算一个总成本,然后挑成本最低的方案。

成本估算的依据主要有三类:表级行数、页数这些物理信息;每列的分布情况,比如高频值、NULL比例、平均宽度、相关性;还有各类成本参数,像顺序扫描成本、随机扫描成本、CPU处理成本。其中前两类都属于"统计信息"的范畴。你可以这样理解:优化器相当于一个地图App,统计信息就是路况和里程数据。路况数据越新、越细致,App 给出的路线就越靠谱;数据过期或者粒度太粗,导航就会自信地把你带进堵死的老路。

2. 统计信息到底存在哪,长的又是什么样

2.1 pg_class 管表、pg_statistic 管列:先分清楚

PostgreSQL 里跟统计信息相关的系统表不止一张,但最常见的就俩。pg_class 里存的是表级基础信息,其中 relpages 表示表占用数据页数的估算值,reltuples 表示行数的估算值。注意,这俩都是估算值,不是精确数,由 VACUUM 和 ANALYZE 负责更新。优化器估算全表扫描行数时,主要就靠 reltuples;估算扫描顺序和IO开销时,会用到 relpages。

列级的统计信息则放在 pg_statistic 表里。这张表的设计比较特殊,一行代表一个表的一列,但具体数据被编码在 stavalues1、stanumbers1 这些数组字段里,普通查询直接查的话,体验很差。所以 PG 提供了 pg_stats 这个视图,把编码后的内容解析成可读字段。我们日常巡检统计信息,基本就是查 pg_stats。

2.2 一张表看懂 pg_stats 里那些关键字段

pg_stats 视图字段很多,但真正影响性能判断的,核心也就下面这几个。

字段名含义对查询的影响
null_frac该列中 NULL 值所占比例决定 IS NULL 条件估算的准确性,也影响等值查询对非空值的概率修正
avg_width该列的平均字节宽度估算扫描和处理行的内存、CPU 开销
n_distinct该列去重值的估算数量;负数表示"去重值数/总行数"的比率影响 GROUP BY、DISTINCT、JOIN 后去重行数的估算
most_common_vals最常见值的列表,即 MCV等值匹配和 IN 条件首选查这里,高频值命中时准确度极高
most_common_freqs与 MCV 对应的出现频率和 MCV 搭配,表示每个高频值的占比
histogram_bounds直方图桶边界,用于描述非高频值的分布范围查询(>, <, BETWEEN)主要靠它估算选择率
correlation物理存储顺序与逻辑值顺序的相关系数越接近 1 或 -1,说明数据顺序性好,索引扫描成本估算越乐观

MCV 和直方图是一种互补关系。PG 会把出现次数最多的若干值单独存进 MCV 列表,精确记录它们的频率;剩下那些没那么常见的值,则被分桶放进直方图里。等值查询命中 MCV 时,估算通常非常准;范围查询在直方图上插值,也能得到还不错的选择率。但这两者都有一个共同问题:如果数据分布发生了剧烈变化,而 ANALYZE 没有及时重跑,桶和 MCV 就全不准了。

2.3 ANALYZE 的采样逻辑,以及它为什么偶尔会"看不清"

ANALYZE 不是把整张表逐行读一遍再做精确统计,PG 采用的是随机块采样:先随机抽一批数据页,再从这些页里抽样行。采样规模受 default_statistics_target 控制,默认值是 100,意思是 MCV 最多存 100 个值,直方图也最多有 100 个桶,整体采样量会随这个参数增大而增大。这样设计的原因很实际:一张 10 亿行的表,如果每次都全量扫描来分析,ANALYZE 本身就会变成慢查询,谁也受不了。

但采样策略也带来一个副作用:对超大表来说,如果某段时间插入或更新的数据占比很小,抽样很可能根本抽不到新数据,生成的统计信息反映的还是旧分布。我碰到过一张 5000 万行的日志表,某一天突然有业务把某列的状态从 0 改成了 2,改动了 200 万行,相对总量只有 4%。自动分析触发之前,如果连续有查询进来,优化器就会用旧统计估算,表现就是执行计划明显偏离正常。理解采样逻辑之后,你就能明白为什么"定期手动 ANALYZE 关键表"这点这么重要,完全指望自动任务是有窗口期的。

3. 统计失真以后,优化器是怎么把查询一步步带偏的

3.1 等值选择率偏差:该走索引的查询变成全表扫描

等值条件是最常见的情况,优化器处理 WHERE status = 'done' 这类过滤时,大致顺序是:如果 status 出现在 MCV 列表里,直接用对应频率;没出现在 MCV 里,就在直方图上取一个桶的比例作为近似频率。因为 MCV 只记录 top 值,如果一个值原来是低频值,后来变成了绝对主流,但统计没跟上,优化器就会以为过滤后只有几千行,于是高高兴兴走索引扫描。

我遇到过一条典型的 SQL:订单状态从 A 批量改成 B 之后,SELECT 很多个字段,WHERE status = 'B'。旧统计里 B 出现频率只有 0.2%,优化器评估后觉得走 B 字段索引最划算。但实际 B 已经占了 85%,索引扫描加回表要做数百万次随机IO,比全表扫描慢得多。如果当时优化器知道 B 是高频值,它压根不会选这条路线。所以别小看 MCV 列表和频率,它们直接影响索引可用性。

3.2 Join 基数误判:嵌套循环的"灾难现场"

统计信息失真最夸张的后果,集中在 JOIN 计划上。嵌套循环适合"外表小、内表探测快"的场景;哈希连接适合"两边都大、一次性建哈希表"的场景;归并连接则适合"两边都已排序"或能省掉排序的情况。优化器选哪一种,核心依据就是 JOIN 后预计的行数(基数)。

用个具体例子:A 表 100 万行,B 表 200 万行,SQL 是 A JOIN B ON B.aid = A.id WHERE B.status = 'done'。如果 B.status 的 MCV 显示 done 只占 5%,优化器会认为内表经过过滤后只剩 10 万行,哪怕外表有 100 万行,它也敢选嵌套循环,因为觉得每次探测内表都很快。可实际 done 占 45%,内表匹配行数膨胀到 90 万,嵌套循环要对 B 的索引做上百万次探测,单次再快也扛不住总量。这类案例从 EXPLAIN 输出里看特别明显:rows 那栏写着几十万,actual rows 却有上千万,差距直接暴露统计失真。

3.3 NULL值和大范围数据:直方图覆盖不到的暗角

还有一个常被忽略的失真点:NULL 与直方图的关系。pg_stats 里 null_frac 单独记录 NULL 比例,直方图只描述非 NULL 且非 MCV 的值。如果某列大量为 NULL,IS NULL 查询还能通过 null_frac 估算得比较准;但一旦和别的条件组合,比如多个 IS NOT NULL 条件叠加,选择率就会被连续相乘,误差可能被指数级放大。

另外直方图本身也有短板。它是一种均匀化近似,遇到高度倾斜但又不算高频的值时,单个桶覆盖的选择率可能和桶内真实分布差异很大。举个例子,某个值分布在全表的 1% 桶里,但桶内总共有 99% 的数据,优化器会按桶均值估算,和真实情况差很多倍。这种问题光调 default_statistics_target 不一定能彻底解决,还得靠 MCV 或扩展统计帮忙。

3.4 多列条件之间的关联性:单列统计的铁索连船

PostgreSQL 默认假设各列是相互独立的。从概率上讲,WHERE country = '某国' AND city = '某城' 的选择率等于两个单列选择率的乘积。听起来合理,但如果 country 和 city 存在明显的函数依赖关系,比如某国的城市组合只在特定范围内出现,这个乘法的结果就会远低于真实选择率,导致估算行数被严重低估。

联合条件造成低估以后,优化器可能低估了数据的规模,选择小号的哈希表或者更激进的并行计划,最终反而放大执行成本。更常见的现象是 GROUP BY 多列时,PG 用各列去重数的乘积来估组数,实际因为有依赖,组数远小于乘积,估算值虚高,内存参数和聚合策略跟着走样。这个"多列相关性"问题,单靠传统的单列统计是无解的,这也是为什么后续版本推出了扩展统计对象。

4. 把统计信息拉回正轨:手动校准和自动维护双轨跑

4.1 ANALYZE 命令的常规用法与新手容易踩的坑

手动校准最直接的方式就是跑 ANALYZE。只分析一张表写 ANALYZE table_name,分析整个库直接 ANALYZE,想顺带清理死元组就 VACUUM ANALYZE table_name。需要注意,ANALYZE 拿的是 SHARE UPDATE EXCLUSIVE 锁,日常 DML 不受影响,但 DDL 类操作会被阻塞,所以核心业务高峰期还是要谨慎。

我见过不少新手上来就对全库执行 ANALYZE,结果大表多的时候,这个命令可能跑上好几分钟,期间还占资源。更合理的做法是只针对统计信息可疑的表做,或者至少避开业务峰值。另外有个细节容易忽略:大批量 DELETE 或 UPDATE 之后,哪怕表里行的总量变化不大,列的分布也可能已经面目全非,这时候不要犹豫,直接 VACUUM ANALYZE 一张表,比等 autovacuum 自动兜底要靠谱得多。

4.2 autovacuum 和 autoanalyze:让数据库自己干活

生产环境不可能全靠人手去刷统计,PG 自带的 autovacuum 会自动触发 vacuum 和 analyze。相关参数有四个:autovacuum_vacuum_threshold 默认 50,autovacuum_analyze_threshold 默认 50,autovacuum_vacuum_scale_factor 默认 0.2,autovacuum_analyze_scale_factor 默认 0.1。大致触发逻辑是:当表上被修改的行数超过"阈值 + 比例 × 当前行数"时,后台进程就会启动对应操作。

这个机制平时很好用,但有两个窗口期要注意。第一,修改是持续累积的,如果业务流量不够大,修改量还没过阈值,统计就一直停留在旧状态,某些低频更新表非常容易中招;第二,大批量任务经常在极短时间内完成修改,等 autovacuum 真正跑起来,可能已经是几分钟甚至更久以后,中间进来的查询就会吃到过期统计。所以 autovacuum 是兜底,不是万能。

4.3 default_statistics_target 调到多少才合适

前面说过,default_statistics_target 默认是 100。对大多数表来说,这个值够用了。但碰到高基数、分布极度倾斜、或者经常用于等值过滤的列,100 个 MCV 和 100 个桶可能不够细。我习惯把报表库中某些核心维度列的统计目标单独调大,比如 ALTER TABLE ... ALTER COLUMN status SET STATISTICS 500,然后再 ANALYZE。

调大之后最直接的变化是 ANALYZE 变慢、pg_statistic 占用空间变大。我实测过一张 2000 万行的表,统计目标从 100 提到 1000,ANALYZE 耗时从 6 秒涨到 23 秒,磁盘占用从十几兆涨到几十兆,但某些范围的估算误差从 20 倍降到 3 倍以内。这个性价比要看具体场景,不要全库无脑调大;只针对查询热点列做精细化调整,收益会好很多。

4.4 校准效果怎么验证:EXPLAIN 里的两个数字

校准有没有效果,别靠感觉,用 EXPLAIN 看两个数字:估算行数(rows=)和实际行数(actual rows=)。EXPLAIN ANALYZE 会真正执行 SQL,所以生产环境要谨慎,可以先在从库或者把查询条件换成 1=0 的变体来试。怎么看偏差:估算行数和实际行数相差一个数量级以内,通常问题不大;相差三个数量级以上,基本可以断定统计严重失真。

一个小技巧:EXPLAIN (ANALYZE, BUFFERS) 还能看到实际读了多少块缓存,这有助于区分"估算行数不准"和"成本参数配置不合理"。比如实际返回的行数和估算差不多,但优化器死活走全表,那是成本参数的问题,而不是统计信息的锅。这个区分很关键,否则你会白调半天统计。

5. 常规统计覆盖不到的区域,交给扩展统计

5.1 多列依赖统计:dependencies 修正组合条件的低估

多列相关性这一块,PostgreSQL 提供了 CREATE STATISTICS 扩展统计对象。最常见的类型是 dependencies,用来发现列与列之间的函数依赖关系。用法很简单:CREATE STATISTICS st_users_city_dep ON country, city FROM users; 然后 ANALYZE users。之后优化器在遇到同表的多个列过滤条件时,会参考依赖程度,不再傻傻地把单列选择率相乘。

这套机制对"国家-城市"这类天然有依赖的字段格外有效。建完依赖统计后,我之前那个组合条件的行数估算可以从低估 200 倍缩小到基本贴合实际。但要记住,扩展统计对象也依赖 ANALYZE 来写入数据,所以新建统计对象之后必须手动 ANALYZE 一次,否则它不会生效。

5.2 ndistinct:多列 GROUP BY 的唯一值估算救星

第二种扩展统计类型是 ndistinct,它专门估算多列组合的去重值数量。比如 GROUP BY a, b,如果 a 和 b 完全不相关,去重数大约是 a 的去重数乘 b 的去重数;如果相关性强,实际去重数会小很多,甚至接近单列去重数。默认情况下 PG 按乘积估算,GROUP BY 之后如果实际组数远小于估算组数,内存预分配和排序策略都会被误导。

William? 不,回到正题。CREATE STATISTICS st_ex_nd ON a, b FROM t 配合 ANALYZE,就可以让优化器直接用组合去重数。我在一个商品属性表上验证过效果:单看每一列去重数都是几千,组合去重数实际只有一万出头,但默认估算会把它们乘到几百万,优化器差点选了一个非常夸张的聚合计划。建完 ndistinct 统计之后,计划才回到合理范围。

5.3 表达式统计:函数包装列不再是统计盲区

另一种常见失真场景是 WHERE lower(email) = 'xx' 这类函数表达式。优化器无法直接使用 email 列上的普通统计来估算 lower(email) 的选择率,只得按默认的固定比例猜,结果通常很糟糕。PostgreSQL 14 开始支持为表达式建扩展统计,直接写 CREATE STATISTICS st_expr ON (lower(email)) FROM users,再 ANALYZE 一次,就可以为这个表达式本身收集 MCV、直方图等信息。

如果你的版本还没到 14,或者表达式很复杂,一个绕开办法是建生成列,把 lower(email) 物化成一列,再在这列上建索引和统计。不过生成列会占用额外的存储空间,每次写入都有计算成本,是否值得要结合写入频率判断。表达式统计现在最大的限制是:查询里的表达式必须和统计定义里的表达式写法一致,稍有不同就匹配不上,比如大小写、多余括号都不行。

5.4 扩展统计的边界:不要看见什么列就想建

扩展统计不是越多越好。每建一个统计对象,ANALYZE 时都要额外采样计算,pg_statistic_ext 表也会增长;同理,不是所有列组合都值得建,我只建议给两类列建:一是高频出现在 WHERE/GROUP BY 的组合维度,二是已知有条件依赖关系或者组合去重数严重偏离乘积的字段。

另外,扩展统计对象并不能替代单列统计。它的定位是"补充层",优化器对单列条件的估算仍然由 pg_statistic 负责。碰上组合爆炸的场景,比如一次查询带了 8 个条件,你不可能穷举所有组合建扩展统计。真到那一步,更该思考是不是查询模式本身可以精简,而不是继续堆统计信息。

6. 一次完整的慢查询排查链路:照着做就行

6.1 用 pg_stat_statements 快速揪出可疑SQL

排查慢查询,我一般先开 pg_stat_statements 扩展,它默认不算常见插件,但一旦启用就能持续记录每条 SQL 的总执行时间、调用次数、平均耗时、缓冲区命中情况。定位问题快的 SQL,一条语句搞定:

SELECT calls, mean_exec_time, max_exec_time, rows, round(100 * mean_exec_time / sum(mean_exec_time) OVER (), 2) AS time_pct FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 20;

拿到可疑 SQL 之后,照着原文去分析它的 WHERE、JOIN 条件涉及哪些表,决定是不是要进入下一步。这一步主要是缩小范围,省得漫无目的地翻日志。

6.2 EXPLAIN ANALYZE 里对比"估的"和"实的"

第二步是给可疑 SQL 套一层 EXPLAIN ANALYZE,重点看每个节点上的两行数字。下面是我处理过的模拟输出,截取其中一个 Scan 节点:

Seq Scan on orders (cost=0.00..312458.20 rows=4800000 width=42) (actual time=0.523..4520.188 rows=4803955 loops=1) Filter: (status = 'done'::text) Rows Removed by Filter: 27000

这里估算 480 万,实际也是 480 万,反而说明当前这个节点是准的。真正要警惕的是中间层节点上估算 800、实际 480 万这种割裂。一旦发现某个节点估算和实际差了两三个数量级,就去定位它扫描的是哪张表、过滤条件是什么字段,然后直奔 pg_stats。

6.3 翻 pg_stats 定位失真类型

第三步不是瞎猜,直接查 pg_stats 里对应表、列的那一行:

SELECT attname, null_frac, n_distinct, most_common_vals, most_common_freqs, histogram_bounds FROM pg_stats WHERE schemaname = 'public' AND tablename = 'orders' AND attname = 'status';

如果 most_common_vals 里根本没有 'done',而 most_common_freqs 中最高的那个频率也只是 0.05,同时你确认线上 'done' 已经占了 85%,那就是典型的 MCV 过期。如果 n_distinct 显示是 -1(表示全部唯一),但你实际知道这列只有三个枚举值,那显然也是统计信息和现实脱节。定位到这一步,问题类型基本清晰了。

6.4 修正、复测、回滚:一套动作一气呵成

修正动作按优先级来:轻量级是直接 ANALYZE 对应表;如果 ANALYZE 之后还是不准,再考虑调大列的 STATISTICS 目标;要是多列组合条件,就建扩展统计。每一次修正后跑一次 EXPLAIN ANALYZE,对比同一节点的估算行数、实际行数和总耗时。

阶段估算行数实际行数执行时间
修复前80048000003m40s
ANALYZE后4200000480000028s
调目标+扩展统计后4600000480000019s

还要留意一个问题:如果 SQL 是通过 PreparedStatement 或服务端预处理跑高频查询,PG 在第一次执行时生成的计划可能被缓存复用。也就是说,哪怕你修好了统计,旧计划还会顶一阵子。这时候要么在测试环境重连会话,要么执行 PREPARE 重建计划,别误以为统计修正无效。

6.5 容易忽略的边界场景记录

有几个边角场景我想单独列出来,因为踩过不止一次。第一个是并行查询,EXPLAIN 里估算行数看着准确,但并行 worker 数量选择错误,导致并行度不足或过度,这种问题根源往往也在统计信息里的行数和页数关系。第二个是数据分区的场景:分区表的统计需要分别分析每个分区,父表的统计信息是汇总层面的,如果只 ANALYZE 父表,子分区的统计可能依然陈旧。

第三个是模拟环境验证的坑。很多团队喜欢在测试库复现生产慢查询,但如果测试库的数据量和分布跟生产完全不一样,统计信息自然不同,复现出来的执行计划也可能完全不同。我在测试库上见过一条 SQL 好好的,一上生产就慢,最后发现原因就是测试库的某张表压根没有 ANALYZE 过。所以做这类验证之前,先确认源头表的数据规模和分布,别让错误复现把结论带偏。

这段时间处理下来,我最大的体会是:PostgreSQL 的统计信息看着是个小东西,平时不声不响,但它一旦失真,能让一条不错的 SQL 跑得比全表扫描还难看。排查慢查询的时候,别先把锅扣在代码和索引头上,养成先看 EXPLAIN 估算、再翻 pg_stats 的习惯,很多问题五分钟就能找到方向。我自己现在做数据库巡检,会把"近期是否发生过大批量数据变更、对应表是否及时 ANALYZE"写进固定检查项,这比等慢查询报警出来再救火要省事得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进