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_timescollection,每次有新成績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_versionfield is simple but very limited —— 只放一個latest_version欄位很簡單但很受限。 - Handles publishing future versions —— 能處理「預先發佈未來版本」。
- Finding ‘all’ current needs an aggregation with
sort andfirst —— 要一次找「所有當前版本」需要 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 的做法:發票明細完整放
invoicescollection(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 都重複存欄位名,欄位名長就浪費空間、拖慢。做法是「程式端用可讀名稱、存進資料庫時統一縮短、查詢時再對應回來」,兼顧可讀性與效率。
$lookupvs 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 資料建模設計