具代表性的面試主題

資料面試:如何用 PostgreSQL 約束防止同一資源的時段重疊?

資料困難
Offer.cc 編輯團隊發佈 更新

題幹

請設計一個預約表,要求同一房間的時段不能重疊,但不同房間可以同時預約。你會如何選擇資料型別、約束、索引與交易策略?

題幹與適用場景

團隊要儲存會議室預約。每筆記錄包含房間、開始時間與結束時間;同一房間不能出現重疊預約,相鄰預約允許首尾相接。應用層已經做衝突檢查,但高並發下仍偶爾出現重複占用。請給出 PostgreSQL 設計,並解釋半開區間、空值、時區、並發寫入、錯誤處理與既有資料遷移。

這道題適合資料工程、後端與資料庫職位。重點是把跨列的業務規則表達成資料庫不變量,而不是只寫一個查詢再寄望呼叫端遵守。回答應能區分 UNIQUECHECK、觸發器與 exclusion constraint 的邊界,並說明約束失敗如何回饋產品流程。

面試官考察點

強回答會把預約時間建模為 tstzrange 或合適的 range 類型,明確使用 [start, end) 讓相鄰區間不衝突;再用 GiST exclusion constraint 組合房間相等與時段重疊。它會說明何時需要 btree_gist、為什麼應用層預檢不能消除競態、如何捕捉約束例外、如何處理無窮邊界與空區間,以及如何在遷移前找出既有衝突。

回答前需要釐清的問題

  • 開始與結束時間的時區規則是什麼,是否允許跨夏令時間切換?
  • 結束時間是否必須晚於開始時間,零長度預約是否有業務意義?
  • 衝突範圍是同一房間,還要按樓層、設備或租戶隔離嗎?
  • 相鄰區間是否允許首尾相接,取消與軟刪除的記錄是否仍占用資源?
  • 現有資料是否已存在重疊,遷移期間能否短暫停寫?

30 秒回答框架

「我會把開始與結束時間規範化為帶時區的半開區間 tstzrange(start_at, end_at, '[)'),並在資料庫上加 EXCLUDE USING gist (room_id WITH =, during WITH &&)。這樣同一房間的重疊區間會被拒絕,相鄰區間允許共存;btree_gist 用於讓整數或 UUID 房間鍵參與 GiST 比較。寫入時直接嘗試新增並把約束衝突轉成可重試的業務錯誤,不能依賴先查再寫。上線前先掃描舊資料、修復衝突,再逐步啟用約束並監控失敗率。」

分步驟深入解答

第一步:選擇時間語義

tstzrange 表達絕對時間,避免把本地時間字串交給資料庫解釋。[start, end) 表示包含開始、不包含結束,因此 [10:00, 11:00)[11:00, 12:00) 不重疊。資料庫文件把範圍運算 && 定義為重疊判斷,並展示範圍約束的典型用途。

寫入前驗證 start_at < end_at,並決定是否允許空範圍。統一儲存時區後,展示層再按使用者時區格式化;不要透過夏令時間當天的本地小時差推導時長。

第二步:把規則寫成 exclusion constraint

可以把起止欄位生成一個範圍欄位,也可以在約束表達式中直接構造範圍。以下使用顯式範圍欄位,方便查詢與稽核:

sql
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE room_reservations (
  reservation_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  room_id bigint NOT NULL,
  during tstzrange NOT NULL,
  CHECK (NOT isempty(during)),
  EXCLUDE USING gist (
    room_id WITH =,
    during WITH &&
  )
);

約束要求任意兩列的比較至少有一個運算結果為 false 或 null;room_id =during && 同時成立時,第二列會被拒絕。PostgreSQL 會為 exclusion constraint 自動建立指定類型的索引。

第三步:理解 btree_gist 與索引代價

範圍本身適合 GiST;普通整數、文字或 UUID 的相等比較通常沒有預設 GiST operator class,因此可以安裝 btree_gist 擴充,讓這些純量參與同一個 GiST 約束。擴充屬於資料庫部署依賴,遷移腳本要明確宣告並在受控環境驗證版本。

GiST 約束索引會增加寫入與更新成本,查詢也應使用範圍運算與合適條件。不要為了「有索引」再建立重複的範圍 GiST 索引;先檢查執行計畫與約束索引是否已滿足讀取需求。

第四步:處理並發與交易

不要先執行 SELECT 檢查衝突,再執行 INSERT;兩個交易可能同時看到空閒並都插入。讓資料庫約束成為最終裁決,應用捕捉唯一的約束名稱,把衝突轉換成「時段已被占用」,必要時讓使用者重新整理或選擇其他時間。

預約還可能涉及付款、通知或配額。先在短交易中寫入預約並提交,再用可靠事件或 outbox 觸發外部副作用。重試只應針對可安全重試的序列化或暫時性錯誤;約束衝突代表業務事實,盲目重試不會成功。

第五步:明確取消、租戶與刪除策略

軟刪除記錄是否繼續占用房間必須成為查詢與約束模型的一部分。若取消記錄要釋放時段,可以把活動預約放在獨立表,或設計可驗證的狀態遷移;僅在查詢中加入 WHERE status = 'active' 無法直接讓 exclusion constraint 忽略其他列。

多租戶場景應把 tenant_id 納入約束鍵,例如 (tenant_id WITH =, room_id WITH =, during WITH &&),並在授權層保證租戶不能寫入別人的房間。約束保證衝突關係,不取代列級權限與業務狀態機。

第六步:遷移既有資料

先用自連接或視窗查詢找出同一房間的重疊對,記錄衝突數量與負責人。修復方案可以合併、拆分、取消或人工確認,不能直接截斷資料。驗證無衝突後在低風險時段建立約束;大表遷移要評估鎖、索引建立時間與回滾路徑,並在發布前做備份還原演練。

第七步:設計錯誤與可觀測性

給約束命名,例如 room_reservations_no_overlap,讓驅動回傳的約束名稱可穩定映射到使用者提示。日誌記錄房間、請求識別碼與時段摘要,避免寫入不必要的個人資訊。監控約束衝突率、遷移殘留衝突、交易耗時與索引膨脹,區分正常競爭與異常客戶端重試風暴。

第八步:用並發場景驗證

至少驗證同房間重疊失敗、同房間相鄰成功、不同房間重疊成功、跨時區等價時間、更新導致衝突、取消釋放、空範圍與缺失值。用兩個並發交易實際壓測,而不是只執行順序腳本;同時驗證恢復、備份與約束重建後的結果。

設計取捨與邊界

Exclusion constraint 適合「任意兩列不得同時滿足某組比較」的持續不變量。它比應用層互斥鎖更接近資料來源,也比觸發器少一套競態維護邏輯。代價是 GiST 寫放大、擴充依賴與約束錯誤需要應用理解。

如果規則涉及跨表容量、動態優先級或需要允許有限重疊,單一 exclusion constraint 可能不夠;可以把資源分配拆成可鎖定的槽位、使用交易級鎖,或引入專門的調度服務,但仍應讓資料庫約束保護能表達的核心不變量。CHECK 不能可靠地引用其他列來維持這種跨列規則。

落地計畫與證據

先在影子表匯入生產資料,執行衝突掃描並按房間與租戶輸出修復清單。隨後建立擴充與約束,回放真實並發寫入,確認錯誤映射、索引成本、備份還原與監控告警。小流量啟用後比較約束衝突與人工衝突率,穩定後再切換正式表。

PostgreSQL 文件說明範圍類型支援 && 等運算,並給出使用 GiST exclusion constraint 防止重疊的預約示例;約束章節進一步定義 exclusion 的成對比較語義,並說明建立約束會自動建立指定索引。這些一手資料足以支援資料型別、運算子與索引結論,部署細節仍需按實際版本驗證。

公開的 booking-system 面試資料也把「並發防止重複預約」與 PostgreSQL exclusion constraint 列為回答要點;本題據此保留預約場景,同時把重點收窄到資料不變量、遷移與失敗驗證,而不是重述整套預約系統設計。

常見誤區與追問

只做「先查再新增」

並發交易會同時通過檢查。把查詢作為使用者體驗提示可以保留,但最終一致性必須由資料庫約束裁決。

timestamp 儲存本地時間

跨地區與夏令時間會讓相同字串代表不同瞬間。先確定時區策略,優先儲存絕對時間,再按展示時區轉換。

UNIQUE(room_id, start_at) 防重疊

唯一約束只能阻止相同起點,不能阻止一個長區間覆蓋多個短區間。範圍運算才能表達重疊關係。

讓約束忽略軟刪除列

普通 exclusion constraint 會比較所有列。把活動記錄與歷史記錄分離,或重新設計可約束的狀態模型;不要只在應用查詢裡過濾。

為什麼不只用觸發器?

觸發器需要自行處理並發、鎖與錯誤語義,容易與備份還原產生複雜邊界。若規則能表達為範圍與比較運算,原生 exclusion constraint 通常更直接;若規則超出其表達能力,再評估觸發器或調度服務。

公開來源

同類題目