NOTE · 技术笔记

DuckDB + Parquet:5/1977 行缺列的坑,和我补上的三道校验

先给你们看一条我的系统日志原文,这是我决定写这篇文章的原因:

当日日K 5/1977 行缺 pctChg/turn(旧分区未含新列)

那天我给行情表加了两个新列,然后发现当天的 1977 行数据里,有 5 行缺列。数字不大,但性质很坏:这 5 行走的是"回退基座帧"的兜底路径——相当于这一天有 5 只标的用了旧数据参与计算。如果没有任何日志,你会以为 1977 行都是新的。这篇把整条链上的三个坑讲清楚,附复现 SQL。

坑一:read_parquet 多文件默认"按第一个文件的 schema 解析"

背景:我的行情库按"数据集 + 月度分区"存 Parquet,读取时一把通配:

SELECT * FROM read_parquet('var/bars/ds=baostock/**/*.parquet');

给表加列(比如新增 pct_chg)之后,只有重写过的分区有新列,旧分区还是老 schema。这时候默认的 read_parquet 会怎样?

它会以第一个文件的 schema 为准:缺列的旧分区在解析时被静默补空/不识别,多列的新分区被裁掉——很多情况下不报错。于是你得到了一个"看起来正常、实际半盲"的结果集。

复现(三行 SQL):

-- 造两个 schema 不一致的 parquet:a.parquet 有两列,b.parquet 有三列
COPY (SELECT 1 AS id, 100 AS close) TO 'a.parquet' (FORMAT PARQUET);
COPY (SELECT 2 AS id, 101 AS close, 2.5 AS pct_chg) TO 'b.parquet' (FORMAT PARQUET);
-- 默认读取:pct_chg 列消失(或 b 的行被裁列)
SELECT * FROM read_parquet(['a.parquet', 'b.parquet']);

修复:永远显式 union_by_name=true,按列名联并所有文件的 schema——缺的补 NULL、多的保留:

SELECT * FROM read_parquet('var/bars/ds=baostock/**/*.parquet', union_by_name=true);

并且把这条写进"读取唯一入口"函数里——任何业务代码不出现裸 read_parquet,杜绝"这次忘了加"。

坑二:按月整写分区,"合并旧行"漏掉就是丢数据

我的分区策略是"每月一个文件"。增量更新某月数据时,天真的写法是"读新数据 → 覆盖当月分区"。结果是:当月前几天已经落盘的行被冲掉。多跑几轮增量,这个月就只剩最后一批数据。

修复:写路径唯一入口 + 合并写,核心逻辑:

def write_partition(month, new_rows):
    old = read_existing(month)                    # 1. 先读旧分区
    merged = merge_by_key(old, new_rows)          # 2. 按主键合并(新行覆盖同键旧行)
    assert len(merged) >= len(old)                # 3. 断言:行数单调不减
    write_parquet(month, merged)                  # 4. 整写回去

第 3 步的断言很关键:无新数据时允许 no-op(行数相等),但绝不允许变少。这条断言上线后,再没出现过"月初数据消失"。

坑三:加列之后的旧分区,需要一次显式回填

坑一和坑二修完还有尾巴:新列加入后,旧月份的 5/1977 这种缺列行必须先回填再谈分析。我的处理规则:

1. 缺列的分区逐月重读重写(用数据源重新拉全量列);

2. 回填动作登记"数据版本变更"——所有引用旧版本的结论自动作废待重算;

3. 回填完成前,代码路径遇到缺列就走显式回退并在产物里记录(就是那条 5/1977 行缺列 日志的由来)——宁可标注"这一天用了兜底",也不静默混入。

顺带说一句入库校验

同样的"静默问题"还会从数据源进来。我遇到过一次:某个数据源在特定日期附近重复应用过一次除权因子,价格序列整体平移,肉眼完全看不出来。抓住它的是入库口的一条恒等式校验:

-- 同一区间同一数据版本:后复权价 ÷ 前复权价 必须逐日为常数
SELECT date, back_close / pre_close AS ratio
FROM bars
WHERE symbol = :s AND date BETWEEN :d0 AND :d1
QUALIFY ratio != (SELECT back_close / pre_close FROM bars WHERE symbol = :s AND date = :d1);

不满足就拒绝整批入库,不是"记一条 warning 继续跑"。速度是 Parquet + DuckDB 的卖点,但让这套组合真正能用在研究里的,是这些校验。

检查清单(可直接对照)

1. 所有多文件读取 → union_by_name=true,且封装进唯一入口;

2. 所有分区写入 → 读旧合并写,行数单调不减有断言;

3. 加列 → 旧分区显式回填 + 数据版本登记;

4. 入库 → 恒等式/行数/日历三类校验,不通过就拒批。


本站内容为个人研究记录与方法分享,模拟,不构成投资建议;据此操作风险自负。


Joyous 的技术笔记 · 沪ICP备2026053770号


隐私最小化:不设 cookie、无表单、无第三方脚本;访问统计仅取服务端日志(≤30 天,仅聚合),不导出 IP。