Flink+Clickhouse實時數倉在廣投集團的最佳實踐
一、業務背景
由于歷史原因,大型集團企業往往多個帳套系統共存,包括國內知名ERP廠商浪潮、用友、金蝶、速達所提供的財務系統,集團財務共享中心的財務人員在核對財務憑證數據時經常需要跨多個系統查詢且每個系統使用方式不一,同時因為系統累計數據龐大,制單和查詢操作經常出現卡頓,工作效率非常低。
數據中臺天然就是為了解決數據孤島和數據口徑不一致問題應運而生的,總的來說就是要將原本存在各帳套系統的數據實時接入中臺,中臺再將不同系統的數據模型進行歸一化處理,并且在數據分析平臺上提供統一的查詢入口。從下面的架構圖可以直觀地了解。
一方面,數據中臺提供的數據查詢服務需要覆蓋原系統的帳套查詢功能,這意味著原系統做的任何業務操作(插入分錄、刪除憑證、廢棄憑證、保存等)在數據中臺都需要有同步的事務反應,確保中臺提供的數據結果與原系統客戶端保持嚴格一致;另一方面,數據分析平臺需要提供級聯、上卷下鉆、多維聚合等附加能力,滿足海量數據分析的需求。重點需要考慮以下幾個問題:
- 開源的CDC監控產品大都是針對Mysql或Postgresql的,然而財務核算系統大多使用Oracel數據庫,如何選擇一套穩定且滿足業務需求(可監控增刪改事務操作)的CDC插件或lib庫;
- 如何保證端到端的數據一致性,包括維度一致性以及全流程數據一致性;
- 實時流處理過程中數據到達順序無法預知時,如何保證雙流join時數據能及時關聯同時不造成數據堵塞;
- 這個需求是典型的集多維分析和事務更新為一體的場景,并且對多維分析的響應時間(毫秒級)以及事務更新效率有極高的要求,所以如何解決HTAP的問題成為一大難題,目前開源社區并沒有提供一個能較好處理此問題的解決方案,包括Tidb和Greenplum。
我們一起來看看廣投集團實時數倉是如何巧妙解決這些問題的。
二、常見的實時數倉方案
常見的實時數倉架構有三種。
第一種是Lambda架構,是目前主流的一套實時數倉架構,存在離線和實時兩條鏈路。實時部分以消息隊列的方式實時增量消費,一般以Flink+Kafka的組合實現,維度表存在關系型數據庫或者HBase;離線部分一般采用T+1周期調度分析歷史存量數據,每天凌晨產出,更新覆蓋前一天的結果數據,計算引擎通常會選擇Hive或者Spark。優點是數據準確度高,不易出錯;缺點是架構復雜,運維成本高。
第二種是Kappa架構,相較于Lambda架構,它移除了離線生產鏈路,思路是通過傳遞任意想要的offset(偏移量)來達到重新消費處理歷史數據的目的。優點是架構相對簡化,數據來源單一,共用一套代碼,開發效率高;缺點是必須要求消息隊列中保存了存量數據,而且主要業務邏輯在計算層,比較消耗內存資源。
第三種是實時OLAP變體架構,是Kappa架構的進一步演化,它的思路是將聚合分析計算由OLAP引擎承擔,減輕實時計算部分的聚合處理壓力。優點是自由度高,可以滿足數據分析師的實時自助分析需求,減輕了計算引擎的處理壓力;缺點是必須要求消息隊列中保存存量數據,且因為是將計算部分的壓力轉移到了查詢層,對查詢引擎的吞吐和實時攝入性能要求較高。
三、為什么選擇Flink+Clickhouse?
以上任何一種架構都難以解決開篇提出的第四個問題,它是影響技術選型的關鍵制約因素。為什么這么說呢?Lambda架構的數據服務層無法同時滿足批量數據查詢、單條數據檢索以及Merge合并,而Kappa架構和實時OLAP變體架構要求實時采集側要拿到全量的Oracle歸檔日志數據,這在實際操作上沒有可行性,一方面Oracle是第三方廠商維護的,不允許對線上系統有過多的侵入,容易造成監聽故障甚至系統癱瘓,另一方面歸檔日志是在開啟那一刻起才開始生成的,之前的存量數據難以進入kafka,但是后來實時數據又必須依賴前面的計算結果。
怎么走出這樣的窘境呢?首先需要達成一個共識,就是計算層必須是Lambda架構,并且計算層離線鏈路的數據歸檔不再來源于實時日志,而是直接從業務庫定期抽取或導入。實際項目中由于產品體系技術兼容性的原因,離線鏈路這里選擇了Hive;實時鏈路上,Flink依靠其狀態管理、容錯機制、低時延和Exactly Once語義的優勢依然占據著流式計算領域難以撼動的地位,所以計算層我們確定了Hive+Flink的架構選型。有了這個共識我們再進一步分析數據服務層,這一層的性能要求有哪些呢,不妨先從大數據領域的4類場景分析:
·batch (B):離線計算
·Analytical(A):交互式分析
·Servering (S):高并發的在線服務
·Transaction (T):事務隔離機制
離線計算通常在計算層,所以我們重點考慮A、S和T,這三個場景在廣投計財實時查詢業務中都有涉及,這也是區別一般互聯網場景的地方,A、S、T的統一服務成為了亟待解決的難題。A要求快速的響應時間,S需要滿足高并發,T支持實時事務更新(傳統數據庫,一般交易場景對事務要求高)。市面上很多號稱能做到HTAP的產品,例如Tidb和Greeplum,深入分析就會發現,HTAP其實是一個偽命題,因為A和T的優化方向不同,為了保證T必然要犧牲A的性能,相反,如果想做到極致的A,則T的寫入鏈路將非常復雜,事務機制和QPS無法滿足需求。所以,不可能在一個系統上性能同時滿足高效的A、S、T。
大數據技術高速發展的時期,涌現出了一批A性能非常好的OLAP引擎,比如基于cube預聚合的kylin、Impala、阿里AnalyticsDB,但是適合實時攝入又能夠做離線分析的數據分析系統選擇性并不多,當前流行的有Druid或Clickhouse,它們是典型的列存架構,能構建index、或者通過向量化計算加速列式計算的分析。在Clickhouse還未被廣泛接受之前,Druid作為實時OLAP被一些互聯網大廠極力推崇使用,但是一直被詬病的是它復雜的技術架構,組件非常多,包括4個節點3個依賴,四個節點分別是實時節點(Realtime Node)、歷史節點(Histrical Node)、查詢節點(Broker Node)、協調節點(Coodinator Node),三個依賴分別是Mysql、Deep storage(如本地磁盤、Hdfs、S3)、Zookeeper,相當于內部實現了一個Lambda+OLAP的架構,學習成本和使用成本都非常高。下面從TPC-DH性能測試來看看幾大OLAP引擎對比。
從性能對比數據可以看出,Clickhouse在億量級數據集上平均響應時間為毫秒級,是其他分析性系統的幾十倍甚至上百倍。
為什么Clickhouse在A方向表現如此優異?它的S性能又如何呢?我們從存儲和查詢兩個維度來論證。
存儲架構
Clickhouse存儲中的最小單位是DataPart,寫入鏈路為了提升吞吐,放棄了部分寫入實時可見性,即數據攢批寫入,一次批量寫入的數據會落盤成一個DataPart,它不像Druid那樣一條一條實時攝入。但ClickHouse把數據延遲攢批寫入的工作交給來客戶端實現,比如達到10條記錄或每過5s間隔寫入,換句話說就是可以在用戶側平衡吞吐量和時延,如果在業務高峰期流量不是太大,可以結合實際場景將參數調小,以達到極致的實時效果。
查詢架構
(1)計算能力方面,Clickhouse采用向量化函數和aggregator算子極大地提升了聚合計算性能,配合完備的SQL能力使得數據分析變得更加簡單、靈活。
(2)數據掃描方面,ClickHouse是完全列式的存儲計算引擎,而且是以有序存儲為核心,在查詢掃描數據的過程中,首先會根據存儲的有序性、列存塊統計信息、分區鍵等信息推斷出需要掃描的列存塊,然后進行并行的數據掃描,像表達式計算、聚合算子都是在正規的計算引擎中處理。從計算引擎到數據掃描,數據流轉都是以列存塊為單位,高度向量化的。
(3)高并發服務方面,Clickhouse的并發能力其實是與并行計算量和機器資源決定的。如果查詢需要掃描的數據量和計算復雜度很大,并發度就會降低,但是如果保證單個query的latency足夠低(增加內存和cpu資源),部分場景下用戶可以通過設置合適的系統參數來提升并發能力,比如max_threads等。其他分析型系統(例如Elasticsearch)的并發能力為什么很好,從Cache設計層面來看,ES的Cache包括Query Cache, Request Cache,Data Cache,Index Cache,從查詢結果到索引掃描結果層層的Cache加速,因為Elasticsearch認為它的場景下存在熱點數據,可能被反復查詢。反觀ClickHouse,只有一個面向IO的UnCompressedBlockCache和系統的PageCache,為了實現更優秀的并發,我們很容易想到在Clickhouse外面加一層Cache,比如redis,但是分析場景下的數據和查詢都是多變的,查詢結果等Cache都不容易命中,而且在廣投業務中實時查詢的數據是基于T之后不斷更新的數據,如果外掛緩存將降低數據查詢的時效性。
事實上,Clickhouse在億數量級數據集基礎上聚合分析查詢響應時間、吞吐和并發能力不亞于ES,并且隨著數據量的增大而擴大。下圖是分別在2億和5億數據集上的測試結果,Q1、Q2、Q3、Q4表示數據量依次增大的sql query。
我們已經分析Clickhouse在A和S方面的優勢,那么它又該如何承載T的業務呢?
前面我們已經講了,在一個系統中不可能同時實現A和T,為什么不讓他們做自己擅長的事情呢?經過深入業務洞察發現,廣投計財實時查詢業務中T的作用范圍是最近一年的數據,經過估算,頻繁發生刪改操作的數據有500萬左右,不到總數據量2.5億的1/50。我們完全可以用兩個不同系統協作實現,其中一個系統實現S和T,一般的關系型數據庫就可以滿足,例如Mysql、Postgresql;另外一個系統實現A,這里我們選擇Clickhouse。我們很容易想到聯邦查詢,例如Presto和Drill的解決方案,其實Clickhouse內部已經集成了多個數據庫引擎來替代聯邦查詢,沒必要引入第三方框架,于是適合廣投計財實時查詢業務的實時數倉架構如下,筆者暫且稱它為LSTAP架構(Lambda+HSTP+OLAP)。
在這個架構中,最外層以一個Clickhouse視圖連接Mysql引擎和Distributed引擎對應的表數據,Mysql只儲存需要實時更新的那部分數據,實時鏈路每天從Mysql中取離線定期刷新的狀態數據,確保不會因為實時鏈路網絡原因、系統故障、應用邏輯錯誤等造成數據質量問題;Distributed引擎對應的Clickhouse表存儲歷史數據,類似于Druid里面的Histrical Node,滿足統計分析和歷史賬單數據的查詢需求。
--映射Mysql表提取最新一年的數據
CREATE TABLE jc_bi.ads_journal_recent_1year
(
`pk_detail` String,
...
`datasource` String,
`synctime` String
)
ENGINE = MySQL('10.100.x.xx:3306',
'jc_bi',
'ads_journal',
'xxx',
'xxx');
--副本表
CREATE TABLE jc_bi.ads_journal_replica
(
`pk_detail` String,
...
`datasource` String,
`synctime` String
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/ads_journal_replica',
'{replica}')
PARTITION BY datasource
ORDER BY (unitcode,...)
SETTINGS index_granularity = 8192;
--Clickhouse分布式表,與副本表ads_journal_replica對應
CREATE TABLE jc_bi.ads_journal_dist
(
`pk_detail` String,
...
`datasource` String,
`synctime` String
)
ENGINE = Distributed('cluster_3shards_1replicas',
'jc_bi',
'ads_journal_replica',
rand() % 3);
--以視圖合并Mysql引擎和distributed引擎的兩張表
CREATE VIEW jc_bi.v_ads_journal
(
`pk_detail` String,
...
`datasource` String,
`synctime` String
) AS
SELECT *
FROM jc_bi.jc_bi.ads_journal_recent_1year
UNION ALL
SELECT *
FROM jc_bi.ads_journal_dist;
四、實時數倉1.0
經過前期的技術調研和性能分析,基本確定了以Flink+Clickhouse為核心構建實時數倉。當然,還需要依賴一些其他技術組件來支起整個實時數倉,比如消息隊列Kafka、維度存儲、CDC組件等。廣投數據中臺項目的基礎設施除了部署了開源的CDH存儲與計算平臺之外,還采購了“Dataphin+QuickBI”分別提供數據治理能力和可視化能力,在計財實時查詢系統中,Dataphin主要用來承擔離線任務調度以及起到HQL ide集成環境的作用,QuickBI作為數據分析門戶提供數據查詢窗口。
這里提幾個關鍵的設計點:
1、實時維度存儲采用Hbase,對于快變維度可以實現實時更新,rowkey采用“md5(主鍵)取前8位+datasource”,保證唯一性和散列性;
2、為了保證離線和實時維度數據一致性,將hive dwd層中維表數據映射到Hbase中,同時為了保障實時查詢系統的穩定性,規避實時鏈路中由于網絡延遲、數據丟失、維度未及時更新造成數據項缺失或其他不可預知的問題等導致的查詢結果不可信以及例如kafka集群某節點掉線,代碼bug導致任務中斷等造成計算結果無法回滾,將離線計算結果每日定期供給到實時應用checkpoint,以此來解決開篇的問題2,即端到端的數據一致性;
create external table cdmd.dim_bd_accsubj_mapping_hbase(
rowkey String,
pk_accsubj String,
...
modifytime String,
datasource String
)stored by 'org.apache.hadoop.hive.hbase.HBaseStorageHandler'
with serdeproperties("hbase.columns.mapping"=":key,f:pk_accsubj,...,f:modifytime,f:datasource")
tblproperties("hbase.table.name"="dim:DIM_BD_ACCSUBJ");
insert overwrite table cdmd.dim_bd_accsubj_mapping_hbase
select
concat(substring(md5(pk_accsubj),0,8),datasource) as rowkey,
pk_accsubj,
...
modifytime,
datasource
from cdmd.dim_bd_accsubj;
3、使用Mysql結合Clickhouse的組合方式提供實時數據寫入、事實數據更新、批量分析、實時響應、高并發查詢為一體的數據服務能力,解決了開篇問題4,這一點在第三章已經詳細論述;
4、采用“多流join+實時維度讀取”的獨創雙保險模式,解決了多流關聯場景下的數據項丟失和數據堵塞問題,即開篇的問題3,這部分將在第五章中詳細介紹。
五、踩過的“坑”
在整個實時數倉構建的過程中,遇到了不少麻煩,尤其是實時Flink應用開發,現將關鍵問題列舉如下:
(1) cdc插件選型
網上關于實時采集Oracle數據的資料并不多,通常的做法有以下幾種:
- 購買Oracel原生提供的OGG,debizum的本質也是基于OGG,這種方式雖然省事但是價格昂貴;
- Kafka提供了連接各種關系型數據庫的Connect,但是它是基于時間戳或整型增量主鍵的觸發式拉取,對源系統壓力大且時延較高,最主要的是無法感知刪除操作;
- 自研kafka的connect,基于Logminer實現重做日志監控和解析,并實現Kafka的connect接口將解析后的數據推送到Kafka topic;
前兩種方案很快就被pass掉了,只剩下第三種方案,在死磕Logminer實現機制和歷經艱辛的研發后,終于實現了Oracle數據增刪改的實時監控并推送到Kafka。但是在使用一段時間以后會出現數據丟失和無響應的情況,主要原因是對Logminer查詢的優化不夠,鑒于項目緊急程度和時間成本的考量,項目組評估決定暫時放棄自研,于是尋求另外的解決方案,最終使用了Streamsets。Streamsets圖形化的配置非常友好,監控也比較穩定。但是很快就發現了它的弊端:online模式(streamsets分為redo模式和online模式,redo模式生成的是原生sql,online模式產生的是解析后的json)下更新操作時無法拿到舊數據。由于后續的架構優化需要利用這個特性,為了解決這個問題,下一步我們將在這個基礎上進行二次開發,具體方案在“實時數倉2.0”章節中介紹。
(2) Idea調試Flink代碼在開啟checkpoint的情況下,觸發報錯時不輸出異常信息且不斷重啟
Flink默認開啟任務重啟策略,當開啟checkpoint時,如果代碼有bug會導致整個任務不斷重啟,而不會拋出異常,很難排查問題。
解決思路:在開發環境注釋checkpoint調試運行,如果業務邏輯代碼沒有問題再開啟恢復checkpoint代碼后重新發布到線上。
(3) hive映射Hbase后數據類型不對應問題
將維度表從hive映射到hbase時,假如在hive中的數據類型是smallint類型,如果映射到hbase中然后將get到的字節數組轉int會報錯。
解決辦法:先將get到的字節數據轉String類型然后使用包裝類轉int。
(4) hdfs配置高可用后namenode節點切換導致hdfs地址失效
在使用RocksDb狀態后端時需要設置一個hdfs路徑用來保存checkpoint文件。如果hdfs路徑寫死為active的節點,當集群出現問題namenode切換時,原來的active狀態的namenode變為standby狀態,代碼會拋出異常:
解決辦法:
Hadoop配置nameservice,然后hdfs路徑使用nameservice路徑。
(5) fastjson的坑
用fastjson將一個pojo對象轉換為json字符串時,如果pojo的屬性名同時有大小寫,那么直接使用JSONObject.toJSONString方法轉換json會造成屬性的大小寫改變。另外使用fastjson將json字符串轉換為JSONObject時可能丟失一些屬性。為了穩定選擇Gson。
(6)實時數據亂序導致計算結果錯誤
財務人員在核算系統上的每個操作動作在數據庫中都有對應的事務變化,但是這個變化可能不是一一對應的,一個動作可能產生多個事務,而且每個系統的規則可能都不一樣:刪除操作可能是物理刪除也可能標記刪除,更新操作可能在原紀錄直接更新,也可能標記刪除后再插入新的數據。比如其中一個核算系統的更新憑證表現為:insert-update-delete-insert,刪除憑證操作表現為:update標志位。
在分布式場景下,數據流從kafka(多個partition分區)到Flink的過程中,數據的先后順序會發生改變導致計算結果錯誤,解決數據亂序問題有兩種方案:第一種是kafka設置單分區,第二種是在Flink中分組處理。第一種方案在生產環境業務高峰期顯然是不太合適的,對kafka單節點壓力較大且無法發揮分布式系統并行處理能力的優勢。于是我們選擇了第二種方案,以主鍵或聯合主鍵作為分區鍵,保證同一主鍵或聯合主鍵對應的數據有序。
(7) 關聯維度等待數據導致數據阻塞問題
在對事實表數據進行拉寬操作時,需要從hbase關聯維度數據,對于實時更新的維度,并不能保證在從hbase取維度數據之前,維度數據已經更新到hbase。
最初的方案是如果從hbase拿不到維度數據則繼續查,直到拿到維度數據或者超過500毫秒。
Demon如下:
public static byte[] getCellBytes(Connection connection, String tableName, String rowkey, String columnFamily, String column) {
//獲取表的連接
Table table = null;
try {
table = connection.getTable(TableName.valueOf("dim", tableName));
} catch (IOException e) {
e.printStackTrace();
log.error("查詢hbase中維表過程中創建table出錯!!!");
}
//創建get對象
Get get = new Get(Bytes.toBytes(rowkey));
Result result = null;
if ("T_GL_VOUCHERENTRY_Data".equals(tableName)) {
long time = System.currentTimeMillis();
byte[] value;
do {
try {
result = table.get(get);
} catch (IOException e) {
e.printStackTrace();
log.error("查詢hbase中維表過程中get result出錯!!!");
}
value = result.getValue(Bytes.toBytes(columnFamily), Bytes.toBytes(column));
} while ( value == null && System.currentTimeMillis() <= time + 500); //未拿到維度數據則等待500ms
return value;
} else {
try {
result = table.get(get);
} catch (IOException e) {
e.printStackTrace();
log.error("查詢hbase中維表過程中get result出錯!!!");
}
byte[] value = result.getValue(Bytes.toBytes(columnFamily), Bytes.toBytes(column));
return value;
}
}
這個方案帶來了致命性問題:
數據處理時延非常高,達不到理想的實時效果。每條憑證數據要關聯多個字段,假如每關聯一個字段都要等待500ms,每條數據都要等好幾秒才能拿到全部維度,在數據量大的情況下數據堵塞非常嚴重,時延超過一小時。
為了解決這個問題,決定采用“多流join”的方案:使用開窗函數,三流join關聯維度。拿浪潮核算系統為例,Flink消費Kafka中事實數據為一條事實流,消費憑證維度數據為一條維度流,消費輔助項維度數據為另一條維度流,采用處理時間(process time)語義,對三條流進行開窗,窗口長度時間為5秒,使用coGroup算子左連接進行三流join。
多join雖然能解決時延問題,但是假如事實數據和維度數據所在的窗口不對齊,那么會導致拿不到相應的維度數據。為了解決這一問題,同時另外運行一個維度更新的Flink任務將更新的維度數據寫入hbase。在事實流與維度流進行左連接join的時候,若維度流中拿不到該維度數據則往hbase查詢,即“多流join+Hbase維度讀取”的雙重保險方案。
Demon如下:
DataStream<DWD_GL_DETAIL> joinDs1 = LangChaoFctDs.coGroup(LangChaoVoucherDim)
.where(new KeySelector<FCT_GL_DETAIL, String>() {
@Override
public String getKey(FCT_GL_DETAIL value) throws Exception {
String pk_voucher = value.getPk_voucher();
return pk_voucher;
}
})
.equalTo(new KeySelector<DIM_BD_VOUCHER, String>() {
@Override
public String getKey(DIM_BD_VOUCHER value) throws Exception {
String pk_voucher = value.getPk_voucher();
return pk_voucher;
}
})
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.apply(new RichCoGroupFunction<FCT_GL_DETAIL, DIM_BD_VOUCHER, DWD_GL_DETAIL>() {
Connection connection;
Gson gson;
@Override
public void open(Configuration parameters) throws Exception {
connection = HbaseConnection.createConnection();
gson = new Gson();
}
@Override
public void coGroup(Iterable<FCT_GL_DETAIL> first, Iterable<DIM_BD_VOUCHER> second, Collector<DWD_GL_DETAIL> out) throws Exception {
for (FCT_GL_DETAIL fct_gl_detail : first) {
String pk_voucher = fct_gl_detail.getPk_voucher();
String datasource = fct_gl_detail.getDatasource();
DWD_GL_DETAIL dwdpojo = new DWD_GL_DETAIL();
DWD_GL_DETAIL dwd_gl_detail = handleDwdLc(connection, gson, fct_gl_detail, dwdpojo);
Boolean ishave = false;
for (DIM_BD_VOUCHER dim_bd_voucher : second) {
//省略中間處理過程
ishave = true;
}
//如果維度流中拿不到維度數據則從hbase中查詢
if (!ishave && pk_voucher != null) {
String rowkey1 = Md5Util.getMD5Str(pk_voucher).substring(0, 8) + datasource;
//省略往hbase查詢憑證維度代碼
}
out.collect(dwd_gl_detail);
}
}
@Override
public void close() throws Exception {
if (connection != null) {
connection.close();
}
}
});
//雙join關聯輔助項維度與關聯憑證維度的邏輯類似,這里省略。
新方案解決了數據處理不過來導致的背壓問題,將處理時延降低到5秒以內。
六、實踐展望-實時數倉2.0
實時數倉1.0在廣投集團已經穩定上線運行近一個月,但是回顧技術架構,盡管它解決了實時計算領域的AST共存問題,其實還有一些優化改進的地方,比如存儲冗余、實時和離線獨立開發、查詢系統依賴組件較多等,接下來我們逐一分析。
統一存儲
仔細分析實時數倉1.0的架構我們會發現:為了解決離線計算問題,往Hive里面存儲一份數據;為了解決流式數據緩沖問題,在Kafka存儲了一份;為了解決維度更新和點查詢的問題,又往HBase里面存儲一份;為了解決列存的快速分析,數據需要Clickhouse里面存一份,甚至為了提供S和T的服務能力,我們又加入了Mysql。這樣帶來的問題就是:
(1)存儲成本高
同一份數據在多個系統中做了冗余存儲,增加了存儲成本。
(2)維護難度大
每個系統的存儲格式不一致,導入導出需要做類型轉換,而且隨著業務量增加,系統本身的維護變得困難,甚至需要配置專業的集群運維人員,對系統異常和安全管控做日常巡檢。
(3)學習成本高
每一套存儲系統運行原理和開發方式都不一樣,對于新人來講很難快速上手,增加了培訓成本。
有沒有一個統一的存儲產品呢?有,數據湖,目前關注度比較高的有Databricks推出的Delta Lake、Uber的Hudi以及Netflix的Iceberg,詳細的參數對比可參考https://mp.weixin.qq.com/s/m8-iFg-ekykWGrG3gXlLew。 Delta Lake和Hudi都和Spark結合的比較好,不得不說,在數據湖的實踐方面,Spark生態構建走在了Flink前面,但是也已經有一些互聯網大廠開始實踐Hudi、Iceberg與Flink結合的實時數倉,期待數據湖開源社區能夠兼容Flink。
統一計算
目前離線和實時采用兩套代碼開發,離線采用HQL,實時使用Flink DataStream API 進行開發,會導致開發時間長,可閱讀性差,代碼交接和運維難度大。
基于數據湖的流批一體、版本管理、自管理schema的特性,以及Flink 1.12以后具備批流API的統一以及與Hive的集成,我們很方便地將Hive中的數據遷移到數據湖,使用Flink SQL進行離線和實時程序開發,并且可以共用一套代碼。但是目前數據湖在寫多讀少的場景性能還有待提升,我們不妨拭目以待。
統一查詢
查詢層為了實現AST服務能力,我們引入了Clickhouse+Mysql的架構。查詢層的架構能否再簡化呢?答案是肯定的。
Flink Sql 集成 Clickhouse快速查詢
首先,通過 jdbc連接器的方式 create table 創建Clickhouse 表
CREATE TABLE gl_detail_ck
( `PK_DETAIL` STRING,
...
`TABLENAME` STRING,
`OPERATION` STRING,
`DATASOURCE` STRING,
`SCN` BIGINT )
WITH ('connector' = 'jdbc',
'url' = 'jdbc:clickhouse://:/',
'table-name' = 'gl_detail',
'username' = '', 'password' = '' );
Flink Sql jdbc的方式默認是不能集成Clickhouse的,為什么呢?
因為在源碼中已經寫死了只支持Derby,Mysql,Postgres方言。如果非要集成,有以下兩種途徑:
(1)修改Flink源代碼,重新打包,添加修改后的jar包
(2)使用反射修改DIALECTS靜態final屬性
第一種方式太麻煩了,在這里我們采用第二種方案:
使用VersionedCollapsingMergeTree引擎實現修改刪除操作
CREATE TABLE jc_bi.gl_detail (
`pk_detail` String,
...
`sign` Int8,
`version` UInt32 )
ENGINE = VersionedCollapsingMergeTree(sign,version)
PARTITION BY datasource
ORDER BY pk_detail
SETTINGS index_granularity = 8192;
以增量折疊代替事務更新的方式需要注意的問題
(1)為了解決數據爆發式增長問題,需要定期執行
optimize table 表名
(2)如果要實現數據增量折疊,必須拿到修改之前的舊數據來以此來抵消上個版本對應的數據,這里采用“Streamsets+原生SQL解析”的方式實現。
demo示例
通過 Streamsets data controller(sdc)消費歸檔日志獲取執行語句后寫入 Kafka 中
{
"sql":"update 'GL_DETAIL' set 'ERRMESSAGE' = NULL where PK_DETAIL=********,、、、",
"TABLENAME":"GL_DETAIL",
"OPERATION":"UPDATE",
"DATASOURCE":"NC",
"SCN":"91685778"
}
通過Flink消費 Kafka 并且解析 Sql 獲取更新前的字段
Sql解析邏輯簡化如下:
public void parse(String sqlRedo) throws JSQLParserException {
//通過jsqlparser開源sql解析框架對Sql進行解析獲取Satement
Statement stmt = CCJSqlParserUtil.parse(sqlRedo);
LinkedHashMap<String,String> afterDataMap = new LinkedHashMap<>();
LinkedHashMap<String,String> beforeDataMap = new LinkedHashMap<>();
parseUpdateStmt((Update) stmt, beforeDataMap, afterDataMap, sqlRedo);
}
private static void parseUpdateStmt(Update update, LinkedHashMap<String,String> beforeDataMap, LinkedHashMap<String,String> afterDataMap, String sqlRedo){
Iterator<Expression> iterator = update.getExpressions().iterator();
//通過獲取更新字段的迭代器填充afterDataMap
for (Column c : update.getColumns()){
afterDataMap.put(cleanString(c.getColumnName()), cleanString(iterator.next().toString()));
}
//通過where語句來獲取未修改的字段的值
if(update.getWhere() != null){
update.getWhere().accept(new ExpressionVisitorAdapter() {
@Override
public void visit(final EqualsTo expr){
String col = cleanString(expr.getLeftExpression().toString());
if(afterDataMap.containsKey(col)){
String value = cleanString(expr.getRightExpression().toString());
beforeDataMap.put(col, value);
} else {
String value = cleanString(expr.getRightExpression().toString());
beforeDataMap.put(col, value);
afterDataMap.put(col, value);
}
}
});
}else{
LOG.error("where is null when LogParser parse sqlRedo, sqlRedo = {}, update = {}", sqlRedo, update.toString());
}
}
最終解析后將數據再寫入到kafka,數據如下:
{
"scn":91685778,
"type":"UPDATE",
"schema":"nc",
"table":"GL_DETAIL",
"ts":6797472127529390080,
"opTime":91945745,
"after_TS":"2021-05-10 18:50:53"、、、、,
"before_TS":"2021-05-10 18:50:23"、、、、
}
Flink Sql進行數據版本標記,再寫入到Clickhouse中,至此實現了與關系型數據庫一致的增刪改查, 主要邏輯如下。
Table sqlParsertest =
tEnv.sqlQuery(
"select "+ "*"+"from (\n" +
"select "+ before_column + " , -1 AS sign ,abs(hashcode("+before_column_string+")) AS version from sqlParsertest where type = 'UPDATE'\n" +
"union all\n" +
"select "+ after_column + ", 1 AS sign , abs(hashcode("+after_column_string+")) AS version from sqlParsertest where type = 'UPDATE' " +
"union all\n" +
"select " +after_column +" ,1 AS sign, abs(hashcode(" +after_column_string +")) AS version from sqlParsertest where type = 'INSERT'\n" +
"union all\n" +
"select "+ before_column+",-1 AS sign, abs(hashcode( "+before_column_string +")) AS version from sqlParsertest where type = 'DELETE' "+
") \n"
);
tEnv.executeSql( " insert into gl_detail_ck select * from " + sqlParsertest);
通過abs(hashcode("+before_column_string+"))來得到版本號,當刪除和更新時都會生成與舊數據相同的版本號,同時通過-1的標志位來實現折疊的效果,從而實現與關系型數據一樣的增刪改查操作。
實現效果如下:
相同版本號且標志位相反的數據被折疊抵消了,實現了增刪改的操作。
如果更新操作僅修改了度量,可以通過變體sql的查詢方式實現折疊的效果,獲得最新數據。
SELECT pk_detail, sum(度量 * sign) FROM gl_detail GROUP BY pk_detail HAVING sum(sign) > 0;
因為在定義version字段之后,VersionedCollapsingMergeTree會自動將version作為排序條件并增加到ORDER BY的末端,就上述的例子而言,最終的排序字段為ORDER BY pk_detail,version desc。
但是如果更新操作修改了度量之外的屬性信息,需要執行:
select * from gl_detail FINAL;
實時數據倉庫2.0架構
在實時數倉1.0架構優化后,架構進行了極度簡化,實時數倉2.0架構如下:
實時數倉2.0架構統一了存儲、計算和查詢,分別由三個獨立產品負責,分別是數據湖、Flink和Clickhouse。數倉分層存儲和維度表管理均由數據湖承擔,Flink SQL負責批流任務的SQL化協同開發,Clickhouse實現變體的事務機制,為用戶提供離線分析和交互查詢。CDC到消息隊列這一鏈路將來是完全可以去掉的,只需要Flink CDC家族中再添加Oracle CDC一員。未來,實時數倉架構將得到極致的簡化并且性能有質的提升。