1368次实验证明:PostgreSQL对数据倾斜的基数估计无解——我的AI4S参赛实录

这是一篇关于在AI4S赛事中,用实验方法系统性探索数据库优化器失效边界的参赛记录。这里有1368次实验,多次踩坑,最后用"三层证据链"严格证明的负面结论。


为什么做这个项目

大多数AI4S项目关注的是"用AI解决科学问题",但我的切入点不同------数据库查询优化器本身就是一种"统计学习"系统,它通过采样数据分布来"估计"查询结果行数,进而选择最优执行计划。在做DBA时我就很好奇,当数据分布偏离均匀假设时,这个"估计"会失效吗?在什么条件下失效?失效到什么程度?那时候只知道出了什么症状要怎么调优,但不知所以然,现在有了AI作为辅助,正好来验证下!

这个问题的实际意义是:生产环境中,DBA经常遇到同一查询因数据分布变化而执行时间出现数量级差异。PostgreSQL使用等宽直方图(一种把值域切成等宽区间的统计方法,详见文末附录)来近似数据分布,这种结构对倾斜数据的表达能力有多差?

项目名叫 pg-ceil (PostgreSQL Cardinality Error Injection & Exploration Lab,基数误差注入与探索实验室),GitHub地址:github.com/xyshanren/p...


实验设计:把"模糊的直觉"变成"可控的实验"

核心参数空间

三个维度的参数网格:

维度 参数 取值
数据倾斜度 θ Zipfian分布参数,θ越大越倾斜 {0.5, 0.8, 1.0, 1.2, 1.5, 2.0}
统计采样率 default_statistics_target(PG控制采样精度的参数) {10, 50, 100, 500, 1000, 10000}
查询类型 范围查询+等值查询 9条模板(Q1-Q9)

评价标准是 q-error(一个衡量估计偏差的指标,计算方式见附录):

  • q-error ≤ 3:正常
  • q-error > 10:失效
  • q-error > 100:灾难
  • q-error = ∞:估计行数>0但实际行数=0(值在数据中不存在)

数据集

TPC-H SF=1 的 lineitem 表(工业标准数据库测试基准,规模因子SF=1对应约600万行),对 l_partkey 列(零件编号,有约20万个不同值)施加Zipfian倾斜(一种幂律分布,少数值高频出现、大量值极少出现),生成6张独立的倾斜表。

实验规模

  • 第一阶段(单表):6θ × 6target × 9query × 3repeat = 972次
  • P0(扩展统计验证):3θ × 2target × 3query × 2phase = 36次
  • P1(JOIN误差级联):3θ × 3target × 6query × 3repeat = 162次
  • 解决验证(部分索引+查询改写):3θ × 2query × 3phase = 18次
  • 补充(θ=0基线 + 软失效验证):180次
  • 总计:1368次实验,0次失败

踩坑全记录:从方案到结果之间的十万八千里

坑1:TPC-H dbgen 编译------一个空格引发的血案

在虚拟机上编译TPC-H数据生成器dbgen时,makefile里的 CC = 变量是空的(期望用户自行填写),但我没注意到这点。第一次sed替换没匹配到任何内容,以为编译成功,结果报 g: Command not found------原来某个步骤把CC值变成了单个字母g

教训 :编译前先检查makefile里的关键变量,不要假设默认值存在。直接 CC = gcc 赋值后一次通过。

坑2:tbl文件行尾的 | 分隔符

dbgen生成的 .tbl 文件每行末尾都有一个 |,用PostgreSQL的COPY命令导入时,这个尾部分隔符会导致多出一列,报 extra data after last expected column 错误。

解决方案:在导入脚本里加一步 sed 's/|$//' 去掉行尾管道符。这种"格式陷阱"在数据工程中非常常见。

坑3:Zipfian数据导入------CSV逗号 vs 管道符

生成Zipfian倾斜数据时,第一版用CSV格式导入,结果 l_comment 字段内含逗号导致解析错误。改用 | 作为分隔符才解决。看起来是小问题,但当你在600万行的表上跑COPY命令时,一个格式错误就是5分钟的等待白费。

坑4:EXPLAIN解析取错了节点

摸底验证时,所有查询的q-error都是1.0------完美得不真实。排查发现parser(解析器)取的是最外层Aggregate节点(聚合节点,比如COUNT求总数后只剩1行结果)的行数,而不是底层Scan节点(扫描节点,真正读取表数据的阶段)的估计行数。修复方法是增加 _find_scan_node 函数,递归查找最底层的扫描节点。

教训 :EXPLAIN输出的JSON是一棵计划树(执行计划的分层表示),务必明确你要哪一层节点的估计值。q-error应该衡量的是 叶子节点(最底层的扫描层) 的估计精度,而非聚合后的最终结果。


核心发现:θ≥1.2是PostgreSQL直方图的硬崩溃阈值

L形安全区图谱

972次单表实验的结果画出来是一个清晰的"L形"(以Q3尾部范围查询为例):

css 复制代码
          采样率   10    50    100   500   1000  10000
倾斜度
θ=0.5           [⚠️26  ⚠️3.9  ✅2.2  ✅1.2  ✅1.0  ✅1.0]
θ=0.8           [❌783  ❌96   ❌47   ⚠️9.6  ⚠️4.9  ✅1.8]
θ=1.0           [❌6265 ❌865  ❌360  ❌75   ❌37   ⚠️4.0]
θ=1.2           [❌∞    ❌∞    ❌∞    ❌∞    ❌∞    ❌∞   ]
θ=1.5           [❌∞    ❌∞    ❌∞    ❌∞    ❌∞    ❌∞   ]
θ=2.0           [❌∞    ❌∞    ❌∞    ❌∞    ❌∞    ❌∞   ]

表中数字是q-error值(越小越好),✅表示正常(≤3),⚠️是偏差(3-10),❌是失效(>10),∞表示灾难级(估计有几百行但实际0行)。

解读这个L形:

  • θ≤0.5:采样率≥50就正常,低倾斜下均匀假设偏差小
  • θ=0.8-1.0:软失效区,增大采样率可以部分缓解(采样率=10000时q-error降到4.0)
  • θ≥1.2:硬崩溃,无论采样率多大都无效,q-error=∞

为什么θ≥1.2是死局

θ=1.2时,20万个不同值中只有1648个值实际出现在数据中(最高频的值占了67%)。查询的尾部范围 195000-200000 里的值压根不存在于数据中------实际0行,但优化器仍按直方图估计出几百行

这不是"估得不准",而是信息论层面的盲点:基于采样的直方图无法区分"值不存在"和"采样没看到这个值"。即使采样率调到10000(约扫描30000个数据页,接近全表扫描),Zipfian尾部值出现的概率仍然低到可以忽略。

头部查询免疫:MCV保护

Q1(头部范围查询 BETWEEN 1 AND 5000)在所有θ和采样率下都正常(q-error≈1.0),原因是PostgreSQL的MCV(Most Common Values,最常见值列表)机制独立于直方图单独存储高频值,头部值被MCV精确捕获,不走直方图的均匀假设路径。


五个实验的完整故事

实验1:单表失效边界(972次)✅

上面已经讲过,关键产物:146组失效参数组合,L形安全区图谱。

实验2:CREATE STATISTICS能救吗?(36次)✅

PostgreSQL 17有扩展统计语法 CREATE STATISTICS,看起来像是针对倾斜数据的武器。实验结果:

  • 单列扩展统计直接被语法拒绝 :尝试对单列创建扩展统计,数据库直接报错 extended statistics require at least 2 columns(扩展统计至少需要2列)。四种统计类型全部拒绝单列。
  • 双列扩展统计不参与单列谓词估计 :创建了 (l_partkey, l_shipdate) 两列的扩展统计,但对只涉及 l_partkey 一列的查询条件毫无影响------q-error不变。

结论:PostgreSQL根本没有针对单列倾斜的内置统计手段,这不是"效果不够好",而是语法层就没有应对方法。

实验3:JOIN误差传播三模式(162次)✅

多表JOIN(联表查询)场景下,子节点的估计误差如何传播到父节点?设计了6条JOIN查询,发现三种传播模式:

模式 查询类型 传播比 机制
吸收 2表联表+尾部过滤 <1.0 Hash Join(哈希连接)独立估计子节点行数,不依赖扫描层估计
直传 3表联表+尾部过滤 ≈1.0 中间节点无法修正子节点误差,原样传递
放大 3表联表+分组聚合 >1.0 分组聚合依赖基数估计选择策略,误差叠加

反直觉发现:Hash Join是误差的"防火墙" 。扫描层q-error=∞,但连接层q-error=1.0------因为Hash Join基于构建侧的实际行数(而非估计值)来执行。这意味着2表联表比3表联表更抗倾斜。

另一个关键发现:增大采样率对联表层的灾难级失效完全无效,失效只与倾斜度θ相关,与采样率无关。

实验4:部分索引+查询改写能绕过吗?(36次)✅

既然直方图不行,用部分索引(Partial Index,只对满足条件的数据建索引)绕开呢?PostgreSQL对部分索引单独采集统计,理论上精度远超直方图。

三种方案全部失败:

  1. 部分索引 :对尾部范围建索引,结果索引里一行都没有(因为尾部值不存在)。即便在软失效区(倾斜度适中、值存在但稀少),索引统计精确(8行/11行),但优化器从不使用部分索引统计做行数估计------部分索引统计只影响"选哪个索引"的决策,不影响"估计有多少行"的计算。

  2. 强制走索引扫描:关闭顺序扫描开关,q-error几乎不变(52.6→48.9,仅改善7%)。

  3. UNION ALL改写:把范围查询拆成"索引覆盖的子查询 + 全表扫描子查询"。结果反而更差(52.6→362)。

机制洞察 (这是整个项目最有价值的发现之一):部分索引的统计是精确的,但优化器的架构设计决定了这个信息不会进入行数估计路径。问题不在统计精度,在架构

实验5:θ=0基线补跑(162次)✅

审核阶段发现缺少未倾斜的基线对照,补跑θ=0(均匀分布,没有任何倾斜)的162次实验:全部q-error≤3,0条∞。确认所有失效均由Zipfian倾斜引起,而非查询模板设计缺陷。


三层证据链:怎么证明一个"无解"结论

数据库领域说"无解"需要非常谨慎。我构建了三层证据链来确保结论的严谨性:

层次 实验 证据
结构层 解决验证 直方图无法表达"值不存在",部分索引统计精确但优化器不用
作用域层 扩展统计验证 双列扩展统计不参与单列谓词估计,作用域不覆盖
语法层 扩展统计补盲点 对单列创建扩展统计,四种类型全部被语法拒绝

三层闭合的结论:

PostgreSQL 17 对Zipfian单列倾斜崩溃无内置解法,也无常见外部绕行方案。 根因是等宽直方图+均匀分布假设的结构性限制,叠加优化器统计架构的双层瓶颈(部分索引统计不进入行数估计路径)。

这个负面结论不是"做不出来",而是"被严格证明了做不出来"。在科学研究中,被清晰解释的失败同样有意义------它划定了系统的能力边界,为后续改进指明方向。


项目工程经验

好的决策

  1. 先补漏洞再搭环境 :方案阶段花了一天做漏洞修订(6个漏洞逐一修补),避免了在错误的设计上跑实验。比如原方案没有明确倾斜列选择,修订后才确定用 l_partkey(20万不同值,足够稀疏能触发尾部失效)。

  2. 摸底验证先行:正式972次实验前先跑了一轮smoke test(冒烟测试,10分钟的小规模验证),发现了解析器取错节点的问题。如果直接跑全量,972次实验的数据全是废的。

  3. JSONL日志格式:每条实验结果以JSON Lines写入(每行一个JSON对象),包含时间戳、参数组合、估计行数、实际行数、q-error、计划树。后续分析时用pandas一行代码就能加载。

  4. 断点续跑:运行器支持从中断处恢复,通过检查已完成的参数组合跳过。实验跑了几个小时中途断过一次,续跑节省了大量时间。

  5. 实验代码和数据分析分离:运行器只负责跑实验写日志,统计和可视化负责分析绘图。分析逻辑改动不影响已完成的实验数据。

不好的决策

  1. 扩展统计实验的数据持久化有缺陷:三个倾斜度的实验跑了三轮,但JSON结果只保留了最后一轮(代码里结果列表在阶段切换时被重置)。日志文件里有完整数据,但JSON只有1/3,后来手动写脚本从日志解析补全。

  2. 解决验证的设计有盲点:最初只测了高倾斜度的"值不存在"场景,没测中等倾斜度的"软失效"场景(值存在但稀少)。审核时才发现这个缺口,补了一轮18次软失效验证。好在结论一致(部分索引对软失效也无效),但如果初始设计就覆盖到,会更干净。


负结果的科学价值

这个项目的结论是"PostgreSQL对倾斜数据无解"------一个负面结论,但负面结论的价值在于:

  1. 划定边界:θ≥1.2是硬崩溃阈值,θ∈0.8,1.0是软失效区,θ≤0.5是安全区。DBA可以根据实际数据分布判断风险等级。

  2. 指明根因:不是采样率不够(调到10000无效),不是统计类型不够(扩展统计语法拒绝单列),不是优化策略不对(部分索引统计不进入行数估计路径),根因在直方图结构和优化器架构。

  3. 指引方向:根本解法需要引入值存在性数据结构(如布隆过滤器、HyperLogLog等概率数据结构),而非优化桶型或采样率,这为后续的数据库改进提供了明确方向。

  4. 可迁移性:MySQL用等高直方图(equi-height,每个桶含大致相同的行数),在范围查询上可能优于PostgreSQL的等宽直方图,但等值查询的"值不存在"盲点是共性------这是所有基于采样的直方图的信息论瓶颈。


赛事心得:AI4S不只是"用AI做科学"

参加AI4S赛事,最大的感悟是:AI for Science不只是"用深度学习预测蛋白质结构"这一种范式,任何用系统性方法探索科学问题边界的工作都算AI4S。

pg-ceil项目没有神经网络、没有梯度下降、没有GPU训练。但它有:

  • 假设驱动的实验设计:参数空间网格化、控制变量、重复实验
  • 严格的证据链:三层闭合证明,每层都有独立的实验支撑
  • 可复现性:全部代码开源,Docker一键部署,1368次实验0失败
  • 明确的科学结论:不是"效果不错",而是"被严格证明了无解"

这种"实验科学"范式在AI4S中同样有价值:数据库优化器本质上是一个基于统计的"学习"系统,研究它的失效边界和研究一个神经网络的泛化边界,方法论上是相通的。

对后续参赛者的建议

  1. 问题定义比模型选择更重要:花时间把"到底要回答什么问题"想清楚,比急于选模型、跑baseline有价值得多。pg-ceil的方案修订阶段花了一整天,但省去了后面无数的返工。

  2. 负面结论也要有证据链:如果结论是"做不出来",需要严格证明"在什么条件下、用什么方法、为什么做不出来",一个孤立的"失败了"没有说服力。

  3. 先跑smoke test再跑全量:永远先用小规模实验验证代码逻辑正确性,特别是解析逻辑。跑了几小时发现数据全废的痛苦,我不想再体验第二次。

  4. 记录踩坑过程:实验结果论文里写,踩坑过程文章里写,后者对后来者的价值不亚于前者。


项目成果

指标 数值
实验总数 1368次
失败次数 0次
失效参数组合 146组(q-error > 10)
灾难参数组合 34组(q-error > 100)
发现的硬崩溃阈值 θ ≥ 1.2
证明无效的解决方案 3种(扩展统计、部分索引、查询改写)
证据链层数 3层(结构层 + 作用域层 + 语法层)
代码行数 ~3000行 Python + SQL
GitHub github.com/xyshanren/p...
Tag v1.0.0

所有代码、数据、实验日志、分析报告均已开源,Docker一键部署,任何人可以复现全部实验。


附录:关键术语速查

为了让不熟悉数据库的读者也能看懂,这里集中解释文中出现的术语和符号。

基本概念

PostgreSQL(简称PG):一款开源关系型数据库,以功能丰富、标准 compliance 强著称。本文的实验对象就是它的查询优化器。

查询优化器:数据库接收到SQL查询后,决定"怎么执行"的组件。比如选择走索引还是全表扫描、多表先连哪两个------这些决策依赖于对"每个步骤会涉及多少行数据"的估计。

基数估计(Cardinality Estimation):优化器对查询结果行数的预估。估得准,计划选得好,查询就快;估不准,可能选了很慢的执行方式。本文的核心研究对象。

数据分布相关

Zipfian分布(齐普夫分布):一种幂律分布,少数值高频出现,大量值极少出现。参数θ(希腊字母theta)控制倾斜程度:θ=0是均匀分布(所有值等频),θ越大越倾斜(头部越集中、尾部越稀疏)。现实中的网页访问量、词频、城市人口等都近似服从Zipfian分布。

θ(theta):希腊字母,本文中表示Zipfian分布的倾斜度参数。

等宽直方图(equi-width histogram):PostgreSQL采用的直方图类型。把值域切成等宽的区间(桶),记录每个桶内的行数。问题:尾部稀疏区的桶可能只有0-2行,但优化器假设桶内均匀分布,容易高估。

等高直方图(equi-height histogram):MySQL采用的直方图类型。每个桶含大致相同的行数,桶的宽度自适应数据密度。在范围查询上比等宽直方图更自然,但等值查询的盲点相同。

MCV(Most Common Values,最常见值):PostgreSQL独立于直方图存储的高频值列表。高频值被MCV精确记录,不走直方图的均匀假设,因此头部查询天然免疫。

评价指标

q-error :衡量估计偏差的指标,计算公式为 max(估计行数/实际行数, 实际行数/估计行数)。值域 [1, ∞):1表示完全准确,10表示偏差10倍,∞表示估计有值但实际为0(或反过来)。这个指标的好处是对称的------高估和低估同等惩罚。

q-error = ∞ 的含义:优化器估计查询会返回几百行,但实际上0行。通常因为查询条件涉及的值在数据中根本不存在(Zipfian分布尾部稀疏导致),但直方图基于均匀假设仍然估计出非零行数。

实验相关

TPC-H:工业标准的数据库决策支持基准测试,包含8张表和22条标准查询。本文用它的lineitem(订单明细)表作为基础数据,SF=1表示数据规模因子为1(lineitem约600万行)。

default_statistics_target:PostgreSQL控制统计采样精度的参数,值越大采样越多、直方图越精细,但ANALYZE耗时也越长。默认值100,本文测试了从10到10000的6个值。

EXPLAIN:PostgreSQL的命令,输出查询的执行计划(不实际执行),包含每个节点的估计行数和实际行数。本文通过解析EXPLAIN输出的JSON来计算q-error。

Plan Tree(计划树):EXPLAIN输出的执行计划是一棵树形结构,叶子节点是扫描操作(Seq Scan全表扫描、Index Scan索引扫描),中间节点是连接操作(Hash Join哈希连接、Nested Loop嵌套循环),根节点可能是聚合(Aggregate)。本文的q-error取叶子扫描层的估计值。

解决方案相关

CREATE STATISTICS(扩展统计):PostgreSQL 17的语法,可创建MCV、依赖关系、n-distinct等扩展统计。但实验发现它要求至少2列,单列被语法拒绝。

Partial Index(部分索引):只对满足WHERE条件的数据建索引,索引体积小且统计独立。实验发现虽然它的统计精确,但优化器行数估计不走这条路径。

Hash Join(哈希连接):一种联表算法,先把一侧数据构建成哈希表,再用另一侧逐行探测。关键特性:基于实际行数执行,不依赖子节点的估计值------因此能"吸收"子节点的估计误差。

数据结构相关

Bloom Filter(布隆过滤器):一种概率数据结构,能高效判断"某个值一定不存在"或"可能存在"。可解决直方图无法感知值存在性的盲点。

HyperLogLog:一种概率数据结构,用于估算集合中不同值的数量(基数),内存占用极小。适合补充直方图缺失的全局分布信息。


如果你对数据库基数估计、数据倾斜诊断、或者跨数据库迁移分析感兴趣,欢迎在GitHub提issue交流。下一篇计划写MySQL的等高直方图在同样实验框架下的表现------my-ceil项目正在筹备中。

相关推荐
AI编程实验室1 小时前
Node.js Agent Handoff 仓库扫描 MVP:忽略规则、include 通配与稳定输出实现
前端·ai编程
奈斯先生vector1 小时前
DeepSeek Harness 插件怎么做才不把权限带进 Agent:从一个只读代码审查器开始
aigc·ai编程
XDevelop AI智能应用软件开发4 小时前
AI演进下的“第五次软件危机”与软件工程重塑
人工智能·软件工程·ai编程·软件危机
黑科技iOS上架4 小时前
Qwen3.8-27B日常量化版在 Mac M5 32G配置上表现如何
经验分享·ai编程
Wang's Blog4 小时前
PostgreSQL笔记4: 市场地位、生态全景与行业应用实践
数据库·笔记·postgresql
HelloDong4 小时前
AI 说「修好了」,凭什么信
人工智能·ai编程·claude
QCodingDev4 小时前
Spring AI Alibaba ReAct Agent实战:从Tool Calling到Agent,企业AI复杂业务该如何设计?
java·人工智能·agent·ai编程·spring ai
Jay-r4 小时前
DeepSeek Harness 极简上手:装好、玩熟、让它自己长新能力
人工智能·windows·ai·github·ai编程·deepseek·harness