暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

HBase实现拉链表

码林杂俎 2021-12-10
255

业务中有一个常见的场景,需要复原某个当时的状态,比如用户在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 state
    FROM states
    WHERE type='user'
    and id=123
    and occur_time<'2021-12-10'
    LIMIT 1

    使用示例:

      # 查询有登录行为的人的会员状态,以及当时这个人历史累计打卡的次数
      select
      user_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_times
      from 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 family
          create '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', 1639029600000
            put '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', 1638338400000
                put 'states', '123', 'state:city', 'BEIJING', 1638424800000
                put 'states', '123', 'state:city', 'BEIJING', 1638597600000
                put 'states', '123', 'state:city', 'BEIJING', 1639029600000
                put 'states', '123', 'state:city', 'BEIJING', 1639116000000

                查询:

                  # get 't1', 'r1', {TIMERANGE => [ts1, ts2]}


                  # 查询2021年12月1日,当天产生的状态变化
                  get 'states', '123', {TIMERANGE=>[1638338400000, 1638424800000]}
                  COLUMN CELL
                  state:city timestamp=1638338400000, value=BEIJING
                  state:join_activity timestamp=1638338400000, value=\x00\x00\x00\x00\x00\x00\x00\x01


                  # 查询2021年12月4日,当天产生的状态变化
                  get 'states', '123', {TIMERANGE=>[1638597600000, 1638684000000]}
                  COLUMN CELL
                  state:city timestamp=1638597600000, value=BEIJING
                  state:join_activity timestamp=1638597600000, value=\x00\x00\x00\x00\x00\x00\x00\x03

                  # 查询2021年12月9日,当天产生的状态变化
                  get 'states', '123', {TIMERANGE=>[1639029600000, 1639116000000]}
                  COLUMN CELL
                  state:city timestamp=1639029600000, value=BEIJING
                  state:vip timestamp=1639029600000, value=grant

                  # 查询2021年12月10日,当天产生的状态变化
                  get 'states', '123', {TIMERANGE=>[1639116000000, 1639202400000]}
                  COLUMN CELL
                  state:city timestamp=1639116000000, value=BEIJING
                  state:vip timestamp=1639116000000, value=revoke


                  场景:最后一次的状态

                  上面示例中,time range筛选的是当天变更的状态,但通常业务是想知道,在当时点上,最后一次的状态。

                  比如,截止到2021年12月10日,总的打卡累计次数,在2021年12月4日更新的最后一次打卡记录应该显示。

                    # 查询截止到2021年12月10日,最后一次状态情况
                    get 'states', '123', {TIMERANGE=>[0, 1639202400000]}
                    COLUMN CELL
                    state:city timestamp=1639116000000, value=BEIJING
                    state:join_activity timestamp=1638597600000, value=\x00\x00\x00\x00\x00\x00\x00\x03
                    state:vip timestamp=1639116000000, value=revoke

                    在HBase中,只要更改时间范围区间就可以实现,这个非常方便。对比通常按时间分区的日志存储设计,汇总一个未知的时间区段内行为的数量,几乎没有查询成本。

                    场景:最近一段周期的状态

                    业务有时会有最近一段周期的状态需求,例如,分析订单时间前30天内打卡的数量

                      # 获取2021122日到2021124日,这三天的打卡数量,按照上面的例子,应该是2
                      get 'states', '123', {COLUMN=>'state:join_activity', TIMERANGE=>[1638424800000, 1638770400000], VERSIONS=>10000}
                      COLUMN CELL
                      state:join_activity timestamp=1638597600000, value=\x00\x00\x00\x00\x00\x00\x00\x03
                      state: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日

                        # 上一个失效时间是20211210
                        get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639116000000, 1639202400000]}
                        COLUMN CELL
                        state:vip timestamp=1639116000000, value=revoke


                        # 2021129日时续费,这时时间戳不能是更新时间2021129日,而是上一次的失效时间20211210日,时间戳一致才能覆盖
                        put 'states', '123', 'state:vip', 'grant', 1639116000000
                        put 'states', '123', 'state:vip', 'revoke', 1639202400000


                        # 状态变更
                        get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639116000000, 1639202400000]}
                        COLUMN CELL
                        state: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, 1639029600000
                          put 'states', '123', 'state:vip', 0, 1639116000000
                          get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639029600000, 1639116000000]}
                          COLUMN CELL
                          state:vip timestamp=1639029600000, value=1639116000000


                          get 'states', '123', {COLUMN=>'state:vip', TIMERANGE=>[1639116000000, 1639202400000]}
                          COLUMN CELL
                          state:vip timestamp=1639116000000, value=0

                          获取的值就是上次会员状态开始的时间,用来计算会员累计时长非常有用

                          场景:改进重复值状态存储

                          像登录城市这种状态,只需要记录有变化的部分,没有的不需要更新

                            # 截止到2021年12月10日,所有的登录城市记录
                            get 'states', '123', {COLUMN=>'state:city', TIMERANGE=>[0, 1639202400000], VERSIONS=>10000}
                            COLUMN CELL
                            state:city timestamp=1639116000000, value=BEIJING
                            state:city timestamp=1639029600000, value=BEIJING
                            state:city timestamp=1638597600000, value=BEIJING
                            state:city timestamp=1638424800000, value=BEIJING
                            state: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 CELL
                                state:city timestamp=1639324800000, value=SHANGHAI
                                state:city timestamp=1639116000000, value=BEIJING
                                state:city timestamp=1639029600000, value=BEIJING
                                state:city timestamp=1638597600000, value=BEIJING
                                state:city timestamp=1638424800000, value=BEIJING
                                state:city timestamp=1638338400000, value=BEIJING

                                可以看到,重复的状态存储已经被正确的忽略。


                                本实验是用户画像属性标签设计的扩充,相信可以很好地处理其它有类似拉链表需求。


                                [1] https://hbase.apache.org/book.html#datamodel


                                文章转载自码林杂俎,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                评论