Alexander Ioffe:EXPLAIN ANALYZE 的样本量为 1:你的查询第二次运行快了 1100 倍,但这并非缓存所致。

Alexander Ioffe:EXPLAIN ANALYZE 的样本量为 1:你的查询第二次运行快了 1100 倍,但这并非缓存所致。

💡 原文英文,约4700词,阅读约需18分钟。
📝

内容提要

以无索引查询为例:当表大于 shared_buffers 的四分之一时,Postgres 默认启用 synchronize_seqscans,新的顺序扫描会从上次停止处开始。首次扫描耗时 78 毫秒、读取 31598 页;后续仅 0.07 毫秒、读取 14 页,快约 1100 倍,这并非缓存所致。单次 EXPLAIN ANALYZE 只是样本量为 1,应多次运行并比较标准差与均值之比,以识别异常单次结果或累积效应。

🔎

延伸解读

单次 EXPLAIN ANALYZE 的样本量陷阱

文章指出,单次 EXPLAIN ANALYZE 的结果只是样本量为 1 的抽样,无法反映执行时间的分布。示例中,同一查询首次运行 78 毫秒,后续仅 0.07 毫秒,快约 1100 倍,但缓存早已预热。标准差与均值之比(stddev/mean)可帮助识别异常:当该比值接近 √N(如 N=10 时约 3.16),通常意味着某一次运行与其他运行显著不同。因此,应多次运行并观察离散程度,而非依赖单次结果。

synchronize_seqscans 如何导致性能差异

当表大于 shared_buffers 的四分之一时,Postgres 默认启用 synchronize_seqscans,新的顺序扫描会从上次扫描停止的位置开始。示例中,首次扫描从表头开始,耗时 78 毫秒,读取 31598 页;后续扫描从上次停止处附近开始,仅 0.07 毫秒,读取 14 页。关闭该设置后,所有运行均从表头开始,耗时稳定在约 80.8 毫秒,标准差降至 1.3%。这表明性能差异并非缓存所致,而是共享扫描位置的状态通道。

预热缓存可能掩盖真实性能问题

常见的基准测试建议先预热缓存,但这会消除 synchronize_seqscans 等状态通道的影响。示例中,在十次计时运行前执行一次预热,所有运行均从租户 42 的聚集区开始,耗时仅 0.08 毫秒,标准差 0.0099。然而,这掩盖了首次请求在竞争扫描后可能付出的高昂代价。因此,测量状态通道时,预热反而会误导,应避免或明确区分冷热运行。

利用 pg_stat_statements 发现离散性

pg_stat_statements 已提供每个语句的 min、max、mean 和 stddev 执行时间。通过计算 stddev/mean 并排序,可以快速定位具有双峰行为的查询。例如,比值接近 1 且 max 远大于 min,可能意味着计划翻转、缓存冷热、参数变化或顺序扫描起始位置依赖前序操作。文章建议,对于可疑查询,应在可丢弃环境中多次运行并检查计划节点的离散程度,而非仅看均值。

❓

Q&A

为什么同一个查询第二次运行会快1100倍,而且不是缓存的原因?

因为当表大于 shared_buffers 的四分之一时,Postgres 默认启用 synchronize_seqscans,新的顺序扫描会从上次扫描停止的位置开始。第一次扫描从表头开始,需要遍历大量不匹配的行;第二次扫描直接从上次停止处附近开始,很快找到匹配行。示例中第一次耗时 78 毫秒、读取 31598 页,后续仅 0.07 毫秒、读取 14 页,快约 1100 倍,且所有页都在 shared_buffers 中,没有发生磁盘读取。

synchronize_seqscans 是什么?它什么时候生效?

synchronize_seqscans 是 Postgres 的一个参数,默认开启(自 8.3 版本起)。当表的大小超过 shared_buffers 的四分之一时,新的顺序扫描会从最近一次扫描报告的位置开始,并在到达表末尾后回绕。扫描每 16 页(128 kB)报告一次位置。这可能导致没有 ORDER BY 的查询返回的行顺序不可预测。

如何判断 EXPLAIN ANALYZE 的单次结果是否具有代表性?

单次 EXPLAIN ANALYZE 只是样本量为 1,应多次运行并计算执行时间的均值和标准差。如果标准差与均值的比值接近 √N(例如 N=10 时约 3.16),说明有一次运行与其他运行显著不同;如果比值接近 0.6 且随运行次数增加而增大,说明存在累积效应。还可以通过关闭 synchronize_seqscans 或增加预热运行来验证。

为什么关闭 synchronize_seqscans 后,查询时间变得稳定但更慢?

关闭 synchronize_seqscans 后,每次顺序扫描都从表头开始,不再利用上次扫描的停止位置。因此每次运行都需要遍历大量不匹配的行,执行时间稳定在约 80.84 毫秒,标准差仅 1.06 毫秒(约 1.3%)。这证实了之前的快速运行是由于共享扫描位置导致的,而非缓存。

在同一个语句中,两个相同的 LIMIT 查询为什么会返回不同的行?

因为 synchronize_seqscans 会移动共享的扫描起始位置。第一个扫描从表头开始,返回一部分行后停止,并更新共享位置;第二个扫描从更新后的位置开始,因此可能跳过一些行,返回不同的结果。即使没有并发写入,只要表超过阈值且使用顺序扫描,没有 ORDER BY 的 LIMIT 查询就可能出现这种情况。

如何用 pg_stat_statements 发现生产环境中执行时间波动大的查询?

可以查询 pg_stat_statements,计算 stddev_exec_time 与 mean_exec_time 的比值,并按比值降序排列。例如:SELECT left(query, 60), calls, min_exec_time, mean_exec_time, max_exec_time, stddev_exec_time, stddev_exec_time / GREATEST(mean_exec_time, 0.01) AS ratio FROM pg_stat_statements WHERE calls >= 20 ORDER BY ratio DESC LIMIT 15; 比值接近 1 且 max 远大于 min 的语句可能具有两种执行模式,需要进一步用多次 EXPLAIN ANALYZE 分析。

🏷️

标签

➡️

继续阅读