資料庫這一層的故障有個共同特徵:每一層單獨看都是綠的。
Migration exit 0、container 狀態 Up、SELECT 查得到、information_schema 也查得到。全部正常,然後 API 回 503。
這篇整理四個我們踩過的這類故障。每一個的重點都不在修法——修法通常一行——而在哪一個觀測會分岔。找到那個觀測,問題就結束了;找不到,就會在「DB 是好的啊」這句話上耗掉一個下午。
一、Migration 建了表,但 PostgREST 的 schema cache 沒 reload
症狀
Migration 成功套用、表確實存在,但 app 打該表的 API 一律失敗:
GET /api/v1/admin/<resource> → 503
{"why": "資料庫操作失敗", "fix": "請稍後重試"}
從 app 這一層完全看不出真因——error 被包成通用的 503,日誌裡只剩一句業務語的「資料庫操作失敗」。而那句話指向「DB 掛了」,DB 完全正常。
根因
PostgREST 在啟動時建立 schema cache,之後只在收到 NOTIFY <channel> 時才重建。
如果 migration runner 只做「套用 SQL 加上記帳」而沒有發 NOTIFY,就會變成:
- 表在 DB 裡 ✓
- PostgREST 跑得很正常(
Up 3 days)✓ - 但它的 schema cache 是三天前建的,不含新表 ✗
直接打 PostgREST 才看得到真因:
{
"code": "PGRST205",
"message": "Could not find the table '<schema>.<table>' in the schema cache"
}
HTTP 狀態是 404。
兩個放大因素
這個坑之所以難查,是兩件事疊在一起:
第一,app 把 404 包成 503。 通用的錯誤包裝函式吞掉了 PostgREST 的 code 與 message,於是結構化日誌裡只剩業務語。這是很常見的設計——不想把資料庫細節漏給前端——但它同時也讓自己看不見。
值得記下來的原則:對外遮蔽細節,對內必須保留原文。錯誤包裝層應該把上游的 code / message 完整寫進日誌,只在回應 body 裡替換成使用者看得懂的話。
第二,每一層單獨看都是綠的。 唯一分岔的觀測是「經由 PostgREST 打那張表」。
判定
# 1) 表在不在 DB
psql -c "SELECT schemaname, tablename FROM pg_tables WHERE tablename='<table>';"
# 2) 經 PostgREST 打得到嗎 —— 這一步才會分岔
curl -s -w '\nHTTP:%{http_code}\n' \
"http://<host>/rest/v1/<table>?select=id&limit=1" \
-H "apikey: $KEY" -H "Authorization: Bearer $KEY" \
-H 'Accept-Profile: <schema>'
判準:找得到表,而且回 PGRST205 → 就是這個坑。不是權限、不是 RLS、不是表不存在。
修法
當下解是發 NOTIFY:
NOTIFY <channel>, 'reload schema';
channel 名字要從實際部署設定的 PGRST_DB_CHANNEL 讀,不要猜。如果部署拓樸是每個 schema 一個獨立的 PostgREST 實例,每一個的 channel 名字都不一樣,發錯 channel 等於沒發。
長期解是把 NOTIFY 併進 migration runner,讓「套用 DDL」與「通知 reload」變成同一個不可分割的步驟。分成兩步、靠人記得跑第二步,遲早會漏。
二、DISABLE TRIGGER 關不掉外鍵約束
症狀
seed 撞上外鍵違規,於是在前後加上:
ALTER TABLE public.tasks DISABLE TRIGGER USER;
-- ... INSERT ...
ALTER TABLE public.tasks ENABLE TRIGGER USER;
預期「所有驗證都被關掉」。實際上外鍵違規照樣發生,錯誤訊息一字不變。
根因
DISABLE TRIGGER USER(以及不帶 ALL 的 DISABLE TRIGGER)只關 user-defined trigger。
PostgreSQL 的外鍵約束是由 internal system trigger(RI_ConstraintTrigger_*)執行的,屬於獨立的系統層:
| 語句 | 影響範圍 | 權限需求 |
|---|---|---|
DISABLE TRIGGER USER | 只有 user trigger,不碰 system trigger | 一般 owner |
DISABLE TRIGGER ALL | 含 system trigger,外鍵確實會被關掉 | superuser |
SET session_replication_role = replica | session 級關閉全部 trigger 含外鍵 | superuser 或 replication 權限 |
心智模型的錯誤點在於:把外鍵想成「一種 trigger」。語法上它確實靠 constraint trigger 實作,但在權限與開關語意上它是獨立的執行層,DISABLE TRIGGER 的預設範圍刻意排除它。
為什麼測不出來
- 開發用的 DB 是 reset-based,外鍵引用的目標都存在,所以沒有 violation 可以觸發,
DISABLE TRIGGER有沒有生效根本測不出來 ALTER TABLE ... DISABLE TRIGGER USER本身執行成功、exit 0、沒有 warning——給了「已經繞過驗證」的假信心
第二點是關鍵。一個沒有效果的語句成功執行了,而成功執行被讀成「有效果」。
修法
正解是根修引用來源。外鍵違規的根因幾乎都是引用了不存在的 id,該修的是引用,不是繞過執行層。
真的需要 bulk load 繞外鍵(極罕見,只有 migration 與 seed 場景):
SET session_replication_role = replica; -- 危險:同時關掉全部 trigger 與外鍵
-- ... bulk load ...
SET session_replication_role = DEFAULT;
代價要講清楚:載入期間零 integrity 保護,錯誤資料會直接進表,而且事後外鍵不會回頭驗證既有的 row。這是需要人拍板的決定,不是 agent 或腳本可以自己選的路。
講到這裡你可能會想,那開發環境也改成累積式的,不就測得出來了?會換來另一種盲點—— 累積式環境的狀態是歷史的產物,你會開始遇到「只有我這台會重現」的問題,而那類問題比外鍵 繞過難查得多。比較划算的做法是留著 reset-based 當日常,另外準備一個會累積、且定期不重置 的環境專跑 seed 與 migration。要的不是換掉環境,是有一個環境走的是與 CI 不同的那條分支。
三、ON CONFLICT (natural key) DO UPDATE 不會改 surrogate id
症狀
staging 部署的 seed 步驟炸外鍵違規,而開發環境與 CI 全綠:
ERROR: insert or update on table "tasks"
violates foreign key constraint "tasks_owner_id_fkey"
DETAIL: Key (owner_id)=(00000000-0000-4000-a000-000000000506)
is not present in table "members".
seed 裡明明有 INSERT INTO members (id, ...) VALUES ('00000000-...-506', ...)。成員「看起來」有被建立,但 staging 的表裡查不到這個 UUID。
根因
INSERT INTO public.members (id, tenant_id, member_no, ...)
VALUES ('00000000-0000-4000-a000-000000000506'::uuid, v_tenant_id, 'M-006', ...)
ON CONFLICT (tenant_id, member_no) DO UPDATE SET name = EXCLUDED.name;
ON CONFLICT (natural key) DO UPDATE 的 SET 清單不含 id = EXCLUDED.id——而且不該含。conflict 路徑保留既有 row 的原始 surrogate id。
所以 pinned UUID 只在 INSERT 路徑物化,也就是只在表為空、該 row 還不存在的時候。
鏈式分析:
- staging 的 DB 是永不 reset 的累積實例,
M-006早就以 auto-generated id 存在了 - 每次部署重跑 seed 都走 conflict 路徑,pinned UUID 永遠不會成為那個 row 的 id
- seed 後段 hard-code 引用那個 pinned UUID → 外鍵違規
為什麼只有 staging 炸
開發環境走 db:reset(drop 加 reload),表為空,走 INSERT 路徑,pinned id 物化,一切正常。CI 也是 reset-based。
只有「累積 state 加上重跑 seed」的環境會走 conflict 路徑。
這是環境拓樸差異,不是程式碼路徑差異——typecheck、測試、lint 全都無從偵測。要抓只能靠一條原則:
在 reset-based 環境驗過的 seed,不構成「在累積型環境會正確」的證據。
這兩種環境跑的是不同的 SQL 分支。
修法
危險組合是「INSERT 帶 pinned UUID literal」加上「ON CONFLICT 的目標不是 (id)」。掃出來之後逐個分流:
- pinned id 有
RETURNING id INTO v_*,而且後續只用那個變數 → 安全 - pinned id 被其他語句 hard-code 引用 → 中招
修法是把 hard-code 引用換成 RETURNING INTO 變數,或改用 natural-key lookup。讓後續語句依賴「查出來的 id」而不是「我們以為會是什麼的 id」。
四、換個搜尋條件就回 500
症狀
使用者停在分頁清單的第 6 頁,改了搜尋條件(或任何會讓結果集變小的篩選)。前端的 page 狀態沒有重置,於是送出:
GET /api/v1/<list-endpoint>?page=6&pageSize=20&search=FB-2 → 500
任何清單端點都能重現:
curl 'http://localhost:3000/api/v1/<list-endpoint>?page=999&pageSize=20'
# → 500
根因
PostgREST 遵循 RFC 7233 的 HTTP Range Requests。當 .range(offset, end) 的 offset 在套用篩選後大於等於實際的列數時,PostgREST 回 HTTP 416 Requested Range Not Satisfiable:
{
"code": "PGRST103",
"message": "Requested range not satisfiable",
"details": "An offset of 100 was requested, but there are only 0 rows."
}
SDK 把它包成 { data: null, error: {...}, count: null }。而大多數 handler 長這樣:
const { data, error, count } = await queryBuilder.range(offset, offset + pageSize - 1)
if (error) {
log.error(error as Error)
throw createError({ statusCode: 500, message: '查詢失敗' })
}
if (error) 這一支把 PGRST103 當成伺服器錯誤處理,但它實際代表的是**「使用者要了一個空的分頁」**——那是完全正常的使用者行為,不是故障。
兩個值得釘住的 SDK 細節:
- 416 回應的
Content-Rangeheader 不保證帶總數,SDK 在很多情況下給count: null。這代表你在這一次請求裡拿不到總數——不要拿count ?? 0當總數回給前端,那會讓一個其實有資料的清單顯示成「查無結果」 .range()本身就是 HTTP 層行為,沒有.maybeRange()或.safeRange()之類的安全版本
為什麼測不到
- 清單端點的單元測試通常只 mock 成功路徑。error 分支只用合成的錯誤測過,沒有測到
PGRST103這個特定情況 - 整合測試通常從
page=1開始,不會驅動前端走「停在第 N 頁然後改篩選」這條路。這個觸發條件在腳本情境裡很罕見,在真實使用者行為裡卻很常見 - TypeScript 不會把
error.code收斂到已知的 PostgREST 錯誤碼,handler 看到的是一個不透明的error,預設就是丟 500
修法
在 handler 裡把 PGRST103 當成空頁處理,而不是錯誤:
連 builder 的組法都要一起改——原因寫在註解裡:
// 篩選條件包成 factory。PostgREST 的 builder 是 mutable 的,`.range()` 改的是
// 同一個物件、回傳的也是它自己,所以「先存一份沒套 range 的 alias」完全擋不住——
// 那個 alias 跟失敗的查詢是同一個東西,拿去問 count 只會再收到一次 416。
// 要一份乾淨的查詢,只能重新建一個。
const buildQuery = (opts?: { headOnly?: boolean }) =>
supabase
.from('items')
.select('*', { count: 'exact', head: opts?.headOnly })
.eq('status', status)
const { data, error, count } = await buildQuery().range(offset, offset + pageSize - 1)
if (error) {
// PGRST103 = offset 超過篩選後的列數。這是「空的一頁」,不是伺服器錯誤。
if (error.code === 'PGRST103') {
// count 在 416 時多半是 null,這一次請求拿不到總數。回 total: 0 會讓前端
// 顯示「查無結果」,但前面幾頁其實是有資料的——所以另問一次。
const { count: total, error: countError } = await buildQuery({ headOnly: true })
// 這一次的 error 不能吞。權限、網路、DB 失敗全都會走到這裡,
// 把它們也塌成 total: 0,就是把同一個 bug 換個地方再犯一次。
if (countError) {
log.error(countError as Error)
throw createError({ statusCode: 500, message: '查詢失敗' })
}
return { items: [], total: total ?? 0, outOfRange: true }
}
log.error(error as Error)
throw createError({ statusCode: 500, message: '查詢失敗' })
}
outOfRange 讓前端知道「不是沒資料,是你要的頁碼超出範圍」,可以據此跳回第一頁。
前端那邊也該修:篩選條件改變時重置 page。兩邊都要做——後端這一層是防禦,前端那一層是正確行為。
收束
四個坑,四個不同的機制,但判定的方法是同一套:找那個會分岔的觀測。
| 坑 | 每一層都綠的觀測 | 唯一分岔的觀測 |
|---|---|---|
| schema cache | pg_tables 查得到表 | 經 PostgREST 打那張表 |
| DISABLE TRIGGER | 語句 exit 0 | 有真實 violation 可觸發的環境 |
| ON CONFLICT | reset-based 環境全綠 | 累積 state 的環境重跑 seed |
| PGRST103 | 第一頁正常 | offset 超過篩選後列數 |
共同的教訓是:「A 環境驗過」不等於「B 環境會對」,只要 A 與 B 走的是不同分支。 上面四個裡有三個都是這個形狀——reset-based 對累積型、啟動時對執行時、第一頁對第 N 頁。
驗收要打在真正會分岔的那個條件上,不是打在最方便重現的那個條件上。