漫谈数据仓库之拉链表(原理、设计以及在Hive中的实现

本文将会谈一谈在数据仓库中拉链表相关的内容,包括它的原理、设计、以及在我们大数据场景下的实现方式。

全文由下面几个部分组成:

  1. 先分享一下拉链表的用途、什么是拉链表。
  2. 通过一些小的使用场景来对拉链表做近一步的阐释,以及拉链表和常用的切片表的区别。
  3. 举一个具体的应用场景,来设计并实现一份拉链表,最后并通过一些例子说明如何使用我们设计的这张表(因为现在Hive的大规模使用,我们会以Hive场景下的设计为例)。
  4. 分析一下拉链表的优缺点,并对前面的提到的一些内容进行补充说明,比如说拉链表和流水表的区别。

0x01 什么是拉链表

拉链表是针对数据仓库设计中表存储数据的方式而定义的,顾名思义,所谓拉链,就是记录历史。记录一个事物从开始,一直到当前状态的所有变化的信息。

我们先看一个示例,这就是一张拉链表,存储的是用户的最基本信息以及每条记录的生命周期。我们可以使用这张表拿到最新的当天的最新数据以及之前的历史数据。

我们暂且不对这张表做细致的讲解,后文会专门来阐述怎么来设计、实现和使用它。

拉链表的使用场景

在数据仓库的数据模型设计过程中,经常会遇到下面这种表的设计:

  1. 有一些表的数据量很大,比如一张用户表,大约10亿条记录,50个字段,这种表,即使使用ORC压缩,单张表的存储也会超过100G,在HDFS使用双备份或者三备份的话就更大一些。
  2. 表中的部分字段会被update更新操作,如用户联系方式,产品的描述信息,订单的状态等等。
  3. 需要查看某一个时间点或者时间段的历史快照信息,比如,查看某一个订单在历史某一个时间点的状态。
  4. 表中的记录变化的比例和频率不是很大,比如,总共有10亿的用户,每天新增和发生变化的有200万左右,变化的比例占的很小。

那么对于这种表我该如何设计呢?下面有几种方案可选:

  1. 方案一:每天只留最新的一份,比如我们每天用Sqoop抽取最新的一份全量数据到Hive中。
  2. 方案二:每天保留一份全量的切片数据。
  3. 方案三:使用拉链表。

为什么使用拉链表

现在我们对前面提到的三种进行逐个的分析。

方案一

这种方案就不用多说了,实现起来很简单,每天drop掉前一天的数据,重新抽一份最新的。

优点很明显,节省空间,一些普通的使用也很方便,不用在选择表的时候加一个时间分区什么的。

缺点同样明显,没有历史数据,要想翻旧账只能通过其它方式,比如从流水表里面抽。

方案二

每天一份全量的切片是一种比较稳妥的方案,而且历史数据也在。

缺点就是存储空间占用量太大太大了,如果对这边表每天都保留一份全量,那么每次全量中会保存很多不变的信息,对存储是极大的浪费,这点我感触还是很深的……

当然我们也可以做一些取舍,比如只保留近一个月的数据?但是,需求是无耻的,数据的生命周期不是我们能完全左右的。

拉链表

拉链表在使用上基本兼顾了我们的需求。

首先它在空间上做了一个取舍,虽说不像方案一那样占用量那么小,但是它每日的增量可能只有方案二的千分之一甚至是万分之一。

其实它能满足方案二所能满足的需求,既能获取最新的数据,也能添加筛选条件也获取历史的数据。

所以我们还是很有必要来使用拉链表的。

0x02 拉链表的设计和实现

如何设计一张拉链表

下面我们来举个栗子详细看一下拉链表。

我们先看一下在Mysql关系型数据库里的user表中信息变化。

在2017-01-01这一天表中的数据是:

在2017-01-02这一天表中的数据是, 用户002和004资料进行了修改,005是新增用户:

在2017-01-03这一天表中的数据是, 用户004和005资料进行了修改,006是新增用户:

如果在数据仓库中设计成历史拉链表保存该表,则会有下面这样一张表,这是最新一天(即2017-01-03)的数据:

说明

  • t_start_date表示该条记录的生命周期开始时间,t_end_date表示该条记录的生命周期结束时间。
  • t_end_date = ‘9999-12-31’表示该条记录目前处于有效状态。
  • 如果查询当前所有有效的记录,则select * from user where t_end_date = ‘9999-12-31’。
  • 如果查询2017-01-02的历史快照,则select from user where t_start_date <= ‘2017-01-02’ and t_end_date >= ‘2017-01-02’。(*where条件筛选当前有效数据,开始日期小于等于当前日期并且结束日期大于等于当前日期,则为有效*)

在Hive中实现拉链表

在现在的大数据场景下,大部分的公司都会选择以Hdfs和Hive为主的数据仓库架构。目前的Hdfs版本来讲,其文件系统中的文件是不能做改变的,也就是说Hive的表只能进行删除和添加操作,而不能进行update。基于这个前提,我们来实现拉链表。

还是以上面的用户表为例,我们要实现用户的拉链表。在实现它之前,我们需要先确定一下我们有哪些数据源可以用。

  1. 我们需要一张ODS层的用户全量表。至少需要用它来初始化。
  2. 每日的用户更新表。

而且我们要确定拉链表的时间粒度,比如说拉链表每天只取一个状态,也就是说如果一天有3个状态变更,我们只取最后一个状态,这种天粒度的表其实已经能解决大部分的问题了。

另外,补充一下每日的用户更新表该怎么获取,据笔者的经验,有以下方式拿到或者间接拿到每日的用户增量,因为它比较重要,所以详细说明:

  1. 我们可以监听Mysql数据的变化,比如说用Canal,最后合并每日的变化,获取到最后的一个状态。
  2. 假设我们每天都会获得一份切片数据,我们可以通过取两天切片数据的不同来作为每日更新表,这种情况下我们可以对所有的字段先进行concat,再取md5,这样就ok了。
  3. 流水表!有每日的变更流水表。
  4. 通过etl工具对操作型数据库按照时间字段增量抽取到ods或者数据仓库(每天抽取前一天的数据),形成每天的增量数据(实际中使用最多的情形)。

拉链表实现方式一:

ods层的user表

现在我们来看一下我们ods层的用户资料切片表的结构:

  1. CREATE EXTERNAL TABLE ods.user ( 
  2.   user_num STRING COMMENT '用户编号', 
  3.   mobile STRING COMMENT '手机号码', 
  4.   reg_date STRING COMMENT '注册日期' 
  5. COMMENT '用户资料表' 
  6. PARTITIONED BY (dt string) 
  7. ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n' 
  8. STORED AS ORC 
  9. LOCATION '/ods/user'; 

ods层的user_update表

然后我们还需要一张用户每日更新表,前面已经分析过该如果得到这张表,现在我们假设它已经存在。

  1. CREATE EXTERNAL TABLE ods.user_update ( 
  2.   user_num STRING COMMENT '用户编号', 
  3.   mobile STRING COMMENT '手机号码', 
  4.   reg_date STRING COMMENT '注册日期' 
  5. COMMENT '每日用户资料更新表' 
  6. PARTITIONED BY (dt string) 
  7. ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n' 
  8. STORED AS ORC 
  9. LOCATION '/ods/user_update'; 

拉链表

现在我们创建一张拉链表:

  1. CREATE EXTERNAL TABLE dws.user_his ( 
  2.   user_num STRING COMMENT '用户编号', 
  3.   mobile STRING COMMENT '手机号码', 
  4.   reg_date STRING COMMENT '用户编号', 
  5.   t_start_date , 
  6.   t_end_date 
  7. COMMENT '用户资料拉链表' 
  8. ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n' 
  9. STORED AS ORC 
  10. LOCATION '/dws/user_his'; 

实现sql语句

然后初始化的sql就不写了,其实就相当于是拿一天的ods层用户表过来就行,我们写一下每日的更新语句。

现在我们假设我们已经已经初始化了2017-01-01的日期,然后需要更新2017-01-02那一天的数据,我们有了下面的Sql。

然后把两个日期设置为变量就可以了。

  1. INSERT OVERWRITE TABLE dws.user_his 
  2. SELECT * FROM 
  3.     SELECT A.user_num, 
  4.            A.mobile, 
  5.            A.reg_date, 
  6.            A.t_start_time, 
  7.            CASE 
  8.                 WHEN A.t_end_time = '9999-12-31' AND B.user_num IS NOT NULL THEN '2017-01-01' 
  9.                 ELSE A.t_end_time 
  10.            END AS t_end_time 
  11.     FROM dws.user_his AS A 
  12.     LEFT JOIN ods.user_update AS B 
  13.     ON A.user_num = B.user_num 
  14. UNION 
  15.     SELECT C.user_num, 
  16.            C.mobile, 
  17.            C.reg_date, 
  18.            '2017-01-02' AS t_start_time, 
  19.            '9999-12-31' AS t_end_time 
  20.     FROM ods.user_update AS C 
  21. ) AS T 

拉链表实现方式二:

操作型数据库的用户表结构:

  1. CREATE EXTERNAL TABLE ods.user ( 
  2.   user_num STRING COMMENT '用户编号', 
  3.   mobile STRING COMMENT '手机号码', 
  4.   reg_date STRING COMMENT '注册日期' ,
  5.   last_modify_date STRING COMMENT '‘最后修改时间’ 
  6. COMMENT '用户资料表' 
  7. PARTITIONED BY (dt string) 
  8. ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n' 
  9. STORED AS ORC 
  10. LOCATION '/ods/user';

      这里我们假设ods.user表的业务主键为user_num+mobile作为联合主键。

每天增量抽取的用户表结构和抽取条件:

1)表结构和上面的表结构保持一致,我们取表名为ods.user_update

2)增量抽取条件:select * from ods.user where last_modify_date = '$date'

拉链表

现在我们创建一张拉链表:

  1. CREATE EXTERNAL TABLE dws.user_his ( 
  2.   user_num STRING COMMENT '用户编号', 
  3.   mobile STRING COMMENT '手机号码', 
  4.   reg_date STRING COMMENT '用户编号',
  5.   last_modify_date STRING COMMENT '‘最后修改时间’ 
  6.   t_start_date , 
  7.   t_end_date 
  8. COMMENT '用户资料拉链表' 
  9. ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n' 
  10. STORED AS ORC 
  11. LOCATION '/dws/user_his'; 

实现sql

1)

merge into dws.user_his tar 

using

(

  select user_num,mobile from ods.user_update 

) sou on tar.user_num=sou.user_num and tar.mobile=sou.mobile and tar.t_start_date < '$date' and tar.t_end_date >  '$date' 

when matched then 

update set tar.t_end_date='9999-12-31'

按照主键筛选,在dws.user_his表中出现过的并且现在为有效数据的,全部更新为闭链数据。

2)

  1. INSERT  TABLE dws.user_his 
  2. SELECT 
  3.   C.user_num, 
  4.         C.mobile, 
  5.         C.reg_date, 
  6.          c.last_modify_date
  7.          '2017-01-02' AS t_start_time, 
  8.          '9999-12-31' AS t_end_time 
  9.   FROM ods.user_update AS C 

比如我们要1月2号的数据,取出来的数据为

select from user where t_start_date <= ‘2017-01-02’ and t_end_date >= ‘2017-01-02’

与1月2号数据完全一致。 

0x03 补充

好了,我们分析了拉链表的原理、设计思路、并且在Hive环境下实现了一份拉链表,下面对拉链表做一些小的补充。

拉链表和流水表

流水表存放的是一个用户的变更记录,比如在一张流水表中,一天的数据中,会存放一个用户的每条修改记录,但是在拉链表中只有一条记录。

这是拉链表设计时需要注意的一个粒度问题。我们当然也可以设置的粒度更小一些,一般按天就足够。

查询性能

拉链表当然也会遇到查询性能的问题,比如说我们存放了5年的拉链数据,那么这张表势必会比较大,当查询的时候性能就比较低了,个人认为两个思路来解决:

  1. 在一些查询引擎中,我们对start_date和end_date做索引,这样能提高不少性能。
  2. 保留部分历史数据,比如说我们一张表里面存放全量的拉链表数据,然后再对外暴露一张只提供近3个月数据的拉链表。

4 拉链表回滚

  4.1 具体操作方案

    假设恢复到t天之前的数据,即未融合t天数据之前的拉链表,假设标记的开始日期和结束日期分别为s、t,具体分析如下:

1 当t-1>e时,s数据、e数据在t天之前产生,保留即可
2 当t-1=e时,e数据在t天产生,需修改
3 当s<t<=e时,e数据在t+n天产生,需修改
4 当s>=t时,s数据、e数据在t+n天产生,删除即可

    具体例子:

spark-sql> select * from t_dw_orders_his order by orderid,dw_start_date;
1       2015-08-18      2015-08-18      创建    2015-08-18      2015-08-21
1       2015-08-18      2015-08-22      支付    2015-08-22      2015-08-22
1       2015-08-18      2015-08-23      完成    2015-08-23      9999-12-31
2       2015-08-18      2015-08-18      创建    2015-08-18      2015-08-21
2       2015-08-18      2015-08-22      完成    2015-08-22      9999-12-31
3       2015-08-19      2015-08-21      支付    2015-08-19      2015-08-20
3       2015-08-19      2015-08-21      支付    2015-08-21      2015-08-22
3       2015-08-19      2015-08-23      完成    2015-08-23      9999-12-31
4       2015-08-19      2015-08-21      完成    2015-08-19      2015-08-20
4       2015-08-19      2015-08-21      完成    2015-08-21      9999-12-31
5       2015-08-19      2015-08-20      支付    2015-08-19      2015-08-22
5       2015-08-19      2015-08-23      完成    2015-08-23      9999-12-31
6       2015-08-20      2015-08-20      创建    2015-08-20      2015-08-21
6       2015-08-20      2015-08-22      支付    2015-08-22      9999-12-31
7       2015-08-20      2015-08-21      支付    2015-08-20      2015-08-20
7       2015-08-20      2015-08-21      支付    2015-08-21      9999-12-31
8       2015-08-21      2015-08-21      创建    2015-08-21      2015-08-21
8       2015-08-21      2015-08-22      支付    2015-08-22      2015-08-22
8       2015-08-21      2015-08-23      完成    2015-08-23      9999-12-31
9       2015-08-22      2015-08-22      创建    2015-08-22      9999-12-31
10      2015-08-22      2015-08-22      支付    2015-08-22      9999-12-31
11      2015-08-23      2015-08-23      创建    2015-08-23      9999-12-31
12      2015-08-23      2015-08-23      创建    2015-08-23      9999-12-31
13      2015-08-23      2015-08-23      支付    2015-08-23      9999-12-31 

    比如在插入2015-08-23的数据后,回滚2015-08-22的数据,使拉链表与2015-08-21的一致,具体操作过程如下

1 增加临时表t_dw_orders_his_tmp1,用来记录t-1>e的数据
CREATE TABLE t_dw_orders_his_tmp1
AS
SELECT 
  orderid,
  createtime,
  modifiedtime,
  status,
  dw_start_date,
  dw_end_date
FROM 
  t_dw_orders_his
WHERE 
  dw_end_date < '2015-08-21'
3       2015-08-19      2015-08-21      支付    2015-08-19      2015-08-20
4       2015-08-19      2015-08-21      完成    2015-08-19      2015-08-20
7       2015-08-20      2015-08-21      支付    2015-08-20      2015-08-20
 

2 增加临时表t_dw_orders_his_tmp2,用来记录t-1=e的数据 
CREATE TABLE t_dw_orders_his_tmp2 
AS 
SELECT   
  orderid,
  createtime,   
  modifiedtime,   
  status,   
  dw_start_date,   
  '9999-12-31' AS dw_end_date 
FROM 
  t_dw_orders_his
WHERE 
  dw_end_date = '2015-08-21'
1       2015-08-18      2015-08-18      创建    2015-08-18      9999-12-31
2       2015-08-18      2015-08-18      创建    2015-08-18      9999-12-31
6       2015-08-20      2015-08-20      创建    2015-08-20      9999-12-31
8       2015-08-21      2015-08-21      创建    2015-08-21      9999-12-31
 
3 增加临时表t_dw_orders_his_tmp3,用来记录s<t<=e的数据
CREATE TABLE t_dw_orders_his_tmp3
AS
SELECT 
  orderid,
  createtime,
  modifiedtime,
  status,
  dw_start_date,
  '9999-12-31' dw_end_date
FROM 
  t_dw_orders_his
WHERE 
  dw_start_date < '2015-08-22' AND dw_end_date >= '2015-08-22'
3       2015-08-19      2015-08-21      支付    2015-08-21      9999-12-31
4       2015-08-19      2015-08-21      完成    2015-08-21      9999-12-31
5       2015-08-19      2015-08-20      支付    2015-08-19      9999-12-31
7       2015-08-20      2015-08-21      支付    2015-08-21      9999-12-31
4 所有数据插入新表t_dw_orders_his_new
CREATE TABLE t_dw_orders_his_new
AS
SELECT * FROM t_dw_orders_his_tmp1
UNION ALL
SELECT * FROM t_dw_orders_his_tmp2
UNION ALL
SELECT * FROM t_dw_orders_his_tmp3
1       2015-08-18      2015-08-18      创建    2015-08-18      9999-12-31
2       2015-08-18      2015-08-18      创建    2015-08-18      9999-12-31
3       2015-08-19      2015-08-21      支付    2015-08-19      2015-08-20
3       2015-08-19      2015-08-21      支付    2015-08-21      9999-12-31
4       2015-08-19      2015-08-21      完成    2015-08-19      2015-08-20
4       2015-08-19      2015-08-21      完成    2015-08-21      9999-12-31
5       2015-08-19      2015-08-20      支付    2015-08-19      9999-12-31 
6       2015-08-20      2015-08-20      创建    2015-08-20      9999-12-31
7       2015-08-20      2015-08-21      支付    2015-08-20      2015-08-20
7       2015-08-20      2015-08-21      支付    2015-08-21      9999-12-31
8       2015-08-21      2015-08-21      创建    2015-08-21      9999-12-31

与原数据一致,验证无错

  4.2 备用方案

    可以采用备份的方案,保证无误和可行。(保存增量数据,并对t_dw_orders_his表每个月备份一次全量数据。如需回滚,最多重跑30天数据即可)

0xFF 总结

我们在这篇文章里面详细地分享了一下和拉链表相关的知识点,但是仍然会有一会遗漏。欢迎交流。

在后面的使用中又有了一些心得,补充进来:

  1. 使用拉链表的时候可以不加t_end_date,即失效日期,但是加上之后,能优化很多查询。
  2. 可以加上当前行状态标识,能快速定位到当前状态。
  3. 在拉链表的设计中可以加一些内容,因为我们每天保存一个状态,如果我们在这个状态里面加一个字段,比如如当天修改次数,那么拉链表的作用就会更大。
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐