1. 先把 HBase 放对位置:它解决的是"写得多、写得快、存得下"的问题
第一次接触 HBase 的人,十个里有九个会问同一句话:这东西跟 MySQL 有什么区别,我为什么不直接用 MySQL。这个问题问得很好,因为它恰恰是理解 HBase 的入口。我见过太多团队在做技术选型时,把 HBase 当成"更大号的 MySQL"来用,结果表结构照搬关系模型,RowKey 随手用自增 ID,上线三个月后 Region 热点打得单台机器 CPU 跑满,然后回头说 HBase 不好用。问题从来不在 HBase,而在于没有搞清楚它到底为谁而生。
HBase 是一个构建在分布式文件系统之上的列式存储数据库,它的设计目标非常明确:用廉价的普通服务器横向堆叠,扛住每秒数十万甚至上百万次的随机写入,同时把 PB 级别的数据安稳地存下来,并且能按 RowKey 做毫秒级的随机读取。注意这几个词——随机写入、横向扩展、按主键读。它不擅长的是多表关联、复杂条件查询、跨行事务和聚合统计。你把这几件事分清楚了,选型就不会跑偏。
这一篇不打算讲怎么装集群、怎么敲命令,那些是后面的事。这里只做一件事:把 HBase 的基本概念一个个拆开,讲清楚每个概念为什么存在、它对应磁盘上的什么东西、以及这些概念在实际使用中会以什么样的方式"咬你一口"。概念不清就上手,后面所有的调优和排错都是盲人摸象。
1.1 一个让我重新认识 HBase 的需求场景
早些年我参与过一个设备状态采集的存储改造。当时的量级是每天大约八亿条记录,每条记录包含设备编号、采集时间、若干个传感器读数,总字段数在三十个左右,但每条记录实际只会写入其中三到五个字段,剩下二十多个字段是空的。这个特征很关键:稀疏。同一批设备里,有的型号上报温度,有的型号上报电压,字段集合几乎不重叠。
原来的方案用的是关系型数据库,按天分表。跑到第二周就撑不住了,原因有两个。第一是写入放大,关系库为了保证索引一致性和事务,每次写入都要维护 B+ 树索引、写 redo 日志、更新统计信息,单机每秒几千条的写入能力在八亿条面前完全不够看。第二是存储浪费,稀疏字段在行式存储里全是 NULL 占位,磁盘利用率极低,而且随着字段增多,表结构变更需要走 DDL,每加一个传感器字段就是一次线上变更。
换成 HBase 之后,这两个问题同时消失了。稀疏字段在 HBase 里根本不占空间,没写的列就不存在;写入是追加式的,落盘顺序写,单机吞吐直接上了一个数量级。这个案例让我真正理解了 HBase 的价值主张:它是为"海量、稀疏、按主键随机读写"这三件事同时成立的数据而设计的。三者缺一个,收益就会打折。如果你的数据只有几千万行、字段规整、还经常要按多个条件做筛选,那老老实实用关系库,别折腾。
1.2 和关系型数据库比,差的不只是 SQL
很多人把 HBase 和关系库的差异简单归结为"一个支持 SQL、一个不支持"。这个理解太浅了,真正拉开差距的是四件事。
| 维度 | 关系型数据库 | HBase |
|---|---|---|
| 数据组织 | 行式存储,整行连续 | 按列族存储,同族列连续 |
| 扩展方式 | 垂直扩容为主,分库分表靠应用层 | 原生水平扩展,Region 自动分裂迁移 |
| 事务能力 | 强事务,跨行跨表 | 单行原子性,无跨行事务 |
| 查询方式 | 多条件、多表关联、聚合 | 按 RowKey 定位,或全表扫描 |
| 写入方式 | 原地更新,维护索引 | 追加写,内存攒批后顺序落盘 |
| 稀疏数据 | NULL 占位 | 不存在的列不占空间 |
这几条差异背后其实只有一个根源:HBase 放弃了原地更新和二级索引。放弃原地更新,换来的是顺序写,磁盘吞吐能跑满;放弃二级索引,换来的是写入路径极短,不需要维护额外结构。天下没有白吃的午餐,代价就是你只能靠 RowKey 找数据,想按别的字段查,要么建一张索引表自己维护,要么走全表扫描——后者在生产环境基本等于事故。
提示:如果有一天你发现自己的 HBase 表需要频繁按非 RowKey 字段做条件查询,这不是"写法问题",这是选型问题。要么补一张索引表,要么换存储组件,不要指望在 HBase 上硬堆过滤条件。
1.3 和 Hive 比,别把两者搞混
这两个东西经常被放在一起比较,因为它们都跟"大数据"沾边。但它们的定位完全不同,混用会出大问题。
Hive 本质上是一层把 SQL 翻译成批处理任务的壳,数据真正躺在分布式文件系统上,查询延迟通常是分钟级甚至小时级,适合离线统计、全量分析、报表跑批。HBase 是真正的数据库,读写延迟在毫秒到几十毫秒量级,适合在线查询、点查、实时写入。
我见过最典型的误用是把 HBase 当离线分析引擎使,写一堆 Scan 加 Filter 去做全表统计,跑一次几十分钟,还把 RegionServer 的读线程池打满,影响线上正常请求。反过来也有人把需要毫秒响应的点查需求丢给 Hive,用户点一下按钮等三分钟,体验灾难。
正确的姿势是两者配合:HBase 承担实时写入和点查,定期把数据导出到文件系统,用 Hive 或类似工具做离线分析。这条链路在很多实际项目里跑得很稳,各自的优势都能发挥出来。
1.4 一个常被忽略的前提:HBase 快的前提是"按键访问"
这一点值得单独拎出来说,因为它是所有性能问题的总根源。
HBase 的高性能建立在一个隐含假设上:你的请求绝大多数是带着 RowKey 来的。RowKey 是唯一的一级索引,天然有序。客户端拿着 RowKey,能通过元数据表直接定位到数据落在哪台机器的哪个 Region 上,一次网络跳转就能拿到结果。这条路走通了,毫秒级响应是常态。
可一旦你拿不到 RowKey,只能做范围扫描或者全表扫描,性能曲线立刻断崖式下跌。原因很简单,Scan 需要在服务端逐条遍历,每条记录都要经过过滤器的判断,还要跨网络传输。一张几亿行的表,全表扫一遍的时间是以小时计的,而且会把 RegionServer 的 IO 和网络带宽吃干净。
所以我在做任何 HBase 表设计之前,会先逼着业务方回答一个问题:这张表将来最常见的查询条件是什么?答案决定了 RowKey 怎么拼。如果答案是"按用户 ID 查""按设备编号加时间段查",那设计就有解;如果答案是"按状态查""按金额区间查""按任意字段组合查",那 HBase 不是好选择,不要硬上。
2. 五个术语撑起整个数据模型
HBase 的数据模型看起来简单,但有几个术语的命名很容易让人想当然,从而理解跑偏。比如 Column Family 叫"列族",很多人第一反应是"一组列的集合",然后就按照业务模块去划分,一个业务一个列族,最后开了十几个。这是典型的踩坑姿势,后面会讲为什么。
先把这五个概念按从大到小的顺序摆清楚:Namespace、Table、RowKey、Column Family、Cell(含 Timestamp)。它们的层级关系是 Namespace 里有多张 Table,Table 里有多行,一行由 RowKey 唯一标识,行内按 Column Family 分组,每个 Column Family 下有若干列,每个单元格(Cell)按 Timestamp 保存多个版本。
这个层级不是逻辑上的方便抽象,它直接对应物理存储结构。这一点是理解 HBase 的关键,后面讲到 Region 和 Store 的时候会更明显。
2.1 Namespace 与 Table:容器层的实际作用
Namespace 相当于关系库里的 database,用来做表的分组和权限隔离。默认有两个:default存放用户表,hbase存放系统表。实际项目里我一般会按业务线或者环境建 Namespace,比如按项目建,好处是做配额控制的时候粒度更细,可以限制某个 Namespace 的总存储量和请求量,防止一个跑批任务把整个集群拖垮。
Table 就是表。但 HBase 的表和关系库的表有个根本区别:表在创建时必须声明 Column Family,而不能声明具体的列。列是在写入时动态产生的,写什么列就有什么列,不需要提前定义,也不占用额外空间。这个特性叫 schema-less,是稀疏存储的基础。
表创建的时候只需要给出表名和列族列表,语句大概长这样:
// 建表时只声明列族,列在写入时动态产生 TableDescriptorBuilder builder = TableDescriptorBuilder.newBuilder(TableName.valueOf("device_metrics")); ColumnFamilyDescriptor cf = ColumnFamilyDescriptorBuilder .newBuilder(Bytes.toBytes("m")) .setMaxVersions(3) // 每个单元格保留 3 个版本 .setTimeToLive(86400 * 30) // 30 天过期 .setBlocksize(65536) // 数据块大小,默认 64KB .build(); builder.setColumnFamily(cf); admin.createTable(builder.build());这段代码里两个参数值得说。setMaxVersions(3)表示每个单元格保留最近三个版本,超出的在压缩时清理掉;setTimeToLive是 TTL,单位是秒,到期数据自动不可见并在压缩时物理删除。这两个参数如果不管,默认版本数是 1、TTL 是永久,看起来没问题,但一旦业务方需要查历史变更,或者数据有时效性需要清理,就会抓瞎。建表时多花两分钟想清楚这两项,能省掉后面很多事。
2.2 RowKey:唯一的定位器,也是排序键
RowKey 是 HBase 里最重要的一个概念,没有之一。它同时承担三个角色:行的唯一标识、数据分布的散列依据、以及所有查询的入口。
第一,它是唯一索引。一行数据只能有一个 RowKey,写入相同 RowKey 就是覆盖(产生新版本)。第二,它决定了数据在表中的物理顺序——HBase 里的行是严格按 RowKey 的字节序排列的,这一点非常重要,因为它意味着范围查询是连续磁盘读,效率极高。第三,它决定了数据分布到哪个 Region,进而分布到哪台机器上。如果 RowKey 设计得集中,所有写入就会压在一台机器上,这就是所谓的"热点"。
RowKey 的最大长度是 64KB,但实际使用中建议控制在几十个字节以内,因为 RowKey 会在每个单元格中冗余存储一份,太长了磁盘占用会很夸张。我见过有人把一整段 JSON 塞进 RowKey,结果表里一半的空间都在重复存这个 JSON,非常浪费。
关于排序这一点,有个容易被忽略的细节:HBase 的排序是按字节比较的,不是按数值大小。如果你用数字做主键的一部分,10会排在2前面,因为字符1的字节值小于2。要让数字按大小排序,必须做零填充,比如统一补到十位:0000000003、0000000010。这个坑我踩过,当时一个按小时统计的表,小时数没补零,导致 Scan 出来的结果是 0、1、10、11、12…19、2、20 这样的顺序,数据没错但顺序全乱,下游做增量计算时直接出了错。
2.3 Column Family:物理存储的真正单位
这是最容易被误读的一个概念。Column Family 不是"逻辑上相关的一组列",它是物理上的存储单元。同一个 Column Family 下的所有数据存储在同一批文件里,不同列族的数据分开存,对应的 Store 也是分开的、独立的 MemStore 和独立的 HFile 集合。
理解这一点,就能明白为什么列族不能多。假设一张表开了 10 个列族,那么每一行数据在物理上要分散到 10 个 Store 里,每个 Store 都有自己的 MemStore 缓冲区和 flush 阈值。这意味着什么?意味着内存被切成了 10 份,每份都要单独攒够才 flush,内存利用率大幅下降;一次 flush 操作会同时触发 10 个文件写入,IO 放大十倍;compaction 的时候,10 个列族各自的文件都要单独合并,后台压力成倍增长。
所以列族的划分原则非常简单粗暴:能用 1 个就别用 2 个,能 2 个别 3 个。通常的做法是按访问模式划分——经常一起读的列放一个族,不常读的大字段放另一个族。比如一个存用户信息的表,基本信息(姓名、等级、状态)一个族,头像等大对象另一个族,这样查基本信息的时候不会把大对象的数据块读进缓存。
列族的名字越短越好,建议 1 到 2 个字符。原因是每个单元格都要存列族名,虽然底层做了字典编码,但短名字在扫描的时候能减少网络传输和 CPU 开销,几十亿行数据量下这个差异是能感觉到的。
2.4 Cell 与 Timestamp:多版本的秘密
Cell 是数据的最小单位,由{RowKey, Column Family, Column Qualifier, Timestamp}四元组唯一确定。注意这里有个 Quadruple 的概念,很多人只知道前三项,忽略了 Timestamp。
HBase 是天然多版本的。每次写入都会带一个时间戳,默认取服务器当前时间毫秒数,也可以由客户端指定。同一个单元格可以有多个版本共存,读取的时候默认返回最新的一个,也可以指定版本数或时间范围。
这个机制的实际用途比想象中广泛。比如设备状态表,同一个设备的同一个字段,每次状态变化写一条,靠 Timestamp 区分先后,就能完整回溯状态变更历史。再比如跨机房同步场景,用时间戳做冲突解决,后写的覆盖先写的。
但多版本是有代价的。版本保留得越多,占用的空间越大,compaction 时合并的工作量也越大。我的经验是:绝大多数业务只需要保留 1 到 3 个版本。如果确实需要完整历史,更好的做法是显式把时间戳设计进 RowKey,比如设备ID+反转时间戳,每次写入是新增一行而不是新版本,这样查询历史的时候可以按范围 Scan,效率更高,也不会让单个单元格膨胀。
注意:版本数是在列族级别设置的,不是列级别。一旦设了
MaxVersions = 5,这个族下所有列都会保留 5 个版本。所以设置之前要确认整个族的所有列都能接受这个存储开销。
2.5 用一个具体场景把五个概念串起来
空讲概念容易飘,举个例子落地。
假设要存设备每小时的运行指标,设计如下:
- Namespace:
iot - Table:
device_metric - RowKey:
设备ID(补零到10位)_时间戳反转 - Column Family:
m(metrics,指标数据) - Column Qualifier:
temp、volt、rpm - Cell:
{设备ID+时间戳, m, temp, 1700000000000},值为36.5
为什么时间戳要反转?因为 HBase 按字节序升序排列,时间戳反转之后,最新的数据排在前面。这样查"某设备最近 100 条记录"就变成了从头 Scan 100 行,而不用扫描全表。这是 HBase 里非常经典的一个设计技巧,后面的章节还会展开。
写入的时候,一条数据可能是这样:
Put put = new Put(Bytes.toBytes("0000000123_8799999999999")); put.addColumn(Bytes.toBytes("m"), Bytes.toBytes("temp"), Bytes.toBytes("36.5")); put.addColumn(Bytes.toBytes("m"), Bytes.toBytes("volt"), Bytes.toBytes("220.3")); table.put(put); // rpm 这个字段没写,那么在 HBase 里它就不存在,不占任何空间注意最后那行注释。如果这台设备这次没有上报转速,那rpm这个单元格压根不会创建,后续读取的时候也读不到。这就是稀疏存储,跟关系库里存一个 NULL 完全不是一回事——关系库的 NULL 在行里是有占位的,HBase 里是彻底不存在。
3. 数据在磁盘上长什么样:Region、Store、MemStore、HFile 的映射关系
前面讲的是逻辑模型,这一节讲物理模型。这两层之间的映射关系是 HBase 最核心的设计,理解了它,后面所有的调优参数你都能自己推导出为什么。
先把映射关系一句话说完:一张表被水平切成多个 Region,一个 Region 包含若干个 Store,一个 Store 对应一个 Column Family,一个 Store 由一块内存(MemStore)和若干磁盘文件(HFile)组成。另外整个 Region 还有一个预写日志 WAL 用于故障恢复。
这个结构看起来层次多,但每一层都是为了解决一个具体问题而存在的。Region 是为了水平扩展,Store 是为了列族隔离,MemStore 是为了把随机写转成顺序写,HFile 是为了有序存储和高效检索,WAL 是为了数据不丢。
3.1 Region:表的水平切片,也是扩展的抓手
Region 是 HBase 分布式的基石。一张表刚开始只有一个 Region,随着数据增长,当 Region 大小达到阈值时会自动分裂成两个,然后分散到不同的机器上。这个过程是自动的,管理员只需要关注集群的整体负载。
每个 Region 负责一段连续的 RowKey 范围,用[startKey, endKey)表示。因为 RowKey 是有序的,所以 Region 的划分天然就是按 RowKey 区间切的。客户端拿着一个 RowKey,只要知道它落在哪个区间,就知道该去哪个 Region、哪台机器上找数据。这就是为什么 HBase 的点查效率高——寻址是一次二分查找,不需要扫描。
Region 的大小控制是个经验活。分裂阈值默认配置大概在 10GB 左右(不同版本略有差异),这个值调小可以让负载更均衡,但会增加元数据管理的开销;调大可以减少分裂次数,但单台机器的恢复时间会变长——机器挂掉之后,它上面的 Region 需要重新分配和恢复,Region 越大恢复越慢。
我个人的经验是把单 Region 控制在 8 到 12GB 之间。这个区间的平衡点在于:一方面 Region 数量不会太多导致管理开销过大,另一方面单台机器挂掉时,几百个 Region 的恢复时间在可接受范围内。当然这跟集群规模有关,机器多的时候可以适当调小,机器少的时候别调太小,否则一堆小 Region 反而拖累性能。
3.2 Store:列族的物理化身
前面提到列族是物理存储单位,落到实现上就是 Store。一个 Region 里有几个列族,就有几个 Store。
这个映射关系解释了很多现象。为什么列族多了性能差?因为每多一个列族,每个 Region 就多一个 Store,多一份 MemStore 内存占用,多一套 HFile 文件集合。假设集群有 5000 个 Region,表有 5 个列族,那么 Store 的数量就是 25000 个,每个 Store 的 MemStore 至少要占一点内存,加起来就是很可观的数字。
为什么列族多了 flush 频繁?因为 flush 的触发条件之一是单个 MemStore 达到阈值。列族多了,每个 MemStore 分到的内存变少,更容易触发 flush。而 flush 是 Region 级别的事件——一个 Store 触发 flush,整个 Region 的所有 Store 都要跟着 flush,哪怕其他 Store 的数据还很少。这就导致了大量小文件的产生,进而加剧 compaction 压力。
这个连锁反应是很多 HBase 集群性能问题的根源。所以我反复强调:列族数量控制在 1 到 3 个,超过 3 个就要仔细审视设计是不是有问题。
3.3 MemStore 与 HFile:把随机写变成顺序写
这是 HBase 写入性能的核心秘密。
数据写进来的时候,不是直接写磁盘,而是先写进内存里的 MemStore。MemStore 内部是一个有序结构(跳表),写入的时候会按 RowKey 排好序。当 MemStore 攒到一定大小(默认 128MB),就整体 flush 成一个 HFile 文件落盘。
关键点在于:落盘是一次顺序写,而且写出的是已经排好序的数据。这就把上层应用的随机写,转换成了磁盘上的顺序写。机械硬盘的顺序写吞吐能到几百 MB/s,而随机写只有几百 IOPS,差距是数量级的。即使在 SSD 上,顺序写也远优于随机写。
HFile 是落盘后的文件格式,内部结构大致是:数据块、索引块、布隆过滤器、元数据、尾部固定结构。读取的时候先读尾部拿到索引位置,再通过索引定位到具体的数据块,只读需要的那一块。这个设计让随机读的代价可控——不需要读整个文件,只读一个 64KB 的数据块。
HFile 还有一个重要特性:不可变。一旦写入完成,文件就不再修改。所有的更新和删除都是通过追加新文件、然后在 compaction 时合并来实现的。这个设计避免了并发修改的问题,但代价是读的时候可能需要查多个文件,以及删除操作不会立即释放空间(要等 compaction)。这就是后面要讲的 compact 机制的由来。
3.4 WAL:崩溃恢复的保险绳
MemStore 在内存里,机器一挂数据就没了。所以每次写入在进 MemStore 之前,会先顺序追加一条记录到 WAL(Write-Ahead Log)里。WAL 也是写在分布式文件系统上的,有多副本保障。
恢复流程是这样的:RegionServer 挂掉之后,HMaster 会把它负责的 Region 分配给其他机器,新接手的机器会回放这些 Region 对应的 WAL,把还没落盘的数据重新构建成 MemStore,然后继续服务。这样就保证了已经返回给客户端成功的写入不会丢。
WAL 是可以关闭的,配置项是Mutation.setDurability(),可以设为SKIP_WAL或ASYNC_WAL。关闭之后写入性能会有明显提升,因为省掉了一次同步刷盘。但代价是机器挂掉时可能丢最近一段数据。这个开关只在能容忍数据丢失的场景下才应该关,比如可重放的日志采集、可重新计算的中间结果。核心交易数据千万别关,丢一条都是事故。
还有一个容易踩的坑:WAL 的同步策略。默认是每次写入都同步刷盘,这在写入密集的场景下会成为瓶颈。可以改成批量同步,或者按 Region 级别设置。但要注意,改成异步之后,数据在操作系统缓冲区里,机器断电一样会丢。这个取舍需要在业务层面明确。
4. 集群里的三类角色与一条寻址链路
搞清楚物理结构之后,再看集群里的角色分工就顺理成章了。HBase 集群的角色不多,主要就是 HMaster、RegionServer,加上外部的协调服务 ZooKeeper。三者各司其职,责任边界很清晰。
理解角色分工的实际意义在于排障。线上出问题的时候,你得知道去哪看日志。客户端连不上,可能是 ZooKeeper 的问题;写入慢,可能是 RegionServer 的问题;Region 分配卡住,可能是 HMaster 的问题。方向对了,排查效率差十倍。
4.1 HMaster:不碰数据,但管着全局
很多人以为 HMaster 是集群的大脑,所有请求都要经过它。这是个常见误解。HMaster 完全不参与数据读写,客户端读写数据的时候根本不跟 HMaster 打交道。
HMaster 真正的职责是管理层面的:
- 负责 Region 的分配和迁移。RegionServer 上线或下线时,由 HMaster 决定哪些 Region 分配到哪台机器。
- 负责表的元数据操作,建表、删表、改表、分裂 Region 的协调。
- 负责负载均衡,定期检查各 RegionServer 的 Region 数量,做迁移让分布更均匀。
- 负责 RegionServer 的故障处理,检测到某台机器失联后,把它上面的 Region 重新分配。
HMaster 可以有多个,通过 ZooKeeper 选举出一个 Active,其余是 Backup。Backup 平时不干活,Active 挂了之后自动接管。这个设计保证了管理面不会成为单点,但因为数据面本来就不依赖 HMaster,所以短暂的管理面不可用对业务影响很小——已有的读写请求照常进行,只是新建表之类的操作会暂时失败。
这个特性在实际运维中很有用。滚动升级的时候,可以先把 HMaster 切走,再慢慢重启 RegionServer,业务基本无感。
4.2 RegionServer:所有读写都落在这里
RegionServer 是真正干活的角色。每台数据节点上跑一个 RegionServer,它负责管理本机上所有的 Region,处理所有来自客户端的读写请求,执行 flush、compaction、split 这些后台任务,写 WAL,维护 BlockCache。
RegionServer 内部有几个关键的线程池需要关注:
| 组件 | 作用 | 相关配置思路 |
|---|---|---|
| Handler | 处理读写 RPC 请求 | 请求量大时适当增加,但别超过 CPU 核数太多 |
| MemStore Flusher | 执行 flush 落盘 | 关注 flush 队列积压情况 |
| Compaction Checker | 检查并触发 compaction | 大合并要避开业务高峰 |
| WAL 同步线程 | 负责日志刷盘 | 影响写入延迟 |
这些线程池的队列一旦积压,表现出来就是请求延迟飙升。我排查过的一次线上问题就是 flush 队列打满,原因是某个列族写入突增,MemStore 频繁触发 flush,而磁盘 IO 又跟不上,导致队列堆积,最终所有写入都被阻塞。后来通过调整该列族的内存配额、加大 flush 阈值、同时把大合并时间窗挪到凌晨,问题才解决。
所以监控 RegionServer 的时候,不能只看 CPU 和内存,flush 队列长度、compaction 队列长度、BlockCache 命中率、WAL 写入延迟这几个指标才是真正能提前发现问题的。
4.3 ZooKeeper 与 hbase:meta:客户端怎么找到数据
客户端要读一行数据,第一步是找到这个 RowKey 在哪个 RegionServer 上。这个过程靠两张"地图"完成。
第一张是 ZooKeeper 里的一个节点,它记录了hbase:meta表在哪个 RegionServer 上。第二张就是hbase:meta表本身,这是一张特殊的系统表,里面记录了集群里所有用户 Region 的位置信息。它的 RowKey 是 Region 的起始键,列里存着这个 Region 属于哪张表、结束键是什么、在哪个 RegionServer 上、启动码是多少。
完整寻址链路是这样的:
- 客户端首次连接时,从 ZooKeeper 读取
hbase:meta的位置,把这个信息缓存在本地。 - 拿着目标 RowKey 去查
hbase:meta,二分查找定位到对应的 Region 和 RegionServer。 - 把 Region 位置缓存到本地,之后相同的请求直接走缓存。
- 只有当 Region 发生迁移、分裂,导致请求失败时,才重新走一次上面的流程刷新缓存。
这个设计的好处是客户端寻址基本没有中心瓶颈。所有客户端各自缓存,请求直接打到对应的 RegionServer,不经过任何中间层。规模扩大时,只要增加 RegionServer 就能线性提升吞吐。
代价是客户端要维护缓存一致性。Region 分裂或者迁移的瞬间,客户端的缓存可能是过期的,请求会失败。这时候客户端有重试机制,会重新拉取元数据再试。所以偶尔在日志里看到RegionMovedException或者NotServingRegionException是正常的,只要不是持续大量出现就不用紧张。但如果这类异常频繁刷屏,那就要查查是不是 Region 在反复分裂或者集群在做大规模迁移。
提示:如果条件允许,可以在客户端开启 meta 缓存预热,提前把热点表的 Region 分布拉下来。对于延迟敏感的业务,这能省掉冷启动时的那几跳。
5. 读写链路拆解与后台的"隐形工作"
这一节讲一次读写请求完整走过哪些环节。为什么要抠这么细?因为 HBase 的性能问题八成都能从链路上找到位置。比如写入慢,是卡在 WAL 刷盘、还是 MemStore 满了、还是 flush 队列积压?读得慢,是 BlockCache 没命中、还是 HFile 文件太多需要查很多个、还是布隆过滤器没生效?链路清楚了,排查就有方向。
5.1 写请求走过的四步
一次 Put 请求,从客户端发出到返回成功,大概经历这么几步。
第一步,客户端定位。根据 RowKey 查本地缓存的 Region 位置,找到目标 RegionServer,把请求发过去。如果缓存失效,先走一遍元数据查询。
第二步,写 WAL。RegionServer 收到请求后,先把这次变更追加到 WAL 文件末尾,然后根据持久化级别决定是否立即同步刷盘。这一步是写入延迟的主要来源之一,因为涉及到一次落盘操作。
第三步,写 MemStore。WAL 写成功后,数据写入对应 Store 的 MemStore,内部按 RowKey 有序插入。到这一步,写入就算成功了,可以返回给客户端。
第四步,异步 flush。当 MemStore 达到阈值,后台线程会把它整体刷成一个 HFile。这一步对客户端是透明的,不影响写入响应,但会影响磁盘 IO 和后续的读性能。
这个流程里有两个地方值得优化。一个是 WAL 的同步策略,如果业务能容忍少量丢失,改成异步批量刷能显著提升吞吐。另一个是 MemStore 的阈值,适当调大能让 flush 频率降低,减少小文件产生,但会占用更多内存,且机器故障时恢复时间变长。
还有一个常被忽略的点:批量写入。逐条 Put 的网络开销很大,改成批量 Put 能显著提升吞吐。但批量不能太大,否则单次请求处理时间过长,容易超时,而且会占用大量客户端内存。我的经验是每批 100 到 1000 条之间,具体看单条数据大小。
5.2 读请求为什么要过三道关卡
读请求比写请求复杂,因为它可能要查多个地方。
第一道,BlockCache。这是 RegionServer 堆内存里的一块区域,缓存最近读过的 HFile 数据块。如果命中了,直接返回,速度极快。命中率是读性能的核心指标,正常应该在 90% 以上。
第二道,MemStore。如果 BlockCache 没命中,查 MemStore,因为最新的数据还在内存里没落盘。这里要注意,读的时候需要同时看 MemStore 和 HFile,然后按时间戳合并结果,因为同一个 RowKey 可能在两边都有。
第三道,HFile。如果前面都没命中,就要读磁盘上的 HFile。这一步的关键优化是布隆过滤器和块索引。布隆过滤器能快速判断"这个 RowKey 肯定不在这个文件里",避免无谓的磁盘读;块索引能定位到具体的数据块,只读需要的那 64KB,而不是整个文件。
这里就能看出文件数量对读性能的影响了。如果一个 Store 下有 50 个 HFile,那么一次读可能要检查 50 次布隆过滤器,如果都命中不了,理论上最坏要读 50 个文件。这就是为什么 compaction 很重要——它把多个小文件合并成少数大文件,直接减少读放大。
实测中,如果发现某个表的读延迟突然上升,第一个要查的就是每个 Store 的文件数。这个值超过 10 就该关注了,超过 30 基本可以确定是 compaction 跟不上导致的。
5.3 Flush、Compact、Split:后台的隐形工作
这三个后台任务平时不显山不露水,但它们对性能的影响远超很多人的想象。
Flush是 MemStore 落盘。触发条件有三个:单个 MemStore 达到大小阈值、整个 RegionServer 的 MemStore 总量超过上限、WAL 文件数量过多。flush 本身是必要的,但频率过高会带来两个问题:产生大量小文件,以及 flush 时会有短暂的写阻塞(因为要保证一致性)。
Compact是文件合并,分两种。Minor Compaction 把小文件合并成较大的文件,触发比较频繁,影响相对小。Major Compaction 把一个 Store 下所有文件合并成一个,同时清理掉删除标记和过期版本,这个操作非常重,会消耗大量 IO 和网络带宽。
Major Compaction 默认是每 7 天自动触发一次,时间是随机的(基于抖动)。这个随机时间点如果在业务高峰期,会直接影响线上性能。我在生产环境上的做法是:关闭自动大合并,改成手动在低峰期触发。配置上是把hbase.hregion.majorcompaction设为 0,然后写一个定时脚本在凌晨低峰期逐个 Region 触发。这样做虽然麻烦一点,但能彻底避免大合并撞上业务高峰。
Split是 Region 分裂。当一个 Region 的大小超过阈值,会分裂成两个子 Region,父 Region 下线,子 Region 等待分配。分裂本身很快,但分裂后的 Region 需要经历 compaction 整理,而且如果分裂频繁发生,会造成大量的 Region 迁移,影响集群稳定性。
这里有个经验:预分区。建表的时候如果预计数据量很大,最好提前把 Region 切好,避免一开始所有写入都打在一个 Region 上。预分区的关键是选好切分点,通常用 RowKey 前缀的均匀分布来做。比如 RowKey 以设备 ID 开头,可以按 ID 的哈希值范围切成 16 个或 32 个 Region。预分区的另一个好处是避免启动阶段的连续分裂,减少集群抖动。
# 建表时预分区,按前两位十六进制切 16 个 Region create 'device_metric', 'm', SPLITS => ['1','2','3','4','5','6','7','8','9','a','b','c','d','e','f']这条命令的意思是,按 RowKey 的首字符切成 16 段,每段一个 Region。这样写入会均匀分散到 16 个 Region 上,从一开始就避免了热点。
6. 上手前踩过的几个坑与对应经验
概念讲完了,最后说点实在的。下面这几条是我在多个项目里反复踩、反复总结出来的,每一条都对应过真实的线上问题。提前知道能省不少事。
6.1 RowKey 设计不当带来的热点
这是 HBase 最高频的问题,没有之一。
热点的本质是:RowKey 是有序的,写入也是按 RowKey 顺序来的。如果你的 RowKey 是单调递增的(比如时间戳、自增 ID、或者带固定前缀的 ID),那么所有写入永远落在最后一个 Region 上,其他 Region 闲着。这就是所谓的"写热点"。
我遇到过一次特别典型的:某张表的 RowKey 设计成yyyyMMddHHmmss_设备ID,本意是方便按时间查。结果写入全部集中在当前时间对应的那一个 Region,单台机器 CPU 跑到 90%,其他十几台机器闲得发慌。改成设备ID_时间戳之后,写入按设备 ID 的哈希分散开了,负载立刻均衡。
解决热点的常见手段有这么几个:
| 手段 | 做法 | 适用场景 | 副作用 |
|---|---|---|---|
| 加盐 | RowKey 前加随机前缀 | 写入压力极大 | 读取需要扫所有前缀,成本高 |
| 哈希 | 对主键做哈希取模再加前缀 | 主键分布均匀 | 范围查询失效 |
| 反转 | 反转时间戳等单调字段 | 需要按时间倒序取最新 | 只解决时间维度的热点 |
| 预分区 | 提前切分 Region | 所有场景都该做 | 切分点选不好会不均 |
这里要说清楚一个权衡:加盐和哈希都会破坏 RowKey 的有序性,导致范围查询变成多路扫描。如果你的业务既要高并发写,又要按时间范围查,那就得用组合方案,比如把盐值放进 RowKey 的高位,查询的时候并发扫所有盐值分区再合并。这个成本不低,所以设计初期就要想清楚。
我常用的一个折中方案是"业务主键 + 反转时间戳"。RowKey 是设备ID_反转时间戳,这样同一个设备的数据连续存放,按设备查很快;同一设备内最新数据排在最前面,查最近 N 条也很高效;不同设备之间因为设备 ID 不同而分散,避免了热点。这个方案在物联网场景里用得很多,实测效果不错。
6.2 Column Family 开太多的代价
前面讲原理的时候提过,这里说个具体数字。
某次接手一个集群,发现某张表开了 8 个列族。查了一下 flush 记录,平均每分钟触发几十次 flush,每个 Store 产生的 HFile 数长期在 20 个以上,compaction 队列常年积压,读延迟波动很大。
原因很简单:8 个列族意味着每个 Region 有 8 个 MemStore 平分内存。假设 RegionServer 给了 8GB 给 MemStore,100 个 Region 平摊,每个 Region 80MB,再分给 8 个列族,每个 Store 只有 10MB。这么小的 MemStore,稍微有点写入就触发了 flush,而且触发一个就全 Region 一起 flush。
后来把 8 个列族合并成 2 个,问题立刻缓解。flush 频率下降了一个数量级,HFile 数量回到了个位数,compaction 压力也小了。
所以列族划分的原则我再说一遍:按读写模式分,不要按业务模块分。访问频率相近、经常一起读的列放一个族;大字段、冷数据单独放一个族。总数量控制在 3 个以内。
6.3 版本数与 TTL 不设导致的膨胀
默认配置下,MaxVersions是 1,TTL 是永久,看起来是最省空间的设置。但实际使用中,很多人会为了"保留历史"把版本数调高,然后就忘了。
我见过一张表设了 100 个版本,两年下来存储量是实际数据量的几十倍。原因很简单,数据频繁更新,每个单元格都堆着上百个历史版本,而这些历史版本在 compaction 之前一直占着磁盘。
更隐蔽的问题是删除。HBase 的删除不是立即物理删除,而是写一个墓碑标记,等到 major compaction 时才真正清理。如果长时间不跑大合并,删除的数据一直占着空间,你看着表里数据少了,磁盘占用却一点没降。
这两个问题的解法都是:明确版本需求和保留周期,建表时就设好。历史变更真的有必要保留的,用"时间戳进 RowKey"的方式显式存储,而不是靠多版本;数据有时效性的,TTL 一定要设,让过期数据自动清理。定期检查各表的版本配置和 TTL 设置,这是我做集群巡检时的固定项。
6.4 把 HBase 当关系库用的几种典型误用
最后集中说几个误用姿势,都是真实见过的。
第一种,全表 Scan 加大量 Filter 做查询。有人写了个按状态筛选的功能,用 SingleColumnValueFilter 扫全表,几亿行扫下来几十分钟。正确的做法是建一张以状态为 RowKey 前缀的索引表,写入时同步维护,查询时走索引表定位主键。
第二种,用 HBase 做小表。数据量只有几十万行,还在用 HBase,完全没必要。这个量级的关系库轻松搞定,运维成本低得多,还支持复杂查询。HBase 的价值在于规模,规模上不去,它的复杂度全是负担。
第三种,指望 HBase 做聚合。有人想直接算"某设备当月平均值",写了个 Scan 加客户端累加。数据量一大就崩。正确姿势是把明细存 HBase,定期导出到分析引擎做聚合,结果存回来供查询。
第四种,忽略 RowKey 长度。RowKey 会在每个单元格冗余存储,设计时觉得多几十字节无所谓,几十亿行乘下来就是几十 GB 的纯冗余。RowKey 能短则短,这是硬道理。
第五种,单行数据过大。HBase 的设计假设是单行不会太大,如果一行有几十 MB,读写都会有问题,而且会导致 Region 分裂不均匀。大对象应该拆到单独的列族或者干脆放对象存储,HBase 里只存引用。
这些坑说到底都指向同一件事:HBase 有它明确的能力边界,在边界内它很强,出了边界就很难受。搞清楚边界在哪,比背一堆参数管用得多。
我在实际项目中的体会是,判断一个需求适不适合 HBase,只需要问三个问题:数据量是不是真的很大、访问是不是主要靠主键、写入是不是远多于复杂查询。三个都是"是",那 HBase 会很舒服;有一个是"否",就得再想想有没有更合适的方案。这个概念判断做对了,后面的技术细节都是水到渠成的事。