EXPLAIN 跑出来的执行计划里,有一列叫 rows。
刚开始学 PostgreSQL 的时候,我以为这列是随便填的——反正真正执行的时候数据库会知道实际行数。后来发现不是。优化器在选执行计划时根本没看过真实数据,它靠的是 rows 这一列的估计值。估计错了,索引选错,连接顺序选错,整个查询慢得像在泥里走路。
那这个数字哪来的?
答案藏在 pg_stats 系统视图里,藏在 ANALYZE 命令收集的统计信息里。但统计信息本身不会直接告诉你"这个 WHERE 条件会命中多少行"——中间还隔着一层叫 selectivity 的东西。
selectivity 本质上不是数据库魔法,是一个很典型的统计估计问题。
翻译成统计语言:已知样本统计量,估计总体里满足条件事件的概率。
$$ \text{selectivity} = P(\text{条件成立}) $$数据库里遇到的 $P(X=x)$、$P(X>a)$、$P(A \cap B)$,都是概率。优化器干的事,就是用 ANALYZE 采的样本,去猜这些概率。
均匀分布是最朴素的假设
从最简单的情况开始。
一张 users 表,有 age 列,总行数 $N = 1000000$。查询:
WHERE age = 20
ANALYZE 之后,pg_stats 里记着 n_distinct = 100,意思是估计有 100 个不同年龄。
PostgreSQL 默认先假设均匀分布。
$$ P(\text{age} = 20) = \frac{1}{100} = 0.01 $$selectivity = 0.01,预计返回 $1000000 \times 0.01 = 10000$ 行。
这是最基础的公式。简单到让人怀疑是不是太天真了——现实里的数据哪有这么均匀。
most_common_vals 里的经验频率
所以 PostgreSQL 还存了 most_common_vals(MCV)和 most_common_freqs。比如 age 列:
18 → 0.25
19 → 0.15
20 → 0.10
再查 WHERE age = 20,直接查表,$P(\text{age} = 20) = 0.10$。不再用 $1 / n_{\text{distinct}}$。
这就是经验频率估计:
$$ \hat{P}(X = x) = \frac{n_x}{n} $$统计学里叫 MLE(最大似然估计)。数据库没发明什么新东西,只是把统计学的标准做法搬了进来。
尾部均摊
麻烦的是查 WHERE age = 35,而 35 不在 MCV 列表里。
PostgreSQL 的处理方式:先把高频值占的概率扣掉,剩下的概率在非高频值之间均摊。
假设高频值总共占了 60%,剩下 40% 的概率分给 90 个非高频值:
$$ P(\text{age} = 35) = \frac{0.4}{90} \approx 0.0044 $$尾部分布均摊。粗糙,但比假设所有值都均匀要合理一点——至少高频值被单独拎出来了。
histogram_bounds:经验 CDF 插值
范围查询更有统计味。
WHERE age < 30
这次 PostgreSQL 看的是 histogram_bounds,比如:
[18, 22, 26, 31, 40, 55]
这是把数据切成近似等频的桶。每个桶大约占 25%。
求 $P(\text{age} < 30)$,拆成两部分:前两桶完整的,加上第三桶里 26 到 30 这一段。
$$ P(\text{age} < 30) = \underbrace{0.5}_{\text{前两桶}} + 0.25 \times \frac{30 - 26}{31 - 26} = 0.5 + 0.25 \times \frac{4}{5} = 0.7 $$这其实就是经验 CDF(经验累计分布函数)的线性插值。桶内假设均匀分布,桶间靠边界值做线性插值。
(第一次看到这个算法的时候,我愣了一下——这不就是初中数学里线性插值的套路吗。但仔细想想,在信息这么少的情况下,线性插值确实是最不坏的选择。你知道桶的边界,知道每个桶占多少比例,桶内分布一无所知,那假设均匀就是最大熵的做法。)
多条件:独立假设
多个条件同时出现时,问题更像统计。
WHERE age = 20 AND gender = 'M'
默认假设独立。
$$ P(A \cap B) = P(A) \cdot P(B) $$如果 $P(\text{age} = 20) = 0.1$,$P(\text{gender} = \text{M}) = 0.5$,得到 0.05,预测 50000 行。
独立假设的好处是简单——两个列的统计信息可以分别维护,不用考虑联合分布。坏处是现实里的列经常相关。
CREATE STATISTICS:打破独立
province 和 city 这两列,明显 $P(\text{city} \mid \text{province}) \neq P(\text{city})$。独立假设在这里会给出离谱的估计。
PostgreSQL 的解法是 CREATE STATISTICS:
CREATE STATISTICS s ON province, city FROM users;
收集联合分布。优化器从 $P(A) \cdot P(B)$ 升级到 $P(A) \cdot P(B \mid A)$。
这一步已经接近统计建模了。代价是 ANALYZE 要花更多时间,存储也要多占空间——联合分布的复杂度是列数的指数级。所以扩展统计不能滥用,只在对着真正相关的列开。
从 selectivity 到成本
selectivity 本身不是目的。它要喂给成本模型。
假设 WHERE age < 30 的 selectivity 是 0.7,表有 100 万行,预计命中 700000 行。每页 100 行,大约要读 7000 页。
走索引的话,7000 次随机读:
$$ 7000 \times \text{random\_page\_cost} $$走全表扫描的话,10000 页顺序读:
$$ 10000 \times \text{seq\_page\_cost} $$两个成本比一下,谁小用谁。
整条链路是:统计估计 → selectivity → 行数估计 → 页数估计 → IO 成本 → 执行计划。
所以 PostgreSQL 的优化器,骨子里是一个概率模型加一个成本函数。ANALYZE 采样,pg_stats 存统计量,selectivity 把统计量翻译成概率,成本函数把概率翻译成 IO 估算。没有什么魔法,每一步都是统计学里现成的方法,只是被拼进了一个工程系统里。
回头看了一遍,发现整篇文章其实没讲什么"数据库知识"——讲的是均匀分布、MLE、经验 CDF、条件概率。这些在概率论课本里都是前几章的内容。数据库的聪明之处不在于发明了什么新算法,而在于知道在什么场景下用哪个现成的统计方法,以及——更重要的——知道这些方法的假设什么时候会崩。