我的好朋友 Claude
第 121 期|Claude Code|創作者|

Claude Code 寫 DB migration:expand-migrate-contract 三段式 0 downtime

Claude 一上嚟就出 naive ALTER TABLE,rename column 即刻 break production。教你 expand-migrate-contract 三段式:加新唔刪舊、dual-write backfill、一個禮拜後先 drop。SaaS 冇 maintenance window 都做得。

難度 ★★★時間 55 分鐘用具 Claude Code CLI、你個 ORM (Prisma / Drizzle / SQLAlchemy / etc.) 或者直接 SQL
【編者撰】一個香港人

情境

你 SaaS 跑緊。users table 有個 column 叫 name,原本一個欄存晒姓 + 名。產品而家要分開 first_name / last_name,因為要做個人化 email。

你開 Claude Code,順手講:

幫我將 users.name split 做 first_name + last_name。

Claude 5 秒鐘出嚟:

ALTER TABLE users RENAME COLUMN name TO last_name;
ALTER TABLE users ADD COLUMN first_name TEXT;
UPDATE users SET first_name = SPLIT_PART(last_name, ' ', 1),
                 last_name  = SPLIT_PART(last_name, ' ', 2);

睇落幾合理。你 prisma migrate deploy 落 production。

3 秒之後 Sentry 爆 50 個 error:所有部署緊嘅舊 pod 仲喺度 SELECT name FROM users,column 唔見咗,HTTP 500。新 pod 起緊嚟未接到流量。中間 90 秒所有 user signup / login 死。

Production migration 嘅死位永遠係:你以為 schema 改 = 一個原子動作,其實 schema 改 + code deploy 中間嗰 60-180 秒,舊 code 同新 schema 一齊共存。一個唔啱就攬炒。

呢篇教 Claude 寫 migration 用 expand-migrate-contract 三段式。慢,但 SaaS / e-commerce 冇 maintenance window 都做得,每一步都 rollback 得返。

跟住做

1. CLAUDE.md 寫死三段式紀律

Claude 慣性識「schema migration」但唔識「production rollout」呢層。你要喺 project root CLAUDE.md 寫死規矩,每次開 session 自動讀入。

CLAUDE.md 加 migration section
Database Migration 紀律呢個 project 跑 production SaaS,冇 maintenance window。任何 schema 改動必須跟 expand-migrate-contract 三段式:Phase 1 — Expand(additive only)
只可以 ADD COLUMN / ADD TABLE / ADD INDEX (CONCURRENTLY)
唔可以 RENAME、DROP、ALTER TYPE
新 column 必須 NULLABLE 或者有 DEFAULT
Deploy 完 schema 變化,舊 code 必須 100% 仲跑得
Phase 2 — Migrate(dual-write + backfill)
App code 同時寫新舊兩個 column(dual-write)
寫 backfill script,分 batch 處理舊 row
驗證:count + sample diff 確認新舊 column equivalent
切讀流量去新 column
Phase 3 — Contract(drop old)
Phase 2 stable run 至少 7 日先做
確認所有 deploy 嘅 pod 都唔再讀舊 column(log audit)
DROP COLUMN / DROP TABLE
任何 PR 想一步到位 RENAME / DROP,一律打回頭。

呢段寫一次,之後每次 Claude 開 session 入 project 都會跟。冇呢段,Claude 慣性出 naive SQL。

2. Phase 1 — Expand:加新唔郁舊

返到我哋 users.name split 個例。Phase 1 嘅 migration 必須係純加嘢(purely additive)

Phase 1 — Expand migration
我要將 users.name split 做 first_name + last_name。跟 CLAUDE.md 嘅三段式紀律,依家寫 Phase 1(expand)migration。要求:
只可以 ADD COLUMN,唔可以 touch name
新 column NULLABLE,default null
用我哋個 ORM(睇 prisma/schema.prisma)generate migration

Claude 出嚟應該係:

-- prisma/migrations/20260527_add_first_last_name/migration.sql
ALTER TABLE users ADD COLUMN first_name TEXT;
ALTER TABLE users ADD COLUMN last_name TEXT;

部署呢個落 production。舊 code 仲喺度 SELECT name,正常運作。新 column NULL,冇人讀,零影響。

⚠️ Postgres 紀律:ADD COLUMN 唔加 DEFAULT 係 instant(metadata only)。如果你要加 NOT NULL DEFAULT,要分兩步:先 ADD nullable、再 backfill、再 ALTER SET NOT NULL。ADD COLUMN NOT NULL DEFAULT 'x' 會 rewrite 成個 table,大 table 鎖死分鐘級。

3. Phase 2 — Dual-write + backfill + 核實

呢一 phase 最長最悶但最關鍵。三件事順住做:

3a. App code dual-write

Phase 2a — Dual-write code change
依家加 dual-write:寫 user 嘅地方(signup / profile update / admin tools),同時寫 name 同 first_name + last_name。紀律:
git grep -n "name:" src/ 列晒所有寫 name 嘅地方
每個地方加 first_name + last_name 嘅寫入
讀取繼續讀 name(未切)
加單元測試 confirm 新 row 三個 column 都填咗
唔好掂讀取邏輯。寫先,讀後。

部署呢個。新寫入嘅 row 三個 column 都有值。舊 row 仲係得 name

3b. Backfill script(idempotent)

Phase 2b — Backfill script
寫一個 backfill script 將舊 row 嘅 name 拆落 first_name + last_name。要求:
Idempotent — 跑兩次結果一樣,跑到一半 crash 都唔會壞
分 batch(每 batch 1000 row),每 batch commit + sleep 100ms 避免 lock spike
WHERE clause 只揀 first_name IS NULL AND name IS NOT NULL(已 backfill 嘅唔再 touch)
拆分邏輯:first space 之前 = first_name,之後全部 = last_name;如果冇 space,全部當 first_name
每 batch print progress(processed / total / ETA)
寫一個 dry-run mode 先抽 100 row print 出嚟畀我睇過
唔好用 ORM 嘅 findMany 載晒成個 table 入 memory。用 raw SQL SELECT ... LIMIT 1000 FOR UPDATE SKIP LOCKED。

Dry-run 先跑、肉眼抽 50 個 case 睇(特別係名字得一個字、外國名、中英混合、有 prefix「Dr.」嗰啲)。OK 再跑 full backfill。

3c. 核對 equivalence

-- 確認冇 row missed
SELECT COUNT(*) FROM users WHERE name IS NOT NULL AND first_name IS NULL;
-- 應該係 0

-- Sample 對比
SELECT name, first_name, last_name FROM users
WHERE name IS NOT NULL ORDER BY random() LIMIT 50;
-- 肉眼睇 50 個 case 啱唔啱

驗證通過先切讀取去新 column —— 改 app code SELECT first_name, last_name,舊 name field 喺 ORM model 標返個 deprecated comment。部署。

4. Phase 3 — Contract:等一個禮拜先 DROP

呢一步 Claude 最想跳過,因為「個 column 都冇人讀啦」。唔好咁

Phase 3 — Contract migration
Phase 2 已經 deploy 咗 8 日,production log 確認過去 7 日零次 SELECT name 或者 WHERE name = ...(grep 過 Datadog APM trace)。依家寫 Phase 3 migration:
DROP COLUMN users.name
同時 update prisma/schema.prisma 刪走 name field
同時寫 rollback migration —— 一個 .sql file 紀錄點樣加返 name column + 從 first_name/last_name reconstruct(即使我哋唔諗住跑,都要寫低,畀未來嘅人睇)
紀律:
唔好順手 DROP 其他 column
唔好 ALTER TABLE ... ALTER COLUMN ... SET NOT NULL 喺 first_name —— 留 nullable 多一個 release cycle,等所有舊 backup restore 都仲可以 ingest

點解等 7 日?因為:

7 日係偏謹慎嘅預設。SaaS 高頻 release 可以 3 日,傳統 enterprise 可能要 30 日。

變化

變化 1:ORM migration(Prisma / Drizzle)

ORM 有自動 generate migration 嘅 magic,但呢個 magic 撞正 expand-migrate-contract 就會出事。

變化 1 — Prisma 三 phase pattern
我用 Prisma。要 split users.name 做 first_name + last_name。紀律:
唔好 一次過改 schema.prisma 然後 prisma migrate dev —— Prisma 會 generate RENAME / DROP 一鑊熟
三個獨立 migration folder:

20260527_add_name_columns — 加 first_name? last_name?(optional)
20260603_drop_name_column — 等 7 日後 + dual-write 切完讀寫先寫
兩者中間 backfill script 唔經 Prisma migration,放 scripts/backfill-names.ts


Phase 1 schema.prisma 三個 field 並存,全部 nullable
Phase 3 schema.prisma 先刪走 name
⚠️ 千祈唔好用 prisma db push 喺 production,佢會直接 sync schema 然後 RENAME。

Drizzle / TypeORM / SQLAlchemy 都係同一套規律,重點係每個 phase 一個 migration file,中間夾住 app deploy

變化 2:Raw SQL(冇 ORM)

直接揸 SQL 反而清晰啲,因為冇 magic。

變化 2 — Raw SQL 三段式
我哋用 sqlx / pg 直接寫 SQL,migration 喺 migrations/ folder 順序執行。每個 phase 一個 .sql + 對應 .down.sql(rollback):Phase 1:
001_up.sql: ALTER TABLE users ADD COLUMN first_name TEXT; ADD COLUMN last_name TEXT;
001_down.sql: ALTER TABLE users DROP COLUMN first_name; DROP COLUMN last_name;
Phase 3(7 日後):
002_up.sql: ALTER TABLE users DROP COLUMN name;
002_down.sql: ALTER TABLE users ADD COLUMN name TEXT; UPDATE users SET name = COALESCE(first_name, '') || ' ' || COALESCE(last_name, '');
每個 down.sql 都要實際測試一次(喺 staging restore prod snapshot → up → down → 確認 row count + sample 一致)。冇測過嘅 rollback 等於冇 rollback。幫我寫埋兩個 phase 嘅 up + down,加 transaction wrapper(BEGIN; ... COMMIT;)。

變化 3:Multi-tenant Postgres(shared DB + RLS)

呢個最棘手:一個 migration 影響幾百個 tenant,唔可以一次過鎖晒佢哋。

變化 3 — Multi-tenant 三段式 + RLS 紀律
我哋 multi-tenant Postgres,所有 tenant 喺同一個 users table,用 tenant_id column + Row-Level Security policy 隔離。Phase 1 expand 紀律加碼:
ADD COLUMN 用 ALTER TABLE ... ADD COLUMN first_name TEXT;(NULLABLE,instant,唔鎖 table)
唔好 加 NOT NULL constraint 喺呢一步 —— 會 rewrite 成 table,鎖死所有 tenant
如果要 index,用 CREATE INDEX CONCURRENTLY —— 慢但唔鎖讀寫
RLS policy 唔需要改(policy 係 tenant_id-based,唔關 column 事)
Phase 2 backfill 紀律加碼:
Backfill script 按 tenant_id 分組做,一個 tenant 做完先做下一個
每個 tenant 之間 sleep 1 秒,分散 connection pool 壓力
大 tenant(>100k user)獨立 batch size + off-peak 跑
跑前喺 tenant_migrations table log 邊個 tenant 做到邊,crash recovery 用
幫我寫成個 plan + Phase 1 migration + backfill script skeleton。

Multi-tenant 嘅心法:冇一個 migration 係 atomic across tenants(跨 tenant 原子化)。你要當佢係 100 個獨立 migration 嘅漸進式 rollout。

拆解:點解 work,同邊度會仆街

跟到上面就已經部署得。下面呢段係畀**想由「staging 跑 OK」做到「真係夠膽 deploy 落 production」**嘅人——初學者可以跳過,唔影響你跟住做。

Migration 最唔老實嘅地方係:喺 staging 同你個細數據庫跑得靚,唔代表喺 production 千萬 row、幾百個 concurrent connection 之下唔出事。三段式幫你避開大部分災難,但仲有呢幾個位會喺真 production 仆街,你要預咗:

1. ADD COLUMN 都未必 instant(睇你加咩) 篇文講過 ADD COLUMN nullable 係 metadata-only、唔鎖 table。但只要你順手加 NOT NULL、加 volatile DEFAULT、或者加 CHECK constraint,就可能觸發 full table rewrite,喺大 table 鎖死分鐘級。Claude 出 SQL 時好容易順手補返個 NOT NULL「睇落穩陣啲」。

2. Backfill 一個大 UPDATE 食晒 connection 同 WAL 篇文已經叫你分 batch,但「分 batch」嘅參數先係生死位。Batch 太大(例如一次 UPDATE 50 萬 row)會撐爆 WAL、拖死 replication lag、食晒 connection pool,連正常 app query 都攞唔到 connection。

3. Dual-write 兩條路唔同步,數據靜靜雞分叉 Phase 2 你以為「寫 user 嘅地方」都 cover 晒,但 admin tool、import script、第三方 webhook、raw SQL patch,往往有條暗路淨係寫舊 name 唔寫新 column。冇 error,靜默失敗。

4. Rollback migration 從來冇真係跑過 你寫咗 down.sql,但十居其九冇喺 staging 真跑過。到 production 出事、半夜要 rollback 嗰刻,先發現 down migration 本身有 bug、或者 reconstruct 唔返原本數據(例如 name 拆完再砌返,中間多咗個 space)。

5. Migration 同 deploy 嘅次序撞返轉頭 三段式假設「schema 先行、code 後到」或者反過來,但你個 CI/CD 究竟邊個先跑?如果 migration 喺 deploy 之後先 run(或者反過來),就會短暫出現「新 code 讀緊未存在嘅 column」或者「舊 code 撞到已 DROP 嘅 column」,即係篇文開頭嗰個 90 秒災難換個方式翻版。

呢幾個位,就係「staging 跑 OK」同「production 千萬 row 都唔仆街」之間嘅距離。

一個心態

Schema change is not an event. It's a process that spans a week.

新手 dev 諗 database migration 係「我寫 SQL → 跑 → 完」。Production migration 嘅單位唔係 SQL,係 release 週期。每個 phase 都係一個獨立 deploy,中間有幾個鐘到幾日嘅緩衝畀你發現問題、rollback、補底。

Claude Code 嘅速度反而係呢度最大陷阱。佢 5 秒鐘出嚟嘅 ALTER TABLE ... RENAME COLUMN 喺 dev 100% 啱,喺 production 100% 攬炒。分別唔喺 SQL 啱唔啱,係你有冇承認 schema 同 code 永遠唔可能同步 deploy 呢個事實

Expand-migrate-contract 三段式嘅靈魂:每一個中間狀態都係一個有效嘅 production state。Phase 1 後舊 code 仲跑得。Phase 2 後新舊 code 並存都跑得。Phase 3 後乾乾淨淨。任何一 phase 出事,你停喺嗰度都唔會死。

CLAUDE.md 寫死呢條紀律,Claude 之後就唔會再出 naive migration。比起每次覆檢都要鬧佢「呢條會 break production」,一次過寫低落 context,慳返你一百個禮拜嘅 review 力氣。

下次有人叫你 split column / rename table,唔好直接出 ALTER。先問自己:「Phase 1 點樣部署完舊 code 仲跑得?」答到先寫 SQL。答唔到,你連 Phase 1 都未準備好。

文中工具 · 連結

  • 開發者用 — terminal 入面同 Claude pair coding

睇完想同 Claude 一齊行一次?

撳一撳,就將成段 tutor 指示(連埋成篇文嘅內容)抄入剪貼簿。 貼入 Claude.ai 或 Claude Desktop,佢會用廣東話帶你一步一步行, 每步問你填關鍵位,最後畀返一個專為你情況寫嘅 prompt 帶走。

下期預告 · 相關情境
訂閱本副刊

每週日早上,
一道新菜送到你 inbox。

一篇 use case、一個香港情境、一個跟得到嘅做法。 冇 sell course、冇話你「再唔學就會失業」。

訂閱通道執緊緊
newsletter service 仲未接通。想第一時間收到新文章——
直接 email 我哋寫一句「訂閱」就得。

Email 「訂閱」畀我