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 都做得。
情境
你 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 自動讀入。
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)。
我要將 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
依家加 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)
寫一個 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 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 日?因為:
- Mobile app 用戶可能未更新,舊 client binary 仲喺度叫 API
- CDN / edge cache 可能仲 hit 緊舊 API response shape
- 你公司啲內部工具(Metabase dashboard、cron job、data export script)你未必數得晒
- 如果 Phase 2 有 bug,你仲有
name做 source of truth 救返
7 日係偏謹慎嘅預設。SaaS 高頻 release 可以 3 日,傳統 enterprise 可能要 30 日。
變化
變化 1:ORM migration(Prisma / Drizzle)
ORM 有自動 generate migration 嘅 magic,但呢個 magic 撞正 expand-migrate-contract 就會出事。
我用 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。
我哋用 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,唔可以一次過鎖晒佢哋。
我哋 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「睇落穩陣啲」。
- 會出事:Phase 1 你以為 instant,實際鎖晒成個
userstable,所有寫入排隊,前端 timeout。 - 點救:Phase 1 一律淨係 nullable、無 DEFAULT。constraint 等 backfill 完先用
ADD CONSTRAINT ... NOT VALID再VALIDATE CONSTRAINT兩步落,避免 full scan 鎖寫。叫 Claude 明寫「呢句喺 X 百萬 row 嘅 table 上鎖唔鎖、鎖幾耐」。
2. Backfill 一個大 UPDATE 食晒 connection 同 WAL 篇文已經叫你分 batch,但「分 batch」嘅參數先係生死位。Batch 太大(例如一次 UPDATE 50 萬 row)會撐爆 WAL、拖死 replication lag、食晒 connection pool,連正常 app query 都攞唔到 connection。
- 會出事:backfill 跑住跑住,replica 落後幾分鐘,read replica 上嘅 query 攞到舊數據,或者 app 全站變慢。
- 點救:batch 細(1000 至 5000 row),每 batch 之間 sleep、commit,睇住 replication lag 同 active connection 數。叫 Claude 加
FOR UPDATE SKIP LOCKED同每 batch 之間檢查 lag、超過 threshold 就自動 pause。
3. Dual-write 兩條路唔同步,數據靜靜雞分叉
Phase 2 你以為「寫 user 嘅地方」都 cover 晒,但 admin tool、import script、第三方 webhook、raw SQL patch,往往有條暗路淨係寫舊 name 唔寫新 column。冇 error,靜默失敗。
- 會出事:過咗幾日先發現一批新 row
first_name係 NULL,但你 Phase 2 核實嗰陣 backfill 已經跑完,呢啲係之後新入嘅,你 DROP 舊 column 嗰刻先知數據對唔返。 - 點救:DROP 之前(Phase 3)再跑多次
WHERE name IS NOT NULL AND first_name IS NULL嘅 count,唔係 0 就停手查邊條暗路漏咗;高危表可以加個 DB trigger 喺過渡期強制同步兩個 column,當安全網。
4. Rollback migration 從來冇真係跑過
你寫咗 down.sql,但十居其九冇喺 staging 真跑過。到 production 出事、半夜要 rollback 嗰刻,先發現 down migration 本身有 bug、或者 reconstruct 唔返原本數據(例如 name 拆完再砌返,中間多咗個 space)。
- 會出事:危機當下你最需要嘅 rollback 反而再爆一個 error,雪上加霜。
- 點救:每個 down migration 都要喺 staging restore 一份 prod snapshot,行一次 up → down → 對 row count + sample,confirm 砌得返。冇測過嘅 rollback 等於冇 rollback。
5. Migration 同 deploy 嘅次序撞返轉頭 三段式假設「schema 先行、code 後到」或者反過來,但你個 CI/CD 究竟邊個先跑?如果 migration 喺 deploy 之後先 run(或者反過來),就會短暫出現「新 code 讀緊未存在嘅 column」或者「舊 code 撞到已 DROP 嘅 column」,即係篇文開頭嗰個 90 秒災難換個方式翻版。
- 會出事:明明跟咗三段式,但因為 pipeline 次序錯,中間幾十秒一樣 500。
- 點救:搞清楚你個 platform 係 migrate-then-deploy 定 deploy-then-migrate,Phase 1(純加)兩種次序都安全,Phase 3(DROP)就必須確保所有舊 pod 都收皮先至 run。叫 Claude 對住你個實際 deploy pipeline 講明每 phase 應該幾時 trigger migration。
呢幾個位,就係「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 都未準備好。
文中工具 · 連結
- Claude Code CLI· 付費
開發者用 — terminal 入面同 Claude pair coding
睇完想同 Claude 一齊行一次?
撳一撳,就將成段 tutor 指示(連埋成篇文嘅內容)抄入剪貼簿。 貼入 Claude.ai 或 Claude Desktop,佢會用廣東話帶你一步一步行, 每步問你填關鍵位,最後畀返一個專為你情況寫嘅 prompt 帶走。
- 創作者 · 60 分鐘
Claude Code 做 safe refactor:先 characterization test、後 mechanical move、再核對等價性
Claude Code 做 safe refactor 嘅三步流程:先 characterization test 釘住現有行為、再 mechanical move 唔改邏輯、最後核對等價性。教你避開 AI 直接重寫撞爛 edge case 嘅死位。
- 創作者 · 60 分鐘
Claude Code 處理 dependency 升級:major version bump 點安全行、breaking change 點處理
React 18 → 19、Next.js 14 → 15、Node 18 → 20。Major version bump 永遠唔會「跑一個 npm update 就完」。教你用 Claude Code 跑一次受控升級:整理 changelog、開獨立 branch 安裝、逐條 breaking change 分流、跑 regression smoke test。中小企接手 5 年舊 codebase 都搞得掂。
- 創作者 · 45 分鐘
Claude Code 唔使開 terminal 都跑得:headless + cron 起夜貓自動化 workflow
Claude Code 唔淨係 interactive。Headless mode 可以入 cron、launchd、GitHub Actions、npm script,凌晨自動跑 code health、dep 審查、PR review。教 4 個真實設定 + 3 個部署變化。