2、快速上手:环境准备(Spark/Flink)、创建第一个Iceberg表、写入与读取数据、理解快照概念

好,咱们直接动手。这一章的目标很简单——让你在本地把 Iceberg 跑起来,亲眼看看它到底怎么工作的。我个人习惯是先用 Spark 做快速验证,因为它的交互式 shell 最适合教学。等跑通了,再用 Flink 感受一下流式写入。

嗯,这里要提前说一句:别被环境配置吓到。我当年第一次搭 Iceberg 环境时也踩了不少坑,尤其是依赖冲突。但只要你照着下面的步骤来,半小时内肯定能见到第一个 Iceberg 表。

2.1 环境准备:Spark + Iceberg 集成

我们先从 Spark 开始。Iceberg 官方提供了专门的 Spark 运行时 JAR,把依赖都打包好了,省心不少。

2.1.1 下载与配置

你需要准备三样东西:

  • Apache Spark 3.4+(我推荐 3.5.1,兼容性最好)
  • Iceberg Spark Runtime JAR(版本 1.5.0 以上)
  • 本地文件系统或 HDFS(做练习用本地路径就行)

下载完 Spark 后,解压到任意目录。然后启动 Spark Shell 时,通过 --packages 参数引入 Iceberg:

./bin/spark-shell \
  --packages org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.5.0 \
  --conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
  --conf spark.sql.catalog.demo=org.apache.iceberg.spark.SparkCatalog \
  --conf spark.sql.catalog.demo.type=hadoop \
  --conf spark.sql.catalog.demo.warehouse=/tmp/iceberg_warehouse

这里我解释一下几个配置的含义:

配置项 含义
spark.sql.extensions 注册 Iceberg 的 SQL 扩展,让 Spark 认识 Iceberg 语法
spark.sql.catalog.demo 定义一个名为 demo 的 Catalog
demo.type=hadoop 使用 Hadoop Catalog,元数据存储在文件系统
demo.warehouse 数据仓库路径,所有 Iceberg 表都放在这里
💡 我的经验: 如果你在公司内网,--packages 下载慢的话,可以先把 JAR 手动下载到本地,用 --jars 参数指定路径。我曾经在客户现场网络受限,全靠离线 JAR 搞定的。

2.2 创建第一个 Iceberg 表

Spark Shell 启动成功后,你会看到 scala> 提示符。接下来我们创建一个最简单的用户行为表:

spark.sql("""
  CREATE TABLE demo.db.user_events (
    event_id BIGINT,
    user_id STRING,
    event_type STRING,
    event_time TIMESTAMP,
    event_data MAP<STRING, STRING>
  ) USING iceberg
  PARTITIONED BY (days(event_time))
""")

执行完后,去 /tmp/iceberg_warehouse 目录看看:

$ ls -l /tmp/iceberg_warehouse/db/user_events/
drwxr-xr-x  metadata/
drwxr-xr-x  data/

你会看到两个目录:metadata 和 data。这就是 Iceberg 的核心——元数据与数据文件分离。传统 Hive 表可没有这么清晰的划分。

关键点: Iceberg 表创建时,metadata 目录下会生成一个 version-hint.text 文件,里面记录当前元数据版本号。这是 Iceberg 实现快照的基础。

2.3 写入数据

表建好了,我们来写点数据。Iceberg 支持标准的 INSERT INTO,也支持 DataFrame 写入。我建议你两种都试试:

2.3.1 SQL 方式写入

spark.sql("""
  INSERT INTO demo.db.user_events VALUES
    (1, 'u001', 'click', TIMESTAMP '2024-01-15 10:00:00', map('page', 'home')),
    (2, 'u002', 'view',  TIMESTAMP '2024-01-15 10:05:00', map('page', 'product')),
    (3, 'u001', 'purchase', TIMESTAMP '2024-01-15 10:10:00', map('amount', '99.9'))
""")

2.3.2 DataFrame 方式写入

import spark.implicits._

val df = Seq(
  (4L, "u003", "click", "2024-01-16 09:00:00", Map("page" -> "search")),
  (5L, "u004", "view",  "2024-01-16 09:15:00", Map("page" -> "cart"))
).toDF("event_id", "user_id", "event_type", "event_time", "event_data")
  .withColumn("event_time", $"event_time".cast("timestamp"))

df.writeTo("demo.db.user_events").append()

写入完成后,我们查一下数据:

spark.sql("SELECT * FROM demo.db.user_events").show()
+--------+-------+----------+-------------------+--------------------+
|event_id|user_id|event_type|         event_time|          event_data|
+--------+-------+----------+-------------------+--------------------+
|       1|   u001|     click|2024-01-15 10:00:00|        {page->home}|
|       2|   u002|      view|2024-01-15 10:05:00|     {page->product}|
|       3|   u001|  purchase|2024-01-15 10:10:00|    {amount->99.9}|
|       4|   u003|     click|2024-01-16 09:00:00|      {page->search}|
|       5|   u004|      view|2024-01-16 09:15:00|       {page->cart}|
+--------+-------+----------+-------------------+--------------------+

数据出来了,看起来和普通表没什么区别,对吧?别急,好戏在后面。

2.4 理解快照(Snapshot)概念

这才是 Iceberg 最核心的东西。每次对 Iceberg 表的写入、删除、更新操作,都会生成一个快照。你可以把快照理解为表在某个时间点的「完整视图」。

我们来看看刚才的操作产生了几个快照:

spark.sql("SELECT * FROM demo.db.user_events.snapshots").show(false)
+------------------------+-------------------+---------+-------------------------+
|committed_at            |snapshot_id        |parent_id|operation                |
+------------------------+-------------------+---------+-------------------------+
|2024-01-15 10:00:00...  |1234567890123456781|null     |append                   |
|2024-01-15 10:05:00...  |1234567890123456782|123456...|append                   |
|2024-01-16 09:00:00...  |1234567890123456783|123456...|append                   |
+------------------------+-------------------+---------+-------------------------+

看到了吗?三次写入,三个快照。每个快照都有一个唯一的 snapshot_id,并且记录了父快照 ID,形成一条链。

⚠️ 注意: 快照不是数据文件的拷贝,而是元数据文件的指针。Iceberg 通过快照来追踪「哪些数据文件属于哪个版本」。所以快照本身很轻量,不会重复存储数据。

2.4.1 时间旅行查询

快照最大的价值就是时间旅行。你可以查询表在任意历史快照时的状态:

-- 查询第一个快照时的数据(只有前3条)
spark.sql("""
  SELECT * FROM demo.db.user_events
  FOR SYSTEM_VERSION AS OF 1234567890123456781
""").show()

-- 或者用时间戳
spark.sql("""
  SELECT * FROM demo.db.user_events
  FOR SYSTEM_TIME AS OF '2024-01-15 10:02:00'
""").show()

为什么会这样?因为 Iceberg 的元数据层记录了每个快照对应的文件列表。查询时,引擎只读取该快照指向的那些文件,其他文件完全忽略。

我曾在一次生产事故中靠这个功能救了场。同事误删了一批数据,我直接用时间旅行查到了删除前的快照,把数据捞了回来。你想想看,要是用传统 Hive 表,数据被覆盖了就真没了。

2.5 用 Flink 写入 Iceberg

Spark 搞定了,我们再来看看 Flink。Flink 的流式写入是 Iceberg 的另一大亮点。

2.5.1 Flink SQL Client 配置

启动 Flink SQL Client 时,同样需要引入 Iceberg 依赖:

./bin/sql-client.sh \
  -j /path/to/iceberg-flink-runtime-1.18-1.5.0.jar \
  -pyfs /path/to/flink-sql-connector-hive-3.1.3_2.12-1.18.0.jar

然后在 SQL Client 中创建 Catalog:

CREATE CATALOG demo_catalog WITH (
  'type' = 'iceberg',
  'catalog-type' = 'hadoop',
  'warehouse' = '/tmp/iceberg_warehouse'
);

USE CATALOG demo_catalog;

2.5.2 流式写入示例

假设我们有一个 Kafka 数据源,实时写入 Iceberg 表:

CREATE TABLE db.user_events_sink (
  event_id BIGINT,
  user_id STRING,
  event_type STRING,
  event_time TIMESTAMP(3),
  event_data MAP<STRING, STRING>
) WITH (
  'connector' = 'iceberg',
  'catalog-name' = 'demo_catalog',
  'catalog-database' = 'db',
  'catalog-table' = 'user_events',
  'write.format.default' = 'parquet',
  'write.upsert.enabled' = 'false'
);

-- 从 Kafka 读取并写入 Iceberg
INSERT INTO db.user_events_sink
SELECT * FROM kafka_source;

Flink 会以 checkpoint 为边界,每个 checkpoint 生成一个 Iceberg 快照。这样即使作业失败,也能从最近成功的 checkpoint 恢复,保证 exactly-once 语义。

💡 避坑指南: 我曾经在 Flink 写入时遇到小文件问题——每个 checkpoint 生成一个文件,如果 checkpoint 间隔太短,会产生大量小文件。我的建议是:write.target-file-size-bytes 设置为 128MB,同时适当调大 checkpoint 间隔(至少 60 秒)。

2.6 本章核心知识图

下面这张图总结了 Iceberg 快照的核心逻辑,你一看就明白:

Iceberg 快照与时间旅行核心逻辑 INSERT 第一次 INSERT 第二次 INSERT 第三次 快照 1 (snap_id: 1) 快照 2 (snap_id: 2) 快照 3 (snap_id: 3) data_file_1 data_file_2 data_file_3 data_file_4 data_file_5 时间旅行:查询快照 1 → 只读 data_file_1, data_file_2, data_file_3 查询快照 2 → 读 data_file_1, data_file_2, data_file_3, data_file_4

从图中你能看到:快照 1 指向 data_file_1、2、3;快照 2 新增了 data_file_4;快照 3 又新增了 data_file_5。查询快照 2 时,Iceberg 只读取前 4 个文件,完全不知道 data_file_5 的存在。

2.7 本章小结

好了,这一章我们做了几件实事:

  • 搭好了 Spark + Iceberg 环境
  • 创建了第一个 Iceberg 表
  • 用 SQL 和 DataFrame 两种方式写入了数据
  • 理解了快照的概念,并实践了时间旅行查询
  • 用 Flink 体验了流式写入

说白了,Iceberg 的核心就一句话:每次写入生成一个轻量级快照,快照指向一组数据文件,查询时可以自由选择任意快照。这个机制让数据版本管理变得异常简单。