Greenplum 实时数据仓库实践——维度表技术

Posted wzy0623

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Greenplum 实时数据仓库实践——维度表技术相关的知识,希望对你有一定的参考价值。

目录

7.1 增加列

7.2 维度子集

7.3 角色扮演维度

7.4 层次维度

7.4.1 固定深度的层次

7.4.2 多路径层次

7.4.3 参差不齐的层次

7.5 退化维度

7.6 杂项维度

7.7 维度合并

7.8 分段维度

小结


        前面章节中,我们实现了实时多维数据仓库的基本功能,如使用Canal和Kafka实现实时数据同步,定义Greenplum rule执行实时数据装载逻辑等。本篇将继续讨论常见的维度表技术。

        我们以最简单的“增加列”开始,继而讨论维度子集、角色扮演维度、层次维度、退化维度、杂项维度、维度合并、分段维度等基本的维度表技术。这些技术都是在实际应用中经常使用的。在说明这些技术的相关概念和使用场景后,我们以销售订单数据仓库为例,给出实现代码和测试过程,必要时会对前面已经完成的配置和脚本做出适当修改。

7.1 增加列

        业务的扩展或变化是不可避免的,尤其像互联网行业,需求变更已经成为常态,唯一不变的就是变化本身,其中最常碰到的扩展是给一个已经存在的表曾加列。以销售订单为例,假设因为业务需要,在操作型源系统的客户表中增加了送货地址的四个字段,并在销售订单表中增加了销售数量字段。由于数据源表增加了字段,数据仓库中的表也要随之修改。本节说明如何在客户维度表和销售订单事实表上添加列,并在新列上应用SCD2,以及对消费配置和rule定义所做的修改。图7-1显示了增加列后的数据仓库模式。

图7-1 增加列后的数据仓库模式

 

1. 停止Canal Server、Canal Adapter

        在实时应用场景中,执行任何DDL语句前,最好先停止消息队列两端的生产者和消费者,以避免可能的数据同步错误。此时不能再像初始装载时那样停止mysql复制,因为主库的修改需要实时同步到从库。

# 停止Canal Server,构成Canal HA的126、127两台都执行
~/canal_113/deployer/bin/stop.sh
# 停止Canal Adapter,126执行
~/canal_113/adapter/bin/stop.sh

2. 修改表结构
        我们需要在已经存在的表上增加列。

(1)修改源数据库表结构
        使用下面的SQL语句修改MySQL中的源数据库模式。

-- 126主库执行
use source;  
alter table customer  
  add shipping_address varchar(50) after customer_state  
, add shipping_zip_code int after shipping_address  
, add shipping_city varchar(30) after shipping_zip_code  
, add shipping_state varchar(2) after shipping_city ;  
alter table sales_order add order_quantity int after order_amount ;

        以上语句给客户表增加了四列,表示客户的送货地址。销售订单表在销售金额列后面增加了销售数量列。after关键字是MySQL对标准SQL的扩展,Greenplum不支持这种扩展,只能把新增列加到已有列的后面。在关系理论中,列是没有顺序的。

(2)修改目标数据库表结构

-- 修改RDS数据库模式里的表
set search_path to rds;
alter table customer 
add column shipping_address varchar(30),
add column shipping_zip_code int, 
add column shipping_city varchar(30), 
add column shipping_state varchar(2);  
alter table sales_order add order_quantity int;  

-- 修改TDS数据库模式里的表
set search_path to tds, rds;
alter table customer_dim
add column shipping_address varchar(30),
add column shipping_zip_code int, 
add column shipping_city varchar(30), 
add column shipping_state varchar(2);  
alter table sales_order_fact add order_quantity int;

3. 修改Canal Adapter表映射
        在customer.yml和sales_order.yml文件中添加新增字段的映射。

[mysql@node2~/canal_113/adapter/conf/rdb]$cat ~/canal_113/adapter/conf/rdb/customer.yml 
dataSourceKey: defaultDS
destination: example
groupId: g1
outerAdapterKey: Greenplum
concurrent: true
dbMapping:
  database: source
  table: customer
  targetTable: rds.customer
  targetPk:
    customer_number: customer_number
#  mapAll: true
  targetColumns:
    customer_number: customer_number
    customer_name: customer_name
    customer_street_address: customer_street_address
    customer_zip_code: customer_zip_code
    customer_city: customer_city
    customer_state: customer_state
    shipping_address: shipping_address
    shipping_zip_code: shipping_zip_code
    shipping_city: shipping_city
    shipping_state: shipping_state
  commitBatch: 30000 # 批量提交的大小

[mysql@node2~/canal_113/adapter/conf/rdb]$cat ~/canal_113/adapter/conf/rdb/sales_order.yml 
dataSourceKey: defaultDS
destination: example
groupId: g1
outerAdapterKey: Greenplum
concurrent: true
dbMapping:
  database: source
  table: sales_order
  targetTable: rds.sales_order
  targetPk:
    order_number: order_number
#  mapAll: true
  targetColumns:
    order_number: order_number
    customer_number: customer_number
    product_code: product_code
    order_date: order_date
    entry_date: entry_date
    order_amount: order_amount
    order_quantity: order_quantity
  commitBatch: 30000 # 批量提交的大小

4. 创建自定义操作符<=>
        rule的定义中需要使用了一个新的关系操作符“<=>”,因为原来的少判断了一种情况。在源系统库中,客户地址和送货地址列都是允许为空的,这样的设计是出于灵活性和容错性的考虑。我们以送货地址为例进行讨论。

        使用“shipping_address <> new.shipping_address”条件判断送货地址是否更改,根据不等号两边的值是否为空,会出现以下三种情况:

  • shipping_address和new.shipping_address都不为空。这种情况下如果两者相等则返回false,说明地址没有变化,否则返回true,说明地址改变了,逻辑正确。
  • shipping_address和new.shipping_address都为空。两者的比较会演变成null<>null,根据Greenplum对“<>”操作符的定义,会返回NULL。因为查询语句中只会返回判断条件为true的记录,所以不会返回数据行,这符合我们的逻辑,说明地址没有改变。
  • shipping_address和 new.shipping_address只有一个为空。就是说地址列从NULL变成非NULL,或者从非NULL变成NULL,这种情况明显应该新增一个版本,但根据“<>”的定义,此时返回值是NULL,查询不会返回行,不符合我们的需求。

        现在使用“not (shipping_address <=> new.shipping_address)”作为判断条件,我们先看一下“<=>”操作符的定义:A <=> B — Returns same result with EQUAL(=) operator for non-null operands, but returns TRUE if both are NULL, FALSE if one of the them is NULL。从这个定义可知,当A和B都为NULL时返回TRUE,其中一个为NULL时返回FALSE,其他情况与等号返回相同的结果。下面再来看这三种情况:

  • shipping_address和new.shipping_address都不为空。这种情况下如果两者相等则返回not (true),即false,说明地址没有变化,否则返回not (false),即true,说明地址改变了,符合逻辑。
  • shipping_address和new.shipping_address都为空。两者的比较会演变成not (null<=>null),根据“<=>”的定义,会返回not (true),即返回false。因为查询语句中只会返回判断条件为true的记录,所以查询不会返回行,这符合我们的逻辑,说明地址没有改变。
  • shipping_address和new.shipping_address只有一个为空。根据“<=>”的定义,此时会返回not (false),即true,查询会返回行,符合需求。

        空值的逻辑判断有其特殊性,为了避免不必要的麻烦,数据库设计时应该尽量将字段设计成非空,必要时用默认值代替NULL,并将此作为一个基本的设计原则。Greenplum没有提供<=>比较操作符,但可以自定义:

create or replace function fn_exactly_equal(left_o anyelement, right_o anyelement) returns boolean as
$$
  select left_o is null and right_o is null or (left_o is not null and right_o is not null and left_o = right_o)
$$
language sql;

create operator <=> (procedure = fn_exactly_equal, leftarg = anyelement, rightarg = anyelement, commutator = <=>);

        Greenplum中的anyelement、anyarray、anynonarray和anyenum四种伪类型被称为多态类型。使用这些类型声明的函数叫做多态函数。多态函数的同一参数在每次调用函数时可以有不同数据类型,实际使用的数据类型由调用函数时传入的参数所确定。当一个查询调用多态函数时,特定的数据类型在运行时解析。每个声明为anyelement的位置(参数或返回值)允许是任何实际的数据类型,但是在任何一次给定的函数调用中,anyelement必须具有相同的实际数据类型。同样,每个声明为anyarray的位置允许是任何实际的数组数据类型,但是在任何一次给定的函数调用中,anyarray也必须具有相同类型。如果某些位置声明为anyarray,而另外一些位置声明为anyelement,那么实际的数组元素类型必须与anyelement的实际数据类型相同。

        anynonarray在操作上与anyelement完全相同,它只是在anyelement的基础上增加了一个额外约束,即实际类型不能是数组。anyenum在操作上也与anyelement完全相同,它只是在anyelement的基础上增加了一个额外约束,即实际类型必须是枚举(enum)类型。anynonarray和anyenum并不是独立的多态类型,它们只是在anyelement上增加了约束而已。例如,f(anyelement, anyenum)与f(anyenum, anyenum)是等价的,实际参数都必须是同样的枚举类型。

        如果一个函数的返回值被声明为多态类型,那么它的参数中至少应该有一个是多态的,并且参数与返回结果的实际数据类型必须匹配。例如,函数声明为assubscript(anyarray, integer) returns anyelement。此函数的的第一个参数为数组类型,而且返回值必须是实际数组元素的数据类型。再比如一个函数的声明为asf(anyarray) returns anyenum,那么参数只能是枚举类型的数组。5. 重建相关rule
(1)重建customer_dim维度表rule
        因为新增列采用SCD2处理,需要在rule中增加对shipping_address的判断,其中使用<=>比较操作符。Greenplum没有提供alter rule命令,只能先删除再新建。

drop rule r_insert_customer on customer;
create rule r_insert_customer as on insert to customer do also 
(insert into customer_dim (customer_number,customer_name,customer_street_address,customer_zip_code,customer_city,customer_state,version,effective_dt,expiry_dt,
shipping_address,shipping_zip_code,shipping_city,shipping_state
) 
 values (new.customer_number,new.customer_name,new.customer_street_address,new.customer_zip_code,new.customer_city,new.customer_state,1,now(),'2200-01-01',
new.shipping_address,new.shipping_zip_code,new.shipping_city,new.shipping_state
 ););

drop rule r_update_customer on customer;
create rule r_update_customer as on update to customer do also 
(update customer_dim set expiry_dt=now() 
  where customer_number=new.customer_number and expiry_dt='2200-01-01' 
    and ( not (customer_street_address <=> new.customer_street_address) or not (shipping_address <=> new.shipping_address));
 
insert into customer_dim (customer_number,customer_name,customer_street_address,customer_zip_code,customer_city,customer_state,version,effective_dt,expiry_dt,
shipping_address,shipping_zip_code,shipping_city,shipping_state) 
select new.customer_number,new.customer_name,new.customer_street_address,new.customer_zip_code,new.customer_city,new.customer_state,version + 1,expiry_dt,'2200-01-01',
new.shipping_address,new.shipping_zip_code,new.shipping_city,new.shipping_state
  from customer_dim 
 where customer_number=new.customer_number 
   and (not (customer_street_address <=> new.customer_street_address) or not (shipping_address <=> new.shipping_address))
   and version=(select max(version) 
                  from customer_dim 
                 where customer_number=new.customer_number);
 
update customer_dim set customer_name=new.customer_name 
 where customer_number=new.customer_number and customer_name<>new.customer_name);

(2)重建sales_order_fact事实表rule
        对于装载销售订单事实表的修改很简单,只要将新增的销售数量列order_quantity添加到查询语句中即可。

drop rule r_insert_sales_order on sales_order;
create rule r_insert_sales_order as on insert to sales_order do also 
(insert into order_dim (order_number,version,effective_dt,expiry_dt) 
 values (new.order_number,1,'2021-06-01','2200-01-01');
 
insert into sales_order_fact(order_sk,customer_sk,product_sk,order_date_sk,year_month,order_amount,order_quantity) 
 select e.order_sk, customer_sk, product_sk, date_sk, to_char(order_date, 'YYYYMM')::int, order_amount, order_quantity
   from rds.sales_order a, customer_dim b, product_dim c, date_dim d, order_dim e
  where a.order_number = new.order_number and e.order_number = new.order_number
    and a.customer_number = b.customer_number and b.expiry_dt = '2200-01-01'
    and a.product_code = c.product_code and c.expiry_dt = '2200-01-01'
    and date(a.order_date) = d.date);

6. 启动Canal Server、Canal Adapter

# 启动Canal Server,在构成Canal HA的126、127两台顺序执行
~/canal_113/deployer/bin/startup.sh

# 在126执行
~/canal_113/adapter/bin/startup.sh

7. 测试
        执行下面的SQL脚本,在MySQL的源数据库中增加客户和销售订单测试数据。

-- 126 MySQL主库执行
use source;    
update customer set shipping_address = customer_street_address, 
shipping_zip_code = customer_zip_code,
shipping_city = customer_city, 
shipping_state = customer_state ;    

insert into customer     
(customer_name,customer_street_address,customer_zip_code,customer_city,customer_state,shipping_address,shipping_zip_code, shipping_city,shipping_state)    
values 
('online distributors','2323 louise dr.', 17055,'pittsburgh','pa','2323 louise dr.',17055,'pittsburgh', 'pa') ;
  
-- 新增订单日期为2021年12月30日的9条订单。
set sql_log_bin = 0;
  
set @start_date := unix_timestamp('2021-12-30');    
set @end_date := unix_timestamp('2021-12-31');    
drop table if exists temp_sales_order_data;    
create table temp_sales_order_data as select * from sales_order where 1=0;     

set @customer_number := floor(1 + rand() * 9);
set @product_code := floor(1 + rand() * 4);  
set @order_date := from_unixtime(@start_date + rand() * (@end_date - @start_date));    
set @amount := floor(1000 + rand() * 9000);  
set @quantity := floor(10 + rand() * 90);  
insert into temp_sales_order_data 
values (1, @customer_number, @product_code, @order_date, @order_date, @amount, @quantity);    

... 新增9条订单 ...   

set sql_log_bin = 1;
insert into sales_order    
select null,customer_number,product_code,order_date,entry_date,order_amount,order_quantity 
  from temp_sales_order_data 
 order by order_date;      
commit ;  

        上面的语句生成了两个表的测试数据。客户表更新了已有八个客户的送货地址,并新增编号为9的客户。销售订单表新增了九条记录。查询customer_dim表,应该看到已存在客户的新记录有了送货地址。老的过期记录的送货地址为空。9号客户是新加的,具有送货地址。查询sales_order_fact表,应该只有9个订单有销售数量,老的销售数据数量字段为空。

7.2 维度子集

        有些需求不需要最细节的数据。例如更想要某个月的销售汇总,而不是事务数据。再比如相对于全部的销售数据,可能对某些特定状态的数据更感兴趣等。此时事实数据需要关联到特定的维度,这些特定维度包含在从细节维度选择的行中,所以叫维度子集。维度子集比细节维度的数据少,因此更易使用,查询也更快。

        有时称细节维度为基本维度,维度子集为子维度,基本维度表与子维度表具有相同的属性或内容,我们称这样的维度表具有一致性。一致的维度具有一致的维度关键字、一致的属性列名字、一致的属性定义以及一致的属性值。如果属性的含义不同或者包含不同的值,维度表就不是一致的。

        子维度是一种一致性维度,由基本维度的列与行的子集构成。当构建聚合事实表,或者需要获取粒度级别较高的数据时,需要用到子维度。例如,有一个进销存业务系统,零售过程获取原子产品级别的数据,而预测过程需要建立品牌级别的数据。无法跨两个业务过程模式,共享单一产品维度表,因为它们需要的粒度是不同的。如果品牌表属性是产品表属性的严格的子集,则产品和品牌维度仍然是一致的。在这个例子中需要建立品牌维度表,它是产品维度表的子集。对基本维度和子维度表来说,属性(例如,品牌和分类描述)是公共的,其标识和定义相同,两个表中的值相同,然而,基本维度和子维度表的主键是不同的。注意:如果子维度的属性是基本维度属性的真子集,则子维度与基本维度保持一致。

        还有另外一种情况,就是当两个维度具有同样粒度级别的细节数据,但其中一个仅表示行的部分子集时,也需要一致性维度子集。例如,某公司产品维度包含跨多个不同业务的所有产品组合,如服装类、电器类等等。对不同业务的分析可能需要浏览企业级维度的子集,需要分析的维度仅包含部分产品行。与该子维度连接的事实表必须被限制在同样的产品子集。如果用户试图使用子集维度,访问包含所有产品的集合,则因为违反了参照完整性,他们可能会得到预料之外的查询结果。需要认识到这种造成用户混淆或错误的维度行子集的情况。

        ETL数据流应当根据基本维度建立一致性子维度,而不是独立于基本维度,以确保一致性。本节中将准备两个特定子维度,月份维度与Pennsylvania州客户维度。它们均取自现有的维度,月份维度是日期维度的子集,Pennsylvania州客户维度是客户维度的子集。

1. 建立包含属性子集的子维度
        当事实表获取比基本维度更高粒度级别的度量时,需要上卷到子维度。在销售订单示例中,当除了需要实时事务销售数据外,还需要月销售汇总数据时,会出现这样的需求。我们可以通过在Greenplum创建物化视图简单实现。

-- 建立月份维度物化视图
create materialized view mv_month_dim as
select row_number() over (order by t1.year,t1.month) month_sk, t1.*
  from (select distinct month, month_name, quarter, year from date_dim) t1
distributed by (month_sk);

-- 查询月份维度物化视图
select * from mv_month_dim order by month_sk;

        这种方案有三个明显的优点:一是实现简单,只要用一个聚合查询定义好物化视图,数据就自动初始装载好了;二是由于物理存储数据,能提供更好的查询性能;三是物化视图也是视图,除刷新外不能更新其数据,因此不存在数据不一致问题。但是,在Greenplum中没有任何办法能做到实时自动刷新物化视图。首先Greenplum的物化视图没有提供类似于Oracle的refresh on commit刷新机制,其次Greenplum的rule与refresh materialized view不兼容,最后在PostgreSQL中通常使用触发器来实现物化视图实时刷新,而Greenplum又不支持触发器。基于以上原因,Greenplum的物化视图通常需要依赖于cron似的调度系统,定时周期性刷新数据,适合非实时场景,如这里的月份维度。

        refresh命令用于刷新物化视图:

-- 全量刷新
refresh materialized view mv_month_dim;

        concurrently选项用于增量刷新,但要求物化视图上存在唯一索引:

dw=> refresh materialized view concurrently mv_month_dim;
ERROR:  cannot refresh materialized view "rds.mv_month_dim" concurrently
HINT:  Create a unique index with no WHERE clause on one or more columns of the materialized view.

        物化视图需要额外的空间,因为新创建的子维度需要物理存储数据。如果需要实时更新的数据,又不想占用空间,常用的做法是在基本维度上建立普通视图生成子维度。

-- 建立月份维度视图
create view month_dim as
select row_number() over (order by t1.year,t1.month) month_sk, t1.*
  from (select distinct month, month_name, quarter, year from date_dim) t1;

-- 查询份维度视图
select * from month_dim;

        这种方法的缺点也十分明显:当基本维度表和子维度表的数据量相差悬殊时,性能会比物理表差得多;如果定义视图的查询很复杂,并且视图很多的话,可能会对元数据存储系统造成压力,影响查询性能。如果数据量不是特别大,该方法是一个不错的选择,它实现简单,不占用存储空间,能提供实时数据并消除数据不一致的可能,而对海量数据提供高性能查询正是Greenplum的强项。

        视图是与存储无关的纯粹的逻辑对象,当查询引用了一个视图,视图的定义被评估后产生一个行集,用作查询后续的处理。这只是一个概念性的描述,实际上,作为查询优化的一部分,Greenplum可能把视图的定义和查询结合起来考虑,而不一定是先生成视图所定义的行集。例如,优化器可能将查询的过滤条件下推到视图中。

2. 建立包含行子集的子维度
        当两个维度处于同一细节粒度,但是其中一个仅仅是行的子集时,会产生另外一种一致性维度构造子集。例如,销售订单示例中,客户维度表包含多个州的客户信息。对于不同州的销售分析可能需要浏览客户维度的子集,需要分析的维度仅包含部分客户数据。通过使用行的子集,不会破坏整个客户集合。当然,与该子集连接的事实表必须被限制在同样的客户子集中。

        月份维度是一个上卷维度,包含基本维度的上层数据。而特定维度子集是选择基本维度的行子集。同样可以考虑物化视图和普通视图两种实现方式。

-- 建立PA维度视图
create view pa_customer_dim as 
select * from customer_dim where customer_state = 'pa';

-- 建立PA维度物化视图
create materialized view mv_pa_customer_dim as 
select * from customer_dim where customer_state = 'pa'
distributed by (customer_sk);

        如果必须兼顾查询性能与实时性,还有一种实现方案是在rule中增加子维度逻辑。例如对于PA维度,可以在上一篇定义的r_delete_customer、r_insert_customer、r_update_customer中增加一套几乎完全相同的逻辑,只要在where条件中增加customer_state = 'pa'。除了情非得已,我还是建议采用普通视图方案,毕竟复制一份相同逻辑会让人感到索然无味。

        注意,PA客户维度子集与月份维度子集有两点区别:

  • pa_customer_dim表和customer_dim表有完全相同的列,而month_dim不包含date_dim表的日期列。
  • pa_customer_dim表的代理键就是客户维度的代理键,而month_dim表里的月份维度代理键并不来自日期维度,而是独立生成的。

        定义好视图后,执行下面的SQL脚本往客户源数据里添加一个PA州的客户和四个OH州的客户:

use source;  
insert into customer  
(customer_name, customer_street_address, customer_zip_code,   
 customer_city, customer_state, shipping_address,  
 shipping_zip_code, shipping_city, shipping_state)  
values  
('pa customer', '1111 louise dr.', '17050', 
'mechanicsburg', 'pa', '1111 louise dr.', 
'17050', 'mechanicsburg', 'pa'),  
('bigger customers', '7777 ridge rd.', '44102', 
'cleveland', 'oh', '7777 ridge rd.', 
'44102', 'cleveland', 'oh'),   
('smaller stores', '8888 jennings fwy.', '44102', 
'cleveland', 'oh', '8888 jennings fwy.', 
'44102', 'cleveland', 'oh'),  
('small-medium retailers', '9999 memphis ave.', '44102', 
'cleveland', 'oh', '9999 memphis ave.', 
'44102', 'cleveland', 'oh'),  
('oh customer', '6666 ridge rd.', '44102', 
'cleveland', 'oh', '6666 ridge rd.', 
'44102','cleveland', 'oh') ;  
commit;

        在目标库执行下面的查询验证结果,pa_customer_dim表此时应该有20条记录。

select customer_number, customer_state, effective_dt, expiry_dt 
  from pa_customer_dim 
 order by customer_number, version;

7.3 角色扮演维度

        单个物理维度可以被事实表多次引用,每个引用连接逻辑上存在差异的角色维度。例如,事实表可以有多个日期,每个日期通过外键引用不同的日期维度,原则上每个外键表示不同的日期维度视图,这样引用具有不同的含义。这些不同的维度视图具有唯一的代理键列名,被称为角色,相关维度被称为角色扮演维度。

        当一个事实表多次引用一个维度表时会用到角色扮演维度。例如,一个销售订单有一个是订单日期,还有一个请求交付日期,这时就需要引用日期维度表两次。我们期望在每个事实表中设置日期维度,因为总是希望按照时间来分析业务情况。在事务型事实表中,主要的日期列是事务日期,例如,订单日期。有时会发现其他日期也可能与每个事实关联,例如,订单事务的请求交付日期。每个日期应该成为事实表的外键。

        本节将说明两类角色扮演维度的实现,分别是表别名和数据库视图。表别名是在SQL语句里引用维度表多次,每次引用都赋予维度表一个别名。而数据库视图,则是按照事实表需要引用维度表的次数,建立相同数量的视图。我们先修改销售订单数据库表结构,添加一个请求交付日期字段,并对数据抽取和装载脚本做相应的修改。这些表结构修改好后,插入测试数据,演示别名和视图在角色扮演维度中的用法。

1. 停止Canal Server、Canal Adapter

# 停止Canal Server,构成Canal HA的126、127两台都执行
~/canal_113/deployer/bin/stop.sh
# 停止Canal Adapter,126执行
~/canal_113/adapter/bin/stop.sh

2. 修改表结构
(1)修改源数据库表结构
        源库中销售订单表sales_order增加request_delivery_date字段。

-- 126 MySQL主库执行
use source;    
alter table sales_order add request_delivery_date date after order_date;

(2)修改目标数据库表结构

-- 修改RDS数据库模式里的表
set search_path to rds;
alter table sales_order add column request_delivery_date date; 

-- 修改TDS数据库模式里的表
set search_path to tds, rds;
alter table sales_order_fact add column request_delivery_date_sk int;

        修改后的数据仓库模式如图7-2所示。

图7-2 数据仓库中增加请求交付日期属性

        从图中可以看到,销售订单事实表和日期维度表之间有两条连线,表示订单日期和请求交付日期都是引用日期维度表的外键。注意,虽然图中显示了表之间的关联关系,但Greenplum中并不强制外键数据库约束。

3. 修改Canal Adapter表映射
        在sales_order.yml文件中添加新增字段的映射。

[mysql@node2~/canal_113/adapter/conf/rdb]$cat sales_order.yml 
...
    request_delivery_date: request_delivery_date
  commitBatch: 30000 # 批量提交的大小

4. 重建sales_order_fact事实表rule
        在装载销售订单事实表时,关联日期维度表两次,分别赋予别名e和f。事实表和两个日期维度表关联,取得日期代理键。e.date_sk表示订单日期代理键,f.date_sk表示请求交付日期的代理键。

drop rule r_insert_sales_order on sales_order;
create rule r_insert_sales_order as on insert to sales_order do also 
(insert into order_dim (order_number,version,effective_dt,expiry_dt) 
 values (new.order_number,1,'2021-06-01','2200-01-01');
 
insert into sales_order_fact(order_sk,customer_sk,product_sk,order_date_sk,year_month,order_amount,order_quantity,request_delivery_date_sk) 
 select d.order_sk, customer_sk, product_sk, e.date_sk, to_char(order_date, 'YYYYMM')::int, order_amount, order_quantity, f.date_sk
   from rds.sales_order a, customer_dim b, product_dim c, order_dim d, date_dim e, date_dim f
  where a.order_number = new.order_number and d.order_number = new.order_number
    and a.customer_number = b.customer_number and b.expiry_dt = '2200-01-01'
    and a.product_code = c.product_code and c.expiry_dt = '2200-01-01'
    and date(a.order_date) = e.date
    and date(a.request_delivery_date) = f.date);

5. 启动Canal Server、Canal Adapter

# 启动Canal Server,在构成Canal HA的126、127两台顺序执行
~/canal_113/deployer/bin/startup.sh

# 在126执行
~/canal_113/adapter/bin/startup.sh

6. 测试
        执行下面的SQL脚本在源库中增加三个带有交货日期的销售订单。

-- 126 MySQL主库执行
use source;    
-- 新增订单日期为2021年12月31日的3条订单。
set sql_log_bin = 0;
  
set @start_date := unix_timestamp('2021-12-31');    
set @end_date := unix_timestamp('2022-01-01');
-- 请求交付日期为2022年1月4日。
set @request_delivery_date := '2022-01-04';    
drop table if exists temp_sales_order_data;    
create table temp_sales_order_data as select * from sales_order where 1=0;     

set @customer_number := floor(1 + rand() * 14);
set @product_code := floor(1 + rand() * 4);  
set @order_date := from_unixtime(@start_date + rand() * (@end_date - @start_date));    
set @amount := floor(1000 + rand() * 9000);  
set @quantity := floor(10 + rand() * 90);  
insert into temp_sales_order_data 
values (1, @customer_number, @product_code, @order_date, @request_delivery_date, @order_date, @amount, @quantity);    

... 新增3条订单 ...   

set sql_log_bin = 1;
insert into sales_order    
select null,customer_number,product_code,order_date,request_delivery_date,entry_date,order_amount,order_quantity 
  from temp_sales_order_data 
 order by order_date;      
commit ;  

        使用查询验证结果,可以看到只有三个新的销售订单具有request_delivery_date_sk值,735对应的日期是2022年1月4日。

dw=> select a.order_sk, request_delivery_date_sk, c.date  
dw->   from sales_order_fact a, date_dim b, date_dim c  
dw->  where a.order_date_sk = b.date_sk   
dw->    and a.request_delivery_date_sk = c.date_sk 
dw->  order by order_sk;
 order_sk | request_delivery_date_sk |    date    
----------+--------------------------+------------
      126 |                      735 | 2022-01-04
      127 |                      735 | 2022-01-04
      128 |                      735 | 2022-01-04
(3 rows)

        使用角色扮演维度查询:

-- 使用表别名查询  
select order_date_dim.date order_date,    
        request_delivery_date_dim.date request_delivery_date,    
        sum(order_amount),count(*)    
  from sales_order_fact a,
        date_dim order_date_dim,    
        date_dim request_delivery_date_dim    
 where a.order_date_sk = order_date_dim.date_sk    
   and a.request_delivery_date_sk = request_delivery_date_dim.date_sk    
 group by order_date_dim.date , request_delivery_date_dim.date    
 order by order_date_dim.date , request_delivery_date_dim.date;  
  
-- 使用视图查询  
-- 创建订单日期视图  
create view order_date_dim 
(order_date_sk, 
 order_date, 
 month, 
 month_name,  
 quarter, 
 year) 
as select * from date_dim;    
-- 创建请求交付日期视图
create view request_delivery_date_dim
(request_delivery_date_sk, 
 request_delivery_date, 
 month, 
 month_name, 
 quarter, 
 year)   
as select * from date_dim;  
-- 查询
select order_date,request_delivery_date,sum(order_amount),count(*)    
  from sales_order_fact a,order_date_dim b,request_delivery_date_dim c    
 where a.order_date_sk = b.order_date_sk    
   and a.request_delivery_date_sk = c.request_delivery_date_sk    
 group by order_date , request_delivery_date    
 order by order_date , request_delivery_date; 

        上面两个查询是等价的。尽管不能连接到单一的日期维度表,但可以建立并管理单独的物理日期维度表,然后使用视图或别名建立两个不同日期维度的描述。注意在每个视图或别名列中需要唯一的标识。例如,订单日期属性应该具有唯一标识order_date以便与请求交付日期request_delivery_date区别。别名与视图在查询中的作用并没有本质的区别,都是为了从逻辑上区分同一个物理维度表。许多BI工具也支持在语义层使用别名。但是,如果有多个BI工具,连同直接基于SQL的访问,都同时在组织中使用的话,不建议采用语义层别名的方法。当某个维度在单一事实表中同时出现多次时,则会存在维度模型的角色扮演。基本维度可能作为单一物理表存在,但是每种角色应该被当成标识不同的视图展现到BI工具中。

7. 一种有问题的设计
        为处理多日期问题,一些设计者试图建立单一日期维度表,该表使用一个键表示每个订单日期和请求交付日期的组合,例如:

create table date_dim (date_sk int, order_date date, delivery_date date);
create table sales_order_fact (date_sk int, order_amount int);

        这种方法存在两个方面的问题。首先,如果需要处理所有日期维度的组合情况,则包含大约每年365行的清楚、简单的日期维度表将会极度膨胀。例如,订单日期和请求交付日期存在如下多对多关系:
订单日期          请求交付日期
2021-12-17         2021-12-20
2021-12-18         2021-12-20
2021-12-19         2021-12-20
2021-12-17         2021-12-21
2021-12-18         2021-12-21
2021-12-19         2021-12-21
2021-12-17         2021-12-22
2021-12-18         2021-12-22
2021-12-19         2021-12-22

        如果使用角色扮演维度,日期维度表中只需要2021-12-17到2021-12-22六条记录。而采用单一日期表设计方案,每一个组合都要唯一标识,明显需要九条记录。当两种日期及其组合很多时,这两种方案的日期维度表记录数会相去甚远。

        其次,合并的日期维度表不再适合其他经常使用的日、周、月等日期维度。日期维度表每行记录的含义不再指唯一一天,因此无法在同一张表中标识出周、月等一致性维度,进而无法简单地处理按时间维度的上卷、聚合等需求。

7.4 层次维度

        大多数维度都具有一个或多个层次。示例数据仓库中的日期维度就有一个四级层次:年、季度、月和日。这些级别用date_dim表里的列表示。日期维度是一个单路径层次,因为除了年-季度-月-日这条路径外,它没有任何其他层次。为了识别数据仓库里一个维度的层次,首先要理解维度中列的含义,然后识别两个或多个列是否具有相同的主题。年、季度、月和日具有相同的主题,因为它们都是关于日期的。具有相同主题的列形成一个组,组中的一列必须包含至少一个组内的其他成员(除了最低级别的列),前面提到的组中,月包含日。这些列的链条形成了一个层次,例如,年-季度-月-日这个链条是一个日期维度的层次。除了日期维度,邮编维度中的地理位置信息,产品维度的产品与产品分类,也都构成层次关系。表7-1显示了三个维度的层次。

customer_dim

product_dim

date_dim

customer_street_address

shipping_address

product_name

date

customer_zip_code

shipping_zip_code

product_category

month

customer_city

shipping_city

quarter

customer_state

shipping_state

year

表7-1 销售订单数据仓库中的层次维度

        本节描述处理层次关系的方法,包括在固定深度的层次上进行分组和钻取查询,多路径层次和参差不齐层次的处理等,从最基本的情况开始讨论。

7.4.1 固定深度的层次

        固定深度层次是一种一对多关系,如一年中有四个季度,一个季度包含三个月等等。当固定深度层次定义完成后,层次就具有了固定的名称,层次级别作为维度表中的不同属性出现。只要满足上述条件,固定深度层次就是最容易理解和查询的层次关系,固定层次也能够提供可预测的、快速的查询性能。可以在固定深度层次上进行分组和钻取查询。

        分组查询是把度量按照一个维度的一个或多个级别进行分组聚合。下面的脚本是一个分组查询的例子。该查询按产品(product_category列)和日期维度的三个层次级别(year、quarter和month列)分组返回销售金额。

select product_category,year,quarter,month,sum(order_amount) s_amount    
  from  sales_order_fact a,product_dim b,date_dim c      
 where  a.product_sk = b.product_sk      
   and  a.year_month = c.year * 100 + c.month      
 group by product_category, year, quarter, month      
 order by product_category, year, quarter, month;

        这是一个非常简单的分组查询,结果输出的每一行度量(销售订单金额)都沿着年-季度-月的层次分组。

        与分组查询类似,钻取查询也把度量按照一个维度的一个或多个级别进行分组。但与分组查询不同的是,分组查询只显示分组后最低级别,即本例中月级别上的度量,而钻取查询显示分组后维度每一个级别的度量。下面使用UNION ALL和GROUPING SETS两种方法进行钻取查询,结果显示了每个日期维度级别,即年、季度和月各级别的订单汇总金额。

-- 使用union all     
select product_category, time, order_amount    
  from (select product_category,     
               case when sequence = 1 then 'year: '||time  
                    when sequence = 2 then 'quarter: '||time   
                    else 'month: '||time    
               end time,    
               order_amount,   
               sequence,   
               date   
          from (select product_category, min(date) date, year time, 1 sequence, sum(order_amount) order_amount    
                  from sales_order_fact a, product_dim b, date_dim c      
                 where a.product_sk = b.product_sk      
                   and a.year_month = c.year * 100 + c.month      
                 group by product_category , year    
                 union all     
                select product_category, min(date) date, quarter time, 2 sequence, sum(order_amount) order_amount    
                  from sales_order_fact a, product_dim b, date_dim c      
                 where a.product_sk = b.product_sk      
                   and a.year_month = c.year * 100 + c.month      
                 group by product_category , year , quarter     
                 union all     
                select product_category, min(date) date, month time, 3 sequence, sum(order_amount) order_amount    
                  from sales_order_fact a, product_dim b, date_dim c      
                 where a.product_sk = b.product_sk      
                   and a.year_month = c.year * 100 + c.month      
                 group by product_category , year , quarter , month) x) y  
 order by product_category , date , sequence , time;  

-- 使用grouping sets  
select product_category,     
       case when gid = 3 then 'year: '||year    
            when gid = 1 then 'quarter: '||quarter    
            else 'month: '||month    
        end time,    
       order_amount  
  from (select product_category, year, quarter, month, min(date) date, sum(order_amount) order_amount,grouping(product_category,year,quarter,month) gid  
          from sales_order_fact a, product_dim b, date_dim c      
         where a.product_sk = b.product_sk   
           and a.year_month = c.year * 100 + c.month     
         group by grouping sets ((product_category,year,quarter,month),(product_category,year,quarter),(product_category,year))) x  
 order by product_category , date , gid desc, time;

        以上两种不同写法的查询语句结果相同:

 product_category |    time    | order_amount 
------------------+------------+--------------
 monitor          | year: 2021 |   2332781.00
 monitor          | quarter: 4 |   2332781.00
 monitor          | month: 12  |   2332781.00
 peripheral       | year: 2021 |    809131.00
 peripheral       | quarter: 4 |    809131.00
 peripheral       | month: 12  |    809131.00
 storage          | year: 2021 |  19350932.00
 storage          | quarter: 2 |   3848670.00
 storage          | month: 6   |   3848670.00
 storage          | quarter: 3 |  13661172.00
 storage          | month: 7   |   4149133.00
 storage          | month: 8   |   4292849.00
 storage          | month: 9   |   5219190.00
 storage          | quarter: 4 |   1841090.00
 storage          | month: 12  |   1841090.00
(15 rows)

        第一条语句的子查询中使用union all集合操作,将年、季度、月三个级别的汇总数据联合成一个结果集。注意union all的每个查询必须包含相同个数和类型的字段。附加的min(date)和sequence导出列用于对输出结果排序显示。这种写法使用标准的SQL语法,具有通用性。

        第二条语句使用Greenplum提供的grouping函数和group by grouping sets子句。grouping set对列出的每一个字段组进行group by操作,如果字段组为空,则不进行分组处理。因此该语句会生成按产品类型、年、季度、月;类型、年、季度;类型、年分组的聚合数据行。grouping(<column> [, …])函数用于区分查询结果中的null值是属于列本身的还是聚合的结果行。该函数为每个参数产生一位0或1,1代表结果行是聚合行,0表示结果行是正常分组数据行。函数值使用了位图策略(bitvector,位向量),即它的二进制形式中的每一位表示对应列是否参与分组,如果某一列参与了分组,对应位就被置为1,否则为0。最后将二进制数转换为十进制数返回。通过这种方式可以区分出数据本身中的null值。

7.4.2 多路径层次

        多路径层次是对单路径层次的扩展。现在数据仓库的月维度只有一条层次路径,即年-季度-月这条路径。现在增加一个新的“促销期”级别,并且加一个新的年-促销期-月的层次路径。这时月维度将有两条层次路径,因此是多路径层次维度。下面的脚本给month_dim表添加一个叫做campaign_session的新列,并建立rds.campaign_session过渡表。

-- 增加促销期列  
set search_path=tds;

alter view month_dim rename to month_dim_old;  

create table tds.month_dim (      
    month_sk bigint,      
    month smallint,    
    month_name varchar(9),   
    campaign_session varchar(30),  
    quarter smallint,    
    year smallint     
) distributed by (month_sk);

insert into tds.month_dim 
select month_sk,month,month_name,null,quarter,year from tds.month_dim_old;  

drop view month_dim_old;  

-- 建立促销期过渡表 
set search_path=rds;
   
create table campaign_session 
(campaign_session varchar(30),month smallint, year smallint) distributed by (campaign_session); 

        假设所有促销期都不跨年,并且一个促销期可以包含一个或多个月份,但一个月份只能属于一个促销期。为了理解促销期如何工作,表7-2给出了一个促销期定义的示例。

促销期

月份

2021 年第一促销期

1月—4月

2021 年第二促销期

5月—7月

2021 年第三促销期

8

2021 年第四促销期

9月—12月

表7-2 2021年促销期

        每个促销期有一个或多个月。一个促销期也许并不是正好一个季度,也就是说,促销期级别不能上卷到季度,但是促销期可以上卷至年级别。假设2021年促销期的数据如下,并保存在/home/gpadmin/campaign_session.csv文件中。

2020 First Campaign,1,2020
2020 First Campaign,2,2020
2020 First Campaign,3,2020
2020 First Campaign,4,2020
2020 Second Campaign,5,2020
2020 Second Campaign,6,2020
2020 Second Campaign,7,2020
2020 Third Campaign,8,2020
2020 Last Campaign,9,2020
2020 Last Campaign,10,2020
2020 Last Campaign,11,2020
2020 Last Campaign,12,2020

        现在可以执行下面的语句把2021年的促销期数据装载进月维度。本地文件必须在Greenplum master主机上的本地目录中,并且copy命令需要使用gpadmin用户执行。

# 用gpadmin执行
psql -d dw -c "copy rds.campaign_session from '/home/gpadmin/campaign_session.csv' with delimiter ',';"

# 用dwtest连接
psql -U dwtest -h mdw -d dw

-- 设置搜索路径
set search_path = tds;  

-- 两表关联更新,注意set中不能使用别名
update month_dim as t1
   set campaign_session = t2.campaign_session
  from rds.campaign_session as t2
 where t1.year = t2.year   
   and t1.month = t2.month;

        此时查询月份维度表,可以看到2021年的促销期已经有数据,其他年份的campaign_session字段值为空。

7.4.3 参差不齐的层次

        在一个或多个级别上没有数据的层次称为不完全层次。例如,在特定月份没有促销期,那么月维度就具有不完全促销期层次。下面是一个不完全促销期的例子,数据存储在ragged_campaign.csv文件中。2022年1月、4月、6月、9月、10月、11月和12月没有促销期。

,1,2021
2021 Early Spring Campaign,2,2021
2021 Early Spring Campaign,3,2021
,4,2021
2021 Spring Campaign,5,2021
,6,2021
2021 Last Campaign,7,2021
2021 Last Campaign,8,2021
,9,2021
,10,2021
,11,2021
,12,2021

        向month_dim表装载2021年的促销期数据。

# 用gpadmin执行
psql -d dw -c "copy rds.campaign_session from '/home/gpadmin/ragged_campaign.csv' with delimiter ',';"

# 用dwtest连接
psql -U dwtest -h mdw -d dw

-- 设置搜索路径
set search_path = tds;  

-- 两表关联更新,注意set中不能使用别名
update month_dim as t1
   set campaign_session = case when t2.campaign_session != '' then t2.campaign_session else t1.month_name end
  from rds.campaign_session as t2
 where t1.year = t2.year   
   and t1.month = t2.month;

        在有促销期的月份,campaign_session列填写促销期名称,而对于没有促销期的月份,该列填写月份名称。轻微参差不齐层次没有固定的层次深度,但层次深度有限。如地理层次深度通常包含3 ~ 6层。与其使用复杂的机制构建难以预测的可变深度层次,不如将其变换为固定深度位置设计,针对不同的维度属性确立最大深度,然后基于业务规则放置属性值。

        下面的语句查询年-促销期-月层次。

select product_category,         
       case when gid = 3 then cast(year as varchar(10))        
            when gid = 1 then campaign_session       
            else month_name  
        end time,     
       order_amount      
  from (select product_category, year, campaign_session, month, month_name,   
               sum(order_amount) order_amount,   
               sum(order_quantity) order_quantity,  
               grouping(product_category,year,campaign_session,month) gid,  
               min(month) min_month 
          from sales_order_fact a, product_dim b, month_dim c
         where a.product_sk = b.product_sk       
           and a.year_month = c.year * 100 + c.month    
           and c.year = 2021                  
         group by grouping sets ((product_category,year,campaign_session,month,month_name),(product_category,year,campaign_session),(product_category,year))) x      
 order by product_category, min_month, gid desc, month;

        查询结果如下:

 product_category |        time        | order_amount 
------------------+--------------------+--------------
 monitor          | 2021               |     75251.00
 monitor          | dec                |     75251.00
 monitor          | dec                |     75251.00
 peripheral       | 2021               |     26101.00
 peripheral       | dec                |     26101.00
 peripheral       | dec                |     26101.00
 storage          | 2021               |    633974.00
 storage          | jun                |    128289.00
 storage          | jun                |    128289.00
 storage          | 2021 Last Campaign |    272322.00
 storage          | jul                |    133843.00
 storage          | aug                |    138479.00
 storage          | sep                |    173973.00
 storage          | sep                |    173973.00
 storage          | dec                |     59390.00
 storage          | dec                |     59390.00
(16 rows)

        min_month列用于排序。在有促销期月份的路径,月级别的行的汇总与促销期级别的行相同。而对于没有促销期的月份,其促销期级别的行与月级别的行相同。也就是说,在没有促销期级别的月份,月上卷了它们自己。例如,2021年6月没有促销期,所以在输出看到,每种产品分类有两个相同的6月的行,其中后一行是月份级别的行,前一行表示是没有促销期的行。对于没有促销期的月份,促销期行的销售订单金额(输出里的order_amount列)与月

以上是关于Greenplum 实时数据仓库实践——维度表技术的主要内容,如果未能解决你的问题,请参考以下文章

Greenplum 实时数据仓库实践——维度表技术

Greenplum 实时数据仓库实践——事实表技术

Greenplum 实时数据仓库实践——实时数据装载

Greenplum 实时数据仓库实践——实时数据装载

Greenplum 实时数据仓库实践——实时数据装载

Greenplum 实时数据仓库实践——数据仓库设计基础