这是「自建 Lakehouse 实战」系列第二篇。第一篇把五大引擎都接到了同一张 Lakekeeper/GCS 上的 Iceberg 目录。本篇问一个直接的问题:**它们写出来的表,彼此读得动吗?**尤其是涉及行级删除(merge-on-read)时。 这正是 Iceberg “开放表格式、引擎无关”承诺的试金石。结论先放这儿:绝大多数组合都能互读,唯独 Doris 在做了 UPDATE/DELETE/MERGE 之后写出的表,Trino 等严格引擎读不了——根因是 Doris 的 positional delete 文件不符合 Iceberg 规范。下面是完整过程。
一、各引擎的原生 CRUD + MERGE
四个引擎都建 format-version=2 的 Iceberg 表,跑 INSERT / SELECT / UPDATE / DELETE / 三分支 MERGE INTO:
| 引擎 | INSERT/SELECT | UPDATE/DELETE | MERGE INTO | 路径风格 |
|---|---|---|---|---|
| Trino 481 | ✅ | ✅ | ✅(三分支全过) | gs:// |
| Spark 3.5.8 | ✅ | ✅ | ✅(需第一篇的 4 个修复) | gs:// |
| Doris 4.1.1 | ✅ | ✅(需 4.1.0+) | ✅(三分支全过) | s3:// |
| StarRocks 4.1.1 | ✅ | DELETE ✅ / UPDATE 受限 | ❌(V2 MERGE 仍在开发,#73684) | — |
Trino、Spark、Doris 的行级 DML 都完整可用;StarRocks 的 DELETE 能产出 merge-on-read 删除文件(这就够验证合规性了),但 UPDATE 在 4.1.1 上报”does not support update”,MERGE INTO 还没合并。
二、跨引擎读矩阵
把上面写出来的表互相读一遍,得到下表(同一张 Lakekeeper 目录、同一个 GCS 桶):
| 表的来源 | Trino 读 | Doris 读 | Spark 读 | StarRocks 读 |
|---|---|---|---|---|
| Trino(含 MERGE 删除) | ✅ | ✅ | ✅ | ✅ |
| Spark(含 MERGE 删除) | ✅ | ✅ | ✅ | ✅ |
| StarRocks(含 DELETE 删除) | ✅ | ✅ | ✅ | ✅ |
| Doris —— 纯 INSERT | ✅ | ✅ | ✅ | ✅ |
| Doris —— 含 UPDATE/DELETE/MERGE | ❌ position is null | ✅ | ❌* | ❌* |
* Trino 的失败是实测确认的;Spark、StarRocks 同样按 field-id 读取删除文件,机理一致,预期同样失败。Doris 能读自己的表,是因为它按列名而非 field-id 读取。
三、根因 的 positional delete 文件缺少保留 field-id
这个 bug 值得展开,因为它非常隐蔽。把出问题的删除文件从 GCS 下载下来,用 pyarrow 检查 parquet 的 field-id,真相才浮出水面:
- Iceberg 规范规定,positional delete 文件的两列必须带保留 field-id:
file_path = 2147483546、pos = 2147483545。 - 引擎读删除文件时,是按 field-id 定位列,而不是按列名。
- Trino 的删除文件
正确。 - StarRocks 的删除文件
也正确( 2147483546/2147483545)。 - Doris 的删除文件:这两列的
PARQUET:field_id都是None——Doris 没写保留 field-id。
于是 Trino 按 id 2147483545 去找 pos 列,找不到,返回 null → 报错 position is null。这不是 Trino 的能力缺陷(它能完整读写自己和 Spark 的 merge-on-read 删除),而是 Doris 写出了不符合规范的删除文件。
需要澄清的一点SELECT content, file_path FROM "表名$files" 可以确认 Trino 和 Doris 都是 merge-on-read(content=1 即 position delete)。两者唯一的差别就是那对 field-id。
(次要差别s3://(GCS S3 互操作),Trino 是 gs://——这个靠在 Trino 里也打开 fs.native-s3.enabled 能解决;但 field-id 缺失才是真正的硬障碍。)
四、实践建议
- 行级 DML 尽量放在 Trino 或 Spark 做:它们的删除文件是通用可读的。
- 如果一张 Doris 表需要被其它引擎读,要么保持纯 INSERT(无删除文件),要么让 Doris 走 copy-on-write(
write.update.mode/write.delete.mode/write.merge.mode设为copy_on_write),这样不产生删除文件。 - StarRocks 在合规性上是干净的——它写的删除文件 Trino 读得动;只是它自身的 UPDATE/MERGE 能力当前还有限。
小结
Iceberg “引擎无关”的承诺在这套环境里基本兑现了:四个引擎共用一张表、互相读取大多顺畅。唯一的破绽是 Doris 的 positional delete 不写保留 field-id,导致严格按规范读取的引擎失败——一个仅凭 SELECT 报错很难定位、必须下载文件看 field-id 才能坐实的细节。下一篇换个维度:在读性能上,这些引擎差多少?
📚 本文是「自建 Lakehouse 实战」系列(共 6 篇):