HBase 经验分享
type
status
date
slug
summary
tags
category
icon
password
问题背景
随着我司服务业务的不断开展,我所在的支付部门各个系统的订单量级越来越大。截至最近,账务系统单张表已达近 10 亿单/天。这部分数据全部存储在 MySQL 的在线库和归档库 中,团队为此需要承担的成本相当高昂。
一般来说,针对此类问题的常见优化手段是冷热数据分离,将冷数据存储到独立的数据库中,以提升系统的稳定性并降低成本。账务系统在过去几年中也一直在做类似的事情。即将一段时间前的数据,通过定时任务写入到归档库中,并从在线库删除。然而这种方式逐渐遇到了以下几个问题:
- 随着业务量的增大,热库的容量都不断告急,几乎是每年都需要进行一次一拆二的扩容。并且支持的存储数据时间也不断缩短。
- 冷库使用 NDB(字节自研的一种数据库方案),支持容量相比 MySQL 要大很多,但成本相比 Mysql 并无明显优势,尤其是使用 9 副本方案的情况下。单月成本达到 100万+,已经成为账务域成本中最大的一块,并且在持续增长当中。
因此如果要做成本优化,从存储优化着手是 ROI 最高的方向。而存储优化中,归档数据存储又是成本最高,优化难度相对最低的一个方向(相比在线数据)。
方案调研
要降低账务系统归档数据的成本,首先就需要了解这部分数据的特点。作为一个吞吐量大、一致性要求高的金融类系统,对于归档数据主要有以下几点要求:
- 最主要的要求——成本低,相对 MySQL 需要有 1/10 左右的成本,否则费力做数据迁移和兼容的 ROI 太低。
- 归档数据仍需要具备和在线数据一样的查询能力。不能是完全的冷数据。主要出于订单幂等的考虑,如果我们不对所有订单做幂等校验,就有可能出现同一笔上游订单,在暂无系统进行多次记账的情况,进而造成资损。
- 支持一定的范围查询能力,对于账务型系统,范围查询动账明细/账单是非常常见的需求,因此新的存储介质也需要有一定范围查询的能力。一些 KV 型存储可能没法达到这种需求。
一些常见的存储方式如下:
对比 | 成本 | 查询能力 | 性能 | 可用性 | 扩容难度 |
NDB | 事务型,成本高 | 强 | 强 | 主备,ha支持常规的主从切换 | 存储节点可扩展 |
Hive | 非常低 | 不支持在线查询 | 弱 | 低 | 扩展性强 |
ByteKV | 很高 | 弱
不支持排序
不支持二级索引 | 强 > NDB | 强(多副本容灾) | 扩展性强 |
HBase | 低 | 较弱
不支持二级索引 | 强 > ByteKV | 弱
目前容灾能力相对不够健全
需要人工实现主备集群和切换 | 扩展性强 |
TODO 补充其他存储介质 | ㅤ | ㅤ | ㅤ | ㅤ | ㅤ |
通过一系列调研,以及借鉴其他系统的经验,最终决定使用 HBase 作为归档库存储介质。
方案介绍
为了使用 HBase 降低归档数据库的成本,就涉及到两步操作:
- 将新增的数据写入 HBase —— 解决增量
- 将历史数据迁移到 HBase —— 解决存量
整个过程大致可以通过下图理解,因此整个方案介绍部分也会按这两部分的顺序介绍:

冷数据迁移
由于归档库中存储了从建立系统至今数千亿行数据,因此必须借助大批量写入数据的能力。字节内部其实有两种做法:
- 通过数据集成 DSL 通用程序实现
- 通过 Bulkload 实现
数据集成 DSL 通用程序
在字节内部,其实存在着大量不同存储介质之间迁移数据的需求。比如 Hive → HBase, Hive → ES, ES → RocketMQ, HBase → TOS 等等。为了快速满足这类需求,基础架构同学通过 DSL 模式的设计,提供了数据迁移统一收敛的程序入口和模版。
简单理解就是对不同的数据源,提供 Reader 和 Writer(底层实现采用不同的 DTS 引擎 Jar 包),对某种数据源的读取和写入操作,只需要提供数据源信息相关的参数即可,无需自己开发迁移程序。
下面就以我们将数据从 Hive 迁移到 HBase 使用的 DSL 信息为例说明:
这里有人可能有疑问,为什么是从 Hive 迁移到 HBase。 一方面其实是 DSL 并不支持 MySQL 作为数据源,另一方面,MySQL 作为在线链路用到的底层依赖,如果被大规模地读取造成性能问题,可能也会对线上造成较大影响。因此这里使用归档库对应的 Hive 表作为数据源。
Bulkload 迁移方案
使用 DSL 任务进行数据迁移,实际上相当于将数据从 hive 查询出来后,批量调用 hbase 的 mutate 接口。除了查询 hive,其他和调用 HBase 的 API 进行写入基本没有区别。
因此 DSL 方案仍有网络上的耗时开销。我们知道 HBase 的底层数据存储介质其实是 HDFS,HBase 的每一张表对应到 HDFS 目录上的一个文件夹,而 Hive 的底层数据存储介质也是 HDFS。因此可以使用 MapReduce 直接生成 HFile 格式的数据文件,上传到 HDFS 进行存储。

通过这种方式迁移数据,可以在离线计算的过程中直接生成 HFile,然后快速加载到线上表中,成本仅为批量直写的十分之一,并且对线上表影响很小,非常适合海量离线数据回灌场景。
HBase 侧的同学提供了一个比较通用的方案,下面大概介绍一下使用方式:
- 在 Dorado 平台创建 Spark 任务,语言类型选择 Java,资源类型使用 HBase 同学提供的 SCM(如果对生成的数据有特殊逻辑,或者 rowKey 需要有特定规则等,可以自己拉分支修改代码并发布 SCM 包)

- 填入任务运行需要的参数可参考示例任务,以下有几个注意点:
- 自定义参数中,需要填入目标 hbase 集群信息,以及从 hive 中查询数据的语句。该语句可以指定查询的范围,可以按时间分批执行,避免单次任务运行时间过长。
- 如果待迁移的数据量比较大,在 MapReduce 任务运行的时候,可能有 OOM 的问题,需要比较精细地调整任务运行参数,可以提 Oncall 咨询 hbase 侧同学。目前示例任务中的参数已经是调优后的配置。大多数情况下没有问题。
- 因为 HBase 的 HFile 中数据是有序的,因此 MapReduce 程序需要将从 hive 查询出来的数据排序并写入。一旦数据量大会非常消耗 CPU 资源,需要充足的 Yarn 队列资源。
任务注意事项
在账务数据迁移的过程中,实际上两种迁移方式都用到了,有一些经验分享如下:
迁移方式 | 优点 | 缺点 |
DSL | 1. 任务开发简单,配置简单,能够很快开始迁移。 | 1. 大批量数据写入对集群性能影响比较大,如果线上已经在使用 hbase,容易造成超时/查询失败的问题 |
Bulkload | 1. 对 hbase 集群影响很小,不会影响在线业务。 | 1. 可能需要自己编写 Bulkload 代码,并且内部指导的文档很少,对 Java 不太熟悉的同学可能有困难。
2. 如果数据量特别大,且 Yarn 队列资源紧缺,可能在运行任务时频繁出现失败,影响迁移进度。 |
如果在迁移数据前想快速进行方案选型,个人觉得可以按照下面几点进行评估:
- 数据量大小:数据量较少(几千万 ~ 上亿)这个量级,可以考虑直接使用 DSL 快速进行迁移;数据量更大的情况下,可以考虑用 Bulkload。
- 线上业务是否已经在依赖 HBase:如果是的话,尽量使用 Bulkload 方式。
- 团队 Yarn 队列资源:如果资源不太充足,推荐使用 DSL 任务进行迁移,否则 Bulkload 任务调优可能需要耗费大量时间。
热数据迁移
相对于已经写到归档库中的冷数据,在线库中的热数据也需要逐步向 HBase 中迁移。
因为之前归档任务已经在将数据从在线 MySQL 迁移至 HBase,因此只需要对归档任务进行改造,逐步将归档任务从 只写NDB → 双写 NDB 和 HBase → 只写 HBase 进行迁移。
这里有一个关键点是需要保证迁移完成后,HBase 中有全量数据,因此需要通过如下方式灰度:

阶段名称 | 说明 |
归档双写 | 将归档任务改造为写 NDB 后,还需要将数据写入 HBase,只有两个数据源都写入成功,本条数据才算归档成功,否则不从在线库删除这条数据。 |
迁移全量数据 | 即冷数据迁移中的流程。 |
开启双读 | 在线业务查询归档数据时,改造为查询 NDB 后,同时也查询 HBase。
为保证服务稳定性,可通过 tcc 进行灰度控制,双读流量逐步放至 100%。
双读查询出来的数据,需要进行比对,保障 NDB 和 HBase 中的数据是一致的,存在不一致则需要打点报警人工处理。 |
离线数据对比 | 为保障 Hive → HBase 迁移的过程中,没有数据丢失的情况,有下面两种比对方式:
1. 将 HBase 的数据导入到一张新 Hive 表,然后两张 Hive 表进行关联比对。
2. 通过外部表的方式将 HBase 挂载到一张 Hive 表上,然后比对两张 Hive 表。 |
关闭 NDB 读 | 数据迁移完毕,比对无误后,可以开始以 HBase 中的数据为准,因此将在线业务查询归档数据时,改造成只读 HBase |
关闭 NDB 写 | 对归档任务进行改造,开始不写 NDB,至此在线业务不再依赖 NDB 中的数据。 |
归档数据清理 | Truncate NDB 中归档的数据,释放数据库空间,降低成本。 |
数据模型说明
RowKey 设计
在进行 HBase 迁移时,非常关键的一点是对 HBase 数据表的设计,这是保证后续查询性能和稳定性的关键。
HBase 对索引的支持主要集中在一级索引(RowKey)上,而对二级索引的支持有限。因此在将数据迁移到 HBase 前,需要着重考虑使用场景。如果存在根据多种条件进行查询的需求,则可能不适合用 HBase 进行存储。
对平台账务系统的订单而言,唯一索引是 biz_no + system_code + event_code,进行幂等校验时,会根据这三个条件查询历史数据,而其他的查询场景极少。因此 RowKey 必然要包含这三个字段。
由于 biz_no 一般是上游系统通过发号器生成,格式和格式比较固定。如果直接使用 biz_no 作为前缀,会导致数据写入集中在 HBase 的某些 region,写入和查询性能都受限。因此 rowKey 的生成规则可以为:
substr(md5(${biz_no}), 0, 16)_${biz_no}_${system_code}_${event_code}
即先用 md5 将 biz_no 进行散列,保证 rowKey 尽量打散,再拼接上其他字段。
在 RowKey 方面还有两个坑需要注意:
- 使用 md5 进行数据打散时,不要只使用结果的前缀。通常 md5 产生的是一个 32 位的字符串,如果只取前 16 位,会导致结果冲突的概率大大提升。而 HBase 写入时默认是覆盖写的,出现这种情况就会导致之前写入的数据被覆盖丢失。
- 在 HBase 平台上建表时,分区策略有两种选项:8位10进制 和 8位16进制,如果前缀中包含字母,则需要选择 8位16进制。否则会导致数据写入集中在某些分区性能受限。
索引设计
对于某些数据表,可能会有根据唯一索引以外的索引进行数据查询的需求,HBase 对这种场景的支持非常有限(因为不支持一张表设置多个索引)。可以通过以下几种方式实现类似功能:
- 使用非原生的二级索引工具,如 Coprocessor 和 Phoenix
- 自定义索引表,可以手动创建额外的索引表来存储非rowkey字段的映射关系。这种方法需要自行维护数据一致性,通常会增加写入复杂性和延迟
之前调研的过程中,发现字节内部使用方案一的 case 非常少,并且内部的 HBase 版本不一定支持,因此建议使用自定义索引的方式,实现简单的二级索引查询需求。一张二级索引表的定义可能如下:
RowKey | Value |
md5(business_order1) | rowKey1 |
md5(business_order2) | rowKey2 |
business_order 即为需要用来查询数据的字段,可能是某个业务上的单号。将其作为 rowkey,对应的数据即为真实的数据表中的 rowKey。通过订单号查询到数据表中的 rowKey,再查询订单表得到具体数据。
数据核对方案
迁移完成后,我们必然需要通过一些手段,来保障数据迁移是否有遗漏。因为一旦遗漏数据,并且切换到完全以 HBase 为准,在记账时校验幂等无法从 HBase 查询到过去已经记过账的订单,必然就会导致重复记账和资损。
整个核对的思路可以通过下图表示:
可用性设计
目前字节内部的 HBase SLA 承诺是 99.9%,低于 NDB 和 MySQL 承诺的 SLA。因此一方面在迁移数据时,要注意不能将关键链路依赖的数据迁移至 HBase。另外就是要通过其他的手段保障 HBase 的可用性。
为保证高可用性,HBase 侧提供的方案是,可以在两个机房部署两个集群,其中一个集群通过 WAL(Write Ahead Log)的方式,向另一机房同步数据。类似 MySQL 的从库,通过冗余数据来提升可用性。但目前 HBase 没有类似 MySQL 的成熟的 HA (High Availability)组件,也就是说故障发生时,无法自动进行切主。
因此目前我们使用的方式是通过 TCC 开关控制账务系统查询的目标集群。其架构大致如下:

常态下,会从两个集群都查询数据,遵从只要有一个集群能查询到数据即可的原则:
- 如果 LF 和 LQ 机房都查询到了数据,则比对两个机房的查询结果,存在不一致的情况需要打点报警人工排查。
- 如果只有 LF 或 LQ 其中一个机房查询到了数据,则直接返回结果。
- 如果两个机房都没有查询到数据,说明这笔订单之前就没有出现过,动账时的幂等判断通过。
Loading...