业务中有一个常见的场景,需要复原某个当时的状态,比如用户在T时刻成为了会员,业务需要分析在前后的行为变化。
常见的解决方案
1. 状态还原计算
找到用户对应的行为发生时间再按行为时间偏移分组,例如,行为发生时间和成为会员的时间相减,按正负分组。这样做也有一定的局限:
a. 需要针对每个状态,设计不同的计算逻辑
例如:会员状态和用户打卡状态分别要查询会员表和打卡日志表
b. 同时需要判断的状态不止一个时,关联查询的表非常多
2. 拉链表方案
理想的拉链表,应该是这样的横表:
用户ID | 日期 | 会员状态 | 打卡状态 | 登录城市 |
123 | 2021年12月1日 | 非会员 | 已打卡 | 北京 |
123 | 2021年12月2日 | 非会员 | 已打卡 | 北京 |
123 | 2021年12月4日 | 非会员 | 已打卡 | 北京 |
123 | 2021年12月9日 | 会员 | 未打卡 | 北京 |
123 | 2021年12月10日 | 非会员 | 未打卡 | 北京 |
但通常为了SQL存储引擎的需要,我们会设计成竖表,将每次状态发生变化归集起来形成一条条记录。
业务 | ID | 状态 | 发生时间 |
会员 | 123 | 成为会员 | 2021年12月9日 |
会员 | 123 | 会员过期 | 2021年12月10日 |
打卡 | 123 | 1 | 2021年12月1日 |
打卡 | 123 | 1 | 2021年12月2日 |
打卡 | 123 | 1 | 2021年12月4日 |
取满足时间条件的最后一个状态:
# 某个人在某个时间是什么状态SELECT stateFROM statesWHERE type='user'and id=123and occur_time<'2021-12-10'LIMIT 1
使用示例:
# 查询有登录行为的人的会员状态,以及当时这个人历史累计打卡的次数selectuser_id,action_time,(select state from states where type='vip' and id=user_id and occur_time<action_time limit 1) vip_state,(select sum(state) from states where type='join_activity' and id=user_id and occur_time<action_time) join_timesfrom action_log
这个方案很好地是解决了查询时应对不同业务逻辑,设计复杂的问题,但它还有比较麻烦的问题需要解决:
状态维护变化很繁琐
如果是系统内的状态变化,可以通过接收各个业务系统触发的状态更新通知的办法解决,如果是外部的状态,就比较麻烦。
例如:登录的地址信息
我们需要清洗登录日志的时候,记录用户当前的城市到拉链表,供后续业务分析使用。但这马上就引发了一个问题:
a. 重复的状态会导致查询效率降低
业务 | ID | 状态 | 发生时间 |
登录城市 | 123 | 北京 | 2021年12月9日 |
登录城市 | 123 | 北京 | 2021年12月10日 |
显然,这样的数据对于我们分析没有作用,我们只关注状态变化的情况。
那么很自然的想到,我们需要在存储数据时,要有维护状态的动作。
update states set state=new_state where ... and state != new_state
那是不是可以把所有的状态维护都改成一个统一的update过程?
答案显然是不行,上面例子中,业务想统计截止到某个时间点,连续打卡的次数。这些重复的状态值又有必要的。这样就导致了另外一个问题:
b. 不同业务对于状态的维护逻辑不一致
还有一种特殊的状态,是带有过期时间的状态,例如上面例子中的会员状态,就是这么一个状态,通常过期状态是依靠的一个外部调度系统来实现的,那这就加大了维护状态工作的复杂度
c. 有带过期时间的状态
HBase版的解决思路
HBase的特点
逻辑视图[1]:
Row Key | Time Stamp | ColumnFamily contents | ColumnFamily anchor |
"com.cnn.www" | t9 | anchor:cnnsi.com = "CNN" | |
"com.cnn.www" | t8 | anchor:my.look.ca = "CNN.com" | |
"com.cnn.www" | t6 | contents:html = "<html>…" | |
"com.cnn.www" | t5 | contents:html = "<html>…" | |
"com.cnn.www" | t3 | contents:html = "<html>…" | |
"com.example.www" | t5 | contents:html = "<html>…" |
物理视图:
Row Key | Time Stamp | Column Family anchor |
"com.cnn.www" | t9 | anchor:cnnsi.com = "CNN" |
"com.cnn.www" | t8 | anchor:my.look.ca = "CNN.com" |
对比发现,我们需要的就是按行输入,按大宽表获取,选型HBase非常适合。
同时,HBase出色的get by rowkey的性能,允许提供给OLTP服务使用。而查询状态的需求,通常是先有ID,再去找状态,而不是反过来,这就很好地利用了HBase的优势,避免了它的劣势。
HBase 探索
# 创建一个版本范围非常大的拉链表,其中'state'为column familycreate 'states',{NAME=>'state', VERSIONS=>2147483647}
场景:会员状态维护
业务 | ID | 状态 | 发生时间 |
会员 | 123 | 成为会员 | 2021年12月9日 |
会员 | 123 | 会员过期 | 2021年12月10日 |
实现:
# put 't1', 'r1', 'c1', 'value', ts1# 时间戳代表标签更新时间put 'states', '123', 'state:vip', 'grant', 1639029600000put 'states', '123', 'state:vip', 'revoke', 1639116000000
场景:打卡状态维护
业务 | ID | 状态 | 发生时间 |
打卡 | 123 | 1 | 2021年12月1日 |
打卡 | 123 | 1 | 2021年12月2日 |
打卡 | 123 | 1 | 2021年12月4日 |
实现:
# 时间戳代表打卡时间,value是长整型值1# 2021年12月1日时执行:incr 'states', '123', 'state:join_activity', 1# 等价于put 'states', '123', 'state:join_activity', "\x00\x00\x00\x00\x00\x00\x00\x01", 1638338400000# 2021年12月2日时执行:incr 'states', '123', 'state:join_activity', 1# 等价于put 'states', '123', 'state:join_activity', "\x00\x00\x00\x00\x00\x00\x00\x02", 1638424800000# 2021年12月4日时执行:incr 'states', '123', 'state:join_activity', 1# 等价于put 'states', '123', 'state:join_activity', "\x00\x00\x00\x00\x00\x00\x00\x03", 1638597600000# get_counter 't1', 'r1', 'c1'# 累计到当前的打卡次数get_counter 'states', '123', 'state:join_activity'
场景:登录状态的维护
业务 | ID | 状态 | 发生时间 |
登录城市 | 123 | 北京 | 2021年12月1日 |
登录城市 | 123 | 北京 | 2021年12月2日 |
登录城市 | 123 | 北京 | 2021年12月4日 |
登录城市 | 123 | 北京 | 2021年12月9日 |
登录城市 | 123 | 北京 | 2021年12月10日 |
实现:
# 时间戳代表登录时间put 'states', '123', 'state:city', 'BEIJING', 1638338400000put 'states', '123', 'state:city', 'BEIJING', 1638424800000put 'states', '123', 'state:city', 'BEIJING', 1638597600000put 'states', '123', 'state:city', 'BEIJING', 1639029600000put 'states', '123', 'state:city', 'BEIJING', 1639116000000
查询:
# get 't1', 'r1', {TIMERANGE => [ts1, ts2]}# 查询2021年12月1日,当天产生的状态变化get 'states', '123', {TIMERANGE=>[1638338400000, 1638424800000]}COLUMN CELLstate:city timestamp=1638338400000, value=BEIJINGstate:join_activity timestamp=1638338400000, value=\x00\x00\x00\x00\x00\x00\x00\x01# 查询2021年12月4日,当天产生的状态变化get 'states', '123', {TIMERANGE=>[1638597600000, 1638684000000]}COLUMN CELLstate:city timestamp=1638597600000, value=BEIJINGstate:join_activity timestamp=1638597600000, value=\x00\x00\x00\x00\x00\x00\x00\x03# 查询2021年12月9日,当天产生的状态变化get 'states', '123', {TIMERANGE=>[1639029600000, 1639116000000]}COLUMN CELLstate:city timestamp=1639029600000, value=BEIJINGstate:vip timestamp=1639029600000, value=grant# 查询2021年12月10日,当天产生的状态变化get 'states', '123', {TIMERANGE=>[1639116000000, 1639202400000]}COLUMN CELLstate:city timestamp=1639116000000, value=BEIJINGstate:vip timestamp=1639116000000, value=revoke
场景:最后一次的状态
上面示例中,time range筛选的是当天变更的状态,但通常业务是想知道,在当时点上,最后一次的状态。
比如,截止到2021年12月10日,总的打卡累计次数,在2021年12月4日更新的最后一次打卡记录应该显示。
# 查询截止到2021年12月10日,最后一次状态情况get 'states', '123', {TIMERANGE=>[0, 1639202400000]}COLUMN CELLstate:city timestamp=1639116000000, value=BEIJINGstate:join_activity timestamp=1638597600000, value=\x00\x00\x00\x00\x00\x00\x00\x03state:vip timestamp=1639116000000, value=revoke
在HBase中,只要更改时间范围区间就可以实现,这个非常方便。对比通常按时间分区的日志存储设计,汇总一个未知的时间区段内行为的数量,几乎没有查询成本。
场景:最近一段周期的状态
业务有时会有最近一段周期的状态需求,例如,分析订单时间前30天内打卡的数量
# 获取2021年12月2日到2021年12月4日,这三天的打卡数量,按照上面的例子,应该是2get 'states', '123', {COLUMN=>'state:join_activity', TIMERANGE=>[1638424800000, 1638770400000], VERSIONS=>10000}COLUMN CELLstate:join_activity timestamp=1638597600000, value=\x00\x00\x00\x00\x00\x00\x00\x03state:join_activity timestamp=1638424800000, value=\x00\x00\x00\x00\x00\x00\x00\x02
通过版本字段,我们可以获知在这个周期内打卡的更新历史,这里总共有2条打卡记录,也就是打卡了2次
场景:带过期时间的状态
我们已经注意到,使用时间周期范围筛选的时候,指定了截止时间为今天最大的时间戳,查询的结果就能够框定在今天内的时间的有效状态,“未来”的状态不会考虑。
那么,取消的会员状态就不必要等到调度去更新和维护,例如,在上面的例子中,我们在2021年12月9日就可以同时插入两条记录,等到10号一到,最后的状态就是“失效”,非常适合业务的场景。
如果在没有过期时续买会员,需要延期会员状态,只需要和数据库一样,同步更新“未来”的过期时间就好。
业务 | ID | 状态 | 发生时间 |
会员 | 123 | 会员续费 | 2021年12月9日 |
会员 | 123 | 会员过期 | 2021年12月11日 |
# 上一个失效时间是2021年12月10日get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639116000000, 1639202400000]}COLUMN CELLstate:vip timestamp=1639116000000, value=revoke# 2021年12月9日时续费,这时时间戳不能是更新时间2021年12月9日,而是上一次的失效时间2021年12月10日,时间戳一致才能覆盖put 'states', '123', 'state:vip', 'grant', 1639116000000put 'states', '123', 'state:vip', 'revoke', 1639202400000# 状态变更get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639116000000, 1639202400000]}COLUMN CELLstate:vip timestamp=1639116000000, value=grant
场景:改进"是否"类状态的存储
我们已经注意到,会员的状态只有两种,那我们可以换个更合适的存储方式,扩充字段的含义
# 清除已有的字段delete 'states', '123', 'state:vip'flush 'states'major_compact 'states'# value为会员第一次生效的时间:2021年12月1日,为0时代表取消;timestamp为会员生效的时间:2021年12月9日put 'states', '123', 'state:vip', 1639116000000, 1639029600000put 'states', '123', 'state:vip', 0, 1639116000000get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639029600000, 1639116000000]}COLUMN CELLstate:vip timestamp=1639029600000, value=1639116000000get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639116000000, 1639202400000]}COLUMN CELLstate:vip timestamp=1639116000000, value=0
获取的值就是上次会员状态开始的时间,用来计算会员累计时长非常有用
场景:改进重复值状态存储
像登录城市这种状态,只需要记录有变化的部分,没有的不需要更新
# 截止到2021年12月10日,所有的登录城市记录get 'states', '123', {COLUMN=>'state:city', TIMERANGE=>[0, 1639202400000], VERSIONS=>10000}COLUMN CELLstate:city timestamp=1639116000000, value=BEIJINGstate:city timestamp=1639029600000, value=BEIJINGstate:city timestamp=1638597600000, value=BEIJINGstate:city timestamp=1638424800000, value=BEIJINGstate:city timestamp=1638338400000, value=BEIJING
一般的想法是,分作两步,在客户端检查判断+写入。这样在极端的情况下,可能导致写入脏数据的发生。
理想的情况,有一个原子性的操作符是最好的,这个操作HBase Client已经支持。
业务 | ID | 状态 | 发生时间 |
登录城市 | 123 | 北京 | 2021年12月11日 |
登录城市 | 123 | 北京 | 2021年12月12日 |
登录城市 | 123 | 上海 | 2021年12月13日 |
public void testPutCity() throws Exception {cityPut(d2021_12_11.getTime(), "BEIJING");cityPut(d2021_12_12.getTime(), "BEIJING");cityPut(d2021_12_13.getTime(), "SHANGHAI");}private void cityPut(long time, String city) throws Exception {// 城市状态维护Put cityPut = new Put(row);cityPut.addColumn(Bytes.toBytes("state"), Bytes.toBytes("city"), time, Bytes.toBytes(city));// 检查不符合才操作table.checkAndPut(row, Bytes.toBytes("state"), Bytes.toBytes("city"),CompareFilter.CompareOp.NOT_EQUAL, Bytes.toBytes(city), cityPut);}
# 截止到2021年12月14日,所有的登录城市记录get 'states', '123', {COLUMN=>'state:city', TIMERANGE=>[0, 1639461600000], VERSIONS=>10000}COLUMN CELLstate:city timestamp=1639324800000, value=SHANGHAIstate:city timestamp=1639116000000, value=BEIJINGstate:city timestamp=1639029600000, value=BEIJINGstate:city timestamp=1638597600000, value=BEIJINGstate:city timestamp=1638424800000, value=BEIJINGstate:city timestamp=1638338400000, value=BEIJING
可以看到,重复的状态存储已经被正确的忽略。
本实验是用户画像属性标签设计的扩充,相信可以很好地处理其它有类似拉链表需求。
[1] https://hbase.apache.org/book.html#datamodel




