1. はじめに
- 増分テーブル: 増分データ、つまり新しい増分と変更を保存するための日付パーティションがあります。
- フルスケールテーブル:日付パーティションなし(毎日上書き更新)、現時点でのデータの最新状態を保存するため、データの履歴変更を記録できません
- スナップショット テーブル: 日付パーティションがあり、データは毎日いっぱいになります (変更があるかどうかに関係なく)。欠点は、各パーティションに大量の重複データが保存され、ストレージ スペースが無駄になることです。
- ジッパー テーブル: ジッパー テーブルは、履歴状態と最新の状態データを維持するために使用されるテーブルです。ジッパーの粒度の違いによると、ジッパー テーブルは実際にはスナップショットと同等ですが、一部の未変更のレコードを削除するように最適化されています。
2. 応用シナリオ
ジッパー テーブルは、大量のデータがあり、フィールド変更の割合と頻度が小さいときに履歴スナップショット情報を表示する必要があるシナリオに適しています。
たとえば、数千万のレコードと数百のフィールドを持つ顧客テーブルがあるとします。したがって、この種のテーブルの場合、ORC 圧縮を使用したとしても、1 つのテーブルのデータ保存容量は 1 日あたり 50GB を超え、HDFS で 3 つのバックアップを使用する場合、保存容量はさらに大きくなります。
では、このテーブルをどのように設計すればよいでしょうか? 以下にいくつかのオプションがあります。
- オプション 1 (フルスケール テーブル): 毎日最新のデータを抽出して前日のデータを上書きする利点は実装が簡単でスペースを節約できることですが、欠点も明らかであり、履歴状態がありません
- 解決策 2 (スナップショット テーブル): 毎日満杯にすると、履歴データを表示できますが、欠点は、ストレージ容量が大きすぎることです。特に顧客情報が頻繁に変更されない場合、フィールドの繰り返しストレージ率が高すぎることです。
- 解決策 3 (ジッパー テーブル): ジッパー テーブルの設計を使用すると、履歴ステータスを表示できるだけでなく、占有するストレージ スペースも非常に少なくて済みます (結局のところ、変更されていないデータを繰り返し保存する必要はありません)。
3. Hive SQL の実践
まずはテスト用の顧客情報のオリジナルテーブルを作成します
CREATE TABLE IF NOT EXISTS datadev.zipper_table_test_cust_src (
`cust_id` STRING COMMENT '客户编号',
`phone` STRING COMMENT '手机号码'
)PARTITIONED BY (
dt STRING COMMENT 'etldate'
)STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
;
次に、テストデータを挿入します
cust_id | 電話 | dt |
001 | 1111 | 20210601 |
002 | 2222 | 20210601 |
003 | 3333 | 20210601 |
004 | 4444 | 20210601 |
001 | 1111 | 20210602 |
002 | 2222-1 | 20210602 |
003 | 3333 | 20210602 |
004 | 4444-1 | 20210602 |
005 | 5555 | 20210602 |
001 | 1111-1 | 20210603 |
002 | 2222-2 | 20210603 |
003 | 3333 | 20210603 |
004 | 4444-1 | 20210603 |
005 | 5555-1 | 20210603 |
006 | 6666 | 20210603 |
002 | 2222-3 | 20210604 |
003 | 3333 | 20210604 |
004 | 4444-1 | 20210604 |
005 | 5555-1 | 20210604 |
006 | 6666 | 20210604 |
007 | 7777 | 20210604 |
データの簡単な説明は次のとおりです。
- 20210601が開始日で、顧客は合計4人です
- 20210602 002、004のお客様情報を更新、005のお客様を追加しました
- 20210603 001、002、005のお客様の情報を更新し、006のお客様を追加しました
- 20210604 顧客002の情報を更新、顧客007を追加、顧客001を削除
さて、本題に戻りますが、ジッパーテーブルをどのようにデザインするか?
まず、ジッパー テーブルには、データ有効日とデータ有効期限という 2 つの重要な監査フィールドがあります。名前が示すように、データ有効日はレコードが有効になった日を記録し、データ有効期限はレコードの有効期限を記録します (9999-12-31 は、現在まで有効であることを意味します)。データに対する操作は次のカテゴリに分類できます。
- 新しく追加されたレコード: データの有効日は今日、有効期限は 9999-12-31
- 変更のないレコード: データの有効期限は以前に使用する必要があり、有効期限は変更されません。
- 変更のあるレコード: == "古いレコードの場合: 保持し、有効期限を今日に変更します。 == "新しいレコードの場合: 追加、発効日は今日、有効期限は 9999-12-31
- 削除されたレコード: ループを閉じる必要があります。有効期限は同日になります。
したがって、ジッパー テーブルの HQL 実装コードは次のようになります。
-- 拉链表建表语句
CREATE TABLE IF NOT EXISTS datadev.zipper_table_test_cust_dst (
`cust_id` STRING COMMENT '客户编号',
`phone` STRING COMMENT '手机号码',
`s_date` DATE COMMENT '生效时间',
`e_date` DATE COMMENT '失效时间'
)STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
;
-- 拉链表实现代码(含数据回滚刷新)
INSERT OVERWRITE TABLE datadev.zipper_table_test_cust_dst
-- part1: 处理新增的、没有变化的记录,以及有变化的记录中的新记录
select NVL(curr.cust_id, prev.cust_id) as cust_id,
NVL(curr.phone, prev.phone) as phone,
-- 没有变化的记录: s_date需要使用之前的
case when NVL(curr.phone, '') = NVL(prev.phone, '') then prev.s_date
else NVL(curr.s_date, prev.s_date)
end as s_date,
NVL(curr.e_date, prev.e_date) as e_date
from (
select cust_id, phone, DATE(from_unixtime(unix_timestamp(dt, 'yyyyMMdd'), 'yyyy-MM-dd')) as s_date, DATE('9999-12-31') as e_date
from datadev.zipper_table_test_cust_src
where dt = '${etldate}'
) as curr
left join (
select cust_id, phone, s_date, if(e_date > from_unixtime(unix_timestamp('${etldate}', 'yyyyMMdd'), 'yyyy-MM-dd'), DATE('9999-12-31'), e_date) as e_date,
row_number() over(partition by cust_id order by e_date desc) as r_num -- 取最新状态
from datadev.zipper_table_test_cust_dst
where regexp_replace(s_date, '-', '') <= '${etldate}' -- 拉链表历史数据回滚
) as prev
on curr.cust_id = prev.cust_id
and prev.r_num = 1
union all
-- part2: 处理删除的记录,以及有变化的记录中的旧记录
select prev_cust.cust_id, prev_cust.phone, prev_cust.s_date,
case when e_date <> '9999-12-31' then e_date
else DATE(from_unixtime(unix_timestamp('${etldate}', 'yyyyMMdd'), 'yyyy-MM-dd'))
END as e_date
from (
select cust_id, phone, s_date, if(e_date > from_unixtime(unix_timestamp('${etldate}', 'yyyyMMdd'), 'yyyy-MM-dd'), DATE('9999-12-31'), e_date) as e_date
from datadev.zipper_table_test_cust_dst
where regexp_replace(s_date, '-', '') <= '${etldate}' -- 拉链表历史数据回滚
) as prev_cust
left join (
select cust_id, phone
from datadev.zipper_table_test_cust_src
where dt = '${etldate}'
) as curr_cust
on curr_cust.cust_id = prev_cust.cust_id
-- 只要变化量
where NVL(prev_cust.phone, '') <> NVL(curr_cust.phone, '')
;
4. テスト
4.1 1日目(20210601): ${etldate}を20210601に置き換えてSQLを実行します。これは初期状態であり、顧客情報に変更はないため、発効日は2021-06-01、発効日は9999-12-31(現在有効という意味)となります。
ziper_table_test_cust_dst.cust_id | ziper_table_test_cust_dst.phone | ziper_table_test_cust_dst.s_date | ziper_table_test_cust_dst.e_date |
001 | 1111 | 2021-06-01 | 9999-12-31 |
002 | 2222 | 2021-06-01 | 9999-12-31 |
003 | 3333 | 2021-06-01 | 9999-12-31 |
004 | 4444 | 2021-06-01 | 9999-12-31 |
4.2 2日目(20210602): ${etldate}を20210602に置き換えてSQLを実行します。この時点で、元のテーブルは携帯電話番号 002 と 004 を変更しているため、2 つのレコードが存在し、1 つはデータの過去の状態を記録し、もう 1 つはデータの現在の状態を記録します。元のテーブルには 005 人の顧客も追加されているため、この時点でのデータの有効日は 2021-06-02、有効期限は 9999-12-31 になります。
ziper_table_test_cust_dst.cust_id | ziper_table_test_cust_dst.phone | ziper_table_test_cust_dst.s_date | ziper_table_test_cust_dst.e_date |
001 | 1111 | 2021-06-01 | 9999-12-31 |
002 | 2222 | 2021-06-01 | 2021-06-02 |
002 | 2222-1 | 2021-06-02 | 9999-12-31 |
003 | 3333 | 2021-06-01 | 9999-12-31 |
004 | 4444 | 2021-06-01 | 2021-06-02 |
004 | 4444-1 | 2021-06-02 | 9999-12-31 |
005 | 5555 | 2021-06-02 | 9999-12-31 |
4.3 3日目(20210603): ${etldate}を20210602に置き換えてSQLを実行します。この時点で、元のテーブルの 001、002、005 が変更され、006 が追加されています。
ziper_table_test_cust_dst.cust_id | ziper_table_test_cust_dst.phone | ziper_table_test_cust_dst.s_date | ziper_table_test_cust_dst.e_date |
001 | 1111 | 2021-06-01 | 2021-06-03 |
001 | 1111-1 | 2021-06-03 | 9999-12-31 |
002 | 2222 | 2021-06-01 | 2021-06-02 |
002 | 2222-1 | 2021-06-02 | 2021-06-03 |
002 | 2222-2 | 2021-06-03 | 9999-12-31 |
003 | 3333 | 2021-06-01 | 9999-12-31 |
004 | 4444 | 2021-06-01 | 2021-06-02 |
004 | 4444-1 | 2021-06-02 | 9999-12-31 |
005 | 5555 | 2021-06-02 | 2021-06-03 |
005 | 5555-1 | 2021-06-03 | 9999-12-31 |
006 | 6666 | 2021-06-03 | 9999-12-31 |
4.4 4日目(20210604): ${etldate}を20210602に置き換えてSQLを実行します。このとき、元のテーブルは002が更新、007が追加、001が削除されています。なお、削除する際にはデータの有効期限を当日に変更する必要があります。
ziper_table_test_cust_dst.cust_id | ziper_table_test_cust_dst.phone | ziper_table_test_cust_dst.s_date | ziper_table_test_cust_dst.e_date |
001 | 1111 | 2021-06-01 | 2021-06-03 |
001 | 1111-1 | 2021-06-03 | 2021-06-04 |
002 | 2222 | 2021-06-01 | 2021-06-02 |
002 | 2222-1 | 2021-06-02 | 2021-06-03 |
002 | 2222-2 | 2021-06-03 | 2021-06-04 |
002 | 2222-3 | 2021-06-04 | 9999-12-31 |
003 | 3333 | 2021-06-01 | 9999-12-31 |
004 | 4444 | 2021-06-01 | 2021-06-02 |
004 | 4444-1 | 2021-06-02 | 9999-12-31 |
005 | 5555 | 2021-06-02 | 2021-06-03 |
005 | 5555-1 | 2021-06-03 | 9999-12-31 |
006 | 6666 | 2021-06-03 | 9999-12-31 |
007 | 7777 | 2021-06-04 | 9999-12-31 |
5、ジッパーテーブルのデータロールバックリフレッシュ
ジッパー テーブルの最新のステータスは、次のコードで確認できます。
select * from datadev.zipper_table_test_cust_dst where e_date = '9999-12-31';
次のコードを使用して、ジッパー テーブルの履歴状態/スナップショットを表示します。
-- 查看拉链表的20210602的快照
select cust_id, phone, s_date, if(e_date > '2021-06-02', DATE('9999-12-31'), e_date) as e_date
from datadev.zipper_table_test_cust_dst
where s_date <= '2021-06-02';
したがって、ジッパー テーブルのデータ ロールバック リフレッシュの場合は、アピール コードに従ってその日の履歴スナップショットを検索し、それをリフレッシュするだけで済みます。(注: 上で投稿したジッパー テーブルの挿入ステートメントには、データのロールバックと更新の機能がすでに含まれています。読者は自分でテストできます。${etldate} をロールバックする日付に置き換えて、INSERT OVERWRITE TABLE 行をコメント アウトし、select を実行して結果を表示します。)
六、另一种实现
上一种实现方式有一个缺点,随着拉链表数据量的增多,每次执行的时间也会随之增多。因此,需要改进:可采用hive结合ES的方式。
-- 拉链表(hive只存储新增/更新量,全量存储于ES)实现代码
-- 临时表,只存放T-1天的新增以及变化的记录
CREATE TABLE IF NOT EXISTS datadev.zipper_table_test_cust_dst_2 (
`id` STRING COMMENT 'es id',
`cust_id` STRING COMMENT '客户编号',
`phone` STRING COMMENT '手机号码',
`s_date` DATE COMMENT '生效时间',
`e_date` DATE COMMENT '失效时间'
)STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
;
drop table datadev.zipper_table_test_cust_dst_2;
select * from datadev.zipper_table_test_cust_dst_2 a;
INSERT OVERWRITE TABLE datadev.zipper_table_test_cust_dst_2
select concat_ws('-', curr.s_date, curr.cust_id) as id,
curr.cust_id as cust_id,
curr.phone as phone,
DATE(curr.s_date) as s_date,
DATE('9999-12-31') as e_date
from (
select cust_id, phone, from_unixtime(unix_timestamp(dt, 'yyyyMMdd'), 'yyyy-MM-dd') as s_date
from datadev.zipper_table_test_cust_src
where dt = '20210603' -- etldate
) as curr
left join (
select *
from datadev.zipper_table_test_cust_src
where dt = '20210602' -- prev_date
) as prev
on prev.cust_id = curr.cust_id
where NVL(curr.phone, '') <> NVL(prev.phone, '')
union all
select concat_ws('-', STRING(prev.s_date), prev.cust_id) as id,
prev.cust_id as cust_id,
prev.phone as phone,
prev.s_date as s_date,
case when NVL(prev.phone, '') = NVL(curr.phone, '') then prev.e_date
else DATE(from_unixtime(unix_timestamp(dt, 'yyyyMMdd'), 'yyyy-MM-dd'))
end as e_date
from (
select cust_id, phone, s_date, e_date,
-- 只更新最新的一条
row_number() over(partition by cust_id order by s_date desc) as r_num
from datadev.zipper_table_test_cust_dst_2
) as prev
inner join (
select *
from datadev.zipper_table_test_cust_src
where dt = '20210603' -- etldate
) as curr
on prev.cust_id = curr.cust_id
where prev.r_num = 1
;
-- mock: load delta data to es
CREATE TABLE IF NOT EXISTS datadev.es_zipper (
`id` STRING COMMENT 'es id',
`cust_id` STRING COMMENT '客户编号',
`phone` STRING COMMENT '手机号码',
`s_date` DATE COMMENT '生效时间',
`e_date` DATE COMMENT '失效时间'
)STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY")
;
drop table datadev.es_zipper;
select * from datadev.es_zipper;
INSERT OVERWRITE TABLE datadev.es_zipper
SELECT nvl(curr.id, prev.id) as id,
nvl(curr.cust_id, prev.cust_id) as cust_id,
nvl(curr.phone, prev.phone) as phone,
nvl(curr.s_date, prev.s_date) as s_date,
nvl(curr.e_date, prev.e_date) as e_date
FROM datadev.es_zipper prev
full join datadev.zipper_table_test_cust_dst_2 curr
on curr.id = prev.id;