4、HBase 作为特征存储:RowKey 设计原则、列族与版本控制、批量写入与读取优化

HBase 做特征存储,说白了就是看中它两点:一是能扛住海量数据,二是读写延迟够低。但用好它并不容易。我见过太多团队,RowKey 设计拍脑袋,结果读写性能一塌糊涂。今天咱们就把这几个核心点掰开揉碎。

4.1 RowKey 设计:特征存储的命门

RowKey 是 HBase 的唯一索引。所有查询都得走它。设计不好,整个集群都跟着遭殃。

4.1.1 三大设计原则

原则 说明 反例
散列性 避免热点,让请求均匀分布到所有 RegionServer 直接用用户 ID 自增前缀
唯一性 每条特征记录必须有全局唯一的 RowKey 不同时间戳的特征用相同 Key
长度控制 RowKey 越短越好,建议 16~64 字节 把整个 JSON 塞进 RowKey

核心思路:RowKey 设计要同时满足「查询快」和「写入不倾斜」。我个人习惯用「盐值 + 业务主键 + 时间戳」的组合模式。

4.1.2 特征存储的典型 RowKey 模式

拿用户画像特征举例。假设我们要存用户近 7 天的点击特征。直接拿 user_id 做 RowKey 会怎样?新用户集中在少数 Region,老用户数据又太大。这就是典型的热点问题。

我常用的方案是这样的:

// 方案一:哈希前缀 + 用户ID + 日期
RowKey = MD5(user_id).substring(0,4) + "_" + user_id + "_" + date

// 方案二:倒序时间戳 + 用户ID(适合查最近N条)
RowKey = (Long.MAX_VALUE - timestamp) + "_" + user_id

// 方案三:业务分桶 + 特征类型 + 主键
RowKey = bucket_id + "_" + feature_type + "_" + entity_id

方案一我用的最多。哈希前缀保证了散列,用户 ID 保证了唯一,日期支持范围扫描。嗯,这里要注意:哈希函数别用太重的,MD5 前 4 位就够了,否则写入时 CPU 先扛不住。

4.1.3 避坑指南

我曾经踩过的坑:有个项目直接用时间戳当 RowKey 前缀。结果所有写入都打到一个 Region 上,RegionServer 频繁触发 Split,写入延迟从 5ms 飙升到 2s。后来改成哈希前缀,问题立刻解决。

还有一点:RowKey 长度不是越短越好。太短会导致散列粒度不够。我一般控制在 32 字节左右,既能保证唯一性,又不会浪费存储。

4.2 列族与版本控制

4.2.1 列族设计:少即是多

HBase 的列族是物理隔离的。每个列族有自己的 MemStore 和 HFile。设计多了,管理成本直线上升。

场景 推荐列族数 理由
在线特征服务 1~2 个 减少跨列族扫描,降低延迟
离线批量特征 2~3 个 区分高频和低频特征,优化 Compaction
混合负载 不超过 3 个 超过 3 个,RegionServer 内存压力剧增

我个人的习惯是:特征存储只用 1 个列族。所有特征列都放在里面。为什么?因为特征查询通常要拿全量特征,跨列族扫描会多一次 RPC,延迟受不了。

4.2.2 版本控制:保留多少历史?

HBase 默认保留 3 个版本。特征存储的场景下,这个值需要仔细掂量。

  • 实时特征:保留 1 个版本就够了。旧版本没意义,还占空间。
  • 时序特征:保留 3~7 个版本。方便回溯和调试。
  • 模型训练特征:保留全部版本,但配合 TTL 自动清理。

小技巧:版本数别设太大。我见过有人设 100 个版本,结果一次 Major Compaction 跑了 6 小时。建议版本数不超过 10,配合 TTL 一起用。

// 创建表时指定版本和TTL
create 'user_features', 
  {NAME => 'f', VERSIONS => 3, TTL => 604800}  // 7天过期

4.3 批量写入优化

特征存储的写入场景通常是高吞吐、低延迟。批量写入是必经之路。

4.3.1 BufferedMutator:批量写入的正确姿势

别用 Put 单条写入。那是给测试用的。生产环境必须用 BufferedMutator。

// 错误示范:逐条写入
Table table = connection.getTable(TableName.valueOf("user_features"));
for (Feature feature : features) {
    Put put = new Put(Bytes.toBytes(feature.getRowKey()));
    put.addColumn("f".getBytes(), "click".getBytes(), feature.getValue());
    table.put(put);  // 每次都是RPC
}

// 正确做法:批量提交
BufferedMutator mutator = connection.getBufferedMutator(
    TableName.valueOf("user_features"));
List<Put> puts = new ArrayList<>();
for (Feature feature : features) {
    Put put = new Put(Bytes.toBytes(feature.getRowKey()));
    put.addColumn("f".getBytes(), "click".getBytes(), feature.getValue());
    puts.add(put);
    if (puts.size() >= 1000) {  // 每1000条刷一次
        mutator.mutate(puts);
        puts.clear();
    }
}
if (!puts.isEmpty()) {
    mutator.mutate(puts);
}
mutator.close();

这里有个细节:缓冲区大小别设太大。我一般设 2MB 或 1000 条,哪个先到就刷。太大容易撑爆内存,太小又浪费吞吐。

4.3.2 写入参数调优

参数 默认值 推荐值 说明
hbase.client.write.buffer 2MB 4~8MB 增大缓冲区,减少RPC次数
hbase.client.max.total.tasks 100 200~500 提高并发写入能力
hbase.client.max.perregion.tasks 40 80~120 单Region并发控制

注意:调大这些参数时,要关注 RegionServer 的 GC 情况。我遇到过调太大导致 Full GC 频繁,写入反而变慢。建议逐步调优,观察监控曲线。

4.4 读取优化

4.4.1 批量 Get 代替单条 Get

特征服务通常要一次拿多个用户的特征。用单条 Get 逐个查,网络开销就占了 80%。

// 错误示范:循环单条查询
for (String userId : userIds) {
    Get get = new Get(Bytes.toBytes(userId));
    Result result = table.get(get);
    // 处理结果
}

// 正确做法:批量Get
List<Get> gets = new ArrayList<>();
for (String userId : userIds) {
    Get get = new Get(Bytes.toBytes(userId));
    get.addFamily("f".getBytes());  // 只取需要的列族
    gets.add(get);
}
Result[] results = table.get(gets);  // 一次RPC搞定

批量 Get 能减少 90% 以上的 RPC 次数。我做过压测,1000 条批量 Get 比单条快 8~10 倍。

4.4.2 Scan 的合理使用

特征存储里,Scan 主要用于离线批量读取。但要注意:Scan 不加限制会扫全表,那是灾难。

// 带条件的Scan
Scan scan = new Scan();
scan.setStartRow(Bytes.toBytes("hash_001_user_20240101"));
scan.setStopRow(Bytes.toBytes("hash_001_user_20240102"));
scan.setCaching(1000);  // 每次RPC返回1000条
scan.setBatch(100);     // 每100列返回一行
scan.addFamily("f".getBytes());

ResultScanner scanner = table.getScanner(scan);
for (Result result : scanner) {
    // 处理结果
}
scanner.close();

setCaching 和 setBatch 这两个参数很关键。Caching 控制行数,Batch 控制列数。我一般设 Caching=1000,Batch=100,这样既不会撑爆客户端内存,又能充分利用网络带宽。

4.4.3 缓存策略

特征读取的延迟敏感度很高。HBase 提供了两级缓存:BlockCache 和 MemStore。

  • BlockCache:读缓存,存 HFile 的 Block。适合热点特征。
  • MemStore:写缓存,但读也能命中。适合刚写入的特征。

我的经验:对于特征存储,建议把 BlockCache 设大一些。我一般给到 RegionServer 堆内存的 40%。热点特征基本都在缓存里,读延迟能控制在 5ms 以内。

4.5 本章核心知识体系

下面这张图总结了 HBase 作为特征存储的核心设计要点:

HBase 特征存储核心设计 RowKey 设计 • 哈希前缀 + 业务ID + 时间 • 长度控制在 16~64 字节 • 避免时间戳前缀热点 • 支持范围扫描 • 唯一性保证 列族与版本 • 列族不超过 3 个 • 特征存储推荐 1 个列族 • 版本数 1~10 个 • 配合 TTL 自动清理 • 避免过多版本导致 Compaction 慢 写入优化 • BufferedMutator 批量提交 • 缓冲区 2~8MB • 每 1000 条刷一次 • 调大 max.total.tasks • 监控 RegionServer GC 读取优化 • 批量 Get 代替单条 Get • Scan 设置 start/stop Row • Caching=1000, Batch=100 • 只取需要的列族 • 利用 BlockCache 缓存热点 常见避坑指南 ❌ 不要用时间戳前缀 → 导致写入热点 ❌ 不要设过多版本 → Compaction 变慢 ❌ 不要单条写入 → RPC 开销巨大 ❌ 不要 Scan 全表 → 必须加 start/stop ❌ 不要忽略 GC 监控 → 调参要逐步验证

说白了,HBase 做特征存储就是一场平衡游戏。RowKey 要散列,列族要精简,版本要克制,读写要批量。把这些点都做到位了,特征服务的延迟和吞吐才能同时达标。