从HBase到Iceberg这条演进路线,我这两年体会特别深。前几年做实时特征存储,选型闭眼就是HBase,RowKey一设计、预分区一搞定,点查快得飞起。但后来业务方动不动就要跑个聚合分析、按时间范围扫全量数据,HBase的扫描性能直接让人头疼,全表扫一遍能把RegionServer压到报警。后来接触到Iceberg,才意识到“表”和“K-V存储”在大数据生态里根本是两个物种。这篇就把我同时用HBase和Iceberg的经验摊开来讲,重点聊聊列式存储技术在这两者之间的演进逻辑、底层原理差异,以及从HBase迁到Iceberg的实际路径。
1. HBase的黄金时代与它解决的真实问题
1.1 为什么十年前大数据架构离不开HBase
先回到HBase最风光的年代。那时候Lambda架构盛行,实时链路通常长这样:业务数据进Kafka,一套Flink做实时ETL,结果落到HBase提供毫秒级点查,批链路再用Hive跑离线报表。HBase承担的是“在线服务层”的角色,支撑用户画像、订单查询、推荐特征读取这类场景。
HBase能扛住这个角色的根本原因,在于它的底层存储结构——LSM-Tree。数据先写MemStore(内存),攒到一定阈值再刷成HFile落盘,通过分层合并(Compaction)维持查询效率。这种设计把随机写转化为顺序写,写入吞吐能做到非常夸张,配上网易、阿里早年公开的HBase集群规模数据,单集群日均写入上百亿行的案例并不罕见。
而且HBase的行键(RowKey)设计非常“程序员友好”。合理设计RowKey可以把热点数据打散到不同RegionServer,实现负载均衡和毫秒级检索。举例来说,订单场景可以用“反向用户ID + 时间戳”作为RowKey,点查某用户最近的订单时,通过前缀扫描直接定位Region,根本不用全表扫描。
1.2 HBase的天花板:扫描与分析的难言之隐
但HBase的问题恰恰在它的强项之外。K-V模型决定了它面向的是“单行或小范围扫描”,一旦查询变成“统计所有用户的消费总额”“近30天某品类的销售趋势”,情况就完全不一样了。
HBase对这类OLAP查询的支持基本靠全表扫描,而全表扫描的效率在HFile存储格式下非常低。原因在于HFile本质上是按RowKey排序的“键值对”存储,即使底层用了块编码,稠密的列式布局也是不存在的——每一行数据的各个列族、各个列,都需要记录key和value,重复字段多、压缩率差,扫描时大量无效列也会被读出来。
还有个痛点:HBase的二级索引原生不支持。想按非RowKey字段查数据,要么自己维护索引表,要么全表扫。我参与的一个风控项目,要按设备ID反查用户,被迫维护了一张“设备-用户”的映射表,双写一致性问题折腾了很久。这种场景做大之后,方案的复杂度会指数上升。
同时HBase的Schema变更能力也很弱。虽然列族可以动态加列,但像“修改列的类型”“嵌套结构演进”这类操作,基本做不了。数据模型一旦定形,后面想调结构,通常只能重刷数据。在大数据生态越来越强调灵活性的当下,这就是硬伤。
2. Iceberg到底改变了什么:从K-V存储到真正的“表”
2.1 Iceberg不是存储引擎,是表格式(Table Format)
很多人第一次听说Iceberg时都会有疑惑:Iceberg到底存在哪里?它和HBase有什么关系?可以明确说:Iceberg不是一个存储引擎,不负责数据存放,数据还是放在HDFS、S3或MinIO这些对象存储上。Iceberg管的是一层“元数据+表语义”,所以它被称为Table Format。
这层Table Format解决的是“让存储在廉价对象存储上的文件拥有数据库表能力”的问题。Iceberg把表数据组织成有序的快照(Snapshot),每次写入或删除都会生成一个Manifest列表,记录哪些数据文件属于哪个快照。查询时,只需要读取对应快照的文件清单,就能精准定位需要扫描的数据文件。
这里就有一个关键区分:HBase的数据文件HFile本身没有跨文件的“表级元数据”,RegionServer的物理存储只是“一段段有序的键值区间”,表的概念是逻辑层面的,依赖HBase Master和ZooKeeper协调;而Iceberg的元数据文件(Metadata File)本身就是可检索、可版本化的,表结构、分区信息、快照历史都沉淀在元数据里。这种设计意味着Iceberg天然具备HBase不具备的能力:
- 完整的ACID语义(通过快照隔离实现);
- 时间旅行(Time Travel),可以读取任意历史快照;
- Schema演进的自由度高;
- 多引擎并发读写统一视图(Spark、Flink、Trino都能查同一张表)。
2.2 列式存储是Iceberg高性能分析的核心底座
Iceberg并不强制数据格式,但生产环境几乎清一色搭配Parquet或ORC。这俩是真正的列式存储格式。列式存储和HBase那套“行式K-V + 块编码”的路线,完全是另一个维度。
列式存储的核心思想是:把同一列的数据连续存放在一起。以Parquet为例,一个文件内部按RowGroup划分,每个RowGroup内部,数据按Column Chunk组织,每列独立编码和压缩。读取时做列裁剪,查询只需要读涉及的列,大幅减少磁盘IO。
这个原理说白了特别像整理藏书:HBase像把每个读者的所有书混放在一个箱子里,你需要找全部读者里所有数学书时,得一个个箱子翻;列式存储则像图书馆把数学书放一个区,物理书放一个区,你要调研数学书有多少,直接去数学区数就行。
列式存储带来的另一个红利是超高压缩率。同一列数值类型一致、取值范围接近,用RLE(游程编码)和字典编码可以压出非常惊人的压缩比。银行流水场景下,用户ID列在Parquet里可能被压成原来的1/10不到。列式+压缩,直接让大规模扫描的成本降到了云上对象存储可以接受的程度。
但必须说清楚,列式存储不是没有代价的。列式文件的写入需要做内存缓冲和排序,不适合单行级高频更新;读取单行(点查)反而比HBase慢得多。Iceberg快照机制也使用户不得不用“批量写入+小文件合并”的方式去写入数据。这也是为什么很多团队在使用时会保留一套Redis或HBase做点查,而把分析查询全部转移到Iceberg上。
3. 数据模型与访问模式的分岔口:什么时候该用谁
3.1 点查型业务继续用HBase没问题
先给个直接判断:如果你的业务以Get/Put为主,单次查询毫秒级返回,写多读少且不关心全量聚合,那么HBase依然是当下最好的选择。典型的场景比如:
- 实时特征存储:推荐系统在线读取用户实时特征;
- 订单缓存与详情页:通过订单ID点查;
- IoT设备状态查询:按设备ID查最新状态;
- 短时窗口内的去重计数等。
这类业务还有一个特点,就是访问局部性强,RowKey前缀能直接命中少量Region。HBase的LSM写入模式在持续写入压力下非常稳,配合上布隆过滤器和块缓存,点查P99能做到50ms以内,这个量级是Iceberg做不到的。
3.2 分析型业务场景,Iceberg是当前最顺滑的选择
如果你要面对的是“全量数据的聚合与关联分析”“历史数据回溯”“湖上多引擎共享数据”,HBase就捉襟见肘了,Iceberg是更顺滑的方案。
举一个我实际参与的日志分析项目例子。原来的技术栈是数据吞吐进Kafka,UDF清洗后落到HBase,再让后端服务直接从HBase做时间范围扫描,展示图表。结果数据量只要一过亿,扫描延迟就飙到几十秒;后端做一次天级聚合,会把HBase集群打到IO饱和,旁边需要点查的业务全被拖下水,加班排查了一周才定位到是扫描流量把DataNode的网络打满了。
后来迁移到Iceberg,底层文件转Parquet,配合分区裁剪(当天只读当天分区)和谓词下推(只读需要的列),同样数据量的聚合查询时间从几十秒降到秒级。Spark SQL直接跑在Iceberg上写起来很自然,成本只有原来的零头。
所以我的判断是:代不代表“谁替代谁”,而更像是一个生态分工的重构。HBase留在在线链路,Iceberg接管分析链路。两者之间甚至可以通过批同步打通——HBase的结果定期批量入湖到Iceberg,供分析使用,互不打架。
3.3 一张表说清HBase与Iceberg的差异
| 维度 | HBase | Iceberg |
|---|---|---|
| 定位 | 分布式NoSQL K-V数据库 | 数据湖表格式(Table Format) |
| 底层文件 | HFile(行式/键值式组织) | Parquet/ORC(列式组织) |
| 核心访问模式 | 随机点查、范围扫描 | 批量扫描、分析、流批一体 |
| 数据更新方式 | 单行Put/Delete | 批量Overwrite/DeleteFile |
| 事务能力 | Row-Level原子性(跨行弱) | 快照隔离 + ACID |
| Schema演进 | 弱,列族模式僵化 | 强,支持Add/Drop/Rename列 |
| 索引机制 | RowKey前缀,布隆过滤 | Manifest + 列统计(MinMax) + 分区裁剪 |
| 多引擎支持 | HBase生态为主 | Spark/Flink/Trino/Presto/Hive |
| 核心成本 | 大量内存与磁盘用于在线服务 | 对象存储相对廉价,压缩比高 |
这张表列完之后,选择逻辑就清晰了:在线K-V与离线分析是两种完全不同的数据访问模式,硬放在一个系统里,两边都痛苦。
4. 从HBase迁到Iceberg的实操路径与关键陷阱
4.1 一套可以复制的迁移步骤
我以Spark(PySpark)为例给出一个成熟的迁移链路。整体思路是:先做存量快照同步,再做增量同步,最后切读。
第一步,准备工作。创建Iceberg表,在Spark SQL下执行:
CREATE TABLE lake.events ( user_id STRING, event_ts TIMESTAMP, event_type STRING, payload MAP<STRING, STRING> ) USING iceberg PARTITIONED BY (days(event_ts));注意这里用的days(event_ts)是Iceberg的隐藏分区(Hidden Partition),不需要额外维护分区字段,引擎根据时间自动映射分区目录。这是Iceberg相比Hive传统分区表非常舒服的一点,不用担心分区字段与数据字段割裂的问题。
第二步,存量同步。用Spark的HBase Connector读取全量HBase数据,落到Iceberg表:
df = spark.read \ .format("org.apache.hadoop.hbase.spark") \ .option("hbase.table", "events_raw") \ .option("hbase.columns.mapping", "rowkey STRING :key, cf:event_ts TIMESTAMP, cf:event_type STRING, cf:payload STRING") \ .load() df.write.format("iceberg") \ .mode("overwrite") \ .option("fanout-enabled", "true") \ .save("lake.db.events")这个过程中的一个重点是:从HBase读出的数据往往是一整行的大宽表结构,迁移前要完成窄表化拆分。比如原表RowKey里嵌了用户ID+时间,迁到Iceberg就把user_id和event_ts拆成两列,分别做分区和排序键——这是从K-V模型到关系模型重新建模的核心。
第三步,增量同步。常见做法是用Flink的Iceberg Connector,消费Kafka里的CDC或业务消息,实时写入Iceberg:
DataStream<RowData> stream = ...; IcebergSink.forRowData(stream) .tableLoader(TableLoader.fromCatalog(...)) .writeParallelism(4) .build();增量链路要特别注意小文件问题。Iceberg每次写入都会生成新文件,频繁低量级写入会产生大量几KB到几MB的小文件,扫描性能急剧下降。建议写入端做微批次攒批、写完立刻触发rewrite_data_files做文件合并,或者在流式写场景用COW(Copy-On-Write)配合定时Compaction。
4.2 我在迁移中踩过的最坑的四个问题
第一坑:HBase的“稀疏存储”特性导致数据迁移后磁盘膨胀很多。HBase对空值是不占用物理空间的,但Parquet里的空值是按Schema固定的,即使为NULL也会占用一定空间。我们迁移完成后发现存储比原来涨了一倍多,排查结果就是大量稀疏字段。对策是迁移前做一轮字段裁剪,明确哪些列必须保留,在Parquet里定义合理的nullable和default值。
第二坑:RowKey语义的丢失。HBase往往把多个业务维度拼在RowKey里,比如“用户ID + 品类 + 时间戳”,但解析规则只写在业务代码里。迁移到Iceberg时如果只是把整个RowKey当字符串搬过去,后续分析时就要反复用字符串截取做过滤,分区裁剪完全失效。正确做法是先解析成结构化字段,再建立分区。这属于“重建数据模型”的工作,绕不过去。
第三坑:时间语义不一致。HBase里的时间戳很多是写入时间而非事件时间,迁到Iceberg按事件时间去分区,就会出现数据落在错误分区的情况。我在日志项目里就遇到历史数据某一天的03:00到05:00的数据事件时间比写入时间晚了8小时(时区设置历史问题),结果分区和Count对不上,排查了大半天。建议迁移时对时间字段做严格的时区归一化校验,提前写校验SQL核对分区行数。
第四坑:只搬迁数据,不搬迁“访问模式”。HBase接口是API,而Iceberg主要走SQL。下游应用改造量比预想大得多,有些老旧的Java服务用HBase Thrift或Client读数据,切换Spark SQL之后需要新起查询服务。提前梳理调用链、规划好查询中间层(比如用Trino提供JDBC接口),会让平滑度大幅提升。
4.3 本地快速验证:Docker搭建Iceberg + MinIO + Spark
如果你只是想快速上手试验,没有必要上来就搭生产集群。参考社区常见方案,Docker Compose三件套就能跑通全流程:
- MinIO模拟对象存储(S3兼容);
- 一个SQL访问Iceberg(或用Spark);
- Iceberg元数据用Hive Metastore或JDBC目录。
services: minio: image: minio/minio command: server /data --console-address ":9001" ports: - "9000:9000" - "9001:9001" spark-iceberg: image: tabular/spark-iceberg:latest depends_on: - minio environment: - AWS_ACCESS_KEY_ID=admin - AWS_SECRET_ACCESS_KEY=password - AWS_REGION=us-east-1 volumes: - ./warehouse:/home/iceberg/warehouse ports: - "8888:8888" - "8080:8080"启动后用spark-sql指定Catalog即可:
CREATE DATABASE mydb; CREATE TABLE mydb.events (...) USING iceberg; INSERT INTO mydb.events VALUES (...); SELECT count(*) FROM mydb.events;这套环境对理解Iceberg快照、Manifest、分区演化特别合适——你每次写操作后去MinIO里看数据文件和元数据文件,能直观看到快照是怎么叠加出来的。这也是我推荐给团队新人的入门路径:先把表格式的物理形态看明白,再谈生产优化。
5. 列式存储演进背后的本质逻辑与选型复盘
5.1 从HBase到Iceberg,背后是“负载假设”的转变
HBase当年被广泛使用,负载假设是“千行级随机访问”,存储上就要为随机读写优化;而现在数据湖场景的负载假设是“亿行级扫描聚合”,存储上就要为吞吐和压缩优化。这两种假设决定了底层数据结构设计的差异——LSM树为写入优化,列式文件为读取优化——很难在同一个系统里兼顾。
Iceberg的成功还在于它把“表”从引擎中解放出来。HBase的表与HBase集群绑定,数据物理上就在RegionServer上,无法让Spark直接读取数据文件做分布式算力扩展;Iceberg则把元数据与数据分离,表可以建在任意S3/OSS/MinIO上,算力层弹性伸缩,数据无需搬迁。对云上大数据团队来说,这就实现了存储和计算的真正解耦,成本优化空间完全不一样。
5.2 选型复盘:同一个业务,两种存储如何共存
最后分享一个我现在常用的选型思路,纯个人经验。如果你正在设计新系统,不要抱着“非此即彼”的思路,而是按查询特征分层:
- 在线实时特征/状态查询,走HBase或Redis;
- 全量历史分析、事件明细、趋势报表,走Iceberg + 对象存储;
- HBase的数据通过批量或CDC定期入Iceberg,作为分析底座。
这个复盘思路我踩过不少坑才形成。早期做项目时什么数据都往HBase塞,因为写入方便、查询方便,结果后期分析需求爆发,天天做全表扫描。后来学乖了,在建表之初就先问业务一个问题:“这个数据是给人看的,还是给机器算的?”——给人看的(APP详情、订单状态)放K-V在线存储,给机器算的(行为日志、销售明细)直接入湖。
5.3 未来演进中的一点个人观察
Iceberg方向已经非常明确,但我个人观察到一个更细的变化正在发生:社区开始把更多数据库能力下沉到表格式层,比如Row-Level Update、位置删除(Position Delete)、增量读取(Incremental Read)。这意味着以后的数据湖上可以更平滑地跑“数据仓库工作负载”,甚至支持一部分流式处理语义,而不再只是“离线分析的底座”。
回到存储演进本身,HBase代表的是一个时代——在复杂场景下提供简单模型,用工程复杂度换取极致在线性能。Iceberg代表的是另一个时代——在简单存储上提供丰富语义,用元数据灵活管理换取分析生态的统一。这两者的演进不是谁淘汰谁,而是大数据生态终于有了分工精细、各司其职的完整链路。对我来说,能够参与这个过程,并把自己过去的踩坑记录转化成对他人有用的经验,本身就是这个行业最迷人的地方。