在不可修改的文件里改行的两种方法 — copy-on-write 与 merge-on-read
一句话总结
Parquet 文件无法修改,所以要改一行,要么把那个文件重新写一遍(copy-on-write),要么另外写一个删除文件,记录“某个文件的第几行已被删除”,在读取时把它过滤掉(merge-on-read)。前者写入昂贵、读取便宜,后者正好相反。由三个表属性来决定这件事。
为什么这会成为问题——百万行文件中的一行
一笔订单变成了退款。包含这笔订单的 Parquet 文件里,还有另外一百万笔订单。文件一旦写入就不会改变(规范的要求条件是“不做原地写入”)。那么,为了改一笔,就要把一百万笔重写一遍吗?
在格式版本 1 中确实如此。版本 2为了解决这个问题,增加了删除文件。这样一来,不必重写不可变的数据文件,也能删除或修改其中的个别行。
工作原理——删除的三种形态
行级删除有两个分支。
| 类型 | 用什么来删除 | 主要由谁使用 |
|---|---|---|
| 位置删除文件(版本 2) | (数据文件路径,行位置) | Spark 的 merge-on-read |
| deletion vector(版本 3 及以上) | 每个数据文件一个位图 | 版本 3 表的位置删除 |
| 等值删除文件 | 列值(例如 order_id = 'O123') | 流式 upsert(Flink 等) |
位置删除文件是只有 file_path 和 pos(从 0 开始计数的行号)两列的文件,按 file_path、pos 的顺序排序写入。读取的一方在读数据文件的同时,跳过该文件中被删除的位置。deletion vector 用 Puffin 文件中的 Roaring 位图,以更小的体积存放同样的信息,每个数据文件最多只放一个。在版本 3 中,位置删除文件被标为即将弃用。
等值删除文件不知道位置,只知道值。流式 upsert 没有余力去找旧行位于哪个文件的第几行,所以只记录“删除这个键的旧行”。代价是读取的一方必须把所有相关数据文件中的行和值都对一遍,所以它是最昂贵的。
哪种删除作用于哪些数据,由序列号决定。根据扫描规划规则,位置删除只作用于序列号相同或更小的数据文件,而等值删除只作用于序列号严格更小的数据文件。所以用等值删除之后,再用同一个键重新放进来的新行,不会被删除。
写入模式——由表来记住
Spark 的 DELETE、UPDATE、MERGE 采用哪一种方式,由表属性 write.delete.mode、write.update.mode、write.merge.mode 决定,三者的默认值都是 copy-on-write。merge-on-read 只有在版本 2 及以上才能使用。
- copy-on-write:把含有被修改行的数据文件整个重写,并把旧文件从列表中去掉。没有删除文件,所以读取最快。
- merge-on-read:数据文件保持原样,只写删除文件(如果是被修改的行,还要写含有新行的数据文件)。写入小而快,但每次读取都必须应用删除。
MERGE INTO 在一次提交里完成 WHEN MATCHED(删除、修改)和 WHEN NOT MATCHED(插入)。如果源中的一行匹配了两个变更,就无法知道该应用哪一个而失败,所以要把变更集按键整理成每个键只有一个。
在现场相遇的样子
CDC 加载表越来越慢。每隔几分钟 MERGE 一次的 merge-on-read 表,删除文件会不断堆积,每次读取都要去对它们。必须通过定期的文件合并(rewrite_data_files 的删除比例和数量标准,rewrite_position_delete_files),把删除融合进数据里。
其他引擎会把已删除的行返回来。不理解删除文件的引擎,在 merge-on-read 表上会把已删除的行也读出来。如果多个引擎读取同一张表,要先确认每个引擎支持哪种删除格式。实验环境中的 DuckDB 1.5.5 和 pyiceberg 0.12,读取时会反映版本 2 的位置删除。
每天集中大改一次的表。如果夜间批处理把昨天的分区整个改掉,白天仪表板读取几百次,那么 copy-on-write 更合适。写入成本只有一次,读取的收益却有几百次。
实际工作中真正重要的事
- copy-on-write 是写放大,merge-on-read 是读放大。按写入频率和读取频率来选择。
- 模式是表属性。三者的默认值都是 copy-on-write,merge-on-read 需要版本 2 及以上。
- merge-on-read 表的文件合并是运维的一部分。删除文件一堆积,读取就变慢。
- 要确认读取的引擎是否支持删除格式。不认识的引擎会把已删除的行返回来。
下一项实验要做什么
用同样的三月订单,创建 copy-on-write 表和 merge-on-read 表,运行同样的 DELETE,通过提交摘要确认:一张重写数据文件,另一张只增加位置删除文件。用变更集对两张表做同样的 MERGE 之后,用 pyarrow 直接打开一个位置删除文件,看它指向哪个文件的第几行,对比两次 MERGE 写入的字节数,并确认 DuckDB 和 pyiceberg 读取时是否反映了删除。