早前與 Azure Business Group 洽談合作內容時,有幸得到 USD $1000 Azure Credit,筆者當然要把握這個機會去測試心目中一個長久以來的 Managed Database 迷思:

「Database 要實踐 Linear Horizontal Scaling 又要有 Managed Service 就一定要揀陌生嘅 NoSQL 或者 NewSQL 起手?」

「想用番自己最熟悉嘅 Relational Database 技術去實踐係咪真係唔得?」

「Day 1 想要一個 Managed and Scale-out-ready Relational Database 起壇,係咪真係做唔到?」

本文會測試 Azure Database for PostgreSQL 上最新 Generally Available (GA) 的 Deployment Option - Hyperscale (Citus),探討一下 Horizontal Scaling 的設計要求、不同設定在不同 Query 的效果及其限制,同場亦會用上另一款歷史悠久的 Managed PostgreSQL Database - Azure Single server 去做 Vertical Scaling 比較兩者的真實效能差異。

於開始之前,簡單介紹一下 Hyperscale (Citus) 這個技術。

Hyperscale (Citus)

Citus 是一個免費的 PostgreSQL Open Source Extension,利用 Data Sharding 的原理去實現 Distributed Database、Parallelism 及 Horizontal Scaling,設計亦解決了 Distributed Transactions 有機會引伸的問題。

值得一提的是 Microsoft 早於 2019 年已收購了 Citus Data,在 Azure Data 設有一條 Dedicated Team 去開發這個 Citus Open Source Extension,所以 Azure Hyperscale (Citus) server group 也可算是 Microsoft 自行投資開發的技術。

Citus 整個概念由 Coordinator 及 Worker Nodes 兩部份組成,Coordinator 將 Query Distibute 到不同 Worker Node,實踐 Parallel 再整合一個結果,概念如下:

在 Query Plan 的 Architecture 也有少許分別,Citus 會有自己的 Distributed Query Planner、Distributed Query Exectors 及 Distributed Join Functions(藍色部份),Citus Query Processing Architecture 如下:

就以上兩項資訊,要完全發揮 Citus 的 Horizontal Scaling 就必須在 Distributed Table 下苦功,代表從 Data Model 結構到 Query 的 Use Cases 都必須有妥當的設計去發揮 Data Sharding,否則大部份 Computing Resource 會消耗在 Coordinator-to-WokerNodes 之間的 Data Transfer Overhead。

所以在準備是次測試前,筆者都花了一段時間閱讀 Citus 各方面的資料,希望可以「1 Take」過測試 Citus 強大的地方。

The Performance Test

Preparation

參考了 Citus 官網一個簡單的 Data Analytic Use Case

選用這個 Use Case 原因有兩個:

  • Unpredictable Data growth - 因為用戶的 User Behaviour 很難去估算,以一個 Horizontal Scaling Database 為基礎,可以大大減低 Data Size 增長後而產生的 Performance 問題
  • Good Data Sharding Models - 日常 Query 通常是根據 Company -> Campaigns -> Ads 逐個去處理,測試也很貼近實際情況
  • 7 x 24 Services - Citus 可以做到 Zero downtime Scale out,對運行 7x24 的服務非常有幫助

雖然本文的中心目的是測試 Citus 的 Horizontal Scaling,但為了有更好的比較,以下所有測試也會在 Single Server 運行一次以作比較之用。

Target Subjects

Service Main/Coordinator Worker Price(HKD)
HyperScale 4C, 32GB, 1TiB 4C x 2, 0.5TiB $14,249
HyperScale 8C, 32GB, 1TiB 4C x 2, 0.5TiB $18,080
HyperScale 8C, 32GB, 1TiB 4C x 4, 0.5TiB $27,403
HyperScale 16C, 64GB, 1TiB 4C x 8, 0.5TiB $53,709
HyperScale 16C, 64GB, 1TiB 8C x 8, 0.5TiB $86,612
HyperScale 16C, 64GB, 1TiB 4C x 16, 0.5TiB $90,998
Single Server 2C, 20GB, 1024GB N/A $3,152
Single Server 4C, 40GB, 1024GB N/A $5,050
Single Server 8C, 80GB, 1024GB N/A $8,853
Single Server 16C, 160GB, 1024GB N/A $16,453
Single Server 32C, 320GB, 1024GB N/A $31,659

Sharded Data Scenarios

由於是次 Performance test 希望透過 Heavily Sharded Data 去測試 Horizontal Scaling Performance,所以 1 個 Unit 的 Sample Data 大約如下:

  • 1 Company -> 10 Compaigns
  • 1 Compaign -> 1 Ad
  • 1 Ad -> 10 Clicks
  • 1 Ad -> 100 Impressions
Table Count
Company 1
Campaign 10
Ad 10
Click 100
Impression 1000
Total 1121
Table Size
Company 480,000
Campaign 4,800,000
Ad 4,800,000
Click 48,000,000
Impression 480,000,000
Total 538,080,000

一共 538M 個 Records 約為 150GB,這次是以 company_id 為主的 Sharding,在 Citus 上要分別建立 distributed table

SELECT create_distributed_table('companies', 'id');
SELECT create_distributed_table('campaigns', 'company_id');
SELECT create_distributed_table('ads', 'company_id');
SELECT create_distributed_table('clicks', 'company_id');
SELECT create_distributed_table('impressions', 'company_id');

根據 Citus Table Co-Location 設計,所有與 company_id 相關的 Data 都會存放在同一個 Worker Node 上,也代表如果我們 以一個 Company 為單位去 Read/Write Database 就能有效發揮到 Distributed Table + Co-Location 威力!

Test Cases (SQL Explain)

整個 Test Suite 有 6 條 Read Queries 及 1 組 Insert Company Unit

  1. 測試在不同環境下 Citus Coordinator 及 Worker 的 Write Performance

  2. 目的是測試在不同 Query 及不同 Citus Worker 數目及 Coordinator vCPU 數目環境下的 Read Performance

Query 0 - Light Load

    # find one company by id within range
    SELECT id
    FROM companies
    WHERE id = $1 AND created_at > $2 AND created_at < $3

Query 1 - Light Load

    # find campaign by `company_id` and some filters
    SELECT id
    FROM campaigns
    WHERE company_id = $1 
        AND created_at > $2 
        AND created_at < $3 
        AND state = $4 
        AND monthly_budget > $5
    ORDER BY created_at
    LIMIT 100

Query 2 - Light Load

    # find ads,campaigns by `company_id` and some filters
    SELECT c.id, a.id
    FROM ads as a
    JOIN campaigns c
        ON c.company_id = a.company_id
               AND c.id = a.campaign_id
    WHERE c.company_id = $1 
        AND a.created_at > $2 
        AND a.created_at < $3 
        AND c.state = $4 
        AND c.monthly_budget > $5
    ORDER BY a.created_at
    LIMIT 100

Query 3 - Light Load

    # find clicks,ads,campaigns by `company_id` and some filters
    SELECT c.id, a.id, ca.id
    FROM clicks as c
    JOIN ads as a
        ON c.company_id = a.company_id
            AND c.ad_id = a.id
    JOIN campaigns ca
        ON ca.company_id = a.company_id
            AND ca.id = a.campaign_id
    WHERE c.company_id = $1 
        AND a.created_at > $2 
        AND a.created_at < $3 
        AND ca.state = $4 
        AND ca.monthly_budget > $5
    ORDER BY a.created_at
    LIMIT 100;

Query 4 - Heavy Load

    # Large amount of data
    # find impression,ads by `company_id` and some filters
    SELECT i.id, a.id
    FROM impressions as i
    JOIN ads as a
        ON i.company_id = a.company_id
            AND i.ad_id = a.id
    WHERE a.company_id = $1 
        AND i.seen_at > $2 
        AND i.seen_at < $3
    ORDER BY i.seen_at
    LIMIT 100;

Query 5 - Heavy Load

    # find best impression on ads, with count(\*), RANK() 
    # by `company_id` and some filters
    SELECT a.campaign_id,
           a.id,
           RANK() OVER (
               PARTITION BY a.campaign_id
               ORDER BY a.campaign_id, count(*) desc
           ),
           count(*) as n_impressions
    FROM ads as a
    JOIN impressions as i
        ON i.company_id = a.company_id
            AND i.ad_id = a.id
    WHERE a.company_id = $1 
        AND i.seen_at > $2 
        AND i.seen_at < $3
    GROUP BY a.campaign_id, a.id
    ORDER BY a.campaign_id, n_impressions desc

Query 6 - Heavy Load & Cross Distributed Tables

    # require table scan and lookup cross distributed tables
    # impression JOIN ads, count(\*), RANK() 
    # by list of `company_id` and some filter
    SELECT a.campaign_id,
           a.id,
           RANK() OVER (
               PARTITION BY a.campaign_id
               ORDER BY a.campaign_id, count(*) desc
           ),
           count(*) as n_impressions
    FROM ads as a
    JOIN impressions as i
        ON i.company_id = a.company_id
            AND i.ad_id = a.id
    WHERE a.company_id = ANY($1) # multiple companies
        AND i.seen_at > $2 
        AND i.seen_at < $3
    GROUP BY a.campaign_id, a.id
    ORDER BY a.campaign_id, n_impressions desc

The Performance Test (Result)

Write Performance

普遍 Relational Database,Table Fields 愈多 Constraint 時,Data Volume 愈多 Insert Rate 就會愈慢。

Citus Distributed Table 將 Data 分佈到不同 Worker Nodes,証實可以減低 Insert Performance Degrade 的速度。

按此開啟完整圖表

Write Performance on Different Hyperscale Config

按此開啟完整圖表

Coordinator vCPU 會直接影響整個 Insert Throughput,因為每個 Query 都要由 Coordinator 處理;由上圖可見,Scale out Worker Nodes 也沒有顯著提升 Inesrt Throughput。

Light Read Queries {0,1,2,3}

按此開啟完整圖表

由於 Query 太簡單,所以 Throughput 很快到了每個 Worker Nodes 的極限,要透過 Vertically Scale-up 才可以有顯著提升。

接著就測試一著 Work Load 較重的 Query。

Heavy Read Queries {4,5}

按此開啟完整圖表

當筆者不斷 Scale out 再進行 Rebalance 後,效能有明顯的上升,而且也是 Linear Scale 的;但隨 Worker Nodes 的數目上升,每個 Nodes 內的 Distributed Table 的 Data Volume 便會下降,如下列表

Nodes 2 4 8 16
Records 120k 60k 30k 15k
Size 75GB ~37GB ~18GB ~9GB

所以當 Data Volume 不足夠多時,Data Sharding / Distributed 比起 Parallelism 所獲得的優化使用更多資源,卻會令整體效能下降。

Heavy Queries {6} (Query Cross Sharded Keys)

按此開啟完整圖表

從圖表可以,Single Server 的效能遠超 Hyperscale,因為當 Query Planner 發現要 Cross Join 不同 Worker Nodes 的 Distributed Tables 時,這𥚃會發生 Data Transfer 的 Network Overhead。

在這個環境下,筆者亦測試了

  • Scale-up Coordinators vCores 不能提升 Throughput - 証明 Overhead 不在 Coordinators Processing 上。
  • Scale-up Worker Nodes vCores 可以提升 Throughput - 主要原因是 Query 6 是一個 Table Scan Query,每個 Worker Nodes 可以透過增 vCPU 去降低 Response Time。
  • Scale-out Worker Nodes 也可以提升 Throughput - 筆者估計主要原因是 Table Scan 的數量減少了一半,也降低了 Worker Nodes 原來的 Response Time。

Citus Rebalancing

Horizontal Scaling (Scale out) 時,要主動做一次 Rebalance,將 Sharded Data 再分佈到其它 Worker Node 上,Azure Portal 上都有註明 Rebalance 動作是 Zero-down time。

# Rebalance the tables
SELECT rebalance_table_shards('companies');
SELECT rebalance_table_shards('campaigns');
SELECT rebalance_table_shards('ads');
SELECT rebalance_table_shards('clicks');
SELECT rebalance_table_shards('impressions');

150GB Data 總共使用了 1 小時 20 分鐘進行 Rebalance,筆者亦測試過在 Rebalance 期間行運之前的 Performance Test 也沒有問題。

留意,如果要 Vertically scale up Coordinator 或 Worker 的話,Citus 同樣也要 Restart 才能進行,如下圖:

Dump & Restore

最後測試一下由 Existing PostgreSQL Migrate 到 Hyperscale(Citus) 的可能性:

  1. 在 Single PostgreSQL DB(Azure Single Server) 用 pg_dump 將 150GB PostgreSQL Database Backup
  2. 建立 Azure Hyperscale Database
  3. 找出現有的 Database Schema 可以有利於 Data Sharding 的 Keys 及 Common Table
  4. 建立 Database Schema,再利用 Data Sharding Keys create_distributed_tablecreate_reference_table
  5. 最後再用 pg_restore,使用 Data-only 的方式還原所有 Data

整個搬運過程十分簡單,這個實驗就可以証明由一個 Vertical Scaling Managed Database 可以輕鬆轉移到一個 Horizontal Scaling Managed PostgreSQL Database。

總結

Azure Hyperscale 真的非常方便,比起筆者之前 Self-Managed 或其它 Managed Database 有過之而無不及,整個測試中,筆者簡單地在 Azure Portal 上提升 Worker Nodes 就能夠實踐 Zero-downtime Horizontal Scale out。

而筆者相信大部份情況下 user_id 也是一個十分常見的 Data Sharding Key,話雖如此,根據上述的 Performance 結果,筆者建議考慮 Citus 之前也必先要留意以下幾個要點:

  1. 思考清楚 Existing System Business Use Case 及 Database Schema 是否合適做 Data Sharding
  2. 估算將來的 Vertical Scaling 是否完全不可能滿足 Data Volume 或 Query Performance
  3. 預留時間針對性地優化 Application SQL Queries,要活用 Sharded Keys 才可以完全發揮到 Citus
  4. 一些 Common Tables 需要額外設定成為一個 Reference Tables 才能夠最佳化使用 Citus 的 Horizontal Scaling,否則每次 Join Tables 時都要經過 Network 做 Distributed Join,Throughput 會大打折扣!
  5. 這篇測試文章還未涵蓋 Distributed Transaction Performance (Scale out) 部份,敬請留意!

如果手上有的 Existing PostgreSQL Database 又開始卡 Performance 的朋友,不妨試下開一個 Azure Hyperscale 試一試!

最後祝各位所在的公司業務蒸蒸日上,早啲遇上 Database Scalability 的問題 :) ,希望這篇測試可以帶到一些 Insight 給 HK Technical Community!

後記

最後整個測試花了 USD $853 Credit 才能完成,不足夠同時測試 Azure Flexible Server(Preview),Azure Flexible Server 可以更方便管理 Database Resource,同時可以做到 Cross Availability Zone for High Availability,Azure Flexible Server 可以隨時暫停省卻 vCPU 及 Memory 的收費,只會收取保留下來的 Disk 及 Backup 用量費用。

而與 Single Server 比較時,在最新的 Flexible Server 架構上,VM 與 Flexible Server 連接時,Infrastructure 更少了一層 Network Layer Overhead,可以有更低 Latency 連接你的 Database。

如果你喜歡 UNBOX TECH 系列的文章,同時也希望日後有更多不同類型的 PaaS / SaaS UNBOX TECH 的中文技術文章,只要在 Facebook Comment、Like、Share 就可給予筆者最大的支持。

是咁的,事源最近 Gap撈Tech Blog 上的 Google Analytics(GA) 同 Facebook Page Link Clicks Count 啲數開始有頗大出入,我估主要喺因為 Desktop Browser AdBlock / iOS >= 14 Safari "Privacy" / Android Chrome "Do Not Track" 都會主動 Block Analytics Tracking,所以基本上 Direct Link 直入幾乎已經冇辦法透過 GA 拎到實數,暫時只能依賴 Facebook Page 的數據 -- 你懂的。

Benchmark Scripts: https://github.com/gaplo917/azure-db-benchmark