1. 为什么你需要关注 Bulk Loading做 HBase 开发的朋友迟早会遇到一个让人头疼的场景某天业务方丢给你一堆历史数据可能是几亿条用户行为日志也可能是从老系统导出的订单表快照要求你尽快灌进 HBase 里。你下意识会用 Java API 写个循环new Put()、Table.put() 一条条往里写结果跑了半天发现写入速度远低于预期RegionServer 的 CPU 居高不下WAL 文件疯狂滚动GC 频繁得像呼吸一样。这不是代码写得有问题而是你用错了工具。常规的写入路径是客户端发请求 → WAL 写入 → MemStore 缓冲 → 触发 flush 落盘为 HFile。这条路径优点是很灵活单条随机写入都能实时响应但问题也很明显每一条数据都要先写日志再写内存还要经过序列化、网络传输、WAL 的刷盘面对海量数据时这个开销会成倍放大。更关键的是WAL 写入是需要落盘的意味着磁盘 IO 是纯随机写性能天花板非常低。Bulk Loading 就是专门为这种“一次性灌入巨量数据”设计的方案。它的核心思路是跳过 HBase 的常规写入路径不写 WAL不经过 MemStore而是直接把数据组织成 HBase 底层存储格式 HFile然后让 RegionServer 直接接管这些文件。说白了常规写入是“请 HBase 帮我一列一列写进去”Bulk Loading 是“我自己把文件摆好直接放进去”。这个方案参考 HBase 官方文档的说法能比常规写入快数倍甚至一个数量级以上具体取决于数据规模和集群配置。如果你正在做历史数据迁移、离线数仓导入、日志回灌、或者日常测试环境的数据初始化掌握 Bulk Loading 基本上是必备技能。下面我从原理到实操走一遍完整流程把我踩过的坑和验证过的调优思路都交代清楚。2. Bulk Loading 的核心原理与常规写入的差异2.1 HBase 的存储层级速览要理解 Bulk Loading先得知道 HBase 的数据到底是怎么存的。逻辑上HBase 的表由多个 Region 组成每个 Region 内部包含若干个 Store一个 Store 对应表里的一个列族。物理层面每个 Store 对应 HDFS 上的一个目录目录里存放若干 HFile 文件。一个 HFile 内部是按 Key 排序的键由 RowKey 列族 列限定符 时间戳构成。常规写入时数据先进 MemStore 做内存排序当 MemStore 大小超过阈值触发 flush把排好序的数据刷成 HFile。后台还有 compaction 机制把小文件合并成大文件减少读放大。这套机制的设计初衷是服务在线读写场景所以对单条写入很友好但对海量静态数据的导入就不太聪明了。2.2 Bulk Loading 为什么快Bulk Loading 的巧妙之处在于它把数据组织工作从 RegionServer 转移到了客户端侧。整个流程分两步走第一步使用 MapReduce 作业读取源数据按照 HBase 表的 Region 边界信息将数据切分、排序、输出为 HFile 格式的文件。这一步本质上就是模拟了 RegionServer 内部 flushed HFile 的全过程。第二步把生成的 HFile 文件直接移动到对应的 Region 存储目录下HBase 通过将 HFile 移入 Region 所拥有的 HDFS 目录将数据“认领”进来。这时 RegionServer 不需要额外的写入计算只需要做一些元数据更新和文件引用整个导入过程对 RegionServer 的负载打击可以控制到最低。对比一下两种路径的开销环节常规写入Bulk LoadingWAL 写入每条数据都要写完全跳过MemStore 成本每条数据排序入堆完全跳过Flush 压力不定期批量刷盘无需额外触发RegionServer 负载被打满几乎无感知客户端网络开销每条都要 RPC只需文件搬运速度天花板受限于磁盘日志刷写取决于 MapReduce 并行度这里有一个很容易被忽视的点跳过 WAL 意味着什么意味着如果导入过程中 RegionServer 宕机这部分数据不会存在于 WAL 里无法通过日志回放恢复。但对于批量导入场景源文件通常还在重新跑一遍作业成本不高所以这个风险是可控的。我在实际项目里更倾向于先保证导入速度再通过源文件备份来兜底。2.3 预分区为什么是前置条件Bulk Loading 第二步需要把 HFile 分配给具体的 Region这就要求表在导入前有明确的 Region 边界。如果表只有一个 RegionMapReduce 阶段就只有一个 reducer 在干活并行度彻底报废。所以实际操作中第一步就得把表按照业务期望的 RowKey 分布做预分区让每个 reducer 处理一个 Region 范围的数据输出正好对应一个 Region 的 HFile。预分区的数量直接决定了 MapReduce 的 reducer 个数而 reducer 个数又决定了导入的总吞吐。这就是为什么很多 HBase 手册里强调“预分区数量要结合数据量和集群规模设计”。某个项目里我把一张 200GB 的表预分成 36 个 Regionreducer 数量就是 36在 12 节点的集群上跑半小时完成全量导入这个速度是常规写入根本无法想象的。3. Bulk Loading 的环境准备与表设计3.1 软件版本选择Bulk Loading 依赖 HBase 自带的两个类org.apache.hadoop.hbase.mapreduce.HFileOutputFormat2和org.apache.hadoop.hbase.mapreduce.LoadIncrementalHFiles。前者用于在 MapReduce 中配置输出为 HFile后者是导入命令行工具。我建议使用 HBase 2.x 版本因为 HFileOutputFormat2 在 2.x 中已经非常成熟对 bulk load 的稳定性比 1.x 好了不少。如果是老项目还在 1.x后续遇到高版本 HBase 的兼容性问题会很难受比如文件格式版本不匹配。另外你需要确保 Hadoop 和 HBase 版本是互相兼容的。我见过一个项目因为 Hadoop 升级到 3.xHBase 还在 2.1结果 HFile 输出阶段出现莫名其妙的ClassNotFoundException排查了几天才发现是版本兼容性问题。官方兼容矩阵建议先看省得自己踩坑。3.2 表结构设计要点设计 HBase 表时需要明确几件事第一RowKey 的设计。Bulk Loading 不改变 RowKey 的设计原则依然要保证分布均匀。你在生成 HFile 前就应该确定 RowKey 方案不能导入之后才发现某个前缀的 key 过多导致 region hotspot。业务上有意义的 RowKey 通常需要倒置或加盐这个设计最好提前验证。第二列族数量慎用多个。存储层面一个列族对应一个 Store如果你的表有两个列族每个 Region 目录下就会有两套 HFile 集合。Bulk Loading 过程中两类文件要分别处理如果设计不当容易导致部分列族数据空文件过多在后续 compaction 时白白浪费 IO。第三预分区边界和 RowKey 前缀必须匹配。比如你按 RowKey 的十六进制前缀[00, 40, 80, c0]分成四个 Region那么 MapReduce 阶段的数据只有在 reducer 端完成了基于 rowkey 的排序才能保证输出的 HFile 数据范围能落到对应的 Region 上。HFileOutputFormat2 内部会使用TotalOrderPartitioner做全排序这个细节不需要你手动改但你得明白它为什么要求表必须先预分区。3.3 源数据的准备与校验Bulk Loading 的源数据常见形式是文本文件CSV/TSV、SequenceFile 或者 Parquet 文件。无论哪种格式在提交 MapReduce 作业之前我建议先做一次数据质量检查。重点看三个维度字段数量和列族、列限定符是否对得上RowKey 是否为空或重复时间戳是否合理如果你显式指定了自定义时间戳导入后查询可能会出问题你可以在 MapReduce 的 map 阶段做校验不满足条件的行直接丢弃并计数。实测一个千万级文件脏数据比例通常在 0.1% 到 1% 之间如果不在源头清理导入完成后你还要写扫表脚本去清洗工作量翻倍。4. 手写 MapReduce 作业生成 HFile4.1 使用 HFileOutputFormat2 的完整代码模式我直接把最常用的编写骨架给你这段代码在 HBase 2.x 环境下经过验证可以作为模板改造。import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.hbase.HBaseConfiguration; import org.apache.hadoop.hbase.TableName; import org.apache.hadoop.hbase.client.Connection; import org.apache.hadoop.hbase.client.ConnectionFactory; import org.apache.hadoop.hbase.client.RegionLocator; import org.apache.hadoop.hbase.client.Table; import org.apache.hadoop.hbase.io.ImmutableBytesWritable; import org.apache.hadoop.hbase.mapreduce.HFileOutputFormat2; import org.apache.hadoop.hbase.mapreduce.LoadIncrementalHFiles; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; public class BulkLoadDriver { public static void main(String[] args) throws Exception { if (args.length ! 3) { System.err.println(Usage: BulkLoadDriver tableName inputPath outputHFileDir); System.exit(1); } String tableName args[0]; String inputPath args[1]; String outputHFileDir args[2]; Configuration conf HBaseConfiguration.create(); Job job Job.getInstance(conf, Bulk Load tableName); job.setJarByClass(BulkLoadDriver.class); FileInputFormat.setInputPaths(job, new Path(inputPath)); FileOutputFormat.setOutputPath(job, new Path(outputHFileDir)); // 这里根据源文件格式调整 job.setInputFormatClass(org.apache.hadoop.mapreduce.lib.input.TextInputFormat.class); job.setMapperClass(HFileMapper.class); job.setMapOutputKeyClass(ImmutableBytesWritable.class); job.setMapOutputValueClass(org.apache.hadoop.hbase.KeyValue.class); // 连接目标 HBase 表 try (Connection conn ConnectionFactory.createConnection(conf); Table table conn.getTable(TableName.valueOf(tableName)); RegionLocator regionLocator conn.getRegionLocator(TableName.valueOf(tableName))) { // 这一步是最关键的配置 HFileOutputFormat2.configureIncrementalLoad(job, table.getDescriptor(), regionLocator); } job.waitForCompletion(true); // 导入 HFile 到 HBase try (Connection conn ConnectionFactory.createConnection(conf)) { LoadIncrementalHFiles loader new LoadIncrementalHFiles(conf); loader.doBulkLoad(new Path(outputHFileDir), conn.getAdmin(), conn.getTable(TableName.valueOf(tableName)), regionLocator); } } }注意HFileOutputFormat2.configureIncrementalLoad这个方法它会根据目标表的 Region 边界自动设置TotalOrderPartitioner和 reducer 数量。你不需要手动指定 reducer 数它会尽量与 Region 数对齐。如果在低版本 HBase 中这个方法不可用可以考虑用HFileOutputFormat.configureIncrementalLoad但升级到 2.x 后强烈建议切换到新版。4.2 Mapper 端如何构造 Put 和 KeyValueMapper 端有两种常见写法输出Put对象或者直接输出KeyValue。个人经验是输出KeyValue更直接因为 HFileOutputFormat2 的 reducer 内部会把 Key 做排序合并Put 对象只是 KeyValue 的封装多一层转换反而增加无谓开销。下面是一个针对 CSV 数据构造 KeyValue 的示例import org.apache.hadoop.hbase.Cell; import org.apache.hadoop.hbase.KeyValue; import org.apache.hadoop.hbase.io.ImmutableBytesWritable; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; public class HFileMapper extends MapperLongWritable, Text, ImmutableBytesWritable, Cell { Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line value.toString(); if (line.trim().isEmpty()) { return; } // 假设 CSV 格式: rowkey,columnFamily,columnQualifier,value String[] parts line.split(,); if (parts.length ! 4) { context.getCounter(BulkLoad, INVALID_LINES).increment(1); return; } byte[] rowKey parts[0].getBytes(); byte[] family parts[1].getBytes(); byte[] qualifier parts[2].getBytes(); byte[] val parts[3].getBytes(); // 时间戳缺省为当前时间 long timestamp System.currentTimeMillis(); KeyValue kv new KeyValue(rowKey, family, qualifier, timestamp, val); context.write(new ImmutableBytesWritable(rowKey), kv); } }这个例子里用Cell作为 Mapper 输出 Value 类型因为 HFileOutputFormat2 的 Reducer 接收的是ImmutableBytesWritable和Cell。如果你直接输出KeyValue也可以它在实现上实现了Cell接口没什么区别。但注意 Java 泛型要写对否则运行时会报ClassCastException。4.3 自定义时间戳与 TTL 的坑如果你导入的数据需要保留原始时间戳比如日志从源头系统导出时带了ts字段可以在构造 KeyValue 时显式传入时间戳。但你要搞清楚 HBase 的可见性语义相同 RowKey 列限定符下最新时间戳的数据会覆盖旧时间戳的数据。如果你把历史数据的时间戳设成过去某个时间而表本身还有实时的增量写入后续增量数据的时间戳比它新查询时还是能正确拿到新数据。反过来混入的时间戳如果反而比现在的还新那这份“未来数据”可能导致 TTL 判断异常严重时会让旧数据提前失效。我在一个日志回灌项目里就吃过这个亏。源日志的ts字段是毫秒时间戳看起来没问题但忘了日志文件里的时间戳和实际业务时间存在时区偏移导入后一查发现大量数据被 TTL 过滤了源头找了两天才发现是时间差导致。所以导入前尽量把时间戳统一成 UTC 毫秒值并且用批处理检查一遍你预期能查到的边界数据。5. 通过命令行工具直接导入 HFile5.1 LoadIncrementalHFiles 命令详解如果不走 MapReduce假设你已经有现成的 HFile 文件比如从别的环境导出或者手工构造的测试文件可以用 HBase 自带的命令行工具直接导入。命令如下hbase org.apache.hadoop.hbase.mapreduce.LoadIncrementalHFiles \ -Dhbase.mapreduce.bulkload.max.hfile.size1073741824 \ /tmp/hfile_output \ your_table_name其中第一个参数是 HFile 所在的 HDFS 路径第二个是目标表名。这条命令会在 HBase 内部遍历 HFile 文件根据文件名中记录的起始 rowkey 找到对应的 Region并把文件移动到 region 目录下。命令里那个可选的-Dhbase.mapreduce.bulkload.max.hfile.size参数控制单个 HFile 的大小上限默认值是 HBase 的hbase.hregion.max.filesize如果单个 HFile 超过了这个值LoadIncrementalHFiles 会触发 split 操作把 Region 拆开。这个行为看起来是自动化但在导入大文件时反而会产生额外的分裂风暴我的建议是尽量控制好 HFile 的生成大小让导入时尽量少触发 split。5.2 命令行方式的适用场景命令行方式适合以下场景这部分 HFile 已经是标准格式不需要自定义 mapper 处理从其他 HBase 集群导出 StoreFile 文件作为备份恢复或测试环境同步快速验证一批小规模数据不想写完整 MapReduce 作业如果是从不同版本 HBase 导出的 HFile导入前先检查 HFile 版本号。HBase 2.x 用的 HFile 格式是 v31.x 是 v2跨版本导入大概率会因Invalid HFile version失败。实际项目里我见过有人尝试硬导结果扫全表时报错最后只能把数据导出成文本重新走流程。5.3 doBulkLoad 的编程式调用命令行只是包装了 Java API在你的代码里直接调用更灵活尤其你需要在导入前后做一些业务操作时。核心流程是Configuration conf HBaseConfiguration.create(); try (Connection conn ConnectionFactory.createConnection(conf)) { TableName tableName TableName.valueOf(your_table); RegionLocator locator conn.getRegionLocator(tableName); LoadIncrementalHFiles loader new LoadIncrementalHFiles(conf); loader.doBulkLoad(new Path(/tmp/hfile_output), conn.getAdmin(), conn.getTable(tableName), locator); }有一件事你必须有心理准备doBulkLoad不是原子操作。它在移动 HFile 的过程中如果某个阶段失败可能有一部分文件已经移动到 Region 目录一部分还在临时目录。重新执行时HBase 会幂等地处理重复引用但你需要检查一遍日志确认哪些 Region 已经加载完。实操中我遇到过半途报错的情况失败原因是权限不足但文件已经拷贝了大半重跑时 HBase 给出了duplicate entry的警告。把 HDFS 上的临时目录清掉再跑反而比在一个半成品目录上重跑更干净。6. 配置参数与性能调优实录6.1 关键参数对照表Bulk Loading 性能受多个参数影响我根据自己的实测经验整理了一份优先级排序表参数作用经验建议mapreduce.reduce.shuffle.input.buffer.percent控制 reducer 端 shuffle 缓冲比例0.2 可以提高大 shuffle 时的稳定性但别超过 0.3mapreduce.reduce.memory.mb单个 reducer 的内存上限根据单机内存适当加大比如 4GBhbase.mapreduce.bulkload.max.hfile.size单个 HFile 最大值设置为你期望的 Region 文件最大值比如 10GBmapreduce.task.io.sort.mbmap 端排序缓冲区大小1GB 起步越大排序越快hbase.mapreduce.bulkload.split.threads导入时分片处理线程数默认 10集群规模大可以调到 20dfs.blocksizeHDFS 块大小如果 HFile 预期较大建议 256MB这些参数不是每个都要调我见过不少项目只调了mapreduce.task.io.sort.mb就获得明显效果。原因很简单MapReduce 阶段的数据排序耗时占比很大排序缓冲太小导致大量中间结果溢写磁盘IO 开销直接拖慢整体作业。6.2 Region 数量与 reducer 的匹配HFileOutputFormat2 会尽量让 reducer 数和目标表的 Region 数一致。假设表的预分区是 36 个 Regionreduce 任务也是 36 个。此时每个 reducer 处理一个 Region 的数据范围输出成若干个 HFile 文件。问题来了如果表 Region 数和源数据分布不均匀比如某个 RowKey 前缀下数据特别多那这个 reducer 的输出文件就可能非常大而其他 reducer 几乎空转。这就是典型的数据倾斜。处理方案我一般是在 RowKey 设计阶段就做 salting给每个 key 加一个随机前缀使数据均匀打散到所有 Region。但注意加了 salt 之后如果你想按原始 key 做 scan 过滤就需要在应用层换算这个改动牵扯面广需要提前评估。如果不用 salting另一个办法是增加预分区数量让每条分区范围变窄倾斜被稀释。代价是 Region 数多了之后HBase 的元数据管理压力也会上升如果导入完后需要做 compactionRegion 多会导致 compaction 队列变长。我测试过一个 100GB 的表预分区 64 个比 32 个整体导入快约 30%但后续手动 major compaction 的时间也几乎多了 50%需要权衡。6.3 压缩与编码的取舍HBase 支持在列族一级设置数据块编码和压缩算法这会影响 HFile 生成阶段以及后续读取性能。Bulk Loading 阶段建议在表设计时就决定好压缩类型。常用的压缩算法有 SNAPPY、GZIP、LZ4、ZSTD。SNAPPY 在压缩比和速度之间平衡最好适合大多数场景。ZSTD 压缩比更高但是 CPU 开销大在导入时会把 MapReduce 阶段的 reducer CPU 打满。我自己的选择如果集群 CPU 核数多、数据量很大且存储吃紧用 ZSTD日常项目用 SNAPPY。有一个坑是数据块编码。DataBlockEncoding支持 FAST_DIFF、PREFIX_TREE 等但这些编码在 bulk load 生成 HFile 时的兼容性需要确认。实测 PREFIX_TREE 在某些版本上会导致 HFile 读取异常因为它的编码依赖块内上下文导入阶段跨文件合并时容易出问题。稳妥的做法是用 FAST_DIFF它支持度和稳定性都很好特别是对重复前缀的 RowKey 有明显压缩效果。6.4 预热而不是紧接在线流量如果你这批数据导入后马上要服务线上查询建议导入完成后手动触发一次 major compaction而不是指望 HBase 自动在后台慢慢合并。原因很简单bulk load 生成的 HFile 直接进入 Region它的文件数量可能很多尤其是你有多列族或分的 reducer 很多时。这个时候扫描数据需要打开的文件句柄数多读性能会明显变差。major compaction 可以把多个 HFile 合并成一个虽然执行时会有短暂 IO 压力但整体长期收益大。我习惯在低峰期执行hbase shell major_compact your_table执行完后观察 HDFS 上每个 Region 目录下的 HFile 数量正常情况下应该降到一个很小的数字。7. 实操过程从零到一完成一次 Bulk Loading7.1 准备测试数据为了让你更直观地走一遍全流程我造一个示例假设要导入用户购买记录表名叫user_purchase_records定义如下。hbase shell EOF create user_purchase_records, {NAME info, COMPRESSION SNAPPY, DATA_BLOCK_ENCODING FAST_DIFF}, {NUMREGIONS 12, SPLITALGO HexStringSplit, SPLITS [1,2,3,4,5,6,7,8,9,a,b]} EOF这样创建的表有 12 个 Regionsplit 时按 rowkey 的前缀十六进制范围划分。源文件purchase_data.csv内容大致是u001,info,order_id,10001 u002,info,order_id,10002 ...7.2 提交 MapReduce 作业将前面写好的BulkLoadDriver打包成 JAR在集群上执行hadoop jar bulkload-demo.jar \ BulkLoadDriver \ user_purchase_records \ /data/input/purchase_data.csv \ /data/hfile_output/purchase作业启动后在 YARN 的 ResourceManager 界面监控进度。重点观察 reducer 的任务数和每个 reducer 处理的数据量是否大致均匀。如果某个 reducer 跑得特别慢大概率是数据倾斜需要检查 rowkey 的分布。7.3 作业完成后检查输出作业成功后输出目录下每个 reducer 会产生一个或多个 HFile。查看目录结构hdfs dfs -ls /data/hfile_output/purchase你会看到类似part-r-00000这样的文件列表每个文件内部就是 HFile 格式了。实际项目中这个目录的属主大概率是跑 YARN 作业的用户后面的 bulk load 阶段需要这个用户对 HBase 目标表有读写权限如果两者不一致会报Permission denied。解决办法是给 HFile 目录设置hbase属主或者调整 HDFS 目录权限。7.4 执行导入并验证hbase org.apache.hadoop.hbase.mapreduce.LoadIncrementalHFiles \ /data/hfile_output/purchase \ user_purchase_records导入命令执行完后日志里会输出每个 Region 加载了哪些文件。为了确保数据真的可用从 shell 做个 count 和随机 get 验证hbase shell count user_purchase_records get user_purchase_records, u001count的速度比较慢数据量大时可以只抽查一个区间。验证时特别注意时间戳和列族字段是否符合预期别只看条数。7.5 数据一致性校验最后一步建议写一个校验脚本对比源文件和 HBase 中的计数。最简单的方式是用 HBase 的TableMapReduceUtil跑一个 count-only 的 job或者直接用Scan在客户端遍历一遍。数据量到千万级别以上时客户端扫描的效率偏低优先考虑基于 MapReduce 的校验。我在实际项目里踩过一个更隐蔽的坑HFile 生成时 reducer 默认把相同的 rowkey 合并成一个版本如果你源文件里有重复 rowkey 且期望保留历史版本需要在 reducer 端显式配置hfile.scanner.caching.size和版本策略否则相同 rowkey 的最新版本覆盖旧版本历史记录直接丢失。这个细节常规文档很少提但如果业务上需要版本保留必须提前测试。8. 常见问题与排查技巧实录8.1 运行时报错Region 边界冲突如果导入时报类似Trying to load hfile to a region that no longer exists的错误多半是因为表做过 split 或者 mergeRegion 边界和 HFile 期望进来的 Region 不匹配。常见解法是重新预分区或者在导入前关闭自动 splitproperty namehbase.hregion.max.filesize/name value107374182400/value /property这里 100GB 只是把自动 split 的阈值调到很高避免导入大文件时频繁分裂。如果数据总量确实超过了阈值导入后主动手动 split 一次。8.2 写入后查询不到数据这是新手最容易遇到的情况原因也最直观HFile 文件虽然放进 Region但如果你查询的时间范围没对上数据看起来就是“不存在”。比如源数据时间戳是过去两周而查询时 scan 默认时间范围是当前时间之前所有数据按理没问题但如果你扫描时带了TimeRange限制数据落在这个范围之外自然查不到。如果排除时间范围问题还有可能是 HFile 文件名记录的 startRow 和 Region 边界没对齐文件被分配到了错误的 Region。这个错误出现的概率不高但一旦发生数据就散落在错误区域。排查方式是在 shell 里scan一个 rowkey 区间看返回是否异常如果相邻 rowkey 的数据分布跳跃剧烈基本可以确认分配错误。8.3 导入时出现 Region 分裂风暴前面提到过LoadIncrementalHFiles 在移动 HFile 后如果发现文件大小超过阈值会触发 Region split。如果你的预分区数量过少而单文件巨大导入瞬间多个 Region 一起分裂会引起元数据短期不稳定甚至影响正在跑的在线服务。优化方案有两个方向一是增大hbase.mapreduce.bulkload.max.hfile.size让导入时不触发分裂二是导入前就预分区足够多让文件与 Region 一一匹配。我更推荐第二种因为 split 对元数据服务的影响是不可控的。8.4 HDFS 空间不足导致导入失败Bulk Loading 生成 HFile 过程中中间 MapReduce 输出占用的存储空间是源数据的好几倍因为可能包含临时副本。如果 HDFS 剩余空间不足很可能 MapReduce 阶段直接 fail。实际操作中要预留至少源文件两倍大小的空间或者说HFile 大小 源数据大小 × (1 压缩率反向系数)SNAPPY 压缩下基本接近原文件大小。计算资源紧张时可以把输出目录放在和 HBase 数据目录不同的磁盘避免同盘竞争。8.5 常用问题速查表症状常见原因解决方法导入一条也不执行日志显示 Region 不存在表名写错或表没有被创建检查表名刷新 shell 确认MapReduce 作业完成但导入文件数为 0输出路径的 reducer 产生了空文件检查源数据是否有文件读取或调整 FileInputFormat导入后 HDFS 空间骤降但业务读不到数据HFile 被移动到冷目录或路径不对检查 Region 目录确认文件是否被 HBase 元数据引用任务内存溢出单个 reducer 处理数据量过大调大 reducer 内存或增加 Region 数量导入速度远慢于预期网络带宽、磁盘 IO 或未使用压缩检查单 reducer 吞吐开启压缩增加并行 reducer。增加 reducer 可以由预分区数量控制9. 经验之谈最后再啰嗦几句从第一次用 Java API 硬灌数据到熟练跑 Bulk Loading中间也是踩了不少坑。个人体会最深的几点第一RowKey 设计一定要结合预分区和业务查询模式一起考虑。很多人上来就写代码发现问题了再改 RowKey但 HBase 里改 RowKey 基本意味着重导数据代价极大。第二Bulk Loading 不等于“完全无感知导入”。它虽然对 RegionServer 写入路径负载很低但导入后的文件数量、大小和 Region 分布直接影响后续查询性能。导入这个动作只是开始要不要做 major compaction、要不要调整 Region 数、要不要清理空 HFile这些上下游动作都要在同一份方案里。第三测试环境一定要模拟真实数据量的量级。我用 1 万条测试数据跑通流程以为没毛病结果在 1 亿条数据量下发现 reducer 内存不够、HDFS 空间不足、导入时间超出预期一系列问题只有在真实规模下才会暴露。建议第一次跑全量前先拿 10% 数据量做一轮完整演练。如果你接下来会经常处理这种导入场景建议把这些操作封装成一个通用工具表名、输入路径、预分区策略作为参数传进去。这样以后无论上游换了什么数据格式改一个 mapper 就能复用。数据规模再大一些还可以考虑分批次导入监控每一批的耗时和文件分布逐步调整参数逼近你集群的最优配置。