做大数据架构选型的朋友对 HBase 应该不陌生,从 2010 年前后开始,它几乎是互联网公司存储实时写入、海量随机读写场景的标准答案。但最近两三年,不管是技术社区还是实际招聘需求里,Iceberg 出现的频率越来越高,很多团队甚至在把 HBase 里的核心业务数据往 Iceberg 上搬。这个演进背后不只是“换一个存储组件”那么简单,而是列式存储技术在整个大数据生态里的位置发生了根本变化。
这篇文章我想抛开官方文档式的功能介绍,从一个实际做过迁移、也踩过不少坑的从业者角度,聊聊从 HBase 到 Iceberg 这条演进线路到底意味着什么。适合正在做技术选型、或者准备把 HBase 上的宽表数据迁到数据湖体系的架构师和数据工程师参考,内容会涉及存储模型差异、核心能力拆解、迁移实操步骤,以及一些文档里不会写的坑。
1. 技术演进的整体脉络与设计思路
1.1 为什么演进的主线落在 HBase 和 Iceberg 上
聊演进之前,得先把 HBase 的历史定位说清楚。HBase 诞生于 Hadoop 生态早期,当时 HDFS 只能解决海量文件的存储问题,却没有办法提供高效的随机读写能力。HBase 在 HDFS 之上抽象出一张稀疏的、按 RowKey 排序的大表,用 MemStore 加 HFile 这套 LSM 架构换来了极高的写入吞吐,也是它后来称霸实时数仓和在线服务场景的根本原因。
Iceberg 的历史比 HBase 短很多,它属于数据湖表格式那一代产品,核心目标不是解决随机读写,而是让 HDFS 或对象存储上的数据文件具备“表”的语义。它通过三层元数据体系(元数据文件、清单列表、数据文件)把一批 Parquet/ORC 文件组织成一张可以并发读写的表,并且支持 ACID 事务、快照隔离和 Schema 演进。
这两个东西放在一起看,恰好代表了大数据存储演进的两个方向:HBase 解决“在线服务怎么扛住高并发”,Iceberg 解决“离线分析怎么让数据表更可信、更好管”。它们的演进关系不是替代,而是承接——业务对实时性和分析性的要求越来越高,单靠 HBase 这类列族数据库不够用了,需要把列式存储的优势以更开放的姿态释放出来。
1.2 列式存储到底“列”在哪里
很多刚接触的人会把 HBase 和 Iceberg 都归为“列式存储”,但这里的“列式”含义差别很大。
HBase 的列式是指列族(Column Family)维度的物理存储隔离,同一个列族下的所有列会落到同一组 HFile 中。但你如果按某个列单独做扫描,HBase 并不会只读取那一列的数据,它依然要遍历这一列所在列族的全部 StoreFile。真正的列式存储,是指 Parquet、ORC 这类文件格式在文件内部按列存数据块,查询只读取涉及的列块即可,IO 开销和参与扫描的数据量成比例下降。
Iceberg 本身不是文件格式,它只是表格式(Table Format),默认搭配 Parquet 或 ORC 文件。所以 Iceberg 获得的列式能力,是从文件格式层面继承来的,而它自己把精力放在如何管理这些文件、如何让表的语义完整。
理解这个区别很重要,因为它决定了你选型时该怎么判断:如果任务是 KV 点查(get/put),列式文件格式其实帮不上太多忙,HBase 反而更合适;如果任务是宽表扫描、聚合分析和日内增量更新,Iceberg 搭配列式文件才能把优势发挥出来。
1.3 演进背后的推动力:从“能存”到“好用”
HBase 时代的数据使用模式很清晰:业务方在写入时把 RowKey 设计好,查询时按 RowKey 点查或按前缀扫。这种模式对存储很友好,但对分析很不友好。你需要做聚合、Join、按时间窗口过滤时,只能自己写 MapReduce 或 Spark 作业去全表扫描,然后还要面对 HBase 扫描时产生的热点、Region Server 负载不均等问题。
Iceberg 推动的演进,本质上是把大数据存储的使用模式从“以 RowKey 为中心的存取模型”转向“以数据为中心的表格模型”。在 Iceberg 上,你可以用 SQL 直接改表结构、隐藏分区、做时间旅行查询,多个计算引擎共享同一张表而不用互相锁表。它把过去需要多个系统配合才能做到的事情,收敛到一个统一的存储表格式上。
我个人的判断是,这个演进不会让 HBase 消失,也不会让 Iceberg 短时间成为所有场景的默认选项,但列式存储的重心正在从“底层文件格式”上移到“表和元数据的管理层”,这是整个生态发展的核心趋势。
2. 核心机制对比与关键技术点拆解
2.1 HBase 的存储模型为什么不适合做数据分析
HBase 的物理存储模型可以简化为:一张表按 RowKey 字典序分区,每个分区(Region)内有多个列族,每个列族在内存里对应一个 MemStore,刷写后生成 HFile。底层文件 HFile 的内部是按 RowKey 有序排列的 KV 结构,每个 Key 包含 RowKey、列族、列限定符、时间戳和操作类型。
这套模型有非常强的地方:写入不落盘直接进内存,批量刷写合并小文件,读的时候通过 BlockCache 和布隆过滤器快速跳过无关文件。但代价也很明显:
- 数据按 RowKey 排序,按非 RowKey 列做过滤需要全表扫描。
- 列族内所有列一起存储,无法做到按列裁剪。
- 表的 Schema 本质上只是“列族 + 列限定符”的约定,没有强约束。
- HBase 的 Scan 接口虽然支持设置 StartRow/StopRow、Filter,但对复杂分析型查询的支持程度很低。
我还记得第一次尝试在 HBase 上做多维度聚合分析时的场景:一个 200 亿行的业务表,按天统计某个维度的去重数量,写出来的 Spark 作业跑了将近两个小时,其中大部分时间浪费在 Scan 和序列化上。后来同样的数据落到 Iceberg 上,按分区裁剪加列裁剪,二十分钟以内就能跑完。这不是说 HBase 不行,而是它被用在了不合适的场景里——但在真实业务里,这种错配非常常见。
2.2 Iceberg 如何解决表管理的核心难题
Iceberg 的三个核心设计目标:快照隔离、Schema 演进的可靠性、查询引擎的可插拔性。
快照隔离是 Iceberg 最硬核的特性。每一次数据提交都会生成一个新的快照(Snapshot),快照之间互相隔离,读取端在拿到某个快照的引用后,可以不受并发写入影响地完成一次一致性读。这也意味着 Iceberg 天然支持时间旅行查询,你可以直接读取某个历史时间点的表状态,不需要任何备份或恢复操作。
Schema 演进在 HBase 时代几乎是靠“约定”和“双写”来完成的,而 Iceberg 的 Schema 演进是原子性的。增加列、删除列、调整嵌套字段类型,都能被记录进元数据,并且查询端不需要重写任何数据文件。列的位置和类型变化对老查询完全透明。
可插拔性体现在 Iceberg 不绑定 Java API。它定义了一套 REST Catalog、JDBC Catalog 和文件级约定,Spark、Flink、Trino、Presto 都可以用它提供的连接器直接操作同一张表,且互相之间跳过表锁。这意味着你的数据工程师用 Spark 跑批,实时团队用 Flink 写入,分析团队用 Trino 查询,看到的始终是一致的数据视图。
2.3 一张表讲清楚核心差异
| 对比维度 | HBase | Iceberg |
|---|---|---|
| 数据组织方式 | RowKey 有序的 KV 结构 | 数据文件 + 清单 + 元数据快照 |
| 文件格式 | HFile(内部 KV,列族内混合) | Parquet / ORC(列式存储) |
| 随机点查能力 | 很强,毫秒级 | 弱,需要走文件裁剪+列裁剪,秒级或更慢 |
| 分析型扫描 | 弱,全表扫描为主 | 强,支持谓词下推与列裁剪 |
| ACID 能力 | 仅行级原子性,跨行需借助外部方案 | 表级 ACID,快照隔离 |
| Schema 变更 | 动态加列但无统一元数据约束 | 原子性 Schema 演进,记录完整变更历史 |
| 时间旅行 | 原生不支持,依赖备份或工具 | 原生快照,支持任意历史时间查询 |
| 事务读写范围 | 延迟批量写为主,不支持更新删除分析 | 支持 Merge-On-Read 更新删除与流批一体写入 |
这个表基本反映了选型时的分界线。如果你手里是订单、风控、用户画像这类需要高频点查和实时写入的场景,HBase 依然有价值;但如果你在做的是数据仓库层面的明细层、汇总层,需要 ACID、Schema 演进而又不想引入厚重数仓组件,Iceberg 明显更有优势。
2.4 为什么 Iceberg 能在不替换 HDFS 的情况下完成演进
Iceberg 演进的关键在于它不强制你更换底层存储。HDFS、S3、OSS 以及各种兼容对象存储的路径,Iceberg 都能直接把数据文件写进去。它把“元数据”和“数据文件”分离:数据文件是标准 Parquet/ORC,元数据以 JSON 和 Avro 格式存在表目录下。这意味着你可以保留原有 Hadoop 集群,不需要像 HBase 迁移到其他系统那样引入新的分布式存储节点。
做过架构的人都知道,存储层的切换是最伤筋动骨的。Iceberg 这一点设计得非常聪明——它没有重造一个存储引擎,而是定义了一套“文件如何构成表”的标准,让所有计算引擎按照这个标准来读取和提交。落地的复杂度就集中在表格式的适配层,而不是存储层。
3. 从 HBase 迁移到 Iceberg 的实操记录
3.1 迁移前的评估与表结构改造
我先说结论:不要试图把 HBase 的表结构原封不动搬到 Iceberg,那样大概率会得到一个性能很差的结果。
HBase 的 RowKey 通常是业务主键或拼接主键,比如“用户ID_订单ID_时间戳”。这个设计在 HBase 里是合理且高效的,但到了 Iceberg,你的第一优先级应该变成分区字段和排序键。需要重新考虑聚类方式、标识主键字段、过滤字段和更新字段。
我经历的某模拟订单系统迁移,原始 HBase 表的 RowKey 是“user_id(4字节) + order_id(8字节) + create_time(4字节)”,列族包含 30 多个列。迁到 Iceberg 时,我们做的关键调整是:
- 拆分了组合 RowKey,user_id、order_id、create_time 分别作为独立列。
- 分区策略选为 days(create_time),对应 HBase 里按时间范围的 Scan 需求。
- 排序键设置为 user_id 和 create_time 的组合,保留原有点查模式的分析适配。
- 列族直接扁平化为普通列,列限定符里原来的冗余字段做了梳理。
表结构出来后,建表语句类似:
CREATE TABLE ods_order_iceberg ( user_id BIGINT, order_id BIGINT, create_time TIMESTAMP, status STRING, amount DECIMAL(10,2), ... ) USING iceberg PARTITIONED BY (days(create_time)) TBLPROPERTIES ( 'write.format.default' = 'parquet', 'write.parquet.compression-codec' = 'zstd', 'write.sort.order' = 'user_id,create_time' );注意这里我不会把 HBase 的全部列都建模成 Iceberg 的列,尤其是那些只有写入没有读取、或者读取频率极低的大字段,可以考虑归档或单独存放,避免拖累列式文件的压缩比和扫描效率。
3.2 批量迁移:SnapShot 导出与增量补偿
HBase 迁移最常见的方式是使用其自带的 Snapshot 导出机制。这个方式不占用线上 Region 读写,导出的是 HFile 层面的快照副本。流程大概如下:
- 对源表执行 snapshot 操作,记录快照名和创建时间。
- 用 ExportSnapshot 命令把快照导入到一个临时 HDFS 目录。
- 写一个 Spark 作业读取这批 HFile,转成 DataFrame 后统一写入 Iceberg。
注意 HFile 转 Parquet 时最容易踩的坑是类型推断。HBase 里所有值都是字节数组,导出时如果不做显式类型转换,Spark 会默认推断成 Binary 类型,写进 Parquet 后下游查询全都得 cast,性能很差。
实践中的处理方式是在转换阶段明确以 StructType 定义每列的字段类型,并且在写入前做一次空值、非法值清洗。比如把空字节转换成 null,把数字类字段的前置零处理掉,日期字段统一格式。这些清洗在 HBase 时代通常不会被严格约束,但在 Iceberg 和 Parquet 里,Schema 是强类型的,脏数据会在写入阶段直接失败或者产生错误结果。
批量迁移的时间窗口要选业务低峰。快照本身虽然不锁表,但从快照导出到写入 Iceberg 的过程中,源表数据是持续变化的,所以一定需要配合增量补偿。
3.3 增量同步与双跑验证
增量同步我推荐使用 Binlog 类方案直接对接,比如把 HBase 的 WAL 或复制机制解析出来,接入消息队列,再消费写入 Iceberg。如果你用的 HBase 版本对复制机制支持不够好,另一种可行做法是定期做增量 Snapshot,按时间戳过滤写入,但延迟会更高。
我们当时采用的方式,是复用已有的离线同步链路:每天凌晨从 HBase 导出一份近 24 小时的增量数据,写入 Iceberg 的当日分区,同时保留一份全量快照表。这样查询端可以结合快照表与增量表做合并视图,等增量数据完整后,再把增量合并进全量表。
双跑验证是整个迁移中最不能省的一步。我在实际操作中会分三层验证:
- 数据量验证:对比同一天两个表的行数、主键去重数、关键字段求和值。
- 抽样对比:每组抽样 10 万行,按主键 Join,逐字段比对新旧源的值差异。
- 业务结果验证:把典型报表查询(日活、GMV、状态分布)跑在新表上,和原 HBase 上的历史结果做比对。
这层验证周期至少要覆盖一个完整业务周期,比如 7 天或者一个月。有的问题不是当天能看出来的,例如分区边界处理和跨天数据的一致性,只有跑过一段完整时间才能暴露。
3.4 写入性能调优与实际效果
迁移时我们遇到了一个典型问题:Spark 写入 Iceberg 时产生大量小文件。默认情况下每个 Spark 分区写一个 Parquet 文件,如果分区数没调好,一天的存量数据会拆成几万个小文件,后续查询和元数据管理都会变慢。
几个我自己验证有效的参数组合:
spark.sql.shuffle.partitions=200 spark.sql.files.maxRecordsPerFile=2000000 spark.driver.memory=8g spark.executor.memory=8g spark.executor.cores=4关键在于write.target-file-size-bytes,我建议直接设置到 512MB 或 1GB,这样 Spark 写入 Iceberg 时会自动控制在目标大小附近,减少小文件数量。另一个重要参数是write.distribution-mode,切换到 hash 模式可以保证相同分区内的数据不跨文件乱序,对后续使用排序键优化查询有直接帮助。
调整完这些参数之后,我们 3 亿行规模的一天的业务数据,写入耗时从原始的 40 多分钟降到 18 分钟左右,文件数量从 2.3 万降到 800 左右。这个优化效果非常明显。
4. 常见问题与排查技巧实录
4.1 小文件膨胀与元数据目录越来越大
冰berg 表跑一段时间后最容易出现的问题就是小文件膨胀。原因无外乎:写入频率高、每批数据量小、分区字段粒度细。尤其是小时级或分钟级任务,每次提交生成的数据文件只有几 MB,日积月累,元数据里记录的清单文件越来越多,查询计划阶段扫描清单的时间就会拉长。
解决办法要分两层:第一层是写入端控制,尽量提高单批次数据规模和减少提交频率;第二层是定期做 Compaction 任务。
# 通过 Spark 执行 Iceberg 的动态文件合并操作 CALL iceberg.system.rewrite_data_files( table => 'ods.orders_iceberg', strategy => 'binpack', target_file_size_bytes => 536870912 );这个命令会把小于目标大小的小文件合并成大文件。在我实际操作中,一般会配合一个定时任务,每天凌晨先做一次rewrite_data_files,再执行expire_snapshots清理历史快照,最后执行remove_orphan_files清理废弃文件,三步走完元数据目录能瘦身 70% 以上。
4.2 快照数量过多导致时间旅行变慢和存储浪费
Iceberg 的每一条写路径都会生成新快照,如果一个表长期高频写入不做清理,快照目录里的文件会积累到几千甚至上万份,每次查询元数据时要加载的快照清单就很重。
这里有一个误区,很多人觉得开了时间旅行功能就应该把历史快照都保留,结果让存储成本和查询耗时成倍上升。实际上业务需要的时间旅行窗口通常只有 7 天或 30 天,超过这个范围的快照完全可以清理。
-- 清理 7 天前的快照,保留当前快照 CALL iceberg.system.expire_snapshots( table => 'ods.orders_iceberg', older_than => TIMESTAMP '2024-11-01 00:00:00' );需要注意的是,expire_snapshots的默认行为会保留当前快照,同时你也可以指定retain_last数量。在做清理前我建议先跑一个 dry run 查询,看看哪些历史快照要被删除,以及会释放多少存储,确认无误后再执行正式清理。
4.3 Schema 演进过程中的默认值陷阱
Iceberg 的 Schema 演进虽然很灵活,但有个细节容易踩坑:新增列的时候如果带默认值,历史数据文件在读取时不会自动填充默认值。
具体来说,老文件里的数据行没有这个新列,如果你设置了 DEFAULT 值,新写入的数据会带上该值,但是历史查询返回的行里该列会是 null 而非默认值。这在指标统计时会造成数据口径不一致。
我自己踩过这个坑之后,现在处理新增列的流程是先不加默认值,等数据回填完成,再用 update 操作补上默认。或者干脆把 DEFAULT 约束交给应用层处理,Iceberg 层面只保证 Schema 兼容性。
4.4 分区演进与历史分区数据的兼容处理
Iceberg 支持分区演进,你可以从days(create_time)改成months(create_time),但这里要注意,历史数据文件依然保留旧的分区布局,新的写入才使用新布局。查询端会自动根据元数据判断每个文件所属的分区布局,但当涉及跨分区演进边界的查询时,实际扫描的文件数可能比预期多。
我建议在做分区演进前,先用rewrite_data_files配合REWRITE策略把历史数据重写到新布局。虽然重写会带来一定的计算成本,但换来的是后续查询计划稳定,尤其是分区粒度变化后,比较容易出现查询扫描范围扩大的情况。
# 重写数据文件以适配新分区策略,同时压缩小文件 CALL iceberg.system.rewrite_data_files( table => 'ods.orders_iceberg', strategy => 'sort', sort_order => 'user_id,create_time' );4.5 迁移后的查询性能回退与排查思路
从 HBase 迁到 Iceberg 后,有一部分查询确实会变慢,主要集中在高频点查场景。比如按 user_id 查某条记录,HBase 可以毫秒级返回,Iceberg 即使通过分区裁剪和列裁剪,也只能到秒级或几百毫秒级。
这不是 Iceberg 有缺陷,而是它的存储模型定位就是分析型。如果业务里还有高频点查需求,务实做法是保留一条 HBase 或宽表 KV 服务,将 Iceberg 分析结果定期回写,而不是削足适履把在线查询全部压到 Iceberg 上。我们的方案是 Iceberg 负责数仓明细与指标加工,Redis 或者 HBase 负责前端在线服务查询,两者通过定时任务同步关键结果。
排查查询变慢时,首先看执行计划是否命中分区裁剪,其次看snapshot-id是否被固定在一个较老的快照上,最后看文件数是否过多。这三个问题基本覆盖了绝大多数 Iceberg 查询性能瓶颈。
5. 迁移后的运行体验与几点额外建议
整个过程跑下来,我对从 HBase 到 Iceberg 的演进有了比较清晰的体感。HBase 像一个训练有素的专项运动员,在随机读写赛道上很强,但很难覆盖分析场景的全部需求;Iceberg 更像一个综合管理平台,它不追求单点性能极限,而是把表管理、多引擎访问、数据可靠性这些基础能力打磨扎实。
我在实际运维中比较受益的几个习惯,可以分享一下:
第一,Iceberg 的元数据监控要接入到现有监控体系里,重点盯快照数量、孤儿文件数量、平均文件大小三个指标。这三个指标直接决定了表健康度和查询性能。
第二,写入任务尽量集中到少数几个高频任务中,不要让大家各自建写入作业,否则小文件问题会从根上失控。我们后来统一收敛到一套数据写入服务,无论实时还是离线都走同一入口,文件治理成本大幅降低。
第三,如果团队之前习惯 HBase 的开发模式,迁移前期一定要花时间培训“查询思维转换”。在 HBase 里写 filter 的人到 Iceberg 后会下意识拒绝重写 SQL,结果就是把 HBase 的全扫描习惯带了过来,性能崩了还怪表格式不行。实际上 Iceberg 的优化空间很大,前提是得按它的方式去设计表、分区和查询。
如果你现在正面临从 HBase 走向 Iceberg 的选型,我的建议是先梳理清楚业务里到底有多少比例的查询是点查,多少是分析扫描,再决定迁移力度。全量替换不见得是正确答案,但把数仓分析这条线完整搬上 Iceberg,让列式存储发挥真正的分析价值,是值得投入的一条路。