PostgreSQL 與 PostgREST 的四個幽靈故障

Migration 成功但 API 說找不到表、DISABLE TRIGGER 關不掉外鍵、upsert 讓 pinned UUID 永遠不生效、換個搜尋條件就回 500。四個每一層單獨看都是綠的資料庫故障,以及它們各自 唯一的分岔觀測點。

資料庫這一層的故障有個共同特徵:每一層單獨看都是綠的。

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 = replicasession 級關閉全部 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 還不存在的時候。

鏈式分析:

  1. staging 的 DB 是永不 reset 的累積實例,M-006 早就以 auto-generated id 存在了
  2. 每次部署重跑 seed 都走 conflict 路徑,pinned UUID 永遠不會成為那個 row 的 id
  3. 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 細節:

  1. 416 回應的 Content-Range header 不保證帶總數,SDK 在很多情況下給 count: null。這代表你在這一次請求裡拿不到總數——不要拿 count ?? 0 當總數回給前端,那會讓一個其實有資料的清單顯示成「查無結果」
  2. .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 cachepg_tables 查得到表經 PostgREST 打那張表
DISABLE TRIGGER語句 exit 0有真實 violation 可觸發的環境
ON CONFLICTreset-based 環境全綠累積 state 的環境重跑 seed
PGRST103第一頁正常offset 超過篩選後列數

共同的教訓是:「A 環境驗過」不等於「B 環境會對」,只要 A 與 B 走的是不同分支。 上面四個裡有三個都是這個形狀——reset-based 對累積型、啟動時對執行時、第一頁對第 N 頁。

驗收要打在真正會分岔的那個條件上,不是打在最方便重現的那個條件上。