Skip to content
DEE
Database Engineering Essentials

[DEE-401] 文件存儲建模

INFO

圍繞存取模式來建模文件,而非實體關係。將一起讀取的資料嵌入同一文件;將共享的、大型的或獨立變更的資料以參考方式連結。

背景

文件存儲(如 MongoDB、Couchbase 和 Amazon DocumentDB)將資料儲存為自包含的文件(通常是 JSON 或 BSON)。與關聯式資料庫將資料分解為正規化表格並透過 join 重組不同,文件資料庫鼓勵反正規化——將相關資料放在單一文件中,讓查詢能在一次讀取中取得所需的一切。

這種取捨是刻意的。關聯式正規化為寫入正確性和儲存效率而最佳化;文件嵌入則為讀取效能和結構彈性而最佳化。設計良好的文件可以消除多表 join、減少網路往返次數,並自然地對應到應用程式已在使用的物件。

然而,文件建模並非「把所有東西塞進一個文件」。MongoDB 有 16 MB 的 BSON 文件大小限制,無限增長的嵌入陣列是一個眾所周知的反模式,會導致寫入效能下降、工作集膨脹,並最終違反文件大小限制。核心技能在於知道何時嵌入、何時參考。

原則

  • MUST圍繞應用程式的讀取方式來建模文件,而非照搬關聯式資料庫的實體關係圖。
  • SHOULD嵌入總是一起讀取且大小有界、可預測的資料。
  • SHOULD以參考方式連結在多個文件間共享的、大型的或獨立於父文件變更的資料。
  • MUST NOT允許文件中的陣列無限增長。無限增長的陣列會降低索引效能、膨脹記憶體使用量,並有觸及 16 MB 文件限制的風險。
  • SHOULD在參考查找所用的欄位上建立索引($lookup、應用層 join)。

視覺化

範例

嵌入:帶有明細項目的訂單

當訂單總是與其明細項目一起顯示,且每筆訂單的項目數量有上限(例如最多 100 筆),嵌入是自然的選擇:

json
{
  "_id": ObjectId("665a1f..."),
  "order_number": "ORD-2025-4821",
  "status": "shipped",
  "customer": {
    "name": "Alice Chen",
    "email": "alice@example.com",
    "shipping_address": {
      "street": "123 Main St",
      "city": "Taipei",
      "country": "TW"
    }
  },
  "items": [
    { "sku": "W-01", "name": "Widget", "qty": 2, "unit_price": 9.99 },
    { "sku": "G-01", "name": "Gadget", "qty": 1, "unit_price": 24.99 }
  ],
  "total": 44.97,
  "created_at": ISODate("2025-06-01T08:30:00Z")
}

單一 findOne({ _id: ... }) 即可回傳訂單詳情頁面所需的一切。不需要 join,不需要多次往返。

參考:跨多筆訂單共享的產品

當產品被數千筆訂單參考,且產品資訊(價格、描述)獨立變更時:

json
// products 集合
{
  "_id": ObjectId("665b2a..."),
  "sku": "W-01",
  "name": "Widget",
  "current_price": 10.99,
  "description": "A high-quality widget",
  "category": "hardware"
}

// orders 集合
{
  "_id": ObjectId("665a1f..."),
  "order_number": "ORD-2025-4821",
  "customer_id": ObjectId("665c3b..."),
  "items": [
    { "product_id": ObjectId("665b2a..."), "qty": 2, "unit_price": 9.99 },
    { "product_id": ObjectId("665b3c..."), "qty": 1, "unit_price": 24.99 }
  ],
  "created_at": ISODate("2025-06-01T08:30:00Z")
}

注意 unit_price 仍儲存在訂單項目上(銷售時的價格),而目前價格存放在產品文件中。product_id 參考僅在應用程式需要在訂單歷史旁顯示目前產品詳情時才使用。

嵌入 vs 參考決策表

因素嵌入參考
讀取模式資料總是一起讀取資料獨立讀取或選擇性讀取
資料大小子文件小且有界子文件大或無限增長
更新頻率嵌入資料很少變更相關資料頻繁且獨立變更
基數一對少(例如 1 筆訂單:5 個項目)一對多或多對多(例如 1 個產品:10,000 筆訂單)
資料共享資料僅屬於此文件資料在多個文件間共享
原子性需求單一文件原子性即足夠無論如何都需要多文件交易
文件增長文件遠低於 16 MB有接近 16 MB 限制的風險

常見錯誤

錯誤為何有害修正方式
把 MongoDB 當關聯式資料庫用 —— 每個實體建一個集合,每次讀取都用 $lookup join消除了 MongoDB 的主要優勢(單文件讀取)。$lookup 是伺服器端 join,明顯慢於嵌入。嵌入一起讀取的資料;將 $lookup 保留給臨時分析,而非主要存取路徑
無限陣列增長 —— 將每則留言、日誌條目或事件無限嵌入父文件陣列持續增長直到文件達到 16 MB。即使在此之前,大型文件也會膨脹 WiredTiger 快取工作集並拖慢更新。使用子集模式:嵌入最近的 N 筆項目;將完整歷史存放在獨立集合中
參考欄位未建索引 —— 儲存 customer_id 作為參考但從未建立索引應用層 join 變成集合掃描。對未索引外鍵欄位的 $lookup 每個文件都是 O(n)。對每個用作 join 目標的欄位建立索引:db.orders.createIndex({ customer_id: 1 })
複製可變資料卻無同步策略 —— 為了讀取速度在每筆訂單中嵌入客戶地址,卻無法更新當客戶搬家時,數百個訂單文件包含過時地址且無自動更新方式。要嘛參考地址(如果需要目前地址),要嘛接受嵌入副本作為歷史快照(如果需要時間點準確性)
為寫入密集工作負載過度嵌入 —— 將頻繁變更的資料嵌入大型父文件每次更新嵌入子文件都會在 WiredTiger 中重寫整個父文件。在大型文件上,這會造成寫入放大。將頻繁更新的資料參考到獨立集合;僅嵌入穩定或讀取密集的資料

相關 DEE

參考資料