01为什么分阶段,而不是一次做完

改造目标很清楚:让湖成为可信底座,仓继续做服务层。但“改哪里、按什么顺序改”决定了风险量级。我按这个顺序分了阶段:

阶段动作可回滚性
零可行性验证:能不能建表、能不能写、能不能查不碰生产,随时可弃
一明细层与汇总层旁路双写到湖(生产路径不动)关掉开关即回到原样
二查询引擎直连湖表(只读)不写就不影响生产
三应用层读路径切到湖改一个环境变量即可回滚
四时间旅行能力做分析纯增量

顺序的理由是:**先在湖上拿到表格式能力(ACID、时间旅行),再证明仓能直连,最后才动读路径。**标题里的“湖仓一体”在阶段二就成立了;阶段三只是把用户流量挪过去。

另一条纪律是一次只改一个变量。每阶段结束时必须能回答“和上一阶段比,唯一的差异是什么”,否则出现差异时无法归因。

02阶段零不能省

阶段零的产出是运行坐标:Spark 与 Iceberg 的 runtime 版本组合、查询引擎的支持矩阵。它的价值是提前发现“这条路根本走不通”。

这一步不过后面全不用做。可行性验证的成本几乎总是低于中途发现不兼容的成本,因为中途发现时你已经有依赖它的数据和下游代码。

03旁路双写而不是直接切换

阶段一的做法不是把输出改成湖表,而是加一个环境开关:

bash
HWLENS_WRITE_MODE=iceberg   # 默认不设 → 行为与今天逐字节相同

入口用一个极薄的 shim 复用同一份生产 main(),DAG 只有一个来源。如果为湖写一份独立入口,两条链路会各自演化,几周后它们算出的东西就不是同一个东西了——那时你对账发现“不一致”,根本分不清是数据问题还是代码漂移。

04关键细节:把刚写的分区读回来再入湖

双写不是“同一份 DataFrame 写两个地方”。实际做法是:先写生产 Parquet 分区,再把刚写完的那个分区读回来写入湖表。

它保证三件事:

  1. 湖表内容与生产落盘内容逐行一致,于是“湖表与生产表对账”是真的在对账,而不是在对比两个中间态;
  2. 避免同一个惰性 DataFrame 触发两次 action 时,近似聚合函数给出不同结果(惰性执行下两次 action 就是两次计算);
  3. 旁路必须响亮失败:读回来发现异常就报错,而不是静默跳过湖写入。旁路的意义是可比对,静默降级会让它失去全部价值。

第 2 条是 Spark 上很容易忽略的:算子惰性 ⇒ “看起来算了一次、其实算了两次”。凡是带近似语义的聚合(分位数一类),两次 action 的结果不保证相同。

05warehouse 路径:一个斜杠引发 scheme null

阶段二最难查的是这个。Doris 建 External Catalog 时 warehouse 填的是带 scheme 的完整 URI:

text
hdfs:///hardware/lake     ← 报错形态
/hardware/lake            ← 最终正确写法

根因链条有点长:Iceberg / Hadoop 的 Path.toString() 会把 hdfs:///a/b 规范化成 hdfs:/a/b(三个斜杠变一个)。查询引擎侧再用 URI 解析时,authority 部分为空,于是 BE 拿到的 namenode 是 null,报 No FileSystem for scheme "null"。

修复后必须清空并逐日重建湖表——Iceberg 表的 location 不能改。而且 Spark 侧的 warehouse 配置要同步改成同样的形态:两侧必须一致,否则一边写出来的元数据另一边读不到。

还有一条前置:引擎节点的配置文件目录里要有 core-site.xml / hdfs-site.xml,否则它拿不到 nameservice 信息。

这类坑的通用教训是:**跨组件传“路径”时,规范化规则的差异要当成接口契约看。**三斜杠和一斜杠在人类眼里是同一个东西,在解析器眼里不是。

06阶段三曾经“无可切对象”

到阶段三要切换读路径时,发现一件事:阶段一只迁了明细层和汇总层,而后端和大屏读的全是 ADS 表。也就是说,湖上没有可切的东西。

于是补了一个前置阶段:ADS 层上湖。这里有两处值得记:

  • 类型对齐要显式做(湖表与仓表的字段类型不完全一致,写入前统一 cast);
  • 有张表的聚合口径原本在查询引擎侧的 SQL 里,为了两条链路可比,改成由计算引擎逐字对齐那段 SQL 来算——否则对账永远有细微差异。
  • 全量替换型维表需要一个“无分区整体替换”的写入函数,因为它没有日期概念。

07读路径切换:单点侵入 + 几十秒回滚

真正切读的时候,改动被压到极小:

  • 一个注入点在 FastAPI 的数据访问层(查询前统一加前缀),一个在 Django 的游标适配层;
  • 表名替换用正则,并加负向后顾,避免已经带前缀的表名被二次替换;
  • 开关来自 systemd drop-in 注入的环境变量;local 模式下零开销、代码路径与切换前相同。

回滚就是把那个 drop-in 文件改名、daemon-reload、重启服务——几十秒。这个回滚成本是设计出来的:只有当“切回去”足够便宜,你才敢真的切。

踩到一个权限细节:只读账号仅有 catalog 级授权不够,必须显式授予该 catalog 下库表的 SELECT 权限。表现为查询报权限错误,而人第一反应是“表不存在”。

08时间旅行:两种用法和一处能力缺口

在计算引擎侧可以用按版本 / 按时间戳读快照,也能查快照元数据表;在查询引擎侧支持 FOR TIME AS OF / FOR VERSION AS OF,但不支持快照元数据表——所以“有哪些快照、各自对应什么时间”这类审计只能走计算引擎。

它的实际价值分两层:

  1. 复现任意中间态:论文里某个结论基于哪一天的表状态,可以精确回去;
  2. 审计历史分区的改写:区分“这是幂等重跑”还是“这是口径变化”。这在做回填时特别有用,因为回填会覆盖历史,而没有版本能力的覆盖是不可审计的。

这也是我把湖仓改造写进论文而不是当作技术炫技的原因:版本能力解决的是可复现性问题,而不只是性能问题。

09相关文章