7.4 实战案例:时序数据与用户画像 本节摘要:两个综合案例收束全册。时序场景(十万传感器、秒级上报)演示加盐 RowKey 与宽窄表的取舍,落点分析贯穿建表、写入、查询全流程;画像场景(亿级用户、标签高频更新)演示宽表与窄表的按需混用、标签版本与 TTL 治理。两个案例的每个设计决策都能在前六章找到机制依据。 案例一:十万传感器的时序存储 需求与四笔账 某物联网平台要存十万只传感器的温度上报:每只每 10 秒一条,单条净荷 200 字节。先按 1.1 节的方法算账—— 写入吞吐:十万除以 10 秒是每秒一万条,乘 200 字节约 2 MB 每秒,加放大系数,三台 RegionServer 绰绰有余。存储量:每天 86 亿条、约 1.
本节摘要:两个综合案例收束全册。时序场景(十万传感器、秒级上报)演示加盐 RowKey 与宽窄表的取舍,落点分析贯穿建表、写入、查询全流程;画像场景(亿级用户、标签高频更新)演示宽表与窄表的按需混用、标签版本与 TTL 治理。两个案例的每个设计决策都能在前六章找到机制依据。
某物联网平台要存十万只传感器的温度上报:每只每 10 秒一条,单条净荷 200 字节。先按 1.1 节的方法算账——
写入吞吐:十万除以 10 秒是每秒一万条,乘 200 字节约 2 MB 每秒,加放大系数,三台 RegionServer 绰绰有余。存储量:每天 86 亿条、约 1.7 TB 净数据(压缩后减半),按业务要求保留 90 天,稳态总量约 80 TB 落盘(含副本)。读模式是关键约束:大盘看某设备最近一小时曲线(高频)、运维看某机房全部设备当前值(中频)、月度报表做聚合(低频批处理)。三类查询全是"按设备加时间"的二维访问,这正是 HBase 行键算术最擅长的形状。
方案 A,行键直接用 设备号+时间戳:
dev00786_1724102400 dev00786_1724102410 dev00786_1724102420
同一设备的数据在行键空间连续,查"设备 786 最近一小时"是一次连续扫描,最舒服。但写入端有隐患:十万设备均匀分布,落点倒是分散的——时序场景的行键首段是设备号而非时间戳,热点来自设备号本身的倾斜(某机房一千只设备共用网关,前缀相同就会集中)。方案 A 可用,前提是设备号分布均匀。
方案 B,行键加盐:写入侧先算设备号哈希取桶,桶号当前缀:
public class TsRowKey { static final int BUCKETS = 16; static byte[] build(String deviceId, long ts) { int bucket = Math.abs(deviceId.hashCode() % BUCKETS); long reversed = Long.MAX_VALUE - ts; // 时间取反:新数据在键空间"向前"插入 return (String.format("%02d", bucket) + "_" + deviceId + "_" + reversed).getBytes(); } }
注意第三段用了时间戳取反(用最大值减):不减的话每个设备内部旧数据在前、新数据在后持续追加,最后一个 Region 永远是写入焦点;取反后新旧数据交替落在设备自身的区间内,配合设备号的哈希打散,单 Region 不再持续高压。代价也明确——查"设备 786 最近一小时"要先算出它的桶号 03,扫描区间变成 03_dev00786_ 一段,仍然是一次连续扫描(桶内有序);但"全部设备当前值"这类机房级查询要并发十六个前缀再拼结果。
方案 C,宽表:一行攒一个设备一小时的数据,行键 设备号+小时,列名用秒偏移:
行键 dev00786_1724102400 列族 t 列 0 值 25.5 (第 0 秒) 列族 t 列 10 值 25.6 (第 10 秒) ... 一行 360 列
十万设备乘每小时一行是每秒不到三行的 put,每行 360 列,MemStore 压力极小,StoreFile 里的 KeyValue 总数也骤减(一个 KeyValue 头只摊一次)。查询"设备最近一小时"退化成一次 Get,是最快的形态。代价:补写历史数据会触发整行重写(宽表不适合更新),列数无上限时单行过大也会撑 Region。
三案的选型结论:本项目写入均匀性有保障(设备号登记制),主存储选方案 B 加盐加时间取反兜住写入分布;大盘查询走方案 C 形态的小时级宽表(由写入端双写或流处理从主表聚合生成),两者互补。方案 A 留给设备数少且稳定的边缘机房。

建表与查询的关键代码(Shell 与 Java 各一段):
hbase:130:0> create 'ts_metric', {NAME => 't', TTL => 7776000, hbase:131:1* COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW'}, hbase:132:1* {SPLITS => ['03','06','09','0c','0f','12','15','18', hbase:133:1* '1b','1e','21','24','27','2a','2d']}
TTL 单位是秒,90 天即 7776000;SPLITS 按 16 个桶的十六进制边界预分区,建表即打散落点,避免新表单 Region 阶段全部写入挤一台机器(5.3 节的预分区策略在时序场景的直接应用)。
Scan scan = new Scan() .withStartRow(TsRowKey.build("dev00786", hourStart)) .withStopRow(TsRowKey.build("dev00786", hourEnd)) .addFamily(Bytes.toBytes("t")); scan.setTimeRange(hourStart, hourEnd); // 双保险:行键与版本时间戳同源 try (ResultScanner rs = table.getScanner(scan)) { for (Result r : rs) { // 逐点还原曲线 } }
落点视角回看:加盐决定写入的横向分布,时间取反决定纵向的新旧交替,预分区让分布从建表第一秒就生效,TTL 让数据生命周期与 Compaction 融合——四个决策全在回答"这行数据落在哪、待多久、怎么走"。月度报表的聚合不走在线集群,交给 Spark 扫描(6.3 节),把分析负载从毫秒级路径上隔离出去。
电商平台要做用户画像:注册用户两亿,标签分三类——基础属性(年龄、城市,低频变更)、行为统计(近 30 天购买次数、活跃时段,每天全量重算)、实时特征(最近一次浏览类目、购物车金额,分钟级更新)。在线消费方是推荐系统:给定用户号,毫秒级取出全部标签。离线消费方是分析平台:按标签圈人("一线城市、美妆高消费女性")。
单一表结构无法同时讨好两类消费。拆成两张:
主表画像宽表,行键就是用户号(取反尾数打散,5.1 节方案),列族按更新频率分两个——base 存基础属性(低频),rt 存实时特征(高频)。列族分离的用意是 5.2 节的机制:Compaction 与 Flush 都以 Store(列族)为单位,高频写的 rt 不带着低频的 base 反复重写。推荐系统一次 Get 拿全量,毫秒级达成。
圈人窄表,行键 标签名+标签值+用户号,只存指针。分析平台按标签前缀扫描即得人群。两表由计算任务维护一致性,重要标签加对账(6.1 节的协处理器在此场景反而要克制——写入方收敛在流处理一条链路上,应用层双写足够,没有挂 Observer 的必要)。
HappyBase 写画像的实战代码:
import happybase conn = happybase.Connection('vm1', port=9090) profile = conn.table('user_profile') def upsert(uid, base=None, rt=None): row = {} if base: # 低频列族 row.update({f'base:{k}': str(v) for k, v in base.items()}) if rt: # 高频列族 row.update({f'rt:{k}': str(v) for k, v in rt.items()}) profile.put(uid.encode(), row) upsert('10086', base={'city': 'hz', 'age': '28'}, rt={'last_cat': 'beauty', 'cart_amt': '399'}) # 推荐侧一次取全量 row = profile.row(b'10086') print(row[b'base:city'], row[b'rt:last_cat']) # b'hz' b'beauty'
行为统计类标签每天全量重算,直接覆盖即可,但有一个陷阱:覆盖写的新版本与旧版本在 Flush 前共存于 MemStore,Major Compaction 前共存于 StoreFile——如果列族 VERSIONS 设成默认 1,读路径永远只回最新版,没问题;但要审计"标签昨天是什么值"时,把 base 与 rt 以外的统计列族设 VERSIONS 为 7,白得一周的历史回看能力(1.2 节的版本语义直接复用)。代价是存储七倍于单版本,只在审计确有需求的列族上开。
TTL 同样参与治理:实时特征只关心最近状态,rt 列族 TTL 设 30 天足够;基础属性不设 TTL。TTL 与版本数都是列族级参数,这正是"按更新频率拆列族"的第二重红利。
两亿用户、行键取反打散、预分区 64 个 Region 起步(单 Region 目标 500 万行以内,Region 上限 10 GB),稳态每台 RegionServer 承载两三百 Region——回到 7.1 节的红线之内。上线前的压测脚本按真实用户号分布回放,重点盯单 Region 请求量方差:方差大说明用户号尾号本身有聚集(手机尾号 0 与 1 的占比偏高是真实存在的社会学现象),必要时加盐位数从一位提到两位。
⚠️ 常见坑:画像表上线半年后圈人任务越来越慢。根因往往是标签膨胀——业务不断加新标签,宽表列族里的死亡列(已下线标签)靠墓碑与 Major Compaction 才清得掉,而 Major Compaction 又被错峰策略压到每周一次。治理办法:标签生命周期登记,下线标签主动 delete 列并触发该表合并,别等它自然腐烂。
把两个案例的设计动作抽出来,是一张可复用的清单:
全册到此收束。回到第一页那个问题——put 进去的一行数据落在哪台机器?你现在不仅知道答案,还能设计它、观测它、迁移它、在它出事时救回来。这比背下 HBase 的所有参数更接近"学会"。