06 設計模式 Design Patterns

05 索引與優化 | 回到 MongoDB 資料建模設計

最後一段:schema 的 design patterns。就像軟體工程的設計模式,是前人把常見設計問題的經驗解法歸納成的模板。

什麼是設計模式(p.61)

  • Schema Concepts that meet a specific need —— 針對特定需求的 schema 概念。
  • Analogous to Design Patterns: Elements of Reusable Object-Oriented Software book —— 類比軟體界經典《設計模式》(GoF)那本書。
  • Provide generalised solutions to common design problems in the form of templates —— 用模板形式,為常見設計問題提供通用解法。
  • Powerful tool for software developers - Important to apply patterns appropriately —— 強大工具,但要恰當地套用(別硬套)。

講師:每個 pattern 解決的是「一類問題」。我們複用前人的經驗,當某類問題出現時就有現成模板可用。下面看六個常用模式,以及它們能解決什麼。

六大模式(Common Design Patterns,p.62)

Attribute、Bucket、Computed、Versioning、Subset、Outlier (Overspill)。


1. Attribute Pattern(屬性模式,p.63-64)

解決的問題:document 有一堆「可搜尋的具名屬性」,但(p.63):

  • Not all values may be present in a given record —— 不是每筆都有全部屬性。
  • Any new record might have a new field name not seen before —— 新資料可能帶來沒見過的新欄位名。

投影片的思考題:什麼系統會這樣?如果每筆 document 欄位名都不同,索引怎麼建?我們是不是混淆了 payload 和 processing?(→ 03 Payload 與 Process 欄位

範例(p.64)—— 電商商品:不同商品屬性不同(手機有顏色/RAM/螢幕尺寸,衣服有尺碼/材質…)。若把每個屬性當成獨立欄位,會有無數個欄位、且沒法統一建索引。

record = {
  sku: "iphonexblack64",
  attrs: [
    { k: "colour", v: "black" },
    { k: "ram", v: 64 },
    { k: "manufacturer", v: "apple" },
    { k: "screensize", v: "5.5 inch" }
  ]
}
db.product.insertOne(record)
 
db.product.createIndex({"attrs":1})                    // 索引整個物件,OR
db.product.createIndex({"attrs.k":1, "attrs.v":1})     // 索引 k 與 v

講師重點:

  • 把「會變化的屬性名」變成:用一個陣列,每個元素是 { k: 屬性名, v: 屬性值 }(k = key,v = value)。
  • 這樣不管商品有什麼屬性,結構都一樣——商品↔屬性成了一對多關係(一個屬性陣列)
  • 建索引時只要對 attrs.k + attrs.v一個索引,就能支援「任意屬性名 + 值」的查詢(例如查 colour=black),不用為每個屬性名各建一個索引。
  • 對照組:若不用 attribute pattern,新增一個屬性(例如某商品才有的 xxx)就得為它加一個新欄位、新索引,欄位和索引會爆炸、無法維護。
  • 這是 e-commerce 很常見的模式;也有變體(例如直接用物件而非 k/v 陣列)來優化某些情況。

2. Bucket Pattern(分桶模式,p.65-66)

Most common design pattern - Strongly matches Document Model(最常用、最貼合 document 模型)。

用途(p.65)Used to store many small, related data items(存大量小的、相關的資料項):

  • Bank Transactions – related by account and date(銀行交易,依帳號與日期相關)
  • IoT Readings – related by sensor and date(IoT 感測讀數,依 sensor 與日期相關)

效益

  • Reduces index sizes up to 200X! —— 索引大小最多可縮小 200 倍
  • Speeds up retrieval of related data —— 加速相關資料的讀取。
  • Enables computed pattern —— 讓 computed pattern 得以實現(見下)。
  • MongoDB 5.0 timeseries collections abstract this concept —— MongoDB 5.0 的 time series collection 把這個概念抽象化了。

範例(p.66)—— IoT 感測器

reading = {sensor:5, value:22, time:new Date('2019-05-11')}
query = {sensor:reading.sensor, count: {$lt:200} }   // 找該 sensor、count<200 的桶
updates = {
  $push: {readings: {ts: reading.time, v: reading.value}},   // 讀數推進陣列
  $inc: { count: 1 }                                          // 計數 +1
}
db.iot.updateOne(query, updates, { upsert: true })   // upsert:沒有就新建桶
 
// 結果:一個 sensor 的多筆讀數被裝進同一個 document(桶)
db.iot.findOne()
// { _id: ObjectId("..."), sensor: 5,
//   readings: [ {ts: ..., v: 16}, {ts: ..., v: 22} ], count: 2 }

講師詳解(大數組問題與桶大小):

  • 要解決的問題:如果一個 sensor 每 10 分鐘回報一次,一天就有幾百上千筆——若「一筆讀數 = 一個 document」,document 數會爆炸(無限成長的一對多,見 02 關聯模型 Linking Models 的 16MB 隱憂)。
  • bucket 的做法:把「同一個 sensor、一段時間內」的多筆讀數,裝進同一個 document 的陣列(一個「桶」),並用 count 記錄桶裡有幾筆。
  • 靠 upsert + size limit 控制桶大小:查詢條件 count < 200,用 updateOne(..., {upsert:true})
    • 桶還沒滿(count<200)→ 讀數 $push 進現有桶、count++
    • 桶滿了(count 已達 200)→ 查詢條件不匹配任何現有桶 → upsert 自動開一個新桶放這筆。
  • 桶大小怎麼定:依「一筆讀數的大小」估算,讓桶不會撐爆 16MB。可以用固定大小(如 200 筆)或固定時間區間當一桶。
  • 效益:桶把「多對多/一對多的巨量小資料」收斂成少量 document,索引項大減(p.65 說最多 200×),相關資料一次讀出也快。

3. Computed Pattern(計算模式,p.67-69)

口訣:“Never Recompute what you can Precompute”(能預先算好的,就別每次重算)。

  • Reads are often more common than writes —— 讀通常遠多於寫。
  • Compute on write is less work than compute on read —— 在寫入時算,比每次讀取時算省事。
  • Equivalent to OLAP cubes in RDBMS —— 相當於 RDBMS 的 OLAP cube(預聚合)。
  • When updating the DB – update some summary collections too —— 更新資料時,順手更新一些彙總 collection。
  • This is one of the caching patterns —— 這是快取類模式的一種。

範例(p.68)—— 運動追蹤 App(Exercise Tracking):記錄每位使用者某路段的成績,並維護「前 N 名最佳成績」。

effort = { athlete:"john", time:920, section:"LDN bridge to eye"}
db.efforts.insertOne(effort)
 
// 每次有新成績,就 push 進 best_times、排序、只留前 20
db.best_times.updateOne(
  { _id: effort.section },
  { $push: { times: { $each:[{ athlete: effort.athlete, time: effort.time }],
                       $slice: 20, $sort: { "time": 1 } } } },
  { upsert: true }
)
 
db.best_times.findOne()
// { _id: "LDN bridge to eye",
//   times: [ {athlete:"carol", time:840}, {athlete:"john", time:920} ] }

講師詳解($push + $each + $sort + $slice):

  • 問題:想隨時查「某路段前 10/20 名」。若每次查詢都把「所有人的所有成績」抓出來排序取前 N,量大時很慢,且每次都算一次。
  • computed 的做法:另開一個 best_times collection,每次有新成績 updateOne 時:
    • $push 把新成績推進 times 陣列;
    • $sort: {time:1} 依成績排序(時間越小越好,升冪);
    • $slice: 20 只保留前 20 筆——超出的自動被丟掉。
  • 這樣寫入時就維護好排行榜,查詢時只要讀這一份文件(updateOne 我不用管它會不會超過 20,最多就 20),省下每次讀取時的大量排序運算。
  • $slice 是「只保留前幾名」的關鍵;$sort 保證留下的是最好的那幾筆而不是隨機的。

Computed fields - bucket(p.69)—— 與 bucket 結合:在桶裡順便存彙總,讀取時免計算:

updates = {
  $push: { readings: { ts: reading.time, v: reading.value } },
  $inc: { count: 1, total: reading.value },   // 順手累加 total
  $max: { max: reading.value }                // 順手記錄最大值
}
db.iot.updateOne(query, updates, { upsert: true })
// 桶裡直接有 count / total / max,甚至可加 mean、min、分佈直方圖
  • Add data summarising the bucket —— 在桶裡加彙總資料。
  • No additional write costs —— 幾乎沒有額外寫入成本(本來就在寫這筆)。
  • Huge savings in read time computation —— 大幅省下讀取時的計算。
  • Add totals (and mean), Max, Min, and even distribution of value histograms for free —— 順手把 total/mean/max/min、甚至值的分佈直方圖都算好。

講師:bucket + computed 是絕配——反正每筆讀數進桶時都在做一次 update,就順手用 $inc(累加 total/count)、$max/$min 把彙總算進去。查詢時要 top N、要平均、要最大值,直接讀桶裡的欄位即可,不用再掃整個陣列聚合。


4. Versioning Pattern(版本模式,p.70-72)

A good example of Payload versus Processing(payload/processing 分野的好例子,見 03 Payload 與 Process 欄位)。

  • Multiple versions of each document —— 每份 document 有多個版本。
  • At any time, there is one ‘current’ version —— 任一時刻有一個「當前」版本。
  • Can have ‘future versions’ not yet in current use —— 可以有「尚未生效的未來版本」。
  • Payload – the document you are keeping versions of —— payload 是你要保存版本的那份文件內容。
  • Processing – the fields you use to identify the current one —— processing 是用來「識別哪個是當前版本」的欄位(如 valid_from / valid_to)。
  • Uses storage – as you store all versions completely, not just deltas —— 較耗儲存,因為每版都完整存,不是只存 delta(差異)。

做法 1(p.71)—— 同一 collection、用 valid_from/valid_to

r = {
  documentid: "secret plan", version: 1.0,
  payload: { d: "Some Data" },
  valid_from: new Date('2008-12-14'),
  valid_to: new Date('2099-12-31')
}
db.versions.insertOne(r)
 
// 找某時間點的當前版本:valid_from < 想要的時間,取最大的 valid_from
now = new Date()
current = db.versions.find({documentid: "secret plan",
    valid_from: { $lt: now }, valid_to: { $gt: now }
}).sort({ valid_from: -1 }).limit(1).pretty()
 
// 出新版本:改 valid_from 為當前日期
r.version = 2.0
r.valid_from = now
db.versions.insertOne(r)
  • A simple latest_version field is simple but very limited —— 只放一個 latest_version 欄位很簡單但很受限。
  • Handles publishing future versions —— 能處理「預先發佈未來版本」。
  • Finding ‘all’ current needs an aggregation with sort and first —— 要一次找「所有當前版本」需要 aggregation(sort + first)。
  • Indexing is tricky if using valid_to as well —— 若同時用 valid_to,索引會比較麻煩。

做法 2(p.72)—— 兩個 collection(current + archive),更好查當前

new_version = {
  documentid: "secret plan", version: 1.0,
  payload: { d: "Some Data" },
  valid_from: new Date('2008-12-14'),
  valid_to: Date('2099-12-31')
}
db.archive.insertOne(new_version)          // 歷史版本都進 archive
 
db.current.updateOne(                       // current 只存最新版
  { _id: new_version.documentid },
  { $set: new_version },
  { upsert: true }
)
// But How do we deal with 'future' records?(未來版本較難處理,需就緒時才複製到 current)
  • Simpler option - two collections, easier to query current —— 兩個 collection 更容易查當前版。
  • Dealing with future records is difficult - need to be copied to current when ready —— 未來版本較難處理,得等生效時才複製進 current。
  • Avoid backend batch/scheduled ops where possible in designs —— 設計上盡量避免後端批次/排程操作。

講師詳解(用什麼決定,看需求):

  • 典型例子:保險保單、文件——保單可能「8/1 起生效、8/31 失效」,也可能有還沒生效的未來版本。查詢時通常只要「最新/當前生效」那版。
  • 做法 1(單 collection + valid_from/valid_to):所有版本放同一個 collection,用「生效起訖時間」判斷當前版。優點是完整;缺點是查當前版要排序取最新、valid_to 的索引較麻煩。
  • 做法 2(current + archive 兩個 collection)current 只放最新生效版(查最快),archive 放歷史版本。適合「大多只查最新、偶爾查歷史」。缺點是未來版本不好處理——要等它生效時,才從別處複製進 current,可能需要排程,而講師建議設計上盡量避免後端批次/排程。
  • 選哪個看需求:只查最新 → 做法 2;經常需要同時查最新+歷史+未來 → 傾向做法 1,把版本放一起。
  • 對照 payload/processing:payload 是「被保存版本的內容」;processing 是「用來判斷哪版當前」的 valid_from/valid_to/version

5. Subset Pattern(子集模式,p.73-74)

  • Use Linking to another document —— 對另一份文件用 link。
  • Maintain a subset of the linked data embedded for speed —— 同時把「被連結資料的一個子集」內嵌進來,換取速度。
  • Hybrid of linking and Embedded - common caching pattern(link + embed 的混合,常見的快取模式):
    • Keep an embedded set of the linked data in the main document —— 主文件內嵌一份被連結資料的子集。
    • Read the most frequently/recently accessed docs directly from the parent doc —— 最常/最近存取的資料直接從父文件讀。
    • Can contain only a subset of all linked documents or all of them —— 可以只嵌一個子集,也可全嵌。

範例(p.74)—— 客戶與發票

i = {
  _id: new ObjectId(), customer: "john page",
  address: "15 Jackton View", date: Date("2019-11-06"),
  items: [
    { i: "shoes", q: 1, p: 15 }, { i: "socks", q: 5, p: 10 },
    { i: "shirts", q: 2, p: 60 }, { i: "slacks", q: 1, p: 20 }
  ],
  total: 105, status: "delivered"
}
db.invoices.insertOne(i)   // 完整發票資料進 invoices
 
// 客戶文件只存「發票的日期與金額」子集(一小份摘要)
db.customers.updateOne(
  { _id: i.customer },
  { $push: { invoices: { id: i._id, date: i.date, spent: i.total } } },
  { upsert: true }
)
  • Some data from every linked record —— 每筆被連結記錄的「一部分資料」。
  • Customer record has just a list of order dates and values —— 客戶文件只放訂單的日期與金額清單。

講師詳解:

  • 問題:一個客戶可能有很多訂單/發票(一對多)。若把每張發票完整內嵌進 customer,customer 會爆大;但若全靠 link,看客戶頁時要一筆筆去撈發票明細,慢。
  • subset 的做法:發票明細完整放 invoices collection(link);同時在 customer只內嵌每張發票的摘要(發票 ID + 日期 + 金額)。
  • 判斷「嵌哪些子集」看查詢需求:看客戶頁時通常只需要「發票 ID、日期、花了多少」這種摘要——需要看明細時,再用 ID 去 invoices 撈完整那張。所以只嵌「經常要一起顯示」的欄位。
  • 本質是 link + embed 的混合快取:常一起讀的子集 embed(快),完整資料 link(不撐爆父文件)——呼應 04 文件設計基礎 的 link/embed 取捨。

6. Outlier / Overspill Pattern(離群/溢出模式,p.75)

  • Creates a separate collection if too many array items —— 當陣列項太多時,開一個額外 collection 裝溢出的部分。
  • In this pattern, this is not the typical case —— 這處理的是「非典型(少數例外)」情況。
  • There is a main record and a flag to say if there is an overspill situation —— 有一份主記錄,加一個 flag 標示「是否發生溢出」。
  • Additional collections only store the extra array items —— 額外 collection 只存溢出的陣列項。

範例——電影資料庫

  • Main cast and Crew in the parent document —— 主要演員/劇組放在父文件。
  • Full cast and crew in a second additional document (can be the same collection) —— 完整卡司放到第二份文件(可以是同一個 collection)。

講師詳解(與 bucket/subset 的差別):

  • 問題:大多數 document 的某陣列項數正常(例如一部電影就幾十個主要演職員),但極少數會爆量(某些大片有上千個)。若為了這少數例外把 schema 設計成「全部拆開」就浪費。
  • outlier 的做法:正常情況全放主文件;只有溢出的那少數才把「多出來的項」放到額外文件,並在主文件用 flag 標記「有溢出」。
  • 對照商品↔評論:一個商品通常幾則~幾十則評論(正常內嵌),但可能有商品爆到幾十萬則(離群)——離群的評論溢出到別的 collection。
  • 這是針對「少數離群值」的優化,跟 bucket(普遍性地分桶)、subset(普遍性地嵌子集)出發點不同。

Common Design Anti-Patterns(反模式,p.76)

會讓資料庫變慢的做法:

  • Very large 1 dimensional arrays —— 超大的一維陣列。
  • Long field names —— 過長的欄位名(欄位名也佔儲存空間,會重複存在每筆)。
  • Lots of fields at the same level / flat documents —— 同一層太多欄位 / 過度扁平的文件。
  • Unnecessary indexes - especially on arrays —— 不必要的索引,尤其是在陣列上(→ 05 索引與優化)。
  • Storing seldom used data fields with frequently used data fields —— 把很少用的欄位跟常用欄位存在一起(該用 subset/link 拆開)。
  • **Using lookup instead of using embed objects** —— 該 embed 卻用 `lookup`(跨 collection join)。
  • Case insensitive queries without case insensitive indexes —— 做不分大小寫查詢,卻沒建對應的不分大小寫索引(collation)。

講師補充:

  • 長欄位名:因為每筆 document 都重複存欄位名,欄位名長就浪費空間、拖慢。做法是「程式端用可讀名稱、存進資料庫時統一縮短、查詢時再對應回來」,兼顧可讀性與效率。
  • $lookup vs embed$lookup 是聚合階段做跨 collection join,量大時很傷;能 embed 就別用 $lookup。
  • 不分大小寫查詢:要搭配 collation 建對應索引,否則會退化成掃描。
  • 這些都可在 schema review 時檢查現有設計有沒有踩到。

課末練習:Social network(p.77)

Build a Twitter-style social network where:

  • Users have followers
  • Users make posts
  • Users view posts from those they follow

要回答的問題:

  • What collections would you have, when and how would you update them?(有哪些 collection?何時、如何更新?)
  • What edge cases do you need to deal with, and how?(有哪些邊界情況?怎麼處理?)
  • How will you track followers and following counts?(怎麼追蹤 follower / following 數?→ 想想 Computed Pattern)
  • How can you keep it fast and efficient?(怎麼保持高效?→ 綜合 link/embed、bucket、computed、subset)

這題是把六大模式綜合起來練手:貼文的一對多、追蹤的多對多、follower 數的預計算(computed)、時間線的分桶(bucket)、以及 link/embed 取捨。

小結(六大模式速查)

模式解決的問題關鍵手法
Attribute屬性名多變、不固定、要能查轉成 [{k, v}] 陣列,對 k+v 建一個索引
Bucket大量小的相關資料(IoT/交易)document 爆炸依 sensor/date 分桶,upsert + size limit 控桶大小
Computed每次讀都要重算(排行、彙總)寫入時預先算好($push/$sort/$slice$inc/$max
Versioning一份文件多版本、要當前/未來版valid_from/valid_to;或 current+archive 兩 collection
Subset一對多,全嵌太大、全 link 太慢完整資料 link,常用子集 embed(快取)
Outlier多數正常、少數陣列爆量主文件 + 溢出 flag,額外項放別的文件

回到入口:MongoDB 資料建模設計