零停機 (Zero Downtime) 的 Postgres 升級方法

來自 Knock Tech Blog 的分享

William
WilliamDesk
33 min readJan 1, 2024

--

Knock 官方部落格上看到的內容,因為沒有人翻譯,因此順手翻譯加上自己的解讀和註釋,希望讓更多人知道技術脈動。

原文出處 (2023/12/12):
https://knock.app/blog/zero-downtime-postgres-upgrades

概述:

最近從 Postgres 11.9 升級至 15.3,透過邏輯複寫 (logical replication)、一系列的腳本 (a suite of support scripts) 以及使用在沒有任何 downtime 的情況下以及使用 Elixir 和 Erlang’s 的 BEAM 虛擬機。

這篇文章將詳細解釋我們是如何做到這一點的,以及如果一些在嘗試時需要考慮的事項。

它更像是一本記錄交戰手冊,其中包括我們這整個過程中學到的東西,也是我們希望我們事先就知道這些內容。

Knock 仰賴 Postgres 支援通知流程作業引擎。從儲存的作業流程配置到推播訊息模板,到消化數百萬進來的日誌和後台排程作業,Postgres 一直都是我們任何系統的重要核心。我們的 Postgres 是運行在 AWS RDS Aurora 上,運行在Aurora 上一直以來都非常的可靠、高效且可擴展性高。因其穩定的基礎讓我們充滿信心地服務每一位加入我們平台的客戶。

升級像是 Postgres 這樣的關聯式資料庫,和一般可以在後台不斷升級而無需通知的 SaaS 軟體不同,通常至少都需要重新啟動資料庫。在大版本升級的情況下,資料庫通常都需要停機數分鐘去更新在磁碟上資料的儲存和索引內容。

擁有的資料量越多,升級所需的時間就越長。

以 Knock 為例,從公司成立以來一直運作在 Postgres 11.9 版本上。儘管 Postgres 11.9 是個非常穩定的版本,提供給我們可靠的服務,但近日 Amazon RDS 公告將於 2024 年 2 月 29 日停用 Postgres 11.9 版本

如果屆時不採取行動 (ex: 與 RDS 團隊安排長期支援合約,可見下面截圖可以額外付費延展支援日至 2024 年 4 月 1 日),那 AWS RDS 使用 Postgres 11.9 的所有用戶將會被強制升級,升級時也就代表會面臨停機的狀況。

RDS 版本停止支援公告 (ref: https://docs.aws.amazon.com/AmazonRDS/latest/PostgreSQLReleaseNotes/postgresql-release-calendar.html#Release.Calendar)

對於像 Knock 來說,停機是不可接受的,無論是計劃性的還是其他的停機狀況。我們承諾客戶服務 24/7 在線。儘管沒有任何服務可以保證完美的正常運作時間,但負責任的開發團隊會努力在服務問題發生之前主動解決它們。

Knock 在今年 6 月將 Postgres 升級計畫規劃在今年的 Roadmap 中,並加上以下限制:

  1. 盡可能升級就升級,以跳至最新的可用版本為目標(當時為 Aurora 的 Postgres 15.3)。
  2. 任何超過 60 秒的停機時間都是完全不可接受的,理想情況下應該實現零系統停機時間。
  3. 升級必須在 AWS 公告的二月截止日期之前進行。
  4. 最大限度地減少對客戶的影響(例如零 API 錯誤回應)。
  5. 有計畫性的實施整個升級流程,以便下次需要升級資料庫時,它是一個非常有參考性的操作手冊。

每個 Postgres 資料庫都需要運行這個過程,從 11.9 到 15.3 將代表有四個主要版本升級。如果每次跳版本大升級都會引發停機,那麼連續進行四個版本升級是不可能的。

為了滿足我們的要求,我們知道我們必須發揮創意。

準備任何方式的 Postgres 升級

升級過程中最重要的事情是:在尋求任何方式升級 Postgres 的團隊,要專注在降低升級流程的風險

  1. 準備一份每次版本升級時可能遭遇的風險清單,例如:
    * 停機時間過長。
    * 資料遺失。
    * 資料庫的性能影響到目前正在運行的 application。
    * 影響到做 vacuum 的頻率或行為。
    * 是否有任何的 replication slots 需要被升級。 (這可能很棘手,後面會提到)
  2. 找出哪些風險最為關鍵;以及哪些風險最容易事先被確認、排除和修復。
    排序你的風險清單,將影響最大的排至在最前面。
  3. 在開發和規劃版本升級解決方案時,請回頭檢視你的風險清單:
    * 是否有排除所有風險的解決方案?
    * 哪些解決方案可以隨著時間推移可降低或分散風險 (可以逐步解決版本升級的每個步驟,無需立馬承擔太多風險)
  4. 當您在推進該版本升級計畫時,請記得隨時審視您的風險清單,並隨時保持更新 (學到的新東西可解決某些風險,或是發現新風險)

逐步並持續降低版本升級計畫的風險,直到您有信心實現該目標。

為了規劃升級步驟,我們從 Postgres 的 Release Note 開始著手,方便我們了解版本之間有哪些更新,藉機識別更多的風險並排除 (例如 Postgres 的 vacuum 工作方式變更,某些版本升級時將會執行 reindex)。

當我們進行規劃時,同時也不斷更新維護這份風險清單。隨著收集到更多資訊,增加了許多新的問題以及調整舊的問題。在升級的過程中,我們有系統性的排除每個問題,直到我們確信可以在不影響可靠性的情況下實現專案目標。

關於監控和指標的說明

擁有全面的監控觀測工具 (感謝 DataDog!)來觀測系統和資料庫的運作狀況,使得監控版本更新的每個步驟成為可能。

需要關注的幾個關鍵指標:

  • 最大 TXN ID 避免 transaction wraparound — 如果該值太高,您的資料庫可能會關閉並進入緊急維護模式
  • DB CPU 使用率
  • 在您的 writer instance 上的 waiting session
  • 查詢延遲 (Query latency)
  • 應用程式上的 API 回覆延遲時間

在 Knock,我們會監控所有這些指標以及一些專屬於該服務的特有指標,例如客戶發送 API 請求時,轉換為推播通知所需的時間。

如果沒有即時的指標,你就會像瞎子開飛機一樣盲目前進。

升級 Postgres 的幾個選項

在研究過程中我們參考了一些資料庫版本升級的先例,以及 Postgres 官方文件建議如何執行更新,以下是一些升級選項:

原地升級 (In-place upgrades),對於 zero-downtime 是完全行不通的

Postgres 最基本的升級選項是原地升級 (In-place upgrades)。在 AWS RDS 上,此升級可以從 AWS console 執行。

執行原地升級時,AWS 會將資料庫關閉,並開始執行升級腳本,最後使系統重新上線。執行此操作通常會需要一些前置準備工作,包括 Postgres replication slots 的刪除,例如需要刪除用於與資料倉儲或其他系統同步的 replication slots。

原地升級過程所需花費的時間需要幾分鐘到幾個小時,或是更長時間,它取決於 Postgres 每個版本之間需要升級的內容大小。

且值得留意的一點是,通常系統上線後通常仍是不可用的狀態,DB 管理員必須執行像是 VACUUM 指令或 REINDEX 更新索引以支援新版本的格式。

原地升級所需要的停機時間已經遠遠超過我們可容忍的範圍,因此這條路是不可行的。

另一個類似的方法是在資料庫停機後使用 pg_dumppg_restore 的方式去將資料庫的資料倒出,再重新倒入新的版本資料庫裡。

這樣的匯出匯入的方式也不適合我們,因為需要斷開所有服務對於舊資料庫的連接以獲得完整的資料庫備份。且對於大型資料庫而言,匯出匯入資料庫也會需要很多時間。

基於複製方式的升級 (Replication-based upgrades)

這方法需要依賴 Postgres 非常出色的複製原語 (primitives): PUBLICATIONSUBSCRIPTION

執行的工作步驟大概如下:

  1. 在您的目標 Postgres 版本上啟動一個新資料庫。
  2. 將設定 (settings)、擴充功能 (extensions)、表格配置 (table configurations)、使用者 (users) 等複製過去。
  3. 在舊資料庫上設定 publication,並在新資料庫上設定對該 publication 的subscription。
  4. 將您的表格添加到 publication 中(這裡有很多微妙之處,後續會提到)
  5. 完全複製後,執行充足的測試以滿足任何剩餘未考量到風險。
  6. 一旦您對新資料庫的配置有足夠信心,開始將您的應用程式指向新資料庫。
  7. 廢棄舊資料庫。

最後,Knock 選擇了這個升級方法,基於以下幾個理由:

  1. 此方法提供了可逐步版本升級的步驟,而不是一次重大的版本升級。
  2. 可以使用真實工作負載和真實資料測試新資料庫,以避免任何差異 (regressions)。
  3. 它能夠最大程度地控制何時以及如何執行升級:一旦新資料庫完全準備好,切換到新資料庫只需幾秒鐘。

儘管這聽起來很簡單,但在此解決方案中需要取決於您的應用程式和環境如何配置。

設定基本複製

  1. 啟動一台 Postgres Server 並搭載你的目標版本 (在本篇例子是 v15.3)。
  2. 設定您所需的資料庫、schemas、tables、partitions、users 和 passwords 以及其他所有內容。
    目標資料庫的 table 必須結構要與來源資料庫的表完全相同,但 table 必須為空。
    要取得 DB schema 快照,要在舊 DB 執行 pg_dumpall 並帶參數 --schema-only--no-role-passwords options 在指令上。接著再針對新 DB 調整指令。然後透過產生出來的 SQL 檔案進行比較,找出並解決新舊 DB 不一致的部分。
    定期比較新舊 DB 是非常有幫助的,特別是如果 舊 DB 有進行任何 db schema 在 migration 處理導致架構改變時,可考慮持續對這兩者 DB 執行 migration 以保持同步狀態。
  3. 在主要的舊 DB instance,執行 CREATE PUBLICATION pg_upgrade_pub;

🚨 儘管你可以透過 FOR ALL TABLES 來為每個 table 設定 publication,但我們發現在大型的 DB 可能會導致一些效能問題。

相反的,透過 ALTER PUBLICATION pg_upgrade_pub ADD TABLE table_name每次僅對一張表做 publication 是更好的方式。

4. 在主要的新 DB instance,設定新的 subscription 指向 publication:

-- Note the _sub suffix, you can call this whatever you like
CREATE SUBSCRIPTION pg_upgrade_sub
-- The connection string can be any standard Postgres connection string.
-- More details here:
-- https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
CONNECTION 'host=old-db.cloud.com dbname=your_app user=root password=<password>'
-- The publication name MUST match the publication created on the old database
PUBLICATION pg_upgrade_pub with (
-- This subscription will not start syncing until you enable it,
-- which can be helpful when getting started
enabled = false,
-- Replication slots track the subscription's progress.
-- By default, you want Postgres to manage this.
-- If you don't create a slot here, you will need to supply one yourself.
create_slot = true,
-- Generally you want Postgres to copy the contents of each table,
-- however for very large tables you may not want this option.
-- More details below.
copy_data = true,
-- This will halt the subscription if something unexpected happens.
-- This is usually because of a unique constraint violation, or
-- a mismatched schema (e.g. a missing or renamed column).
-- We found it helpful to halt the subscription on error so we could
-- fix the problem and then resume replication.
-- Errors are logged to the database's logs.
disable_on_error = true
);

現在,已經擁有了舊 DB 到新 DB 複製管道,接著啟用 subscription。

ALTER SUBSCRIPTION pg_upgrade_sub ENABLE;

-- To check the status of the subscription...

-- Watch out for subenabled - if it turns false,
-- replication is stopped and potentially backing up on the primary!
SELECT * FROM pg_subscription;

-- More details on monitoring subscriptions using that table here:
-- https://www.postgresql.org/docs/16/catalog-pg-subscription.html

選擇想要複製的資料表 (tables)

接下來的下一步是根據規劃的資料表清單,一張表一張表執行,並隨時觀測每張表是否有完整的複製過去,後面會說明如何去監控複製所有表的過程。

一般來說,可以將資料表根據佔用的磁碟空間和儲存在 DB 上 tuples 分成三大類。

  1. 小到可以在幾分鐘內完成同步的資料表:只需將它們添加到 publication 並刷新 subscription 即可。
  2. 大型、append-only 資料表:可以先進行同步未來的改動,之後再將舊的資料用備份或快照的方式同步回來。
  3. 大型、且頻繁更新的資料表:這是最難同步的部分,需要格外留意。

對於我們來說, 「小」的定義是小於 50 GB 的儲存空間且低於 10 萬個 tuples。任何超過這些閥值的都是大資料表。

❓ 什麼是 tuple?

每一次插入或更新一筆資料至 Postgres 資料表就是一個 “tuple”。如果資料表有 3 次插入以及 2 次更新,就代表該表有 5 個 tuples。Tuples 在 Postgres 中用來作為併發機制 (concurrency mechanism) 使用 (詳情請看官方文件)。Postgres 的 VACUUM 程序就是來清除舊的且無用的 tuples。

我們在複製一掌資料表時,我們會複製所有的 tuples — 插入和更新。相同資料數目的資料表,「有很多未清理的 tuples 的資料表」的複製時間較「少量 tuples 資料表」來的長。

❓ 什麼是 append-only table?

常用於需要高度數據完整性和追蹤數據歷史的場合,如財務交易、審計記錄或時間序列數據。這些表格限制了修改或刪除已存在記錄的能力,允許只對數據進行增加(append)操作。

以下的 query 步驟可以幫助釐清資料庫所佔用的磁碟空間,以及算出總 tuples 數量,用以決定這個資料庫的大小。

SELECT
relname AS tablename,
n_live_tup + n_dead_tup + n_mod_since_analyze as total_tuple_count,
pg_size_pretty(pg_total_relation_size(quote_ident(relname))) AS simple_size,
pg_relation_size(quote_ident(relname)) as size_in_bytes
FROM pg_stat_user_tables;

準備你的複製來源 DB 的其中一個前置步驟就是 VACUUM 你的資料表。這可以幫助你減少需要複製過去的 tuples 數量,也有能有效節省複製時間。

在使用 VACUUM 之前,請先查閱官方文件說明

🤔 為什麼資料表的大小是很重要的參考指標?

同步資料表所花費的時間與在磁碟的儲存空間大小、以及所包含的 tuples 呈正相關。資料表越大,複製所花的時間就越長。這是因為 Postgres 需要將整個資料表複製到新資料庫,然後套用初始複製後發生的任何變更。

同步時間過長,可能會阻止您的主要 Postgres instance 執行 VACUUM 操作,這會導致效能隨著時間推移而下降。如果沒留意,將可能發生 transaction wraparound 導致資料庫強制停止。

基於上述原因,我們採取的策略是一次只添加一張資料表進行複製,並根據每張表的大小和寫入模式使用不同的策略複製,並在過程中隨時觀測系統效能確保不會降低服務品質。

如果在 migrate 資料表中發生問題,可以隨時從複製的資料庫中刪除資料表,並在稍後重新操作它。(雖然需要 truncate 資料表後,並重新從 schema建構開始操作起。)

如何複製 「小型」資料表

要 migrate 小型的資料表,只需將它們添加到 publication 並刷新 subscription 即可。

-- On the old database

ALTER PUBLICATION pg_upgrade_pub ADD TABLE my_table_name;

-- ON the new database

ALTER SUBSCRIPTION pg_upgrade_sub REFRESH PUBLICATION;

Postgres 將會處理複製、同步以及對資料表的任何設定。非常小的表甚至同步不需要花到一秒鐘的時間。

大型、append-only 資料表

雖然是大型的資料表,但因為是 append-only 的關係,不會有任何 update (或者在近期內不會一直對資料表做 update)。

和前述的操作流程一樣,拆成 PUBLICATIONSUBSCRIPTION 兩個步驟即可,但可以在 subscription 上多添加設定 copy_data 為 false,並添加 suffix 為名稱做出區隔,例如帶後綴 _nocopy

當你準備要開始 migrate 大型、append-only 的資料表時,可以將他們加入至 nocopy publication,並刷新 subscription 並設定 copy_data = false

-- On the old database

ALTER PUBLICATION pg_upgrade_pub_nocopy ADD TABLE my_append_only_table_name;

-- On the new database

ALTER SUBSCRIPTION pg_upgrade_sub REFRESH PUBLICATION WITH ( copy_data = false );

我們發現透過這樣的方式處理效果非常好,尤其是我們在處理存放了許多各式各樣客戶 log 的 partitioned tables (分區資料表) 時格外出色。我們不需要 migrate root partitioned tables,僅需要 migrate underlying tables,而且運作的相當不錯。

當 subscription 開始運作時,您應該會開始看見新的 log 開始出現在新的 DB裡面,例如可透過以下指令確認:

SELECT COUNT(*) FROM my_append_only_table_name; -- Returns more than zero

確認沒有問題後,您可以開始把其餘舊的所有資料開始倒進去新目標 DB 裡,可以透過任何你喜歡的方式 (e.g. pg_dump)。

以下是我們在 AWS RDS Aurora 操作倒資料的流程:

  1. 在 AWS Console 對正式環境的 DB 進行 snapshot。
  2. 將 snapshot restore 成新的 DB instance。
  3. 在 snapshot DB 上,針對你想要複製的資料表加上 suffix _snapshot 。這可以避免我們有兩個複製 pipeline 同時餵給同張 table 在新目標 DB 裡。
  4. 建立一樣的資料表們在新目標 DB 上,並確保這些資料表們的 schema 是一致的,有帶 suffix 名稱的資料表也請做相同的 suffix。
  5. 建立一個 publication 在 snapshot DB 上,並將新目標 DB 進行 subscription 準備開始進行複製資料表。
  6. 開啟 subscription 並監控進程。
  7. 如果 subscription 同步的資料已經要同步完畢,你可以將這些表開始進行 merge,使用 INSERT ... ON CONFLICT
INSERT INTO my_append_only_table_name
SELECT * FROM my_append_only_table_name_snapshot
ON CONFLICT (id) DO NOTHING;
複製大型、append-only 表的圖示流程。Reference: https://knock.app/blog/zero-downtime-postgres-upgrades

非常大型的資料表有時候會花費數天才能完成,但都是背景作業,因此這樣的操作處理不會影響到您的正式環境。

當資料表完全合併之後,記得要去比較一下資料表確保行數一致 (稍後會再詳細介紹)。當你確定資料表的是一致且完全同步時,可以開始 drop 掉snapshot 相關的資料表在新 DB、並終止對 snapshot 的 subscription,最後 terminate snapshot DB instance。

大型資料表,且資料更新非常頻繁

這是非常難處理的資料表,會花非常多時間進行同步複製,而且可能會影響到來源 DB 的系統效能 (如果 AUTOVACUUM 停止運作)。也因為裡面的資料內容是一直不斷的在更新,我們沒有辦法像 append-only 的方式一樣進行。

以下有幾個面向要去考慮:

  1. 有辦法對該資料表做大掃除減少資料表大小嗎?
  2. 近期有做過 vacuum 嗎?
  3. 有辦法透過 partition 將這些資料表拆小一點嗎?
  4. 裡面的資料是否可以在一段時間內暫時停止更新 (e.g. 一個禮拜)。如果可行,這可以讓您用 append-only 的方式處理這張表。

如果你的來源 DB 不是 PG 15 或是更高版本,你的選擇將會非常侷限。您只能透過 「小型資料表」的模式進行作業,並依靠您的現有監控 (您應該確實有監控服務在運行,對吧?) 確保在複製過程中沒有對服務效能造成影響。如果必要,您可以直接 rollback 移除 publication 資料表,並重新刷新 subscription (下面會提到操作方式)。

如果資料表還是太大,請嘗試在低流量時段進行操作,進而減少負載和寫入活動。希望這樣的方式能最大限度的減少您對系統的影響。

大型資料表,且來源 DB 是 PG 15 或更高的版本

如果你的來源 DB 是在 PG 15 或是更高的版本,你可以透過拆分多個子複製從複數個 publication 而來 (有點像是 partitioning 或 sharding 的概念)。透過這個方式,您就可以將大型資料表拆分成多個小區塊進行 migrate,但代價是會使用到更多的 replication slots這份 Postgres 文件有更詳細的說明資訊以及一些可設定參數。

🤞 因為我們是從 11.9 migrate 至 15.3,因此我們沒有這個選項可以使用,所以也沒有測試該方法是否可行或需要留意的部分。

即便如此,我們還是有注意到這個方法可能行得通,如果您有嘗試過,請告訴我們,我們很想聽聽看效果如何!

操作的目標是將大型的資料表分割成數個可被管理的區塊。(對我們來說這大約會額外多儲存了 100 GB 的非索引資料)。假設我們要將該資料表拆成 3 個 partitions 做為範例,訣竅是使用 WHERE 將每個 subscription 要處理的 rows 進行拆分。

-- On the source database

-- For three partitions...
CREATE PUBLICATION pg_upgrade_pub_0;
CREATE PUBLICATION pg_upgrade_pub_1;
CREATE PUBLICATION pg_upgrade_pub_2;

ALTER PUBLICATION pg_upgrade_pub_0 ADD TABLE big_table
-- id must be the primary key.
-- Use hashint4 for int IDs, hashint8 for bigint IDs
-- Use hashtext(id::text) for UUID or other key types
-- If you have a composite PK, concatenate the columns together before hashing as text
-- Postgres' hash functions return positive & negative numbers - we abs() the result to make it positive
-- % 3 is used to pick which of three partitions. Adjust the integer for the number of partitions you will create.
-- = 0 assigns rows to the first partition (zero-indexed, so we will finish with partitions 0, 1, and 2)
WHERE abs(hashint4(id)) % 3 = 0;

-- Repeat the above ALTER statement for each publication, adjust the where clause accordingly.

在目標 DB,幫每個 partition 建立 subscription 。

您會只想要一次只 migrate 一次切片的方式進行作業。通常來說,您可以遵循 「小型資料表」的方式去作業,只是這次是有使用 WHERE 的方式進行設定在每個 publication。

透過這樣的拆分方式,可以更方便作業大型資料表。

但如果您的 replication slots 已經存在過多,可以考慮另外一種做法:
仍然使用 「小型資料表」的方式去作業,但不採用 WHERE的方式拆分資料表,用資料表加上 _0 在 publication 之中作為替代。這會有助於減少在 migrate 時所使用的 replication slots 數量。

檢查資料表的複製狀態

當資料表已經加上 subscription 開始複製,資料表將會經歷五種不同的狀態 (可在目標 DB 被觀察到,透過 system table pg_subscription_rel srsubstate 欄位):

  1. 初始化資料表 subscription (Status code: i)
  2. 開始複製資料表的內容 (Status code: d)
    🚨 這個步驟需要持續保留住 old Postgres transaction ID,也就表示會阻止 VACUUM 的進行,意味著可能會引發效能問題。如果運行時間過長觸發 transaction wraparound 將會導致資料庫中止運作。
    此步驟需要一次只複製一張資料表。
  3. 複製完畢,等待最終同步 (Status code: f)
  4. 完成同步初始化 (Status code: s)
  5. 已設置完成且可開始運行 normal replication。 (Status code: r)

為了避免在 Step 2 發生任何意外,我們結論是有必要遵守一次僅處理一張資料表的方式進行操作,並隨時觀測系統效能狀態,確保最壞的情況 transaction wraparound 沒有機會發生。

如果你發現已經快要發生 transaction wraparound,最好的方式就是終止這次 migrate 並將資料表拆分成更小碎片後再重頭開始。

如果我們在 publication 直接採用 FOR ALL TABLES 的設定,Postgres 會直接在同一時間開始同步所有的大型資料表,並且阻止自動 VACUUM 的進行,隨時同步時間不斷推移,將會開始嚴重影響到 DB 的效能,導致系統穩定性下降

執行複製會對來源 DB 帶來 CPU 等其他成本耗損,一次僅增加一張資料表會有更好的控制能力,DBA 可以更好的控制和確認是否影響到現在正在運行中系統。

中止資料表複製

如果您必須要停止對資料表的複製,請用以下反向指令去操作:

-- On the old database

ALTER PUBLICATION pg_upgrade_pub_nocopy DROP TABLE my_append_only_table_name;

-- ON the new database

ALTER SUBSCRIPTION pg_upgrade_sub REFRESH PUBLICATION;

在緊急情況時,你也可以直接完全 drop 掉 publications 和 subscriptions,然後再重新開始流程。Postgres 將會自動清理用在 publication 和 subscription 所建立的 replication slots,這應可以降低來源 DB 的壓力。

🚨 請留意如果你僅禁止 subscription 而非完整移除 publication 的資料表並 refresh subscription,來源 DB 還是會記住保留住舊有的 transaction IDs,這可能會導致 transaction wraparound 進而讓 DB 中止運作。

總結來說,僅取消 subscription 並無法解決複製帶給來源 DB 的相關效能問題。

關於 replication slots 的注意事項

在 Postgres 中的 replication slots 會儲存在 DB 中的 activity log,這些 log 可以在另一個 DB 或是 application 做使用 (consume)。Postgres 是使用 Log Sequence Number (LSN) 來追蹤 replication slots 的進程。這代表如果你有一個 replication slot 在你的 DB 裡 (e.g. 複製任何資料變化至 data warehouse ),您將無法將 replication slots 的 LSN 從舊 DB 複製到新 DB 上。

如果您的 application 或是任何工具有用到 replication slots,那會需要確認一下相關的文件確認該 application 是如何 consume replication slots 再決定如何最佳化自己的 migrate 策略 (e.g. 像是 data warehouse 工具,應該都會有一些設定可操作合併兩著 DB 重複的資訊)。擁有一些幂等機制 (Idempotence mechanism) 去刪除新舊 DB 之間重複的 transactions 是非常有幫助的。

冪等機制 (Idempotence mechanism):是一種策略,用來確保當一樣作業執行多次都會回傳一樣的結果。

完成 migration

當所有來源 DB 的 table 都設定好 publication,且目標 DB 的 table 都也完成 subscriptions 且同步完成時,接下來你需要去驗證每個 table 是否都有同步一致。

不幸的是最終一致性 (eventual consistency) 的問題,無法讓新舊 DB 的資料完全一致 (舊資料庫的寫入與新資料庫寫入時的 lag),但您仍然可以去 count table 至少他們逼近一致確認運作狀況沒有問題。

在 Knock 我們邊寫了一個 script 來確認每個 table 在新舊 DB 之間的之間的總行數並比較結果。有些表有 inserted_at 的欄位,我們會透過此欄位篩選 10 秒前來確認結果,這個時間差已經足夠確認 table 是否一致,也可以認定在未來 10 秒後還未被同步過來的資料會同步完成。

您會需要規劃一個適合屬於您應用程式需求的策略去評估,我們認為幾秒鐘的同步差距已無傷大雅,並相信 Postges 的複製是可靠的。

在某些情況下,我們也抽查了一些 table 的內容,以確保資料真的如同假設般的匹配。透過隨機抽查和比對 table,可以幫助您驗證這件事。

應用層的改變

當這些 DB 全部都平行同步作業時,您會需要擬定一個策略去轉換過去新的 DB。

當最終切換開始時,你可以直接改動 application DB config 檔案,將它指向新的 DB endpoint,然後重啟 application。這樣的操作非常簡單且直覺,也正是我們 migrate 低流量的 DB 的方式。

有些 application 有大量併發的存取,需要發揮一些創意。我們需要避免新舊 DB 發生寫入衝突的情況。寫入衝突可能會導致服務中斷,因此需要人工下去調整 DB 狀態。

在 Knock 我們將 application config 連接到兩個 DB上。當準備要執行切換時,我們跑了個 script,大致做了以下這幾件事:

  1. 將所有的 application instance 的 query 移至新 DB 查找。
  2. 目前正在運行的所有 DB query 即將被強制取消,但在取消之前還有 500 ms 的作業時間可當緩衝。
  3. 在切換的第一秒,我們所有的 application 將人為的將對新 DB 的所有查詢暫停一秒鐘的時間,這可以讓 pending 的 transactions 有足透的時間複製到新 DB 上,不會查訊到過時的舊資料。
    500 ms 已經遠高於我們大多數的 DB 查詢時間,且由於強制斷開連結,我們看到沒有任何錯誤發生。
  4. 第一秒之後,資料庫的活動恢復所有正常行為,但全數切到新 DB 上。
  5. 在切換的過程中,我們有一些專門在對 DB 負載的服務,也透過 script 將其關閉並重新,讓這些服務重新連向新 DB。

還有一件事: 序列 (sequences)

複製過程有一項不會被同步到的部分是 Postgres 的 sequence。sequences 是單調遞增的整數,並保證不會有任何重複。但複製時,這些序列在新 DB 中並不會自動遞增。

但這個問題相對好控制,我們在切換新舊 DB 的程序之前執行了一個 script,大致做了以下這幾件事:

  1. 連接到兩個新舊 DB 上。
  2. 使用 DB 語法取得下一個序列值 SELECT nextval('sequence_name')
  3. 透過語法 SELECT setval('sequence_name', value::int4 + 100000) 設定該值在新 DB 有個緩衝區 (在我們的案例裡,10 萬筆行數是我們設定的在新 DB 切換時可用的緩衝量)。這會讓 table 的序列上會有間隙,但通常問題不大。
    對我們來說,我們的序列是 bigint, 10 萬的序列值被跳過幾乎可以忽略不計,因此不會有任何問題。
    您會需要知道這個間隙要用到多大,就不會使用到過多的可用空間,如果您只是想在切換新舊 DB 時的正式開始切換的時間點進行序列調整的操作,且大概只有幾百筆資料,那或許可以設定在 5000 左右。

切換前的最終檢查

以下是我們在執行最後切換時確認的一些事項:

  1. 所有 table 的資料是否都有一致同步如同預期?
  2. 所有的 subscriptions 都已經被啟用,且運作沒有任何 error 產生?
  3. schema 有一致嗎?您能夠暫停凍結任何新的 schema migration 去減少在 migrate 時發生的風險?
  4. 新 DB 的大小是否符合您的 DB 負載服務?
  5. 是否需要增加任何 read replica 讓 database cluster topology 和以前的舊 DB 完全一致?
  6. 是否已經對新 DB 做了 reindex 以及基本的 VACUUM 維護,以確保目前新 DB 已可被用到 production 環境。
  7. 是否已經檢查了 Postgres 的 release note 是否有任何異動會造成 application 性能下降、查詢錯誤或不兼容等問題。
  8. 是否已經對版本的臨時 DB 執行過自動測試和手動測試驗證系統效能?
  9. 是否有透過 pgbench 執行過 load test 確保新 DB 效能狀況?
  10. 如果還有一件事能讓整個切換再降低風險,那會是什麼事?
  11. 不斷在測試環境練習切換的步驟直到你夠熟悉任何狀況。這樣的試跑有助於縮小上線到正式環境時的差距。
  12. 備份 DB,以防萬一。

切換

在 Knock 我們花了幾週的時間同步和複製所有的 tables,通常在工作時間之後以及流量較低的區間內進行此操作。同時也在 staging 環境練習了數次切換 DB 的準備,直到無需太多工程師手介入參與也能正常運作的程度。

PG 15 的 replica DB 以及 application code 上的設定皆已完成預備,我們就開始執行最後的檢查以及準備正式切換。

經過幾個月的準備,實際的切換上很順利:application 在幾秒鐘內就切換完畢,我們有設定了一個短暫的故意延遲,但 application 沒有發生任何問題正常繼續運行。

我們將所有 DB 存取全數指向新 DB,並刪除了新 DB 上的 subscription、舊 DB。成功的從 Postgres 11.9 升級至 15.3,且停機時間為 0。

結論

雖然一次要跳 Postgres 的四個主要版本是痛苦的工程,但是可以實際完成的,且在很多方面它比預定停機時間升級 DB 來說更安全:在執行實際的切換之前可以多次練習、測試。在反覆練習和測試過程中的任何時候,都可以從舊資料庫中刪除 publication 並重新開始,且不會降低我們的服務效率。

客戶總是期望 100% 的可用性。雖然這在技術上是不可能的,但 zero downtime migrations 可以更輕鬆地保持系統平穩運行,而不會造成重大服務中斷。

我的結論

以本身經驗來說,我不太建議大跳四個版本這樣的方式作升級,由本文發現,受迫於時間壓力才會不得不做更新,但極度可能會沒有資源去控制未知的風險 (當然公司人力充足的情況下是沒什麼問題)。

實務上工作量我相信都超出大家的 loading,也不會閒到沒事一直更新東更新西,尤其第三方服務用多了,那更新量簡直嚇人。但至少一年為週期去定期檢查語言、框架、第三方服務、DB、快取、OS 等版本內容,需要大跳一個版本就跳,我相信只要確認下一個大版本透過幾次的 patch 已穩定狀態下,作升級是相對風險更低的操作。

但本文章對於 Postgres 的研究非常透徹,學習到蠻多 Postgres 以往我不太熟的知識 (例如 VACUUM 的更深入的原理)。且為了 zero downtime 下了不少苦心研究並寫成文章,真的是獲益良多。

目前我們在做 DB 版本升級,還是採用熟悉的老套路 pg_dumppg_restore 的方式去達到升版,且由於微服務 DB 數量眾多,這樣的方式通常需要一個早上的歲修停機時間才有辦法完整處理,或許下次可以嘗試看看 Knock 的方法,或許可以減少更多停機處理時間。

最後,有看到 Knock 是採用 AWS Aurora,也讓我想起之前在台灣技術社群上有看過相關 Aurora 的討論,AWS Aurora 的優點可以在官網上查詢到,是個非常優秀的 DB ,但想帶給大家不同的觀點。

AWS Aurora 是 AWS 結合了 MySQL 和 Postgres 的 DB 複合體,MySQL 的版權是 GPL v2、Postgres 接近 BSD 或是 MIT,理論上,沾染到 GPL v2 的套件後做的任何修改再發表的產品是要被公開修改的 source code,但 AWS 並沒有這樣做,因為沒有「發表軟體」的行為。也因此 AWS Aurora 除了 AWS 工程師外,其他人並不曉得程式的實際情況,對於開源派的工程師來說會是相對沒有信任感的。

以上是看完文章的分享,希望對各位工程大大們有所幫助。

2024 年快樂。

--

--