分散式 PostgreSQL 叢集(Citus)官方示例 - 多租戶應用程式實戰

為少發表於2022-03-15

如果您正在構建軟體即服務 (SaaS) 應用程式,您可能已經在資料模型中內建了租賃的概念。 通常,大多數資訊與租戶/客戶/帳戶相關,並且資料庫表捕獲這種自然關係。

對於 SaaS 應用程式,每個租戶的資料可以一起儲存在單個資料庫例項中,並與其他租戶保持隔離和不可見。這在三個方面是有效的。 首先,應用程式改進適用於所有客戶端。 其次,租戶之間共享資料庫可以有效地使用硬體。 最後,為所有租戶管理單個資料庫比為每個租戶管理不同的資料庫伺服器要簡單得多。

但是,傳統上,單個關聯式資料庫例項難以擴充套件到大型多租戶應用程式所需的資料量。 當資料超過單個資料庫節點的容量時,開發人員被迫放棄關係模型的優勢。

Citus 允許使用者編寫多租戶應用程式,就好像他們連線到單個 PostgreSQL 資料庫一樣,而實際上該資料庫是一個水平可擴充套件的機器叢集。 客戶端程式碼需要最少的修改,並且可以繼續使用完整的 SQL 功能。

本指南採用了一個示例多租戶應用程式,並描述瞭如何使用 Citus 對其進行建模以實現可擴充套件性。 在此過程中,我們研究了多租戶應用程式的典型挑戰,例如將租戶與嘈雜的鄰居隔離、擴充套件硬體以容納更多資料以及儲存不同租戶的資料。PostgreSQLCitus 提供了應對這些挑戰所需的所有工具,所以讓我們開始構建吧。

讓我們做一個應用程式 - 廣告分析

我們將為跟蹤線上廣告效果並在頂部提供分析儀表板的應用程式構建後端。 它非常適合多租戶應用程式,因為使用者對資料的請求一次只涉及一家公司(他們自己的)。Github 上提供了完整示例應用程式的程式碼。

citus-example-ad-analytics

讓我們從考慮這個應用程式的簡化 schema 開始。 該應用程式必須跟蹤多家公司,每家公司都執行廣告活動。 廣告系列有許多廣告,每個廣告都有其點選次數和展示次數的關聯記錄。

這是示例 schema。稍後我們將進行一些小的更改,這使我們能夠在分散式環境中有效地分發和隔離資料。

CREATE TABLE companies (
  id bigserial PRIMARY KEY,
  name text NOT NULL,
  image_url text,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE campaigns (
  id bigserial PRIMARY KEY,
  company_id bigint REFERENCES companies (id),
  name text NOT NULL,
  cost_model text NOT NULL,
  state text NOT NULL,
  monthly_budget bigint,
  blacklisted_site_urls text[],
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE ads (
  id bigserial PRIMARY KEY,
  campaign_id bigint REFERENCES campaigns (id),
  name text NOT NULL,
  image_url text,
  target_url text,
  impressions_count bigint DEFAULT 0,
  clicks_count bigint DEFAULT 0,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE clicks (
  id bigserial PRIMARY KEY,
  ad_id bigint REFERENCES ads (id),
  clicked_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_click_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL
);

CREATE TABLE impressions (
  id bigserial PRIMARY KEY,
  ad_id bigint REFERENCES ads (id),
  seen_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_impression_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL
);

我們可以對 schema 進行一些修改,這將在像 Citus 這樣的分散式環境中提高效能。 要了解如何,我們必須熟悉 Citus 如何分發資料和執行查詢。

擴充套件關係資料模型

關係資料模型非常適合應用程式。 它保護資料完整性,允許靈活查詢,並適應不斷變化的資料。 傳統上唯一的問題是關聯式資料庫不被認為能夠擴充套件到大型 SaaS 應用程式所需的工作負載。開發人員必須忍受 NoSQL 資料庫 — 或後端服務的集合 — 才能達到這個規模。

使用 Citus,您可以保留資料模型並使其可擴充套件。Citus 對應用程式來說似乎是一個 PostgreSQL 資料庫,但它在內部將查詢路由到可並行處理請求的可調整數量的物理伺服器(節點)。

多租戶應用程式有一個很好的特性,我們可以利用它:查詢通常總是一次請求一個租戶的資訊,而不是多個租戶的資訊。例如,當銷售人員在 CRM 中搜尋潛在客戶資訊時,搜尋結果是特定於他的僱主的; 其他企業的線索和註釋不包括在內。

由於應用程式查詢僅限於單個租戶,例如商店或公司,因此快速進行多租戶應用程式查詢的一種方法是將給定租戶的所有資料儲存在同一節點上。 這最大限度地減少了節點之間的網路開銷,並允許 Citus 有效地支援所有應用程式的連線(joins)鍵約束(key constraints)事務(transactions)。 有了這個,您可以跨多個節點進行擴充套件,而無需完全重新編寫或重新構建您的應用程式。

我們在 Citus 中通過確保 schema 中的每個表都有一個列來清楚地標記哪個租戶擁有哪些行來做到這一點。 在廣告分析應用程式中,租戶是公司,因此我們必須確保所有表都有一個 company_id 列。

當為同一公司標記行時,我們可以告訴 Citus 使用此列來讀取和寫入同一節點的行。 在 Citus 的術語中,company_id 將是分佈列,您可以在分散式資料建模中瞭解更多資訊。

準備表和攝取資料

在上一節中,我們確定了多租戶應用程式的正確分佈列:公司 ID(company_id)。 即使在單機資料庫中,通過新增公司 ID 對錶進行非規範化也是很有用的,無論是為了行級安全還是為了額外的索引。 正如我們所看到的,額外的好處是包括額外的列也有助於多機器擴充套件。

到目前為止,我們建立的 schema 使用單獨的 id 列作為每個表的主鍵。Citus 要求主鍵外來鍵約束包括分佈列。 這一要求使得在分散式環境中執行這些約束更加有效,因為只需檢查單個節點即可保證它們。

SQL 中,此要求轉化為通過包含 company_id 來組合主鍵和外來鍵。 這與多租戶情況相容,因為我們真正需要的是確保每個租戶的唯一性。

綜上所述,這裡是為按 company_id 分配表準備的更改。

CREATE TABLE companies (
  id bigserial PRIMARY KEY,
  name text NOT NULL,
  image_url text,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL
);

CREATE TABLE campaigns (
  id bigserial,       -- was: PRIMARY KEY
  company_id bigint REFERENCES companies (id),
  name text NOT NULL,
  cost_model text NOT NULL,
  state text NOT NULL,
  monthly_budget bigint,
  blacklisted_site_urls text[],
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL,
  PRIMARY KEY (company_id, id) -- added
);

CREATE TABLE ads (
  id bigserial,       -- was: PRIMARY KEY
  company_id bigint,  -- added
  campaign_id bigint, -- was: REFERENCES campaigns (id)
  name text NOT NULL,
  image_url text,
  target_url text,
  impressions_count bigint DEFAULT 0,
  clicks_count bigint DEFAULT 0,
  created_at timestamp without time zone NOT NULL,
  updated_at timestamp without time zone NOT NULL,
  PRIMARY KEY (company_id, id),         -- added
  FOREIGN KEY (company_id, campaign_id) -- added
    REFERENCES campaigns (company_id, id)
);

CREATE TABLE clicks (
  id bigserial,        -- was: PRIMARY KEY
  company_id bigint,   -- added
  ad_id bigint,        -- was: REFERENCES ads (id),
  clicked_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_click_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL,
  PRIMARY KEY (company_id, id),      -- added
  FOREIGN KEY (company_id, ad_id)    -- added
    REFERENCES ads (company_id, id)
);

CREATE TABLE impressions (
  id bigserial,         -- was: PRIMARY KEY
  company_id bigint,    -- added
  ad_id bigint,         -- was: REFERENCES ads (id),
  seen_at timestamp without time zone NOT NULL,
  site_url text NOT NULL,
  cost_per_impression_usd numeric(20,10),
  user_ip inet NOT NULL,
  user_data jsonb NOT NULL,
  PRIMARY KEY (company_id, id),       -- added
  FOREIGN KEY (company_id, ad_id)     -- added
    REFERENCES ads (company_id, id)
);

您可以瞭解有關在多租戶架構遷移中遷移您自己的資料模型的更多資訊

自己試試

本指南旨在讓您可以在自己的 Citus 資料庫中進行操作。 本教程假設您已經安裝並執行了 Citus。如果您沒有執行 Citus,則可以使用單節點 Citus 中的選項之一在本地安裝設定 Citus

您將使用 psql 執行 SQL 命令並連線到 Coordinator 節點:

  • Docker:docker exec -it citus_master psql -U postgres

此時,您可以在自己的 Citus 叢集中隨意下載並執行 SQL 以建立模式。 一旦模式準備好,我們就可以告訴 Citus 在工作人員上建立分片。 從協調器節點執行:

此時,您可以通過下載 schema.sql並執行 SQL 來建立 schema,在你自己的 Citus 叢集中進行操作。一旦 schema 準備好,我們可以告訴 Citusworkers 上建立 shards。從 coordinator 節點執行:

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');

create_distributed_table 函式通知 Citus 表應該分佈在節點之間,並且應該計劃對這些表的未來傳入查詢以進行分散式執行。 該函式還在工作節點上為表建立分片,這些分片是 Citus 用於將資料分配給節點的低階別資料儲存單元。

下一步是從命令列將樣本資料載入到叢集中。

# download and ingest datasets from the shell

for dataset in companies campaigns ads clicks impressions geo_ips; do
  curl -O https://examples.citusdata.com/mt_ref_arch/${dataset}.csv
done

如果您使用 Docker,則應使用 docker cp 命令將檔案複製到 Docker 容器中。

for dataset in companies campaigns ads clicks impressions geo_ips; do
  docker cp ${dataset}.csv citus:.
done

作為 PostgreSQL 的擴充套件,Citus 支援使用 COPY 命令進行批量載入。 使用它來攝取您下載的資料,如果您將檔案下載到其他位置,請確保指定正確的檔案路徑。 回到 psql 裡面執行這個:

\copy companies from 'companies.csv' with csv
\copy campaigns from 'campaigns.csv' with csv
\copy ads from 'ads.csv' with csv
\copy clicks from 'clicks.csv' with csv
\copy impressions from 'impressions.csv' with csv

整合應用程式

好訊息是:一旦您完成了前面概述的輕微 schema 修改,您的應用程式就可以用很少的工作量進行擴充套件。你只需將應用程式連線到 Citus,讓資料庫負責保持查詢速度和資料安全。

任何包含 company_id filter 的應用程式查詢或更新語句將繼續按原樣工作。 如前所述,這種 filter 在多租戶應用程式中很常見。使用物件關係對映器 (ORM) 時,您可以通過 wherefilter 等方法識別這些查詢。

ActiveRecord:

Impression.where(company_id: 5).count

Django:

Impression.objects.filter(company_id=5).count()

基本上,當在資料庫中執行的結果 SQL 在每個表(包括 JOIN 查詢中的表)上包含 WHERE company_id = :value 子句時,Citus 將識別出該查詢應該路由到單個節點,並按原樣在那裡執行。這確保了所有 SQL 功能都可用。該節點畢竟是一個普通的 PostgreSQL 伺服器。

此外,為了更簡單,您可以使用我們的 Railsactiverecord-multi-tenant 庫或 Djangodjango-multitenant 庫,它們會自動將這些過濾器新增到您的所有查詢中,即使是複雜的查詢。檢視我們的 Ruby on RailsDjango 遷移指南。

本指南與框架無關,因此我們將指出一些使用 SQLCitus 功能。 發揮您的想象力,以瞭解這些陳述將如何以您選擇的語言表達。

這是在單個租戶上執行的簡單查詢和更新。

-- campaigns with highest budget

SELECT name, cost_model, state, monthly_budget
  FROM campaigns
 WHERE company_id = 5
 ORDER BY monthly_budget DESC
 LIMIT 10;

-- double the budgets!

UPDATE campaigns
   SET monthly_budget = monthly_budget*2
 WHERE company_id = 5;

使用者使用 NoSQL 資料庫擴充套件應用程式的一個常見痛點是缺少 transactionsjoins。但是,事務在 Citus 中的工作方式與您期望的一樣:

-- transactionally reallocate campaign budget money

BEGIN;

UPDATE campaigns
   SET monthly_budget = monthly_budget + 1000
 WHERE company_id = 5
   AND id = 40;

UPDATE campaigns
   SET monthly_budget = monthly_budget - 1000
 WHERE company_id = 5
   AND id = 41;

COMMIT;

作為 SQL 支援的最後一個 demo,我們有一個包含聚合(aggregates)視窗(window)函式的查詢,它在 Citus 中的工作方式與在 PostgreSQL 中的工作方式相同。 該查詢根據展示次數對每個廣告系列中的廣告進行排名。

SELECT a.campaign_id,
       RANK() OVER (
         PARTITION BY a.campaign_id
         ORDER BY a.campaign_id, count(*) desc
       ), count(*) as n_impressions, a.id
  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 = 5
GROUP BY a.campaign_id, a.id
ORDER BY a.campaign_id, n_impressions desc;

簡而言之,當查詢範圍為租戶時,插入更新刪除複雜的 SQL 和事務都按預期工作。

在租戶之間共享資料

到目前為止,所有表都通過 company_id 分發,但有時有些資料可以由所有租戶共享,並且不“屬於”特定的任何租戶。 例如,所有使用此示例廣告平臺的公司都可能希望根據 IP 地址獲取其受眾的地理資訊。在單機資料庫中,這可以通過 geo-ip 的查詢表來完成,如下所示。(一個真實的表可能會使用 PostGIS,但可以使用簡化的示例。)

CREATE TABLE geo_ips (
  addrs cidr NOT NULL PRIMARY KEY,
  latlon point NOT NULL
    CHECK (-90  <= latlon[0] AND latlon[0] <= 90 AND
           -180 <= latlon[1] AND latlon[1] <= 180)
);
CREATE INDEX ON geo_ips USING gist (addrs inet_ops);

為了在分散式設定中有效地使用此表,我們需要找到一種方法來共同定位 geo_ips 表,不僅針對一個公司,而且針對每個公司。這樣,在查詢時不需要產生網路流量。 我們在 Citus 中通過將 geo_ips 指定為參考表來執行此操作。

-- Make synchronized copies of geo_ips on all workers

SELECT create_reference_table('geo_ips');

參考表在所有工作節點之間複製,Citus 在修改期間自動保持它們同步。 請注意,我們呼叫 create_reference_table 而不是 create_distributed_table

現在 geo_ips 已建立為參考表,使用示例資料載入它:

\copy geo_ips from 'geo_ips.csv' with csv

現在,將點選與這個表聯接(join)起來可以高效地執行。例如,我們可以詢問點選廣告290 的每個人的位置。

SELECT c.id, clicked_at, latlon
  FROM geo_ips, clicks c
 WHERE addrs >> c.user_ip
   AND c.company_id = 5
   AND c.ad_id = 290;

Schema 的線上更改

多租戶系統的另一個挑戰是保持所有租戶的 schema 同步。 任何 schema 更改都需要一致地反映在所有租戶中。在 Citus 中,您可以簡單地使用標準 PostgreSQL DDL 命令來更改表的 schemaCitus 將使用兩階段提交協議將它們從 coordinator 節點傳播到 worker

例如,此應用程式中的廣告可以使用文字標題。我們可以通過在 coordinator 上發出標準 SQL 來向表中新增一列:

ALTER TABLE ads
  ADD COLUMN caption text;

這也會更新所有 worker。 此命令完成後,Citus 叢集將接受在新 caption 列中讀取或寫入資料的查詢。

有關 DDL 命令如何通過叢集傳播的更完整說明,請參閱修改表

當租戶的資料不同時

鑑於所有租戶共享一個共同的 schema 和硬體基礎設施,我們如何容納想要儲存其他人不需要的資訊的租戶? 例如,一個使用我們廣告資料庫的租戶應用程式可能希望通過點選儲存跟蹤 cookie 資訊,而另一個租戶可能關心 browser agents。 傳統上,使用多租戶共享模式方法的資料庫採用建立固定數量的預分配“自定義”列,或具有外部“擴充套件表”。 但是,PostgreSQL 為其非結構化列型別提供了一種更簡單的方法,尤其是 JSONB

請注意,我們的 schemaclicks 中已經有一個名為 user_dataJSONB 欄位。每個租戶都可以使用它進行靈活的儲存。

假設公司 5 在欄位中包含資訊以跟蹤使用者是否在移動裝置上。 該公司可以查詢以查詢誰點選更多,移動訪問者或傳統訪問者:

SELECT
  user_data->>'is_mobile' AS is_mobile,
  count(*) AS count
FROM clicks
WHERE company_id = 5
GROUP BY user_data->>'is_mobile'
ORDER BY count DESC;

資料庫管理員甚至可以建立部分索引來提高單個租戶查詢模式的速度。這是一項改進公司 5 對移動裝置使用者點選的過濾器的方法:

CREATE INDEX click_user_data_is_mobile
ON clicks ((user_data->>'is_mobile'))
WHERE company_id = 5;

此外,PostgreSQL 支援 JSONB 上的 GIN 索引。 在 JSONB 列上建立 GIN index 將為該 JSON 文件中的每個 keyvalue 建立一個索引。這加速了許多 JSONB 運算子,例如 ??|?&

CREATE INDEX click_user_data
ON clicks USING gin (user_data);

-- this speeds up queries like, "which clicks have
-- the is_mobile key present in user_data?"

SELECT id
  FROM clicks
 WHERE user_data ? 'is_mobile'
   AND company_id = 5;

擴充套件硬體資源

隨著業務的增長或租戶想要儲存更多資料,多租戶資料庫應針對未來規模進行設計。 Citus 可以通過新增新機器輕鬆擴充套件,而無需進行任何更改或讓應用程式停機。

能夠重新平衡 Citus 叢集中的資料使您可以增加資料大小或客戶數量並按需提高效能。 新增新機器允許您將資料保留在記憶體中,即使它比單臺機器可以儲存的資料大得多。

此外,如果只有少數大型租戶的資料增加,那麼您可以將這些特定租戶隔離到單獨的節點以獲得更好的效能。(租戶隔離Citus 企業版的一個功能。)

要橫向擴充套件您的 Citus 叢集,請首先向其中新增一個新的 worker 節點。在 Azure Database for PostgreSQL - Hyperscale (Citus) 上,可以使用 Azure Portal 新增所需數量的節點。或者,如果您執行自己的 Citus 安裝,則可以使用 citus_add_node UDF 手動新增節點。

新增節點後,它將在系統中可用。但是,此時沒有租戶儲存在上面,Citus 還不會在那裡執行任何查詢。要移動現有資料,您可以要求 Citus 重新平衡資料。 此操作在當前活動節點之間移動稱為分片的行束,以嘗試均衡每個節點上的資料量。

SELECT rebalance_table_shards('companies');

Rebalancing 保留了 Table Co-Location,這意味著我們可以告訴 Citus 重新平衡公司表,它會接受提示並重新平衡由 company_id 分配的其他表。 此外,使用 Citus 企業版,應用程式在分片重新平衡期間無需停機。 讀取請求無縫地繼續,並且寫入僅在它們影響當前正在執行的分片時才被鎖定。 在 Citus 社群版中,對分片的寫入在重新平衡期間被阻止,但讀取不受影響。

您可以在此處瞭解有關分片重新平衡如何工作的更多資訊:Scaling Out(新增新節點)

與大租戶打交道

本部分使用僅在 Citus Enterprise 中可用的功能。

上一節描述了隨著租戶數量的增加而擴充套件叢集的通用方法。 但是,使用者經常有兩個問題。首先是他們最大的租戶如果變得太大會發生什麼。 第二個是在單個工作節點上託管大型租戶和小型租戶對效能的影響,以及可以做些什麼。

關於第一個問題,調查來自大型 SaaS 站點的資料表明,隨著租戶數量的增加,租戶資料的大小通常傾向於遵循 Zipfian 分佈

例如,在一個包含 100 個租戶的資料庫中,預計最大的租戶將佔資料的 20% 左右。 在一個大型 SaaS 公司更現實的例子中,如果有 10k 個租戶,最大的將佔資料的 2% 左右。即使是 10TB 的資料,最大的租戶也需要 200GB,這很容易適應單個節點。

另一個問題是關於大型和小型租戶在同一節點上時的效能。 標準分片重新平衡將提高整體效能,但它可能會或可能不會改善大小租戶的混合。 再平衡器只是分配分片以均衡節點上的儲存使用,而不檢查在每個分片上分配了哪些租戶。

為了改善資源分配並保證租戶的 QoS,將大型租戶移動到專用節點是值得的。 Citus 提供了執行此操作的工具。

在我們的例子中,假設我們的老朋友公司 id=5 非常大。 我們可以分兩步隔離此租戶的資料。我們將在此處介紹這些命令,您可以諮詢 Tenant Isolation 以瞭解有關它們的更多資訊。

首先將租戶的資料隔離到一個適合移動的專用分片中。CASCADE 選項也將此更改應用於我們由 company_id 分發的其餘表。

SELECT isolate_tenant_to_new_shard(
  'companies', 5, 'CASCADE'
);

輸出是專用於儲存 company_id=5 的分片 ID

┌─────────────────────────────┐
│ isolate_tenant_to_new_shard │
├─────────────────────────────┤
│                      102240 │
└─────────────────────────────┘

接下來,我們將資料通過網路移動到一個新的專用節點。 如上一節所述建立一個新節點。記下其主機名,如 Cloud Console 的 “Nodes” 選項卡中所示。

-- find the node currently holding the new shard

SELECT nodename, nodeport
  FROM pg_dist_placement AS placement,
       pg_dist_node AS node
 WHERE placement.groupid = node.groupid
   AND node.noderole = 'primary'
   AND shardid = 102240;

-- move the shard to your choice of worker (it will also move the
-- other shards created with the CASCADE option)

-- note that you should set wal_level for all nodes to be >= logical
-- to use citus_move_shard_placement.
-- you also need to restart your cluster after setting wal_level in
-- postgresql.conf files.

SELECT citus_move_shard_placement(
  102240,
  'source_host', source_port,
  'dest_host', dest_port);

您可以通過再次查詢 pg_dist_placement 來確認分片移動。

接下來

有了這個,您現在知道如何使用 Citus 為您的多租戶應用程式提供可擴充套件性。 如果您有現有架構並希望將其遷移到 Citus,請參閱多租戶轉換

要調整前端應用程式,特別是 Ruby on RailsDjango,請閱讀 Ruby on RailsDjango。 最後,嘗試 Azure Database for PostgreSQL - Hyperscale (Citus),這是管理 Citus 群集的最簡單方法。

更多

相關文章