1. 从一次写入抖动说起:Region 到底在忙什么
很多人第一次接触 HBase,脑子里装的都是“列式存储”“海量数据随机读写”这些大词,但真正上手之后,第一个让人睡不好觉的往往不是读写 API,而是某天监控面板上突然冒出来的写入抖动。我印象很深的一次,是帮一个做物联网设备上报的团队看问题:他们的表设计得很简单,设备 ID 做 rowkey,每天几亿条写入,平时稳稳当当,结果某天开始每隔几分钟就出现一次明显的延迟尖峰,持续十几秒又恢复。查了半天 JVM、查了半天网络,最后发现根因在 Region 的拆分和迁移上。
这件事让我意识到,Region 不是一个抽象概念,它是 HBase 里真正干活、真正会“累”会“喘”的物理单元。你如果只把它当成“表的分片”,那遇到问题时根本无从下手。所以这篇我想把 Region、HFile 管理、rowkey 设计这三块串起来讲,因为它们本来就是一条链上的东西:rowkey 决定数据怎么分布,数据分布决定 Region 怎么切,Region 怎么切又决定 HFile 怎么落盘和合并。任何一环设计歪了,最后都会以“性能抖动”的形式还给你。
先给不太熟的朋友补一句:HBase 里的表在逻辑上是一张表,但物理上会被水平切成很多段,每一段就是一个Region。每个 Region 负责一段连续的 rowkey 区间,比如[0000, 1000)是一个 Region,[1000, 2000)是另一个。Region 是负载均衡、故障恢复、数据迁移的最小单位。你往表里写数据,HBase 先根据 rowkey 找到它属于哪个 Region,再找到这个 Region 当前由哪台 RegionServer 托管,最后才落到那台机器上。
这里有个关键点很多人忽略:Region 的边界是“左闭右开”的连续区间,而且这个区间是动态变化的。表刚建的时候可能只有一个 Region,随着数据增长,它会自动 split 成两个,再各自继续 split。这个“自动”听起来很美好,但恰恰是很多抖动问题的源头。因为 split 本身要消耗资源,split 之后新 Region 还要经历一次 compaction 才能把引用文件清理掉,这期间读写都会受影响。
我后来总结了一个判断经验:如果你看到写入延迟呈现“周期性尖峰”,而且尖峰间隔和你的数据增长速度大致相关,那八成是 Region split 在作祟。这时候不要急着调 JVM 参数,先去看 Region 的数量变化曲线和 split 日志,方向对了再谈优化。
2. Region 的拆分逻辑与预分区实战
2.1 自动 split 的触发条件与代价
HBase 默认的 split 策略是基于 Region 大小的。早期版本用的是IncreasingToUpperBoundRegionSplitPolicy,简单说就是:当 Region 的大小超过某个阈值时触发 split,而这个阈值会随着 Region 数量动态调整。后来比较常用的是SteppingSplitPolicy,逻辑更简单直接——如果当前 RegionServer 上 Region 数量少,阈值就低一点,鼓励早点拆;数量多了阈值就高,避免拆得太碎。
具体阈值怎么算?以SteppingSplitPolicy为例,它的判断逻辑大致是:如果当前 RegionServer 上托管的 Region 数量为 1,那么 split 阈值是flushSize * 2;否则阈值是flushSize * 4(不同版本略有差异,以实际配置为准)。假设hbase.hregion.memstore.flush.size是默认的 128MB,那么单 Region 时大约 256MB 就会触发 split,多 Region 时大约 512MB 触发。
这个机制的问题在于:它是“事后补救”而不是“事前规划”。数据先写满,再 split,split 过程中这个 Region 的写入会被短暂阻塞(虽然时间不长,但高并发下会被放大)。而且 split 之后,父 Region 下线,两个子 Region 上线,客户端需要重新获取 meta 信息,这中间有一个短暂的“路由真空期”。如果你的写入是持续高压的,这个真空期就会表现为一次抖动。
提示:split 本身并不复制数据,它只是把父 Region 的引用文件(Reference File)分给两个子 Region,真正的数据清理要等到后续 compaction 才完成。所以 split 之后短期内磁盘占用不降反升,这是正常现象,别慌。
2.2 预分区:把 split 的主动权握在自己手里
既然自动 split 有代价,那最稳妥的做法就是在建表时就把分区规划好,让数据从一开始就均匀落到各个 Region 上,尽量避免运行期 split。这就是预分区(pre-splitting)的核心思路。
预分区怎么做?最常用的是在create命令里指定SPLITS。比如你要存设备 ID,格式是device_00000001到device_99999999,你可以按前缀均匀切:
create 'device_table', {NAME => 'cf', VERSIONS => 1}, SPLITS => ['device_2', 'device_4', 'device_6', 'device_8']这样建出来的表一开始就有 5 个 Region,边界分别是(-∞, device_2)、[device_2, device_4)、[device_4, device_6)、[device_6, device_8)、[device_8, +∞)。只要你的 rowkey 分布大致均匀,这 5 个 Region 的负载就会比较均衡。
但预分区有个坑:如果你切分点选得不好,反而会造成热点。我见过有人图省事,直接按0到9切十个区,结果所有设备 ID 都以device_1开头,数据全挤在第一个 Region 里,其他九个 Region 空着。这就是典型的“切了等于没切”。
所以预分区的关键是先搞清楚你的 rowkey 分布特征。如果是时间序列数据,rowkey 往往以时间开头,那你可以按时间范围切;如果是用户 ID,通常哈希后分布均匀,可以按哈希前缀切。实在拿不准,可以先跑一批样本数据,统计一下前缀分布,再决定切分点。
2.3 Region 数量多少算合适
这是被问得最多的问题之一。我的经验是:单台 RegionServer 上托管的 Region 数量,控制在 50 到 200 之间比较舒服。太少,负载不均,一台机器闲着另一台累死;太多,RegionServer 的元数据管理开销、compaction 调度开销都会上升,而且一旦宕机,恢复时要重新分配的 Region 太多,恢复时间会很长。
假设你有 10 台 RegionServer,每台希望托管 100 个 Region,那总 Region 数就是 1000 个左右。再结合你的数据总量,就能反推出每个 Region 大概多大。比如总数据量 1TB,1000 个 Region,每个 Region 平均 1GB,这个大小是比较健康的——既不会因为太小而频繁 split,也不会因为太大而导致 compaction 时间过长。
当然这只是个起点,实际还要看你的读写比例、单条数据大小、compaction 策略。但有了这个基准,你至少不会拍脑袋决定。
3. HFile 的生命周期:从 MemStore 刷写到多级合并
3.1 一次写入的完整旅程
要理解 HFile 管理,得先搞清楚一条数据从写入到落盘经历了什么。当你调用Put写入一条数据时,它并不是直接写到磁盘文件的,而是先写WAL(Write-Ahead Log)保证持久性,然后写入MemStore(内存中的有序结构)。MemStore 写满之后,会 flush 成一个 HFile 落到磁盘。所以 HFile 本质上是 MemStore 的一次“快照”。
这里有个很重要的认知:HBase 的写入是“顺序写 WAL + 内存写 MemStore”,真正的随机写被推迟到了 flush 和 compaction 阶段。这也是 HBase 写性能好的根本原因。但代价是,读的时候可能要同时查 MemStore 和多个 HFile,因为同一条数据的多个版本可能散落在不同文件里。
HFile 本身是 HBase 自己的文件格式,内部结构大致是:多个 Data Block(实际数据)、一个 Index Block(索引)、一个 Bloom Filter(可选的布隆过滤器)、以及 Trailer(元信息)。Data Block 是读取的最小单位,默认 64KB。Index Block 记录每个 Data Block 的起始 key,读取时先查 Index 定位到具体 Block,再读 Block。Bloom Filter 则用来快速判断“这个 key 到底在不在这个 HFile 里”,避免无谓的磁盘 IO。
3.2 Flush 触发条件与常见调优
MemStore flush 的触发条件主要有几个:
- MemStore 大小达到阈值:默认
hbase.hregion.memstore.flush.size是 128MB。这是最常用的触发条件。 - RegionServer 全局 MemStore 用量达到上限:
hbase.regionserver.global.memstore.size默认是堆的 40%,超过后会强制 flush 一些 Region。 - WAL 文件数量达到上限:
hbase.regionserver.max.logs默认 32,超过后会 flush 最老的 Region。 - 手动 flush:运维时偶尔会用。
调优的核心思路是让 flush 尽量平滑,避免“批量 flush”造成的 IO 尖峰。我一般会关注两个参数:hbase.hregion.memstore.flush.size和hbase.hregion.memstore.block.multiplier。后者默认是 4,意思是当 MemStore 大小达到flush.size * 4时,会阻塞写入,强制 flush。这个阻塞是很多写入抖动的直接原因。
如果你的写入压力很大,可以适当调大flush.size,比如调到 256MB,减少 flush 频率。但别调太大,否则单次 flush 的数据量太大,会造成更明显的 IO 尖峰,而且 Region 恢复时要重放的 WAL 也更多。
注意:调大
flush.size的同时,记得同步调整hbase.hregion.memstore.block.multiplier,否则阻塞阈值也会水涨船高,反而更容易触发阻塞。
3.3 Compaction:HFile 的“垃圾回收”
HFile 越积越多,读性能就会下降,因为一次读可能要查十几个文件。所以 HBase 需要定期把多个 HFile 合并成一个,这个过程就是Compaction。Compaction 分两种:
- Minor Compaction:选取几个较小的、相邻的 HFile 合并,频率高,开销小。
- Major Compaction:把一个 Region 下所有 HFile 合并成一个,同时清理掉过期和删除的数据,频率低(默认 7 天一次),开销大。
Major Compaction 是很多“周期性抖动”的元凶。因为它要重写整个 Region 的数据,IO 和 CPU 消耗都很大。如果你的集群在凌晨出现规律性的延迟上升,很可能就是 Major Compaction 在跑。
我的建议是:如果业务对延迟敏感,可以考虑关闭自动 Major Compaction,改在业务低峰期手动触发。配置项是hbase.hregion.majorcompaction,设为 0 即可关闭自动触发。然后写个定时任务,在凌晨低峰期逐个 Region 触发major_compact。这样虽然麻烦一点,但能把抖动控制在你可预期的窗口内。
另外,Minor Compaction 的触发条件也值得关注。hbase.hstore.compactionThreshold默认是 3,意思是当一个 Store(一个列族的存储单元)下的 HFile 数量达到 3 个时,就会触发 Minor Compaction。如果你发现 HFile 数量经常在 3 到 5 之间波动,说明 compaction 在正常工作;如果长期居高不下,可能是 compaction 跟不上写入速度,需要调大 compaction 线程数或检查磁盘 IO。
4. Rowkey 设计:决定一切的地基
4.1 为什么 rowkey 是 HBase 设计的核心
如果只能给一条 HBase 设计建议,我会说:把 rowkey 设计好,其他问题至少少一半。原因很简单:rowkey 决定了数据在 Region 上的分布,决定了读写会打到哪些机器,决定了 scan 的效率,甚至决定了 compaction 的代价。
HBase 的 rowkey 是按字典序排序的,这个“排序”是双刃剑。好处是范围 scan 很快,相邻的 rowkey 在物理上也相邻;坏处是如果 rowkey 设计得不好,写入会全部集中到一个 Region 上,形成写热点。
最典型的反面教材就是用时间戳或自增 ID 做 rowkey 前缀。比如20240101000001、20240101000002这样递增,所有新写入的数据都会落到最后一个 Region 上,因为字典序最大的 rowkey 在最后。结果就是一台 RegionServer 累死,其他机器围观。这就是所谓的“单调递增 rowkey 热点问题”。
4.2 加盐、哈希与反转:三种打散热点的思路
解决热点问题的核心思路是让 rowkey 在字典序上分散开。常见做法有三种:
第一种是加盐(Salting)。在 rowkey 前面加一个随机前缀,比如0_20240101、3_20240101、7_20240101。前缀的取值范围根据你预期的 Region 数量来定,比如你预分区 10 个 Region,就用 0 到 9 做前缀。这样数据会均匀落到 10 个 Region 上。缺点是 scan 的时候需要同时 scan 10 个前缀,稍微麻烦一点,但可以接受。
第二种是哈希(Hashing)。把原始 rowkey 做一次 MD5 或 MurmurHash,取前几位作为前缀。比如a3f2_原始ID。哈希的好处是分布更均匀,不依赖你手动指定前缀范围。缺点同样是 scan 时需要处理多个前缀,而且哈希后的 rowkey 失去了原始顺序,范围查询会变得困难。
第三种是反转(Reversing)。把 rowkey 的字节序反转,比如000001变成100000。这样递增的 ID 反转后就变成了递减,新数据会落到第一个 Region 而不是最后一个。但这种方法只适合固定长度的 ID,而且本质上还是把热点转移了,没有真正打散。
我个人的选择优先级是:如果业务允许,优先用哈希前缀;如果业务对范围查询有强需求,用加盐;反转只在特定场景下用。哈希前缀虽然牺牲了一点 scan 便利性,但分布最均匀,运维最省心。
4.3 Rowkey 长度与组合设计
Rowkey 的长度也很讲究。HBase 的 rowkey 建议控制在 10 到 100 字节之间。太短,信息量不够,容易冲突;太长,会浪费存储空间,而且因为 rowkey 会出现在每个 HFile 的索引和 Bloom Filter 里,过长会显著增加内存占用。
组合 rowkey 是常见做法,比如用户ID + 时间戳 + 类型。这里有个技巧:把变化频率低、查询时必带的字段放在前面,变化频率高的放在后面。比如查询总是按用户查,那就用户 ID 在前;如果查询总是按时间范围查,那时间戳在前。顺序不同,scan 的效率可能差好几倍。
还有一个容易被忽略的点:rowkey 里不要放会频繁更新的字段。因为 HBase 的更新本质上是插入新版本,旧版本要等 compaction 才清理。如果 rowkey 里包含一个经常变的字段,那每次更新都会产生一条全新的 rowkey,数据量会膨胀得很快。
5. 三者联动:一个真实场景的完整推演
5.1 场景设定与初始设计
假设我们要做一个订单查询系统,需求是:按订单 ID 精确查询、按用户 ID 查该用户的所有订单、按时间范围查某天的订单。数据量每天 500 万条,保留 90 天,总数据量约 4.5 亿条。
初始设计很容易写成这样:rowkey 用订单ID,因为订单 ID 是唯一的,精确查询最快。但问题马上来了:订单 ID 如果是自增的,写入就会全部落到最后一个 Region,形成严重热点。而且按用户 ID 查询和按时间查询都需要全表 scan,完全不可用。
所以我们需要重新设计 rowkey,让它同时支持这三种查询。一个可行的方案是:主表用用户ID + 时间戳 + 订单ID做 rowkey,这样按用户查和按时间查都能走范围 scan。但按订单 ID 精确查询就变成了全表 scan,需要额外建一张索引表,rowkey 用订单ID,value 存用户ID + 时间戳,查的时候先查索引表再查主表。
5.2 Region 预分区与 HFile 策略的配合
有了 rowkey 设计,接下来做预分区。用户 ID 如果是数字,分布通常比较均匀,可以按范围切;如果是字符串,建议先哈希再切。假设我们按用户 ID 的哈希前缀切 20 个 Region,每个 Region 负责 5% 的用户。
HFile 策略上,因为订单数据是“写多读少、按时间范围查”的模式,我建议:
- 调大
flush.size到 256MB,减少 flush 频率。 - 关闭自动 Major Compaction,改在凌晨 2 点到 4 点手动触发。
- 列族设置
VERSIONS => 1,因为订单数据不需要多版本,减少 compaction 时的版本清理开销。 - 开启 Bloom Filter(
BLOOMFILTER => 'ROW'),加速精确查询。
5.3 监控指标与调优闭环
设计完之后不是就完事了,要建立监控闭环。我一般关注这几个指标:
| 指标 | 含义 | 健康范围 |
|---|---|---|
| Region 数量 | 总 Region 数 | 稳定,无剧烈波动 |
| 单 RegionServer Region 数 | 负载均衡度 | 各机器差异不超过 20% |
| MemStore 大小 | 内存压力 | 低于阻塞阈值的 70% |
| HFile 数量 | 读放大程度 | 单 Store 下 3 到 10 个 |
| Compaction 队列长度 | compaction 是否积压 | 接近 0 |
| 写入延迟 P99 | 写入抖动 | 稳定,无周期性尖峰 |
如果发现 Region 数量持续增长,说明预分区不够,需要提前扩容;如果 HFile 数量长期偏高,说明 compaction 跟不上,需要调大线程数或检查磁盘;如果写入延迟有周期性尖峰,对照 split 日志和 compaction 日志,基本能定位到原因。
6. 几个我踩过的坑和对应的解法
6.1 预分区切分点选错导致的热点
前面提过按0到9切十个区结果全挤在第一个区的例子。我后来复盘,发现根本原因是没有先看数据分布就拍脑袋切。正确的做法是:先导入一批样本数据,用hbase shell的count或者写个 MapReduce 任务统计前缀分布,再根据分布决定切分点。如果数据本身分布就不均匀,那预分区也救不了,得先改 rowkey 设计。
6.2 Major Compaction 撞上业务高峰
有一次线上在晚上 8 点出现大面积延迟,查下来是 Major Compaction 自动触发了。默认的hbase.hregion.majorcompaction是 7 天,但它是从 Region 上线时间开始算的,不是统一时间。所以如果你的 Region 是分批上线的,Major Compaction 就会分散在不同时间点触发,很难预测。解法就是前面说的:关掉自动,改手动定时。
6.3 Rowkey 过长导致的内存问题
有个项目 rowkey 用了 200 多字节,包含了一堆冗余信息。结果运行一段时间后,RegionServer 的堆内存一直涨,GC 频繁。查下来是 Bloom Filter 和 Index Block 占用了大量内存,因为它们的体积和 rowkey 长度直接相关。后来把 rowkey 压缩到 50 字节以内,内存问题就缓解了。所以 rowkey 真的不是越长越好,够用就行。
6.4 Region 拆分后客户端路由更新延迟
这个坑比较隐蔽。Region split 之后,客户端缓存的 meta 信息会失效,需要重新从 meta 表拉取。如果客户端缓存刷新不及时,就会短暂地把请求发到已经下线的父 Region 上,导致重试和延迟。解法是调小客户端的 meta 缓存刷新间隔,或者开启hbase.client.meta.cache.period的主动刷新。不过这个参数调太小会增加 meta 表的压力,需要权衡。
7. 写在最后的一点个人体会
HBase 这套东西,刚学的时候觉得概念多、参数杂,但用久了会发现它的设计其实很“诚实”:你哪里设计得不对,它就会在哪里以性能问题的形式告诉你。Region 拆分频繁,说明预分区没做好;HFile 数量居高不下,说明 compaction 策略没调好;写入热点,说明 rowkey 设计有问题。每一个现象背后都有明确的原因,只要顺着链路查,基本都能找到答案。
我现在做 HBase 表设计,习惯先画一张图:rowkey 长什么样、数据怎么分布、预分区怎么切、flush 和 compaction 怎么配。这张图想清楚了,后面运维就轻松很多。反过来,如果一开始图省事,后面就得花几倍的精力去填坑。这个经验,希望对正在设计 HBase 表的你有用。