☰
Spark 读取 HBase 性能调优实战:SHC Connector 原理与踩坑记录
2026/10/14 7:00:53 网站建设 项目流程

业务方丢过来一个需求:每天凌晨要把 HBase 里的用户标签数据拉进 Spark 做宽表聚合,再落到 Hive 里出报表。链路不复杂,但第一版用原生 HBase API 写 Spark 任务时,光是 Scan 的封装、Result 反序列化、Region 并行度控制就耗掉两天。后来切换到 SHC(Spark-HBase Connector)方案,用 DataFrame 直接读,代码量砍掉一大半,性能也稳了不少。这篇文章把这次改造的完整过程、原理细节和踩过的坑都整理出来,给后面要接 HBase 的同事当个参考。

先说结论:SHC 并不是一个“为了省事牺牲性能”的玩具方案,它把 HBase 的 Region 天然并行度、RowKey 过滤、列族裁剪这些底层机制,都暴露成了 Spark 里可感知、可调优的配置项。只要 Catalog 定义得当、分区策略合理,读 HBase 的性能基本不会输给手写 API 的方案。下面从方案选型开始讲。

1. 项目背景与方案选型

1.1 为什么没直接用原生 HBase API

用 hbase-client 配合 SparkContext 读 HBase,看起来自由度最高,实则成本全在后半程。你得自己写 TableInputFormat 相关的 JobConf,自己处理 Scan 的 startRow/stopRow,还要把 Result 对象一个个转成自定义的 case class。最麻烦的是,HBase 的 Region 分布和 Spark 的分区数默认并不对齐,想做到按 Region 并行读取、动态感知 Region 分裂,这些逻辑都要自己维护。

我们当时第一版就是这种写法。跑小数据量没问题,一旦表里有几亿行,就会出现两类问题:一类是分区不均匀导致部分 Executor 空转,另一类是 Result 转对象时字段漏配,线上报错要到跑完半个任务才暴露。与其说是 HBase 难读,不如说是这种“从 RDD 起手”的开发方式把太多底层细节暴露到了业务代码里。这不是某个人的水平问题,而是方案选择的问题。

1.2 SHC 到底是什么

SHC 的全称是 Spark-HBase Connector,是 HBase 社区维护的开源连接器。它做的事可以一句话概括:让你用 DataFrame 的 API 读写 HBase 表,Spark 端看到的是一个普通的二维表(有 rowkey、有列名、有类型),HBase 端的行键、列族、列限定符、时间戳这些概念,全部由一段称为 Catalog 的 Json 配置来桥接。

使用方式也简单,加载一个外部数据源格式即可:

val df = spark.read .format("org.apache.hadoop.hbase.spark") .options(Map(HBaseTableCatalog.tableCatalog -> catalogJson)) .load()

这对业务的改造影响是决定性的:原先要写十几个文件的数据接入层,现在变成一个 DataFrame。下游无论是 join 维表、聚合统计还是直接写 Hive,全都复用 Spark SQL 的能力,不需要为 HBase 单独写一套处理逻辑。

1.3 方案选型时对比过的几条路线

当时我们实际对比了四条路线,简单列一下各自的特点:

方案优点缺点适用场景
原生 HBase API + RDD灵活,可精细控制 Scan代码量大,要自己管分区少数一次性脚本
PhoenixSQL 语义完整,支持二级索引需要引入 Phoenix 依赖与服务查询场景多的在线业务
SHCDataFrame 集成好,开发效率高谓词下推有限,版本适配有成本离线批处理、数据集成
HBase 2.x + 新连接器支持 Spark 3,机制更新社区迭代中,用例积累少新集群升级场景

最后选 SHC,核心原因是集群当时是 Spark 2.4 + HBase 1.x 的配套,SHC 有对应稳定的版本,而且业务链路是纯批处理,对实时性没有诉求。这种“老版本组合”下,SHC 是最稳的均衡解。

2. SHC 核心原理与 Catalog 机制

2.1 它是把 HBase 表“翻译”成 Spark 表的

先用一个类比来理解 SHC 的定位:HBase 存储层像一间没有目录的大仓库,货架(Region)上摆着海量货物(KeyValue),而 Spark 拿到的 DataFrame 像一份标准化的货品清单。SHC 就是那个负责“翻译清单坐标到仓库货位”的调度员:它根据 Catalog 里声明的 rowkey、列族、列名、类型,把一个二维表的读取请求翻译成对 StoreFile 的 Scan 请求。

真正执行读取时,SHC 会基于 HBase 的 TableInputFormat 从底层拉取数据,然后把每个 KeyValue 按 Catalog 的列定义重新组织成 Spark 的 InternalRow。这就是为什么它可以和 DataFrame API 无缝衔接,也解释了为什么要严格声明列族和列名——没有这两层映射,Scan 出来的字节流根本不知道该往哪个字段上填。

2.2 Catalog 是关键中的关键

Catalog 是一段 JSON,它定义了 Spark 表和 HBase 表的映射规则。一个典型的读取配置长这样:

{ "table": { "namespace": "default", "name": "user_profile" }, "rowkey": "rowkey", "columns": { "rowkey": { "cf": "rowkey", "col": "rowkey", "type": "string" }, "name": { "cf": "info", "col": "name", "type": "string" }, "age": { "cf": "info", "col": "age", "type": "int" }, "score": { "cf": "info", "col": "score", "type": "double" } }, "bulkload": false }

这里最容易被忽略的是 rowkey 在 columns 里也必须声明,它的 cf 和 col 都写成 "rowkey" 是社区里通用的一种约定。如果不写这一列,读取出来的 DataFrame 里就没有 rowkey,后续要拿 rowkey 做关联时还得再读一次,非常被动。

columns 里的每个字段分三部分:cf 是列族,col 是列限定符,type 是 Spark 侧声明的数据类型。注意这个 type 不光是写给人看的,SHC 会按照这个类型去做字节转换。如果你的 HBase 里存的是字符串,Catalog 却声明成 int,跑起来不会立刻报错,但数据读出来会是错的,而且很难察觉。所以 Catalog 里的类型一定要和写入端序列化方式保持一致,这是做数据接入时最需要较真的地方。

2.3 谓词下推到底推了什么

SHC 意义上的谓词下推,主要是把 DataFrame 里的过滤条件下推到 HBase 的 Scan 层面,从而减少网络传输和 RPC 次数。实测下来,它并非“所有条件都推”,而是有明确边界:

  • rowkey 的等值条件(=)、范围条件(>、<、between)可以下推,扫描大表时收益最明显;
  • 普通列上的等值条件,在部分版本里也能下推成 HBase 的 Filter,但要看具体写法;
  • 非 rowkey 上的函数计算、正则匹配这类复杂条件,一般不会下推,会在 Spark 侧完成过滤。

一个容易踩的坑是:如果只写df.filter(col("name") === "zhangsan"),你以为下推了,实际可能是在 Spark 侧全表扫完再过滤。判断方法很简单,看 Spark UI 里的 Stage 输出行数和物理计划里有没有PushedFilters。我们当时排查过一个线上问题,就是没注意这一点,一个 10 亿行的表跑了 40 分钟,改成在 Scan 里显式设置 rowkey 范围后,缩到了 5 分钟。

3. 环境准备与依赖配置

3.1 版本匹配是第一道坎

SHC 的版本和 Spark、HBase 的版本耦合非常紧。用错版本最常见的症状是运行时抛出NoSuchMethodError或者ClassNotFoundException,而且往往发生在 Executor 端,日志还不完整。

建议动手前先确定这几个版本坐标:

组件版本范围说明
Spark2.4.x / 2.3.xSHC 1.x 主要支持 Spark 2
HBase1.x / 2.x不同分支依赖不同的 hbase-client 版本
Scala2.11 / 2.12必须与 Spark 编译版本一致
SHC1.1.x / 2.x对应 Spark 2.4 和 HBase 2.x

如果集群是 Spark 3.x + HBase 2.x,那就不能直接用旧版 SHC,要用新维护的 hbase-connectors 项目下的连接器。这里没有万能版本,动手前一定先去对应仓库确认兼容性说明,别凭感觉选。

3.2 依赖引入的几种方式

引入依赖有两种常见做法。如果用 Maven 管理 Spark 工程,直接加 dependency 就行;如果你是在已有 Spark 集群上跑任务,更建议用--jars提交,把包含 SHC 及其依赖的 jar 一起带上。

<dependency> <groupId>org.apache.hbase</groupId> <artifactId>shc-core</artifactId> <version>1.1.1-2.4</version> </dependency>

特别提醒:SHC 运行期还依赖 HBase 客户端相关 jar,包括 hbase-client、hbase-common、hbase-server、hbase-protocol 等。提交任务用了--jars的话,最好把 HBase 的 lib 目录下的相关 jar 一起带上,否则 Executor 侧很容易因为缺类而挂掉。这个坑我们遇到过不止一次,所以现在提交脚本里的 jars 列表都是写死的,不依赖 classpath 自动扫描。

3.3 必要配置项清单

在我们生产环境里,下面这些配置是必须确认的:

配置项示例值作用
hbase.zookeeper.quorumnode1,node2,node3ZK 地址,SHC 通过它定位 HBase
hbase.zookeeper.property.clientPort2181ZK 端口
hbase.client.scanner.caching500单次 RPC 拉取的记录数
hbase.client.scanner.batch0Scan 批次大小,0 表示不做 batch
spark.serializerKryoSerializer避免 Java 序列化性能问题

这些配置既可以在 SparkConf 里设置,也可以通过--conf参数传入,甚至可以把 hbase-site.xml 打进工程。实际部署下来显式在 SparkConf 里设一遍最可靠,不依赖服务器默认配置,出了问题也好排查。

4. 核心 API 使用与代码实现

4.1 读取 HBase 的核心代码模板

给出一段实际可用的读取模板,直接使用 DataFrameReader 的通用方式,不依赖任何额外 DSL:

import org.apache.hadoop.hbase.spark.HBaseTableCatalog val catalog = """ |{ | "table": { | "namespace": "default", | "name": "user_profile" | }, | "rowkey": "rowkey", | "columns": { | "rowkey": { "cf": "rowkey", "col": "rowkey", "type": "string" }, | "name": { "cf": "info", "col": "name", "type": "string" }, | "age": { "cf": "info", "col": "age", "type": "int" } | } |} """.stripMargin val df = spark.read .options(Map(HBaseTableCatalog.tableCatalog -> catalog)) .format("org.apache.hadoop.hbase.spark") .load() df.createOrReplaceTempView("user_profile")

跑完这段代码,就可以用 SQL 直接查这张临时视图:

SELECT rowkey, name, age FROM user_profile WHERE age > 30

注意一个细节:Catalog 里声明的字段名会成为 Spark 表的列名,所以建议把列名起得和 HBase 列限定符一致,避免后续维护时出现“代码里的字段”和“HBase 里的列名”对不上的混乱。

4.2 指定 RowKey 范围与过滤条件

读取大表时最实用的一个技巧是用 rowkey 范围来限制扫描区间。如果数据本身的分布是按业务维度设计 rowkey 的,这一步能极大减少扫描量,而且写法很自然。

val df = spark.read .options(Map( HBaseTableCatalog.tableCatalog -> catalog, HBaseTableCatalog.pushDownRowKeyFilter -> "true" )) .format("org.apache.hadoop.hbase.spark") .load() .filter(col("rowkey").between("20240101_0001", "20240131_9999"))

上面这种写法,SHC 会把 between 转换成 Scan 的 startRow 和 stopRow。相比先全表 Scan 再在 Spark 侧过滤,性能差异是数量级的。我们的一个标签表,加了 rowkey 范围后,任务从 40 分钟降到 6 分钟,而且 Spark UI 中看到的输入数据量明显变小。这里有一个判断技巧:如果你发现输入数据量和全表行数差不多,说明下推没成功,需要回头检查 rowkey 过滤条件的表达方式。

4.3 写入回写与批量处理注意点

SHC 也能写 HBase,用法和读对称:df.write.format("org.apache.hadoop.hbase.spark")。写入和读取的评估角度不同,但有两个实测经验可以分享。

一是并发度:SHC 写 HBase 的并发度默认受 DataFrame 分区数限制,分区太少会产生热点,建议先repartition到接近 Region 数的规模,让写入请求分散到多个 RegionServer。二是 rowkey 设计:如果目标表预分区比较多,rowkey 尽量带业务前缀来散列,避免把所有写入集中到连续的 rowkey 区间里,形成单 Region 热点。

写入场景不是这篇文章的重点,但如果你要拿 SHC 做“Spark 加工后回写 HBase”,上面两点能避免很多实际问题。

5. 性能调优与参数配置

5.1 分区策略怎么调才有效

HBase 表的数据是按 Region 分布的,SHC 默认的策略是尽量按照 Region 数来划分 Spark 分区。这个策略的好处是分区和 RegionServer 之间有天然的数据本地性,减少跨节点传输。

但默认策略不总是最优。一种常见情况是大量 Region 的数据量不均,比如某个 Region 的数据量是其他 Region 的十倍,对应 Spark 分区的执行时间就会被拉长,整个 Stage 被拖慢。对这种倾斜,最简单的处理是加一层repartition,把倾斜分区分裂成更小粒度;或者改用 rowkey 前缀自定义分区,把数据按行键前缀切成多个独立区间。

我这里做过一个对比压测:同样 2 亿行数据,默认 Region 分区跑了 12 分钟,改用 rowkey 前缀切分为 32 个区间后跑了 7 分钟。虽然自定义分区需要多写一点逻辑,但对有明确 rowkey 规则的表收益非常明显。

5.2 连接与 RPC 参数怎么配

读取 HBase 时,Spark Executor 会和 HBase RegionServer 建立 RPC 连接来扫描数据。有两个参数直接影响吞吐,值得花时间调:

  • hbase.client.scanner.caching:一次 RPC 从 RegionServer 拉取的行数。数据行比较小(几百字节以内)时,500 甚至 1000 都可以;但如果单行是 KB 级以上,缓存太大容易触发 RPC 超时,要调小到 100 或者更低。
  • hbase.client.scanner.batch:单次 RPC 返回的 KeyValue 数量。列数多的表对这个参数敏感,建议根据单行大小测试后设定。

如果拿不准,建议先设 caching=200、batch=0,观察 Spark UI 的输入数据量和运行时间,再逐步调整。这类参数确实不能照抄,要结合 rowkey 长度、列数、单行大小来综合判断。

5.3 列裁剪与其他优化

一个容易被忽略的点:如果你只需要部分列,务必在 Catalog 里只声明这些列。SHC 会根据 Catalog 生成对应的列集合,底层就能做列族和列限定符的过滤,只扫描需要的数据,既减少网络传输,也减少后续反序列化开销。

我们有个表有 30 多列,业务只需要其中 3 列,Catalog 里只声明这 3 列后,读取耗时降到原来的三分之一。这类优化不需要改 HBase 侧的任何配置,但收益却非常可观。另外,如果 HBase 表开启了 BlockCache,并且 RegionServer 和 Spark Executor 部署在同一批节点上,读取性能会更好。SHC 读取时默认会尽量利用数据本地性,不过 Executor 核数开得过大,或调度策略不感知 locality,本地性就会打折扣。

6. 常见问题与排查实录

6.1 Catalog 类型映射错误

症状表现:读出来的数据不报错,但数值明显错误,例如字符串被截断或数字乱码。

排查思路:先确认 HBase 里该列的实际存储类型。如果是写入时用 String 序列化,读取时 Catalog 里就必须写type: "string";如果是写入时用 Bytes.toLong,读取时就要写type: "long"。SHC 不会自动做类型推断,整条链路的序列化规则必须由应用层保证一致。

这个坑特别隐蔽,因为一旦类型对不上,Spark 端不会抛异常,只会把字节流按错误类型解析。当时我们排查一个年龄字段溢出问题,最后定位到是写入端用 int 序列化,读取端声明成了 long,两边解析的字节长度不一致,读出来全是垃圾值。这里说一下我的经验:每次写完 Catalog,先挑 10 条已知数据核对字段值,比跑全量后才发现问题要划算得多。

6.2 谓词下推不生效

症状表现:加了过滤条件,Spark UI 的输入行数没有下降,任务依然扫描全表。

排查步骤:第一步看 Spark 物理计划中的PushedFilters字段;第二步确认过滤条件是否只作用在 rowkey 上;第三步检查 SHC 版本是否支持该条件下的下推。

一个有效的手段是直接用 Scala API 写显式条件,而不是用 SQL 字符串拼接。SQL 里的复杂表达式下推成功率比 DataFrame DSL 低。此外,rowkey 范围推荐用between或>=与<=的组合,少用!=这种无法转换成 startRow/stopRow 的条件。

6.3 连接 HBase 超时

症状表现:任务启动后一直卡在获取连接阶段,随后抛 ZK connection timeout。

这类问题九成是 ZK 地址配置错了。SHC 读取 HBase 时不是直连 RegionServer,而是先通过 ZK 找到 HBase 的系统表,再定位到目标表的 Region。如果你在 SparkConf 里配了hbase.zookeeper.quorum,注意多个地址之间用逗号分隔,不要带空格,也不要写zookeeper://这种协议前缀。

另一个容易忽略的点:提交任务所在网络的防火墙策略。生产环境常常只有部分节点能访问 ZK 端口,如果 Executor 所在节点网络不通,也会表现出连接超时。排查时可以用telnet验证提交节点和 ZK 节点之间端口是否可达,这一步能快速缩小范围。

6.4 类冲突与序列化异常

症状表现:Executor 报ClassNotFoundException、NoSuchMethodError,或者 Kryo 序列化异常。

原因一般是 jar 包冲突,尤其是 HBase 依赖的htrace-core、guava、jackson等项目,版本和 Spark 自身携带的不一致时,容易产生冲突。建议提交任务时用--jars精确指定版本,避免把整个 HBase lib 目录一股脑打进去。

Kryo 序列化异常则多发生在自定义对象作为临时视图出现时。处理思路是设置spark.kryo.registrator,或者把自定义对象转成基本类型列,减少框架序列化自定义类的负担。

6.5 常用问题速查表

现象可能原因处理建议
数据读出乱码Catalog 类型与存储类型不一致统一序列化规则,核对写入端类型
过滤后输入行数不变谓词下推失败检查 PushedFilters,改用 rowkey 条件
启动即连接超时ZK 地址错误或网络不通复核 quorum、端口,用 telnet 验证
Executor 抛类找不到jar 冲突或依赖缺失用 --jars 精确引入 HBase 依赖
Stage 输入分布严重倾斜Region 数据分布不均自定义分区或 repartition

7. 实操总结与心得

这次改造给我最大的感受是:SHC 不是一个“黑盒”,而是一层对底层机制做了合理抽象的适配组件。它把 HBase 读取中常见的注意事项从代码层转移到了配置层,但对应要操的心并没有消失——只不过从写业务代码变成了设计 Catalog、调整分区、校验类型。

如果你也准备用 SHC 接 HBase,我的建议是按这个顺序来:先确认版本矩阵,再写好 Catalog,用一个 100 万行的小表验证类型映射和谓词下推效果,然后再上全量。不要一上来就追求大数据量压测,先把“读对”这件事做扎实,后面调“读快”才有意义。

最后再分享一个自己养成的习惯:每次上线前,我都会在 Spark UI 里看一眼读取阶段的 Input 数据量,如果发现过滤条件没有减少输入数据量,立刻回查 Catalog 和代码。这一个动作能避免大部分性能事故。HBase 上的数据处理,很多问题不是出现在 HBase 侧,而是出现在“以为自己已经把条件下推下去了”的这种误判上。把这个细节盯住,整个链路基本就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询