跳到主要內容
maxstudioNews
AI 自動化DEV Community (Claude Code)約 12 分鐘

如何使用 Claude Code 在零停機下完成 1.8 億行 PostgreSQL 資料表遷移

本文作者分享如何利用 Claude Code 協助,在不造成任何停機的情況下,將一個擁有 1.8 億行資料的 PostgreSQL 資料表從 int4 主鍵遷移到 bigint。Claude Code 在處理重複性工作時表現出色,但也在關鍵的鎖定行為上犯了兩個可能導致正式環境停擺的錯誤。這篇文章強調了 AI 輔助工具的優點與潛在風險,以及在關鍵任務中人工監督的重要性。

如何使用 Claude Code 在零停機下完成 1.8 億行 PostgreSQL 資料表遷移
圖片來源:DEV Community (Claude Code)

簡而言之,我們的事件資料表(events table)的主鍵(primary key)是 int4 型別,即將達到 2,147,483,647 的上限。這張資料表有 1.8 億行資料,且不允許任何停機時間。我使用 Claude Code 規劃並撰寫了一個擴展/收縮(expand/contract)遷移方案,將 int4 轉換為 bigint,整個過程歷時九天,期間沒有任何寫入鎖定。

這個 AI 代理在處理八成無聊的工作時表現出色,是個很棒的副駕駛,但在兩個地方自信地犯了錯,這些錯誤本可能導致正式環境停擺。

問題:

這是最初的警報:「events_id_seq: 1,931,204,118 / 2,147,483,647 (89.9%) 預計耗盡:約 41 天」。事件資料表是應用程式中最繁忙的資料表,高峰期每秒約有 1,400 次插入,並透過外部鍵(foreign keys)被其他三個資料表引用,我們的 API 幾乎每個請求都會從中讀取資料。

它在多年前使用 serial 建立,這意味著它是 int4 型別。一旦序列達到最大值,所有插入操作都會失敗,不是「變慢」,而是「失敗」。教科書上的解決方案只有一行:ALTER TABLE events ALTER COLUMN id TYPE bigint;但這簡單的一行是個陷阱。它會取得 ACCESS EXCLUSIVE 鎖定,並重寫整個資料表及其所有索引。

對於 1.8 億行資料(包含索引約 210 GB),我們在測試環境演練這個指令時,耗時 3 小時 12 分鐘。核心資料表三小時無法讀寫,這不是遷移,而是帶有額外步驟的停機。

我為自己設定的限制:

任何陳述式持有重鎖定的時間不得超過約 2 秒。讀取副本的複製延遲(replication lag)必須保持在 5 秒以下。每個步驟在最終交換之前都必須可逆。必須在 41 天的截止日期前完成,並預留緩衝時間以應對突發狀況。

背景設定:我們使用託管服務上的 PostgreSQL 16.4、Node.js 22.x API,以及在我的終端機中運行,並具有資料庫結構讀取權限和測試資料庫存取權限的 Claude Code(當時是 v2.x 版)。

解決方案:

步驟 0:讓 AI 代理先了解影響範圍

在撰寫任何 SQL 之前,我要求 Claude Code 繪製所有與 events.id 相關的內容,不僅是資料表本身,而是整個依賴圖:「找出所有依賴 events.id 的外部鍵、索引、視圖、觸發器和應用程式查詢。輸出為表格。暫時不要提出解決方案。」

「暫時不要提出解決方案」這句話很重要。如果沒有它,AI 代理會直接跳到計畫,而清單會變得草率。有了這句話,我得到了一個清晰的清單:其他三個資料表中的三個外部鍵欄位(也是 int4,也需要遷移)、四個索引、一個實體化視圖(materialized view),以及應用程式程式碼中將 id 轉換型別的 11 個 ORM 查詢。其中有兩個轉換是我之前不知道的。

步驟 1:擴展,新增新欄位並保持同步

我們採用的計畫是經典的擴展/收縮模式:新增 id_new bigint 欄位 → 觸發器同步新寫入 → 批次回填舊資料 → 並行建立唯一索引 → 透過 CHECK 驗證 NOT NULL → 在一個短交易中交換 → 收縮:刪除舊欄位。

在現代 PostgreSQL 中,新增一個可為空且沒有預設值的欄位僅是元資料操作,因此是即時的:SET lock_timeout = '2s'; ALTER TABLE events ADD COLUMN id_new bigint; 然後是一個觸發器,以便每個新增或更新的行都能自動填入 id_new:

CREATE OR REPLACE FUNCTION events_sync_id_new() RETURNS trigger AS $$ BEGIN NEW.id_new := NEW.id; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER events_sync_id_new BEFORE INSERT OR UPDATE ON events FOR EACH ROW EXECUTE FUNCTION events_sync_id_new();

SET lock_timeout = '2s' 這行成為這個專案中每個 DDL 陳述式(資料定義語言陳述式)的規則。如果 ALTER 無法在 2 秒內取得鎖定(因為有長時間運行的查詢正在持有鎖定),它會放棄而不是排隊。這很重要,因為一個排隊的 ACCESS EXCLUSIVE 請求會阻塞其後的所有查詢,即使是普通的讀取。失敗的 DDL 可以重試,但鎖定隊列堆積是無法撤銷的。

步驟 2:以小批次、限速回填

這是 Claude Code 為我省下最多打字的地方。我要求一個回填腳本,並明確要求:按主鍵範圍分批、批次之間暫停,以及檢查複製延遲並自動減速。以下是我們最終使用的核心部分(Node.js, pg client):

const BATCH = 20_000; const MAX_LAG_SECONDS = 5; async function replicaLagSeconds(replica) { const { rows } = await replica.query(SELECT COALESCE(EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp()), 0) AS lag); return Number(rows[0].lag); } async function backfill(primary, replica, startId, endId) { for (let lo = startId; lo <= endId; lo += BATCH) { const hi = lo + BATCH - 1; await primary.query(UPDATE events SET id_new = id WHERE id BETWEEN $1 AND $2 AND id_new IS NULL, [lo, hi]); let lag = await replicaLagSeconds(replica); while (lag > MAX_LAG_SECONDS) { await sleep(2_000); // back off until replicas catch up lag = await replicaLagSeconds(replica); } await saveCheckpoint(hi); // resumable after a crash or deploy } }

一些真正有幫助的細節:基於範圍的批次,而不是 LIMIT / OFFSET。OFFSET 會隨著進度變慢,而主鍵範圍則保持恆定時間。AND id_new IS NULL 使每個批次都是冪等(idempotent)的,重新運行一個範圍是無害的。

每個批次後都有一個檢查點。腳本曾被終止兩次(一次是部署,一次是我手動),兩次都從上次儲存的 id 恢復。回填過程持續了約 5.5 天,高峰時段平均每秒約 380 行,夜間速度更快,因為很少觸發延遲檢查。整個運行期間的峰值複製延遲為 4.1 秒。

步驟 3:在不阻塞寫入的情況下建立索引

您不能在沒有唯一索引的情況下將 id_new 設為主鍵,而正常的 CREATE INDEX 會在整個建立過程中阻塞寫入。因此,我們使用:CREATE UNIQUE INDEX CONCURRENTLY events_id_new_idx ON events (id_new); 這花了 47 分鐘,期間寫入操作正常進行。

步驟 4:在不使用全表鎖定的情況下證明 NOT NULL

主鍵要求 NOT NULL。ALTER COLUMN ... SET NOT NULL 通常會在重鎖定下掃描整個資料表。技巧(Postgres 12+)是將 CHECK 限制新增為 NOT VALID,然後使用較弱的鎖定單獨驗證它,再讓 SET NOT NULL 重用該證明:

SET lock_timeout = '2s'; ALTER TABLE events ADD CONSTRAINT events_id_new_not_null CHECK (id_new IS NOT NULL) NOT VALID; -- 取得 SHARE UPDATE EXCLUSIVE 鎖定:讀寫操作持續進行 ALTER TABLE events VALIDATE CONSTRAINT events_id_new_not_null;

步驟 5:交換,一個簡短、經過演練的交易

以上所有步驟都是為了大約 1.4 秒的實際鎖定時間做準備:

BEGIN; SET LOCAL lock_timeout = '2s'; ALTER TABLE events ALTER COLUMN id_new SET NOT NULL; -- 即時,使用 CHECK ALTER TABLE events DROP CONSTRAINT events_pkey; ALTER TABLE events ADD CONSTRAINT events_pkey PRIMARY KEY USING INDEX events_id_new_idx; ALTER SEQUENCE events_id_seq AS bigint; ALTER TABLE events ALTER COLUMN id_new SET DEFAULT nextval('events_id_seq'); ALTER SEQUENCE events_id_seq OWNED BY events.id_new; ALTER TABLE events ALTER COLUMN id DROP DEFAULT; ALTER TABLE events RENAME COLUMN id TO id_old; ALTER TABLE events RENAME COLUMN id_new TO id; DROP TRIGGER events_sync_id_new ON events; COMMIT;

我們在測試快照上演練了六次。在正式環境中,它在 1.4 秒內完成。第一次嘗試因為一個長時間的分析查詢而觸發了 lock_timeout,乾淨地回滾,30 秒後的第二次嘗試則成功了。這正是我們想要的行為。之後,三個外部鍵資料表也經歷了相同的過程(較小的資料表,所以更快),一週後,當我們確信沒有任何東西讀取 id_old 時,我們將其刪除。

AI 代理自信卻錯誤的地方:

我想在這裡誠實地說明,因為這是大多數「我用 AI 做了 X」的文章會跳過的部分。

❌ 錯誤 1:遷移交易中的 CONCURRENTLY

Claude Code 生成的第一個遷移檔案將 CREATE UNIQUE INDEX CONCURRENTLY 包裹在我們遷移工具的預設交易中。Postgres 會直接拒絕這種做法(CREATE INDEX CONCURRENTLY 無法在交易區塊內運行),因此它會大聲失敗,而不是默默地失敗。這很煩人,但很安全。解決方法是將該遷移標記為非交易性。

⚠️ 錯誤 2:可能導致停機的「更簡單」的交換

這個錯誤讓我感到害怕。在遷移中期,我要求 AI 代理「簡化交換步驟」。它的建議是放棄 NOT VALID / VALIDATE 的操作,直接在交換交易中執行 SET NOT NULL,並附註「欄位已完全回填,所以這會很快」。欄位確實已完全回填,但沒有經過驗證的 CHECK,SET NOT NULL 仍然會掃描所有 1.8 億行來證明它,同時持有 ACCESS EXCLUSIVE 鎖定。

在測試環境中,這個「簡化」的交換持有鎖定 4 分 38 秒。在正式環境中,這意味著所有 API 請求同時逾時。AI 代理的推理對於資料的樣子是正確的,但對於資料庫如何實現這一點的鎖定行為卻是錯誤的。這是我現在會注意的模式:AI 代理對於資料的樣子推理得很好,但對於資料庫如何實現這一點的行為卻很糟糕。

學到的教訓:

讓 AI 代理在規劃之前先盤點。要求「找出所有依賴項,暫時不要提出解決方案」產生了一張比「如何遷移這個欄位?」更好的地圖。應用程式程式碼中兩個隱藏的型別轉換本會是正式環境的錯誤。

將您的安全規則編碼為限制,而不是希望。在每個 DDL 上設定 lock_timeout = '2s',並在回填中加入複製延遲保護,將「小心」變成了資料庫強制執行的規則。一旦這些規則被寫入任務中,AI 代理就會完美地遵循它們。

在沒有測試環境演練的情況下,絕不接受鎖定敏感步驟的「簡化」建議。要求 AI 代理說明每個陳述式會取得什麼鎖定以及持續多久。當我開始問「這會取得什麼鎖定,持續多久?」時,答案變得更加謹慎。

演練交換步驟直到它變得無聊。六次演練感覺過度了。然後第一次正式環境嘗試確實遇到了鎖定逾時,但沒有人恐慌,因為我們已經見過這種回滾情況了。

讓 AI 代理撰寫無聊的程式碼,但您要掌握時間線。Claude Code 撰寫了約 90% 的腳本和 SQL。我負責操作順序、放行/不放行的決定以及回滾計畫。這種分工感覺很對。

接下來:

我們還有四個 int4 資料表在「兩年內」的清單上。我的計畫是將整個流程變成一個可重複使用的操作手冊(runbook),與 AI 代理協同:一個檢查清單範本、一個參數化的回填腳本,以及一個自動測量鎖定持續時間並在任何陳述式超過 2 秒時失敗的測試環境演練步驟。目標是讓下一次遷移主要是一個審查工作,而不是一個為期九天的專案。

總結:

如果您正坐在一個繁忙資料表上的 serial 欄位上,今天就檢查您的序列:SELECT last_value FROM events_id_seq; 然後將它與 2,147,483,647 進行比較。未來的您會很感激。👉 在 Dev.to 上關注我,獲取更多關於使用 AI 編碼代理交付實際系統的建構日誌,並在評論中分享您自己的遷移恐怖故事。我特別好奇是否有人有更簡潔的方法來處理外部鍵資料表。

Share

覺得值得一看?分享給朋友。

以上為 AI 自動翻譯導讀。原文版權歸 DEV Community (Claude Code) 所有, 建議透過「閱讀原文」前往原始網站,以取得最完整資訊並支持原作者。

Newsletter

不用每天追,AI 重點每週寄給你

43 個來源、每 2 小時更新,太多看不完。電子報每週挑最值得看的幾則,附上 Max 的看法。

每週一封。Email 只用來寄電子報,每封信都能一鍵退訂。