新开张的快递调度中心里,调度员小路第一天排任务,系统让他选“大路直送”还是“巷子穿梭”,可他连每个片区有多少住户都不知道,只能闭眼瞎猜。直到组长递给他一本《城市配送导航手册》——哪条街人多、哪个小区收货量大、哪些地址常被拒收,全写在上面。小路从此不再靠运气。 数据库的查询优化器就是这样的调度员:接到一条 SQL,它要在全表扫描、索引扫描、多种连接方式里选出最便宜的路线;而它选路的凭据,正是提前收集好的索引统计信息(statistics)。手册怎么记录、多久更新、会不会过时,直接决定每一条查询的行驶路线。这篇文章就用这本导航手册的故事,讲透统计信息的原理与维护。

岗位关联

统计信息是查询优化器做决策的底层情报,理解它,你就能解释“为什么加了索引反而变慢”“为什么同一个 SQL 时快时慢”,这是从普通 SQL 使用者进阶为高级 DBA 与性能工程师的关键一步。

目录
  1. 可视化演示
  2. 统计信息概览
  3. 直方图估算
  4. 高频值列表
  5. 采样分析机制
  6. 自动统计收集
  7. 统计过期退化
  8. 手动干预维护
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

统计信息概览

新开张的快递调度中心里,调度员小路第一次排任务,系统让他选“大路直送”还是“巷子穿梭”,可他连每个片区有多少住户都不知道,只能闭眼瞎猜。直到组长递给他一本《城市配送导航手册》,上面记着每条街的住户密度、收货习惯和空置率,小路这才敢拍板选路线。没有这本手册,再聪明的调度员也只是个盲人。

数据库的查询优化器就是这位调度员。收到一条 SQL 后,它要在全表扫描、索引扫描、哈希连接等多种方案中挑“最便宜”的执行计划,而挑路的依据正是提前收集好的统计信息(statistics):表有多少行、某列的值大致如何分布、空值占比多少。这些信息不是实时扫描全表得到的,而是由建索引、ANALYZE 等操作触发生成,存放在数据库的系统目录中。

统计信息不保存每一行明细,只保存概括性的“画像”,因此体积小、生成快。优化器先拿画像估算“这条条件会命中多少行”,再把行数代入代价模型,决定走哪条路。

手册里的数字越真实,调度员的路线就越靠谱。统计信息正是这样一份“越准越值钱”的导航手册,它是整条优化链路的第一块地基。

r^=N×s\displaystyle \hat{r} = N \times s
估算返回行数 = 表总行数 × 查询条件的选择度。选择度越接近 1,说明越多的行会被命中,扫描方式就越倾向于全表扫。
  1. 总行数 N优化器从表的统计元数据里拿到的最新估算行数,相当于手册上记的“片区总住户”。
    N=reltuplesN = \text{reltuples}
  2. 选择度 s查询条件过滤后剩余行数占总行数的比例,范围在 0 到 1 之间,0 表示几乎全被过滤。
    s∈[0,1]s \in [0, 1]
  3. 估算行数两者相乘得到预计返回行数,这个值直接决定优化器选全表扫描还是索引扫描。
    r^=N×s\hat{r} = N \times s

直方图估算

导航手册里最有用的是一张“城区人口分布图”:它把一条大街按路口切成若干段,每一段标注大致居住了多少户。查询优化器手里的直方图(histogram)就是这张分布图,它把某列的值域切成一个个“桶(bucket)”,每个桶记录落入该范围的样本比例。

当 SQL 里的条件命中某个值域时,优化器不可能逐一数行,它只能看条件跨过哪几个桶,按桶里的频率折算。比如查询“下单量在 50 到 70 之间”的订单,而直方图在 50~60、60~70 各有一个桶,优化器就假设桶内数据是均匀铺开的,再乘上桶的占比。

每块街区画得越细,估算就越准,但网格太密,手册也会臃肿。数据库要从桶数量和估算精度之间找平衡。

r^=N×∑i=1k(fi×oi)\displaystyle \hat{r} = N \times \sum_{i=1}^{k} (f_i \times o_i)
总估算行数等于每个桶的“频率 × 重叠比例”累加后,再乘表总行数。桶越多,分布图越精细,但存储与维护成本也越高。
  1. 桶数量 k直方图把列值范围切分成 k 个桶,相当于分布图被切成 k 段。
    k=bucket countk = \text{bucket count}
  2. 桶频率 f_i第 i 个桶的样本行数占全部样本的比例,它回答“这个街区住了全城多少人”。
    fi=cinf_i = \frac{c_i}{n}
  3. 重叠比例 o_i查询区间与第 i 个桶相交的长度占整个桶宽的比例,桶内按均匀分布折算。
    oi=overlap(q,bi)widthio_i = \frac{\text{overlap}(q, b_i)}{\text{width}_i}
  4. 累加输出把所有桶的贡献累加,再乘上表总行数 N,得到最终估算。
    r^=N×∑i=1kfioi\hat{r} = N \times \sum_{i=1}^{k} f_i o_i

高频值列表

分布图有个致命弱点:如果某个收货地址特别热门,比如“电商 1 号仓”承包了全城 30% 的包裹,它在均匀分布图里只会被摊平成一格,优化器会严重低估它。于是手册的首页多了一张“热门地址榜”——MCV(Most Common Values,高频值列表),专门记录出现频率最高的前 N 个值及其精确占比。

优化器处理查询条件时先查榜:如果命中 MCV 里的值,直接使用记录的精确频率;没上榜的值,才退回直方图去按桶估算。NULL 和列间相关性统计也单独记录,避免优化器对空值和关联列做出离谱猜测。

热门榜和分布图是配套使用的:榜上高频值用精确频率,榜外长尾值用桶估算,两者互补,优化器才不会被“头部效应”骗走。

c=∑v∈Mf(v)+fnull\displaystyle c = \sum_{v \in M} f(v) + f_{\text{null}}
MCV 覆盖率等于榜单上所有高频值频率之和,再加上 NULL 的频率。覆盖率越高,查询“有据可查”的比例越大。
  1. MCV 集合 M榜单上排在最前面的若干个值,数量通常由数据库配置决定,如 PostgreSQL 的 default_statistics_target。
    M={v1,v2,…,vn}M = \{v_1, v_2, \dots, v_n\}
  2. 单个值频率 f(v)该值在样本中出现的次数除以样本总数,是榜单上的“热度百分比”。
    f(v)=count(v)nf(v) = \frac{\text{count}(v)}{n}
  3. NULL 频率空值单独记录一条比例,因为空值既不进直方图也不进 MCV,必须独立处理。
    fnull=count(NULL)nf_{\text{null}} = \frac{\text{count}(NULL)}{n}

采样分析机制

要给全城几百万人挨家挨户登门造册,成本高到无法承受。勘察队的做法是抽查:选几条代表性街道,数清这几条街的住户,再按比例推算全城。数据库执行 ANALYZE 命令时也这样做——它不会逐行读取整张表,而是随机抽取一部分数据页和行,用样本估计真实分布。

采样必然带来误差:样本越少,运气成分越大,估算越容易偏离真实值。数据库通过控制采样率来平衡精度与开销:小表干脆全表统计,大表则按比例抽样。工程实现上,PostgreSQL 使用基于随机页的采样,避免只扫描文件开头而忽略末尾的数据。

抽查虽然带着误差,却让手册的更新成本从“全城普查”降到了“几条街勘察”,换来的是可以接受的精度。这就是 ANALYZE 最核心的工程智慧。

e≈p(1−p)n\displaystyle e \approx \sqrt{\frac{p(1-p)}{n}}
这是比例估计的标准误差公式:p 是真实比例,n 是样本量。样本量越大,误差越小;当样本量接近全表时,误差趋近于零。
  1. 样本量 n勘察队实际抽查的行数,由采样率乘以表行数得到。
    n=sample_rate×Nn = \text{sample\_rate} \times N
  2. 真实比例 p某个值在全表中真实出现的比例,这是我们想估计却没直接量到的答案。
    p=count(v)Np = \frac{\text{count}(v)}{N}
  3. 误差 e估算比例的波动范围,n 越大分母越大,误差越小,结果越可信。
    e≈p(1−p)ne \approx \sqrt{\frac{p(1-p)}{n}}

自动统计收集

城市每天都在变:新区入住、老区拆迁、商场开业,靠人工盯手册迟早过时。因此快递公司设了一个“后台巡检队”——数据库里的 autovacuum 自动统计收集机制。它常驻后台,持续观察每张表的数据变化量,攒到一定程度就自动派队重新统计。

触发条件不是“时间到了就跑”,而是看表里新增、修改、删除的行数是否达到阈值。如果一张一亿行的大表只改了 100 行,没必要兴师动众重新普查;但如果批量导入了 500 万行,就必须立刻更新手册。阈值由固定值加比例因子共同决定,兼顾小表的敏感与大表的稳定。

巡检队不会因为一个包裹就整城重扫,也不会等到城市面目全非才行动,而是按变化量决定出击节奏。合理的阈值是“效率”和“时效”的平衡点。

T=max⁡(A,  S×N)\displaystyle T = \max(A, \; S \times N)
自动触发阈值取“固定阈值”和“当前行数 × 比例因子”中的较大值。行数越多,需要积累的变更量越大,避免频繁触发。
  1. 固定阈值 A参数 autovacuum_analyze_threshold,通常是一个很小的值,保证小表也能及时触发。
    A=analyze thresholdA = \text{analyze threshold}
  2. 比例因子 S参数 autovacuum_analyze_scale_factor,默认约 0.1,含义是“变更比例超过总行数的 10% 才值得重扫”。
    S=0.1S = 0.1
  3. 当前行数 N表的最新估算行数,乘上比例因子后得到一个随表规模增长的动态门槛,两者取大者。
    T=max⁡(A,S×N)T = \max(A, S \times N)

统计过期退化

旧城区改造完成,枢纽立交封路,可导航手册还是三年前的版本。调度员小路照着旧地图,把货车全派进了一条已经变成步行街的老路。统计过期(statistics staleness)在数据库里也一样致命:数据分布早已漂移,优化器手里的手册却停留在过去,于是估算行数严重失真,选出的执行计划自然偏离最优。

最典型的症状是“该走索引却全表扫描”:某列新增了几百万行,直方图还停留在旧分布,优化器以为条件只命中 1% 的行,选了索引扫描,实际命中了 60%,导致大量随机读;反过来,也可能该全表扫描时被误导去走失效的索引。代价模型的输入错了,输出必然错。

当偏差率飙高时,DBA 就该意识到:导航手册已经配不上这座变化的城市,再聪明的优化器也会被一本旧地图带沟里。

m=∣r^stale−r∣r\displaystyle m = \frac{\left| \hat{r}_{\text{stale}} - r \right|}{r}
偏差率 m 等于“旧估算行数与实际行数差值的绝对值除以实际行数”。偏差率越大,统计过期导致的计划退化越严重。
  1. 旧估算行数统计过期时,优化器拿到的还是历史样本算出来的行数。
    r^stale=Nold×sold\hat{r}_{\text{stale}} = N_{\text{old}} \times s_{\text{old}}
  2. 实际行数 r查询真正返回的行数,是唯一能衡量估算对错的标尺。
    r=actual rowsr = \text{actual rows}
  3. 偏差率 m用实际行数归一化误差,m 为 0 时完全准确,m 越大说明手册与真实世界偏离越远。
    m=∣r^stale−r∣rm = \frac{|\hat{r}_{\text{stale}} - r|}{r}

手动干预维护

大促前夜,调度中心不等后台巡检队的周例行动作,直接派一队勘察员全城复核热门路线。数据库运维也是这样:在关键场景下,DBA 会显式执行 ANALYZE TABLE 强制更新统计信息,而不是被动等待自动收集。大批量 ETL、数据迁移、历史数据清理之后,统计信息往往会立刻失效,此时手动干预就是最快的纠偏手段。

手动维护通常组合操作:先 VACUUM 清理死元组腾出空间,再 ANALYZE 重新采样建手册;更重型的 VACUUM FULL 则只在碎片极其严重时使用,因为它会锁表重建,代价远高于温和的统计刷新。运维上建议把这类操作放进维护窗口,避开业务高峰。

自动巡检负责日常养护,手动干预负责关键时刻的紧急救援。好的 DBA 既依赖自动机制,也懂得在数据剧烈变化时主动更新导航手册,让优化器永远有一张可信的图。

a=1−∣r^fresh−r∣r\displaystyle a = 1 - \frac{\left| \hat{r}_{\text{fresh}} - r \right|}{r}
更新后的准确率 a 用“1 减去新估算与实际行数的偏差率”衡量。a 越接近 1,说明新统计越可信,执行计划越可靠。
  1. 新统计估算行数执行 ANALYZE 后,优化器拿到的全新估算值,对应勘察队带回的最新住户数据。
    r^fresh=Nnew×snew\hat{r}_{\text{fresh}} = N_{\text{new}} \times s_{\text{new}}
  2. 实际行数 r查询真实返回的结果行数,作为评估准确度的基准。
    r=actual rowsr = \text{actual rows}
  3. 准确率 a用 1 减去归一化误差,值越接近 1,说明手册与真实城市越吻合。
    a=1−∣r^fresh−r∣ra = 1 - \frac{|\hat{r}_{\text{fresh}} - r|}{r}

AI 应用场景

电商慢查询救援

阶段:查询性能诊断与优化

解决的问题:传统排障靠 DBA 手工看 EXPLAIN 和统计信息,耗时且容易漏掉“统计过期”这一隐蔽根因。AI 诊断工具自动对比估算行数与实际行数,标记偏差率异常的表,并建议执行 ANALYZE 刷新统计。

具体案例:某电商订单表近千万行,订单明细左连接查询从 8 秒飙升到 30 秒。AI 助手发现 status 列新增状态值未出现在直方图中,判断统计过期;在维护窗口执行分析的 ANALYZE 后,查询降到 0.3 秒。

数仓 ETL 后自动统计刷新

阶段:数据管道维护与自动化运维

解决的问题:数据仓库每晚 ETL 批量写入数百万行,统计信息来不及自动触发更新,第二天早晨报表查询全表扫描严重超时。AI 调度平台根据表写入量预测统计失效风险,在 ETL 完成后自动触发 ANALYZE。

具体案例:某金融数仓在 20 张核心事实表 ETL 完成后,由调度系统按“写入行数超过当前表行数 10%”的规则自动刷新统计,避免了次日 90% 的报表慢查询。

Python 演示

python54 行
# -*- coding: utf-8 -*-# 简化版直方图统计与 ANALYZE 模拟import random class Histogram:    def __init__(self, edges, counts):        self.edges = edges        self.counts = counts     def estimate(self, low, high):        total = 0.0        for i in range(len(self.edges) - 1):            left = self.edges[i]            right = self.edges[i + 1]            if right <= low or left >= high:                continue            overlap = (min(high, right) - max(low, left)) / (right - left)            total += self.counts[i] * max(0.0, overlap)        return int(round(total)) class TableData:    def __init__(self, values):        self.values = values     def analyze(self, sample_ratio):        n = len(self.values)        k = max(1, int(n * sample_ratio))        sample = random.sample(self.values, k)        lo = min(sample)        hi = max(sample)        width = (hi - lo) / 4.0        edges = [lo + width * i for i in range(5)]        counts = [0] * 4        for v in sample:            idx = min(3, int((v - lo) / width))            counts[idx] += 1        return Histogram(edges, counts) def main():    random.seed(42)    values = [random.randint(10, 20) for _ in range(600)]    values += [random.randint(30, 80) for _ in range(400)]    stale = Histogram([0, 30, 60, 90, 120], [50, 300, 200, 50])    low, high = 15, 85    actual = sum(1 for v in values if low <= v <= high)    est_old = stale.estimate(low, high)    fresh = TableData(values).analyze(0.25)    est_new = fresh.estimate(low, high)    print("实际返回行数:", actual)    print("旧统计估算:", est_old)    print("ANALYZE后估算:", est_new) if __name__ == "__main__":    main()
目的:模拟优化器用新旧统计信息估算范围查询行数的过程,直观展示统计过期与 ANALYZE 刷新带来的差异。
  1. class Histogram:封装直方图的桶边界和每个桶的样本计数。
  2. overlap = (min(high, right) - max(low, left)) / (right - left)计算查询区间与单个桶的重叠比例,是选择度估算的核心一步。
  3. def analyze(self, sample_ratio):模拟 ANALYZE:按比例从表中随机抽样,而不是全量扫描。
  4. sample = random.sample(self.values, k)随机抽样体现勘察队抽查逻辑,样本量 k 由采样率决定。
  5. counts[idx] += 1把采样值落入对应桶,逐步构建新直方图。
  6. stale = Histogram([0, 30, 60, 90, 120], [50, 300, 200, 50])构造一份过时的旧统计,模拟手册滞后。
  7. fresh = TableData(values).analyze(0.25)用 25% 的样本重建统计,模拟手动或自动 ANALYZE。

常见误区

误区我一直以为统计信息是数据库自动实时维护的,插入一条数据后,优化器马上就能拿到最新分布。既然系统全程接管,为什么还要手动关心?

正解统计信息靠采样和定期任务维护,不是行级实时更新。每次写入都全表重算代价太高,数据库通常按变更量阈值触发 ANALYZE,或在维护窗口执行。就像导航手册不会因为一个人搬家就整本重印,只有板块变化足够大才更新。手动干预依然是最可靠的兜底手段。

为何常见:数据库自动做事的黑盒感太强,用户容易把“自动”误解为“实时”。

误区直方图把值分成桶之后,桶内数据一定是均匀分布的,所以可以放心地用桶宽直接推算选择度。

正解桶内均匀只是一个工程近似假设,真实数据经常倾斜,比如 60~70 这个桶里 90% 的行都挤在 60~61。这时按桶宽折算会严重失准。MCV 专门兜底高频值,但桶内剩余部分的低频值仍可能失真,这也是为什么统计信息需要合理配置桶数量。

为何常见:教科书和示意图为了直观,会把直方图画成整整齐齐的方块,让人误以为现实也如此规整。

误区只要给列加了索引,查询就会变快,因为优化器一定会优先走索引。如果没走,就是数据库故意捣乱。

正解优化器选路看的是统计信息算出的代价。当估算出查询要返回 30% 的行时,全表扫描往往比索引扫描更快,优化器会果断弃用索引。统计过期时甚至可能误判。加索引前先看选择度,索引不是万能加速器。

为何常见:很多初学者把索引当成“物理加速键”,忽略了优化器本质上是在做基于代价的决策。

误区我不敢在业务高峰期执行 ANALYZE,怕它像重建索引一样锁住整张表,导致线上写入全部阻塞。

正解ANALYZE 只是对表做只读采样扫描,持有的是轻量级共享锁,通常会很快结束,几乎不阻塞正常读写。真正需要警惕的是 VACUUM FULL 这类重建表物理结构的重型操作。把 ANALYZE 和全表重建混为一谈,是运维事故的常见源头。

为何常见:ANALYZE、VACUUM、VACUUM FULL 三个命令名字相近,行为差异却被忽略了。

误区既然统计过期会拖慢查询,那就定个每分钟自动 ANALYZE 的任务,刷新越频繁手册越新。

正解频繁执行大比例采样会消耗大量 I/O 和 CPU,且收益快速递减——表没怎么变时,重扫只是浪费资源。自动机制用阈值和比例因子控制节奏,只有关键表或大批量写入后才值得额外手动 ANALYZE。

为何常见:把“过期有害”简单推导成“越新越好”,忽略了统计收集本身有成本。

面试题

Q1. 执行 ANALYZE 的主要作用是

单选题 · easy

  • A.重建表中的全部索引
  • B.清理表死元组空间
  • C.采样更新统计信息← 正确答案
  • D.压缩表物理文件

答案:C。ANALYZE 的任务是抽样并更新表的统计信息,相当于勘察队更新导航手册。重建索引、清理死元组、压缩文件分别是 REINDEX、VACUUM、VACUUM FULL 的职责,不能混为一谈。

Q2. 优化器估算某条件返回行数主要依赖

单选题 · easy

  • A.直方图统计信息← 正确答案
  • B.索引树层级结构
  • C.磁盘读写速度
  • D.表文件字节大小

答案:A。优化器判断“命中多少行”依靠直方图、MCV 等统计信息,而不是索引的树高或磁盘参数。索引结构影响访问代价,但行数估算的输入是统计手册。

Q3. 以下属于统计信息内容的有

多选题 · easy

  • A.列值直方图信息← 正确答案
  • B.高频值MCV列表← 正确答案
  • C.NULL值占比统计← 正确答案
  • D.行级锁冲突次数

答案:ABC。统计信息描述数据分布,包括直方图、MCV 和 NULL 比例。锁冲突次数是数据库运行状态指标,不属于列分布的统计内容。

Q4. 直方图桶内估算的基本假设是

单选题 · mid

  • A.桶内所有值都相等
  • B.桶内数据服从均匀分布← 正确答案
  • C.桶与桶之间相互独立
  • D.空桶会被直接舍弃

答案:B。直方图在桶内做均匀分布假设,用重叠比例折算行数。现实数据可能倾斜,因此需要 MCV 补足高频值,而不是假设每个值都相等。

Q5. MCV 列表主要解决的问题是

单选题 · mid

  • A.估算低频长尾数据
  • B.修正高频重复值误差← 正确答案
  • C.处理全部空值场景
  • D.建立列间函数依赖

答案:B。单个高频值可能占据大量行数,若只靠均匀桶估算会被严重低估。MCV 记录 TopN 高频值及精确频率,修正这类误差。低频长尾仍交给直方图。

Q6. 统计信息过期的典型后果有

多选题 · mid

  • A.执行计划质量退化← 正确答案
  • B.索引物理结构损坏
  • C.优化器错估返回行数← 正确答案
  • D.查询结果数据丢失

答案:AC。统计过期让优化器拿旧手册做新决策,典型后果是错估行数、选错执行计划。它不会破坏索引物理结构,也不会丢数据,只是性能变差。

Q7. autovacuum 自动触发 ANALYZE 的条件是

单选题 · hard

  • A.索引膨胀超过预设阈值
  • B.表变更行数达到阈值← 正确答案
  • C.表被长期持锁超过1秒
  • D.磁盘剩余空间低于限值

答案:B。自动 ANALYZE 以变更行数是否超过 max(固定阈值,比例因子 × 当前行数) 为触发条件。索引膨胀、持锁时间、磁盘空间都不是统计刷新的直接触发器。

Q8. 某列高频值更新频繁但统计未刷新,最合理的手动干预是

单选题 · hard

  • A.删除并重建对应索引
  • B.对表执行显式ANALYZE← 正确答案
  • C.执行VACUUM FULL重整
  • D.重启数据库实例进程

答案:B。数据分布已变但统计没跟上,直接 ANALYZE 重采样即可让优化器拿到新手册。REINDEX 管索引结构,VACUUM FULL 管物理碎片,重启实例更是杀鸡用牛刀。

Q9. 关于 ANALYZE 采样机制,说法正确的有

多选题 · hard

  • A.通常采用随机页采样← 正确答案
  • B.采样误差随样本量增大下降← 正确答案
  • C.小表一般直接全表统计← 正确答案
  • D.MCV存储全量明细数据

答案:ABC。ANALYZE 通过随机页抽样估算分布,样本越多误差越小;表很小时抽样成本低,干脆全表统计。MCV 只存 TopN 热门值,不存全量明细。

Q10. 当查询值未命中 MCV 列表时,选择度通常通过什么估算

单选题 · extreme

  • A.用非高频值桶估算选择度← 正确答案
  • B.直接返回一个固定常数
  • C.抛错要求重跑ANALYZE
  • D.强制用户改写SQL语句

答案:A。MCV 负责高频值,直方图负责其余范围。未命中 MCV 的值会回退到直方图桶,用桶频率和重叠比例估算选择度,这是两者的典型互补关系。