这是一篇关于在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对部分索引单独采集统计,理论上精度远超直方图。
三种方案全部失败:
-
部分索引 :对尾部范围建索引,结果索引里一行都没有(因为尾部值不存在)。即便在软失效区(倾斜度适中、值存在但稀少),索引统计精确(8行/11行),但优化器从不使用部分索引统计做行数估计------部分索引统计只影响"选哪个索引"的决策,不影响"估计有多少行"的计算。
-
强制走索引扫描:关闭顺序扫描开关,q-error几乎不变(52.6→48.9,仅改善7%)。
-
UNION ALL改写:把范围查询拆成"索引覆盖的子查询 + 全表扫描子查询"。结果反而更差(52.6→362)。
机制洞察 (这是整个项目最有价值的发现之一):部分索引的统计是精确的,但优化器的架构设计决定了这个信息不会进入行数估计路径。问题不在统计精度,在架构。
实验5:θ=0基线补跑(162次)✅
审核阶段发现缺少未倾斜的基线对照,补跑θ=0(均匀分布,没有任何倾斜)的162次实验:全部q-error≤3,0条∞。确认所有失效均由Zipfian倾斜引起,而非查询模板设计缺陷。
三层证据链:怎么证明一个"无解"结论
数据库领域说"无解"需要非常谨慎。我构建了三层证据链来确保结论的严谨性:
| 层次 | 实验 | 证据 |
|---|---|---|
| 结构层 | 解决验证 | 直方图无法表达"值不存在",部分索引统计精确但优化器不用 |
| 作用域层 | 扩展统计验证 | 双列扩展统计不参与单列谓词估计,作用域不覆盖 |
| 语法层 | 扩展统计补盲点 | 对单列创建扩展统计,四种类型全部被语法拒绝 |
三层闭合的结论:
PostgreSQL 17 对Zipfian单列倾斜崩溃无内置解法,也无常见外部绕行方案。 根因是等宽直方图+均匀分布假设的结构性限制,叠加优化器统计架构的双层瓶颈(部分索引统计不进入行数估计路径)。
这个负面结论不是"做不出来",而是"被严格证明了做不出来"。在科学研究中,被清晰解释的失败同样有意义------它划定了系统的能力边界,为后续改进指明方向。
项目工程经验
好的决策
-
先补漏洞再搭环境 :方案阶段花了一天做漏洞修订(6个漏洞逐一修补),避免了在错误的设计上跑实验。比如原方案没有明确倾斜列选择,修订后才确定用
l_partkey(20万不同值,足够稀疏能触发尾部失效)。 -
摸底验证先行:正式972次实验前先跑了一轮smoke test(冒烟测试,10分钟的小规模验证),发现了解析器取错节点的问题。如果直接跑全量,972次实验的数据全是废的。
-
JSONL日志格式:每条实验结果以JSON Lines写入(每行一个JSON对象),包含时间戳、参数组合、估计行数、实际行数、q-error、计划树。后续分析时用pandas一行代码就能加载。
-
断点续跑:运行器支持从中断处恢复,通过检查已完成的参数组合跳过。实验跑了几个小时中途断过一次,续跑节省了大量时间。
-
实验代码和数据分析分离:运行器只负责跑实验写日志,统计和可视化负责分析绘图。分析逻辑改动不影响已完成的实验数据。
不好的决策
-
扩展统计实验的数据持久化有缺陷:三个倾斜度的实验跑了三轮,但JSON结果只保留了最后一轮(代码里结果列表在阶段切换时被重置)。日志文件里有完整数据,但JSON只有1/3,后来手动写脚本从日志解析补全。
-
解决验证的设计有盲点:最初只测了高倾斜度的"值不存在"场景,没测中等倾斜度的"软失效"场景(值存在但稀少)。审核时才发现这个缺口,补了一轮18次软失效验证。好在结论一致(部分索引对软失效也无效),但如果初始设计就覆盖到,会更干净。
负结果的科学价值
这个项目的结论是"PostgreSQL对倾斜数据无解"------一个负面结论,但负面结论的价值在于:
-
划定边界:θ≥1.2是硬崩溃阈值,θ∈0.8,1.0是软失效区,θ≤0.5是安全区。DBA可以根据实际数据分布判断风险等级。
-
指明根因:不是采样率不够(调到10000无效),不是统计类型不够(扩展统计语法拒绝单列),不是优化策略不对(部分索引统计不进入行数估计路径),根因在直方图结构和优化器架构。
-
指引方向:根本解法需要引入值存在性数据结构(如布隆过滤器、HyperLogLog等概率数据结构),而非优化桶型或采样率,这为后续的数据库改进提供了明确方向。
-
可迁移性:MySQL用等高直方图(equi-height,每个桶含大致相同的行数),在范围查询上可能优于PostgreSQL的等宽直方图,但等值查询的"值不存在"盲点是共性------这是所有基于采样的直方图的信息论瓶颈。
赛事心得:AI4S不只是"用AI做科学"
参加AI4S赛事,最大的感悟是:AI for Science不只是"用深度学习预测蛋白质结构"这一种范式,任何用系统性方法探索科学问题边界的工作都算AI4S。
pg-ceil项目没有神经网络、没有梯度下降、没有GPU训练。但它有:
- 假设驱动的实验设计:参数空间网格化、控制变量、重复实验
- 严格的证据链:三层闭合证明,每层都有独立的实验支撑
- 可复现性:全部代码开源,Docker一键部署,1368次实验0失败
- 明确的科学结论:不是"效果不错",而是"被严格证明了无解"
这种"实验科学"范式在AI4S中同样有价值:数据库优化器本质上是一个基于统计的"学习"系统,研究它的失效边界和研究一个神经网络的泛化边界,方法论上是相通的。
对后续参赛者的建议
-
问题定义比模型选择更重要:花时间把"到底要回答什么问题"想清楚,比急于选模型、跑baseline有价值得多。pg-ceil的方案修订阶段花了一整天,但省去了后面无数的返工。
-
负面结论也要有证据链:如果结论是"做不出来",需要严格证明"在什么条件下、用什么方法、为什么做不出来",一个孤立的"失败了"没有说服力。
-
先跑smoke test再跑全量:永远先用小规模实验验证代码逻辑正确性,特别是解析逻辑。跑了几小时发现数据全废的痛苦,我不想再体验第二次。
-
记录踩坑过程:实验结果论文里写,踩坑过程文章里写,后者对后来者的价值不亚于前者。
项目成果
| 指标 | 数值 |
|---|---|
| 实验总数 | 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项目正在筹备中。