1. 为什么要用 Bulk Loading:从一次导入故障说起
大概三年前,我接过一个数据迁移项目,要把业务库里积累的上亿条历史订单数据搬进 HBase。当时团队里没人专门做过 HBase 大批量导入,也就默认用常规 put 接口往里面写。结果跑了不到两个小时,集群就开始报警:RPC 队列积压、GC 时间飙高、部分 RegionServer 直接进入假死状态。那一刻所有人都在盯着监控屏,气氛很僵硬。
后来我们把任务停了,认真查资料、做压测,才意识到用 put 一条条写海量数据是多不明智的选择。正确的做法,是改用 HBase 官方提供的 Bulk Loading 机制,也就是标题里说的"批量加载"。这篇文章我想把这个过程完整拆开讲一遍,包括它为什么快、底层原理是什么、具体怎么做、有哪些坑,以及我在生产环境里总结下来的参数配置和排查经验。希望读完你不仅能跑通流程,还能理解每一步背后的原因,避免重走我当时的路。
适合看这篇文章的读者包括:需要给 HBase 导入大批量数据的开发或者运维同学、正在做实时数仓或用户画像等项目冷启动数据同步的朋友,以及虽然用过 HBase 但始终没搞懂 Bulk Loading 内部逻辑的进阶学习者。基础部分我会尽量讲得通俗一些,就算你之前没用过 HBase,也能顺着思路理解个大概。
1.1 常规 put 写数据的瓶颈在哪
HBase 的常规写入路径,简单说是这样的:客户端先把写入请求通过 RPC 发给 RegionServer,RegonServer 把数据追加到 WAL 日志,然后再写入内存里的 MemStore,等 MemStore 达到一定大小后再刷写成一个 HFile 文件存入 HDFS。这套设计保证了随机写入的低延迟,但用在批量导入上时,瓶颈会特别明显。
首先是每一条数据都要走完整的远程调用链路。上亿行数据,每一行都要序列化、网络传输、反序列化、WAL 同步落盘、MemStore 写入,这里面的 CPU 开销、IO 开销、网络开销会被放大到一个非常夸张的程度。我当时做过简单估算,在一批普通的物理机上,单 RegionServer 的 put 吞吐大概也就每秒几万行,这还是在没有强一致性和事务要求的前提下。如果数据量达到几亿甚至几十亿行,靠 put 导入基本是等不起的。
其次是内存和 GC 压力。MemStore 写入本身要消耗内存,当多个 Region 同时在写入时,RegionServer 堆内存会迅速被撑满,触发频繁的 Minor GC 甚至 Full GC。GC 一长,整个 RegionServer 的读写都受影响,于是业务写入和导入任务互相干扰,最终形成恶性循环。我那次就是因为导入把集群拖垮,连带正常查询都出现了超时。
还有一个隐蔽的问题:大量 put 请求还会触发频繁的 region 分裂和 compaction。每往一个 region 写够一定数据,region 就会分裂一次,这个过程要消耗资源。同时生成的小 HFile 越多,后台 minor compaction 越繁忙,整个集群的写放大现象会特别严重。换句话说,用 put 导入的不仅仅是"慢",还会产生大量的额外运维负担。
1.2 Bulk Loading 的核心思路
Bulk Loading 的思路和 put 完全不同。它不再一条条把数据发给 RegionServer,而是先在离线的计算过程中,把数据直接组织成 HBase 底层存储格式 HFile,然后再把 HFile 文件“移动”到 HBase 对应的目录下,让 HBase 直接加载并管理这些文件。
这有点像我平时搬仓库:常规做法是把货物一件件从旧仓库搬到新仓库的货架上,搬一件记录一件;而 Bulk Loading 相当于用大卡车一次性把整批货拉到仓库门口,然后叉车整板整板地放进货架。虽然最终效果都是货物进了仓库,但效率完全不是一个数量级。
这种方式的优势体现在三方面。第一,不需要走 WAL。因为数据在加载前已经完全准备好,HBase 不需要再对单条记录做容灾日志,直接从 HFile 中读取数据即可。第二,不需要频繁刷写 MemStore。HFile 在离线阶段就已经按照 HBase 的格式排好序、分好块,加载时 HBase 把文件“认领”过来,不需要在内存里再反复整理。第三,数据在写入 HFile 的过程中,可以利用分布式计算框架(比如 MapReduce 或者 Spark)并行处理,极大提升了吞吐。我后来再用 Bulk Loading 导入同样的数据量时,整体耗时从“跑不完”缩短到十几分钟,效果非常明显。
1.3 适合与不适合的场景
Bulk Loading 不是万能的,它适合的场景有比较明确的边界。最适合的是那种一次性或者周期性的全量初始化,比如冷数据迁移、历史数据补录、离线数仓把清洗结果灌入 HBase,这些场景下数据量大,但对实时一致性要求低,适合用离线批处理。
它也适合周期性的快照导入。举个例子,你每天凌晨从数据仓库导出一批用户标签数据,希望以全量覆盖的方式更新到 HBase 里,那么先用 Spark 生成 HFile,再执行 Bulk Load,会比夜里用 put 持续刷新要稳得多,而且不会跟白天的业务流量争抢资源。
不太适合的场景主要包括两类。一类是数据量不大且实时性要求高的场景,比如实时风控、在线推荐,这些本来就应该用 put 或者通过消息队列异步写入,Bulk Loading 的离线延迟根本满足不了。另一类是频繁小批量更新的场景,如果你每次导入只有几千行、执行一次 Bulk Load 的开销比直接 put 还大,那就不值得。此外,如果源数据本身存在严重倾斜,会导致 HFile 集中在少数 region, Bulk Load 之后依然要面对热点查询问题,这个需要提前通过预分区和后续数据均衡来解决。
2. 原理拆解:为什么 Bulk Loading 能这么快
要真正会用 Bulk Loading,光记住操作步骤是不够的。我建议把底层原理看明白,因为生产环境会遇到各种诡异问题,不理解原理,排查起来会很盲目。这一节我会尽量用通俗的话把关键机制讲清楚。
2.1 数据在 HBase 中的存储结构
HBase 的一张表在底层是按照行键(RowKey)排序后存储的。表会按照 RowKey 的范围被切成很多 region,每个 region 会分配到具体的 RegionServer 上。当 MemStore 攒够数据要刷写时,会把内存中的数据排好序,形成一个不可变的 HFile 文件。这个 HFile 内部按块存储,每块是一条条 KeyValue,也就是“RowKey + 列族 + 列限定符 + 时间戳 + 值”的组合。
这里有个特别重要的特性:HFile 中的数据是按 RowKey 有序排列的。这个特性不是可有可无,而是 HBase 所有存储和查询逻辑的基础。有序性让 HBase 能通过维护一个很小的索引结构,快速定位任意一行数据,也使得一次顺序扫描整个文件成为可能。Bulk Loading 之所以高效,就是因为它严格遵循了这套存储规范,离线生成的文件结构和 HBase 自己刷出来的文件完全同级。
理解了这一点,你就能明白为什么 Bulk Loading 不是“attach 一堆文件”就行,它要求有严格的数据组织步骤。只要最终 HFile 满足 HBase 的格式,HBase 就可以像对待自产文件一样管理它。这个过程本质上就是让外部计算框架“代工”生产存储文件,HBase 只做最后的认领和元数据登记。
2.2 Bulk Load 为什么快:绕过了哪些环节
Bulk Loading 快,是因为它绕过了常规写入路径最重的几个环节。我们把这些环节逐一列出来看看。
第一条,绕过了 WAL。普通 put 为了保证数据不丢,先要写 WAL,数据同步到 HDFS 上之后才会返回客户端成功。Bulk Load 生成的 HFile 本身已经在 HDFS 上了,文件的数据是持久化过的,不需要再额外写一份日志。写 WAL 这个过程涉及磁盘同步、批量刷盘,代价不小,省掉它导入速度自然上一个台阶。
第二条,绕过了 MemStore 的反复合并。普通写入的数据先进内存,内存攒够后再刷成文件,这个过程中内存要不断分配、回收、做 compaction 相关的整理。Bulk Load 在生成 HFile 时就按目标格式做好排序和分块,加载进 HBase 时直接跳过 MemStore,不存在“越小越散”的中间文件状态。
第三条,绕过了多次网络 RPC。普通 put 的每一条记录都要客户端到服务端来一次请求;而 Bulk Loading 阶段,客户端的分布式任务直接在 HDFS 上写文件,最后只有一个 LoadIncrementalHFiles 操作做“文件认领”,跟数据量多少没有线性关系。网络开销从“每行一次”变成“每文件一次”,差距非常明显。
当然,HBase 也不是什么都不做。它在加载 HFile 时,会校验文件里的数据是否属于对应的表、检查 region 边界、更新 meta 表信息,然后把文件“正式挂到”region 名下。因为核心只是写元数据和移动文件,所以整个过程非常快。
2.3 HFileOutputFormat2 与两阶段加载
在生产上,我们一般用 HFileOutputFormat2 配合 Hadoop 的 MapReduce 或者 Spark 的 HFileOutputFormat2 支持来生成 HFile。HFileOutputFormat2 会做几件关键的事情:帮助任务确定目标表有哪些 region、每个 region 的 RowKey 范围是什么,然后让每个 Map/Reduce 任务只负责某一个范围内的数据。
具体来说,使用 HFileOutputFormat2 时,需要依赖 HBase 提供的 TotalOrderPartitioner 来做全局排序分区。因为 HFile 要求数据按 RowKey 有序,并且文件要对应到具体 region 的存储位置。如果没有做全局排序,直接生成一堆无序 HFile,加载端就需要花很多时间去排序整理,甚至可能加载失败。
整个 Bulk Loading 通常分两个阶段。第一阶段是离线生成 HFile:用 MapReduce 或者 Spark 读取数据源,把记录转换成 Put/Delete 对象,写到 HDFS 的临时路径,最终生成 HFile 格式的文件。第二阶段是加载:使用 hbase 自带的 LoadIncrementalHFiles 工具,遍历这些 HFile,根据 RowKey 范围匹配到目标表已有的 region,然后执行文件加载。
有人会问,既然阶段二也是要匹配 region,为什么它不慢?因为匹配工作是批量进行的,LoadIncrementalHFiles 会在 HBase 侧通过 meta 表快速找出每个 HFile 归属哪个 region,然后把文件移动到 region 对应的 HDFS 目录里。它做的是“文件级别”的搬家,而不是“行级别”的写入。理解这两个阶段,就抓住了 Bulk Loading 的主干。
3. 实操步骤:以 MapReduce 和 Spark 两条路线为例
这一节给出完整的可执行步骤。我会分别讲 MapReduce 和 Spark 两条路线,因为很多老集群还在用 MR,但新项目大多数已经转向 Spark。两条路线底层都是 HFileOutputFormat2,只是编程接口不同,你可以根据自己的技术栈选择。
3.1 环境准备与预分区设计
动手之前,先把环境确认清楚。你需要准备一个可用的 HBase 集群,一个能访问 HDFS 的提交机,以及跟 HBase 版本匹配的客户端依赖。特别注意版本兼容问题:如果你的 HBase 是 2.x,客户端依赖版本尽量不要用 1.x 的 hbase-client,否则可能出现 RPC 协议不兼容、Serialization 报错等很奇怪的问题。我建议用 maven 或者 sbt 管理依赖,并锁定 hbase.version 属性,方便后续升级。
然后是预分区设计。这一步非常关键,但很多人会忽略。HFile 在加载时,会按照目标表的 region 边界来决定文件落到哪个目录。如果表里只有一个初始 region,所有数据都会挤到这一个 region,加载后会造成严重的热点,后续还要花大力气做 split 和 balance。正确做法是先根据 RowKey 的分布特征,提前把表分成足够数量的 region,比如按照业务 ID 的 hash 值分 100 个区间。预分区的数量可以从数据量和单个 region 的预计容量反推,通常控制在单 region 2GB 到 10GB 之间比较合适。
具体做法可以用 HBase shell 的 create 命令,传入 SPLITS 或者 SPLIT_FILE 参数。比如:
create 'ods_order', {NAME => 'd', COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW'}, {SPLITS => ['a', 'b', 'c', ...]}想更精确一点,可以写一个 splits.txt 文件,每行一个切分点 RowKey,然后:
create 'ods_order', {NAME => 'd'}, {SPLITS_FILE => 'splits.txt'}这里插一句,预分区的切分点要能覆盖数据中最小的 RowKey 到最大的 RowKey,否则超出边界的 HFile 在加载时还需要额外做 split 处理,性能会打折。我自己通常会在生成数据前先采样一批 RowKey,计算出大致的范围,再去设计方案。
3.2 路线一:MapReduce 生成 HFile
MapReduce 生成 HFile 的核心是使用 HFileOutputFormat2 来配置作业。下面是一个简化但完整的示例,假设数据源是 HDFS 上的文本文件,每行格式为 rowkey\td:col\tvalue:
Configuration conf = HBaseConfiguration.create(); conf.set("hbase.zookeeper.quorum", "zk1,zk2,zk3"); Job job = Job.getInstance(conf, "Generate HFile for ods_order"); FileInputFormat.addInputPath(job, new Path("/data/order_raw")); Path outputPath = new Path("/tmp/order_hfile"); FileOutputFormat.setOutputPath(job, outputPath); TableName tableName = TableName.valueOf("ods_order"); HFileOutputFormat2.configureIncrementalLoad(job, tableName, connection.getAdmin()); job.setMapperClass(OrderMapper.class); job.setMapOutputKeyClass(ImmutableBytesWritable.class); job.setMapOutputValueClass(Put.class); job.setReducerClass(PutSortReducer.class); job.waitForCompletion(true);这里有两个非常关键的地方。第一,configureIncrementalLoad 会做一堆配置,包括从目标表读取 region 边界信息、设置 Partitioner 为 TotalOrderPartitioner、配置比较器等。你不能漏掉它,否则输出 HFile 的分区可能和 region 不匹配。第二,Mapper 的输出 key 是 ImmutableBytesWritable,value 是 Put,但如果你想直接输出 KeyValue 也是可以的。Reducer 使用 PutSortReducer,它会自动按 RowKey 排序并且合并同一个 RowKey 的多个 Put,保证生成的 HFile 是有序的。
Mapper 内部的逻辑其实就是把文本行转成 Put,注意时间戳和列族的处理:
public class OrderMapper extends Mapper<LongWritable, Text, ImmutableBytesWritable, Put> { @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String[] fields = value.toString().split("\t"); byte[] rowKey = Bytes.toBytes(fields[0]); Put put = new Put(rowKey); put.addColumn(Bytes.toBytes("d"), Bytes.toBytes("order_amount"), Bytes.toBytes(fields[1])); context.write(new ImmutableBytesWritable(rowKey), put); } }完成作业后,检查输出目录是否生成了 HFile。如果数据量大,MR 作业的并行度可以通过 reduce task 数量来控制,通常 reduce task 数量和预分区数量保持一致,或者略多一点。要注意,不要设置成没有 reducer 的情况,否则文件内部无序,后续会出问题。跑完以后就可以进入加载阶段了,这个我在 3.4 节详细说。
3.3 路线二:Spark 生成 HFile
当前很多团队都在用 Spark,所以我也给出 Scala 的实现思路。Spark 侧的核心是把 RDD/DataFrame 转换成 Put 对象,然后用 saveAsNewAPIHadoopFile,输出格式指定为 HFileOutputFormat2。
一个典型的实现长这样:
import org.apache.hadoop.hbase.HBaseConfiguration import org.apache.hadoop.hbase.io.ImmutableBytesWritable import org.apache.hadoop.hbase.mapreduce.HFileOutputFormat2 import org.apache.hadoop.hbase.protobuf.ProtobufUtil import org.apache.hadoop.hbase.client.Put import org.apache.hadoop.hbase.util.Bytes import org.apache.hadoop.mapreduce.Job import org.apache.spark.sql.SparkSession val spark = SparkSession.builder() .appName("Generate HFile with Spark") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .getOrCreate() val tableName = "ods_order" val conf = HBaseConfiguration.create() conf.set("hbase.zookeeper.quorum", "zk1,zk2,zk3") val connection = ConnectionFactory.createConnection(conf) val table = connection.getTable(TableName.valueOf(tableName)) val job = Job.getInstance(conf, "Generate HFile with Spark") HFileOutputFormat2.configureIncrementalLoad(job, table.getName, connection.getAdmin) val df = spark.read.text("/data/order_raw") val putsRdd = df.rdd.map { row => val parts = row.getString(0).split("\t") val rowKey = Bytes.toBytes(parts(0)) val put = new Put(rowKey) put.addColumn(Bytes.toBytes("d"), Bytes.toBytes("order_amount"), Bytes.toBytes(parts(1))) (new ImmutableBytesWritable(rowKey), put) } putsRdd.saveAsNewAPIHadoopFile( "/tmp/order_hfile_spark", classOf[ImmutableBytesWritable], classOf[Put], classOf[HFileOutputFormat2], job.getConfiguration ) spark.stop()这里注意,saveAsNewAPIHadoopFile 的四个泛型参数必须和 HFileOutputFormat2 的输入类型匹配,否则会运行时报类型错误。另外,如果数据源是 DataFrame,建议先把 DataFrame 转成 RDD 再处理,因为 HFileOutputFormat2 跟 DataFrame 的编码器组合起来有时会出现序列化问题。
Spark 版本的选择会影响 API 细节。比如旧版本 Spark 2.x 的写法,到 Spark 3.x 里某些类路径会失效。好在新版本基本保持了 saveAsNewAPIHadoopFile 的兼容,只是依赖 scope 和集群模式需要额外注意。如果你在本地调试,不要拿本地模式直接跑大数据量,HFileOutputFormat2 需要获取全表 region 边界,本地模式可能导致配置差异,尽量用集群模式或者至少使用和集群一致的 HDFS 配置。
3.4 使用 LoadIncrementalHFiles 执行加载
HFile 生成之后,接下来就是把文件加载进 HBase。官方工具是 hbase 自带的 LoadIncrementalHFiles,命令行如下:
hbase org.apache.hadoop.hbase.tool.LoadIncrementalHFiles /tmp/order_hfile ods_order执行时它主要做几件事:扫描 HFile 文件,解析每个文件的起始 RowKey 和结束 RowKey;查询目标表的 meta 信息,找到这些 RowKey 落在哪些 region 上;如果 HFile 的输出范围刚好和一个 region 完整匹配,就直接 move;如果 HFile 的 RowKey 范围横跨了多个 region,它会把文件按 region 边界拆分成多个小文件再 move,或者按需做一次大 split。所以在正常情况下,你不用手工做任何事,它自己会处理。
命令行的输出会列出来每个文件的加载状态。如果你看到类似 "Bulk Load ... done" 的信息,基本就成功了。但建议不要只依赖命令行输出,还要去 HBase shell 里实际 count 一下数据量,检查是否有数据丢失或者重复。
另外还有一种场景,你要加载的表当前是 disabled 状态,LoadIncrementalHFiles 也支持,但需要追加参数。平时如果表是 enable 状态,加载时会短暂地拿锁,如果并发写入较多,建议在业务低谷执行。加载完成后,旧数据不会被自动覆盖,因为每个 HFile 的 KeyValue 都带着时间戳和版本信息,新旧文件会共存,查询时按版本规则返回值。理解这一点对后面的数据校验很重要。
3.5 数据验证与一致性检查
加载完以后,千万别急着宣布成功。我每次都会做几个维度的验证。
第一层是数量校验。用 HBase shell 的 count 命令或者写一个简单的 Scan 任务,统计总行数。注意 count 是统计 cell 数量,如果你的表一行只有一个 cell,那它和行数一致;如果一行有多个列,count 结果会更大。最好用 RowCounter MapReduce 任务来精确统计行数:
hbase org.apache.hadoop.hbase.mapreduce.RowCounter ods_order第二层是抽样比对。从源系统的关键字段里拿出一批数据,比如一天的订单或者部分用户 ID,和 HBase 中查询到的值逐字段比较。抽样比对是发现脏数据最有效的方式。我过去曾在时间戳设置上踩过坑,导致同一行出现了新旧两个版本,抽样比对一下就暴露了问题。
第三层是数据一致性检查。如果有多个列族、多个版本,可以写脚本扫描几个关键 region 的 HFile 个数、大小以及文件内部 KeyValue 是否有序。HBase 提供了 hfile 命令可以对单个文件做检查,帮助确认文件格式是否正确。生产环境还可以把监控打开,观察加载后 region 数量、RegionServer 的 IO 情况,确保没有热点。
4. 参数调优与生产配置
Bulk Loading 跑得顺不顺利,除了流程正确,很大程度上取决于参数怎么调。我在这里整理一份比较完整的调优清单,并解释每个参数影响什么。理解参数背后的机制,远比照抄配置有意义。
4.1 影响性能的核心参数
先看 HBase 侧的关键参数。
hbase.client.write.buffer在 Bulk Loading 的生成阶段几乎用不上,因为不通过客户端写入,但如果你在 mapper 里使用 BufferedMutator 来写小文件,那还是有影响的。更关键的是 HFile 生成相关的参数:hfile.block.cache.size决定了读缓存大小,生成 HFile 时也会影响内存占用;hbase.regionserver.handler.count影响 RPC 处理并发。Bulk Loading 时 RegionServer 主要做文件认领操作,handler 数不必调太高,默认值一般够用。
计算框架侧,MapReduce 的mapreduce.reduce.memory.mb和mapreduce.reduce.cpu.vcores直接影响排序和合并性能。HFile 生成阶段 reduce 端的核心工作是对大量 Put 做排序合并,如果内存设定太小红,会发生大量磁盘溢写,性能急剧下降。我在实践中通常把 reduce 内存设为 4GB 到 8GB,并且打开压缩,让溢写数据尽快合并。
Spark 侧主要关注spark.executor.memory、spark.sql.shuffle.partitions和spark.default.parallelism。如果数据量在十亿行级别,executor 内存不应该低于 8GB。另外,RDD 分区数最好和预分区数量匹配,避免生成过多碎 HFile。
HDFS 侧的dfs.block.size也值得注意。HFile 的单文件大小最终影响 RegionServer 读取文件的效率,如果 HFile 都是几百 MB 的碎片,读取时会频繁 seek,性能反而变差。可以适当把 block size 调到 256MB,让大文件连续读更顺畅。
4.2 压缩与编码选择
HBase 的列族可以配置 COMPRESSION 和 DATA_BLOCK_ENCODING。Bulk Loading 生成的 HFile 会继承表结构里的设置,所以建表时就要想好。压缩推荐 SNAPPY 或者 ZSTD。SNAPPY 解压速度快、CPU 开销小,用在导入阶段最稳妥;ZSTD 压缩率更高,但 CPU 占用略高。如果你的集群 CPU 很紧张,还是选 SNAPPY 更保险。存储挺贵的场景可以上 ZSTD,比如冷数据平台。
DATA_BLOCK_ENCODING 建议从 NONE 换成 PREFIX_TREE 或者 DIFF。它不影响性能上限,但能显著降低 HFile 在磁盘上的体积,同时减少 Scan 时的 IO。要注意的是,如果列的值非常随机,前缀编码的收益不大,可以先用小数据量测试看看压缩率变化。
这里有个小坑:如果你整表有些列族没设编码,导入后这些列族的数据体积会明显比其他列族大,影响后续 Compaction 策略。所以建表时最好把所有列族的编码统一设置,别偷懒。生成 HFile 时,HFileOutputFormat2 会读取表的 descriptor,按列族配置写出对应格式,如果你在生成端临时改了列族属性但表结构没改,加载时会出现格式不一致的报错。
4.3 资源估算与并发控制
在跑大数据量前,我习惯先做一个粗略估算。假设要导入 1 亿行、每行 200 字节,原始数据约 20GB。加上索引、布隆过滤器、时间戳等开销,最终 HFile 可能膨胀到 30GB-40GB。如果集群有 10 个 RegionServer,每个 RegionServer 分摊约 4GB 存储,一次导入可能产生几百个 HFile。这时候要考虑 NameNode 的文件数压力以及后续 Compaction 压力。
并发控制上,如果 MR/Spark 任务同时跑太多,虽然生成阶段很快,但 LoadIncrementalHFiles 阶段会集中访问 meta 表,meta 表所在的 RegionServer 可能会被打满。我通常的做法是,先生成全部 HFile,然后分批次执行加载,每批控制在 50 到 100 个文件之间。这样 meta 的压力小,观察进度也方便。
还有一个细节:加载前最好把目标表的 region 数量调整为预期的并发度。比如你有 200 个 map/reduce 并发,就不要只开 20 个 region,否则 HFile 最终都堆积在少数 region 上,后续 split 也没法提前消除热点。最理想的情况是,region 数量和并发 task 数量同数量级,且 RowKey 落在每个 region 的数据量相对均衡。
5. 常见问题与排查实录
大部分人在第一次跑 Bulk Loading 时都会遇到各种问题。这里我把自己踩过坑的案例拿出来分享,并整理成速查表,方便你有需要时快速定位。
5.1 问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 任务报错 Region too large / Region split | HFile 范围跨多个 region 或 region 边界不匹配 | 检查预分区与 RowKey 分布,增加 split 点 |
| 加载成功但查询不到数据 | 时间戳早于已有数据版本 | 显式设置 Put 的时间戳为导入时间 |
| 加载报错 Wrong column family | HFile 中的列族和表结构不一致 | 确认生成 HFile 时使用的列族名 |
| 任务运行缓慢,GC 严重 | reduce/executor 内存不足 | 调大内存、增加并发 Reduce 或分区 |
| LoadIncrementalHFiles 找不到文件 | 输出路径包含嵌套子目录 | 修改输出格式或文件路径,使其是 HFile 的父目录 |
| 出现大量小 HFile | 分区数过多或 reduce 输出碎片化 | 减少分区数或做文件合并 |
| 导入后数据倾斜、热点严重 | RowKey 设计不合理 | 对 RowKey 加盐或重新设计分区 |
| 加载阶段 meta 表繁忙 | 同时执行过多 Bulk Load | 分批次执行,错峰加载 |
5.2 踩坑案例:region 热点与数据倾斜
有次导用户行为表,RowKey 用的是用户 ID 拼接时间戳。用户 ID 本身分布很不均匀,头部的一些用户数据量占了总量的一半,结果按用户 ID 字符串前缀分区的 region 全部集中在头部,后面大部分 region 几乎是空的。加载完成之后,这些热点 region 上的 RegionServer IO 直接被打满,业务查询时快时慢。
后来我改用 RowKey hash 前缀加用户 ID 的方式,例如用用户 ID 的 MD5 前两位作为前缀,这样数据能更均匀地落在所有 region 上。虽然牺牲了按用户 ID 连续扫描的能力,但大幅减少了热点。如果你必须要按时间范围扫描,可以按"日期 + hash(用户ID)"的方式设计 RowKey,既保证一定的时间局部性,又打散热点。
经验教训是:Bulk Loading 本身不产生热点,但它会把源数据的倾斜原样搬到 HBase 里。如果你在数据源阶段就发现了数据倾斜,最好在生成 HFile 前通过预处理把倾斜问题解决掉,否则后面做的 region 再均衡都治标不治本。
5.3 踩坑案例:文件数与 NameNode 压力
另一个印象很深的坑是文件数爆炸。当时跑一个 Spark 生成 HFile 的任务,为了追求并行度,把分区数设置成了 2000,而实际预分区数量只有 100。结果每个 region 的目录下都堆积了大量 HFile,NameNode 的文件元数据迅速上涨,最终整个集群的读取变慢。
为什么会有大量文件?因为 HFileOutputFormat2 在生成文件时,每个 reducer 或分区的输出就是一个独立 HFile。如果分区数远大于 region 数,加载阶段又不会自动合并,最终每个 region 下面挂了几十个小文件。小文件会让 Scan 时需要打开的 file 变多,并发一高,RegionServer 的 file handle 也会紧张。
解决办法有两个。第一是把 Spark 分区数或 reduce task 数控制在 region 数量附近,比如 region 100 个,分区就设 120 左右。第二是加载后主动对每个 region 执行一次 major compaction,把多个小 HFile 合并成大文件。注意 major compaction 也是资源大户,尽量在业务低峰执行,不然会影响线上查询。
5.4 踩坑案例:版本冲突和脏数据
在一次补数据的任务中,我发现导入完成后查询出来的某些行居然有两个版本的订单金额。原因很尴尬:我在生成 Put 时没有显式设置时间戳,系统自动用了当前时间。但同一批数据在多个 partition 中的写入时间相近,而且某些 RowKey 在源数据里存在重复记录,加载时多个 HFile 中出现了同一 RowKey 的不同值,查询时按时间戳返回最新版本,结果就是看起来“很怪”。
正确的做法是:如果确定源数据没有重复,或者希望后续加载的数据覆盖旧数据,尽量在生成 Put 时设置统一的时间戳,或者直接把重复 RowKey 在离线阶段去重。如果你用的是 Delete 对象,也需要注意时间戳设置,Delete 默认删除所有版本,但 Bulk Load 场景下可以和 Put 配合,达到覆盖更新的效果。
另外一个隐藏 bug:如果你使用了HFileOutputFormat2.configureIncrementalLoad,它会自动把 job 的时间戳相关属性设置好,但不会帮你处理重复 RowKey。所以一定要在数据源阶段就保证 RowKey 唯一性,或者在生成 HFile 之前做 groupByKey 去重。不要想着 HBase 能自动帮你解决,它只是物理存储,业务逻辑上的幂等需要自己设计。
5.5 运维小技巧
最后分享几个实用的小技巧。
第一个,加载前对目标表做一次快照。Bulk Loading 过程中如果出现意外,比如误加载了错误数据,想回退很麻烦。好在 HBase 支持快照,你可以加载前执行snapshot 'ods_order', 'ods_order_before_bulk_load',如果出问题就基于快照恢复。这个操作成本很低,收益却很大。
第二个,监控要盯紧。加载期间重点观察 RegionServer 的 HFile 打不开、Compaction 队列长度、meta 表所在节点的 CPU。如果 Compaction 队列持续积压,说明生成的小文件太多了,可以考虑先合并再加载,或者在加载后分批做 major compaction。
第三个,自动化脚本。高频的 Bulk Loading 可以封装成 shell 脚本或者使用工作流调度,把预分区检查、生成 HFile、加载、验证作为四个阶段串联起来。脚本里至少要对每个阶段设置重试逻辑,比如 HDFS 路径冲突就加时间戳,加载失败就自动触发快照恢复。自动化能减少人为失误,但每个操作都要有日志输出,方便事后追查。
6. 个人经验总结与扩展思考
6.1 我的几组实测数据
为了更直观地说明 Bulk Loading 的效果,我把自己在测试集群上的实测数据整理一下(集群规模大概 5 个 RegionServer,每个配了 64GB 内存、8 块 SATA 盘,数据量 2 亿行,单行平均 300 字节)。
用 put 直接导入时,稳定状态大约每秒 3 万到 5 万行,线程池开大之后 RPC 队列开始堆积,GC 也明显变差,整体推算要跑 1 个小时以上,而且对线上查询影响很大。用 Spark 生成 HFile 再 Bulk Load 之后,生成阶段约 8 分钟,加载阶段约 2 分钟,总共不到 15 分钟完成。加载过程中 RegionServer 的 CPU 和 IO 都相对平稳,线上查询几乎没受干扰。
当然这个数据没有普遍代表性,不同机器、网络、数据结构差异很大。但趋势很明显:批量导入场景下,Bulk Loading 能把导入时间从小时级降低到分钟级,同时减少对集群的冲击。我后来做数据量更大的导入(几亿到几十亿行)时,效果依然成立,只是在生成阶段需要的资源和 HDFS 带宽会明显上升。
6.2 后续还可以这样扩展
Bulk Loading 的思路其实不局限于全量导入。我后来在一个项目里就把同样的机制用到了日级标签更新里:每天凌晨用离线任务生成当天的 HFile,加载到一张带“天分区”逻辑的 HBase 表里,替代了原先的 put 批量更新,运维负担少了很多。
还有一个扩展方向是结合 snapshot 和 Export/Import 做跨集群数据迁移。你可以先把某个表做 snapshot,然后通过 ExportSnapshot 导出到另一个集群的 HDFS 路径,再通过 Bulk Loading 导入。这样的迁移速度比逐条 Scan 再 Put 快得多,而且不会对源集群产生长时间压力。
如果你用的是实时数仓,常见的做法是把 HBase 和消息队列结合。上游实时流继续用 put 写最新数据,下游离线任务定时用 Bulk Loading 灌历史数据或全量快照。两者职责分离,互不干扰,效果很好。Bulk Loading 永远不用替代 put,它更合适的定位是“离线批量初始化”和“周期性导入”的利器。
最后再分享一个我在踩过坑之后形成的小习惯:每次跑完 Bulk Loading,我都会在运维平台上记录一下 HFile 数量、加载时长、region 分布和 Compaction 队列长度这几个指标。积累一段时间后,你就能根据这些历史数据判断未来导入任务该开多少并发、该设置什么参数。很多时候,优化手段不是来自书本,而是来自这些看似琐碎的现场记录。大数据量导入这件事,从来不是“能用就行”,而是要跑得稳、停得住、查得清,这也是我写下这篇文章最想告诉你的。