簡而言之,我們的事件資料表(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 編碼代理交付實際系統的建構日誌,並在評論中分享您自己的遷移恐怖故事。我特別好奇是否有人有更簡潔的方法來處理外部鍵資料表。