在我們要構(gòu)建一個(gè)項(xiàng)目(應(yīng)用程序)時(shí),通常第一件事情就要設(shè)計(jì)數(shù)據(jù)庫。和關(guān)系型數(shù)據(jù)庫將數(shù)據(jù)存儲(chǔ)在固定的表格(這些表格由行和列組成)里所不同的是,云開發(fā)的數(shù)據(jù)庫使用結(jié)構(gòu)化的文檔來存儲(chǔ)數(shù)據(jù),不再是關(guān)系型數(shù)據(jù)庫里每個(gè)行列交匯處都必須有且只有一個(gè)值,它可以是一個(gè)數(shù)組、一個(gè)對象,或者更加復(fù)雜的嵌套。
實(shí)現(xiàn)云開發(fā)數(shù)據(jù)庫之前,需要了解存儲(chǔ)的數(shù)據(jù)的性質(zhì),如何存儲(chǔ)這些數(shù)據(jù),以及將如何訪問它們,這需要你預(yù)先就要做出決定,進(jìn)而通過組織數(shù)據(jù)和頁面數(shù)據(jù)交互來獲得最佳性能。具體地說,你需要先預(yù)先思考如下問題:
應(yīng)用程序復(fù)雜業(yè)務(wù)功能的背后,都是簡單的數(shù)據(jù),在設(shè)計(jì)數(shù)據(jù)庫的時(shí)候要清楚的知道哪些功能會(huì)執(zhí)行什么樣的數(shù)據(jù)操作,集合與集合、集合與字段之間有著什么關(guān)系。
范式化(normalization) 是將數(shù)據(jù)像關(guān)系型數(shù)據(jù)庫一樣分散到不同的集合里,而不同的集合之間是可以通過唯一的ID來相互引用數(shù)據(jù)的。不過要引用這些數(shù)據(jù)往往需要進(jìn)行多次查詢或使用lookup進(jìn)行聯(lián)表查詢。
而 反范式化(denormalization) 則是將文檔所需的數(shù)據(jù)都嵌入到文檔的內(nèi)部,如果要更新數(shù)據(jù),可能整個(gè)文檔都要查出來,修改之后再存儲(chǔ)到數(shù)據(jù)庫里,如果沒有更新指令這種可以進(jìn)行字段級(jí)別的更新,大文檔要新增字段性能會(huì)比較低下。反觀范式化設(shè)計(jì),由于集合比較分散,也就比較小,更新數(shù)據(jù)時(shí)可以只更新一個(gè)相對較小的文檔。
數(shù)據(jù)既可以內(nèi)嵌(反范式化),也可以采用引用(范式化),兩種策略并沒有優(yōu)劣之分,也都有各自的優(yōu)缺點(diǎn),關(guān)鍵是要選擇適合自己應(yīng)用場景的方案。完全反范式化的設(shè)計(jì)(將文檔所需要的所有數(shù)據(jù)都嵌入到一個(gè)文檔里面)可以大大減少文檔查詢的次數(shù)。如果數(shù)據(jù)更新更頻繁那么范式化的設(shè)計(jì)是一個(gè)比較好的選擇,而如果數(shù)據(jù)查詢更頻繁,而不需要怎么更新,那就沒有必要把數(shù)據(jù)分散到不同的集合而犧牲查詢的效率。對于復(fù)雜的應(yīng)用比如博客系統(tǒng)、商城系統(tǒng),只用一個(gè)集合(完全反范式化設(shè)計(jì))會(huì)導(dǎo)致集合過大,冗余數(shù)據(jù)更多,數(shù)據(jù)寫入性能差等問題,這時(shí)候就需要進(jìn)行一定的范式化設(shè)計(jì),也就是用更多的集合,而不是更大的集合。
更適合內(nèi)嵌 | 更適合引用 | 說明 |
---|---|---|
內(nèi)嵌文檔最終會(huì)比較小 | 內(nèi)嵌文檔最終會(huì)比較大 | 一個(gè)記錄的上限是16M,業(yè)務(wù)會(huì)持續(xù)不斷增長的數(shù)據(jù)不適合內(nèi)嵌,比如一個(gè)博客的文章會(huì)持續(xù)增長就不能內(nèi)嵌到記錄里,博客的評論雖然也會(huì)增長,但是增長量有限就可以內(nèi)嵌 |
記錄不會(huì)改變 | 記錄經(jīng)常會(huì)改變 | 當(dāng)新建一個(gè)記錄之后,如果業(yè)務(wù)只需要更新記錄里的字段或嵌套里的字段,而不是更新整個(gè)記錄,那可以用內(nèi)嵌 |
最終數(shù)據(jù)一致即可 | 中間階段的數(shù)據(jù)必須一致 | 內(nèi)嵌會(huì)影響數(shù)據(jù)的一致性,但是大多數(shù)業(yè)務(wù)并不需要強(qiáng)一致,比如把用戶評論內(nèi)嵌在文章集合里,用戶更改頭像后以前評論的頭像不會(huì)馬上更改,這不會(huì)有太大影響 |
文檔數(shù)據(jù)小幅增加 | 文檔數(shù)據(jù)大幅增加 | 如果業(yè)務(wù)需要大幅度更新記錄里的很多值或者大幅新增記錄,比如有大量用戶下訂單,用戶的訂單數(shù)據(jù)就不要內(nèi)嵌,而是以記錄的形式存在 |
數(shù)據(jù)通常需要二次查詢才能獲得 | 數(shù)據(jù)通常不包含在結(jié)果中 | 內(nèi)嵌文檔的可以通過一次查詢就能獲取到嵌套的數(shù)組和對象,比如文章記錄內(nèi)嵌套評論,查詢文章就能把該文章的評論全部獲取到,減少了查詢次數(shù) |
需要快速查詢 | 需要快速增刪改 | 如果你的數(shù)據(jù)增刪改等寫入比較頻繁,用嵌套數(shù)組和對象處理就會(huì)比較麻煩 |
像云開發(fā)數(shù)據(jù)庫這種非關(guān)系型數(shù)據(jù)庫,它的存儲(chǔ)單位是文檔,而文檔的字段是可以嵌套數(shù)組和對象的,這種內(nèi)嵌的方式把非關(guān)系型數(shù)據(jù)庫的表與表之間的關(guān)系嵌套在了一個(gè)文檔里,也就減少了需要跨集合操作的關(guān)聯(lián)關(guān)系。
在前面我們了解到云開發(fā)數(shù)據(jù)庫的一個(gè)文檔里可以內(nèi)嵌非常多的數(shù)據(jù),甚至做到一個(gè)完整的應(yīng)用只需一個(gè)集合。比如一個(gè)用戶,只有一個(gè)購物車在關(guān)系型數(shù)據(jù)庫里,我們需要建兩張表來存儲(chǔ)數(shù)據(jù),一張表是存儲(chǔ)所有客戶信息的用戶列表User,還有一張存儲(chǔ)所有用戶訂單的訂單列表Order,但是云開發(fā)數(shù)據(jù)庫可以將原本的多張表內(nèi)嵌成一張表。
{
"name": "小明",
"age": 27,
"address":"深圳南山騰訊大廈",
"orders": [{
"item":"蘋果",
"price":15,
"number":3
},{
"item":"火龍果",
"price":18,
"number":4
}]
}
}
采用這個(gè)內(nèi)嵌式的設(shè)計(jì)模型,當(dāng)我們要查詢一個(gè)用戶的信息和他的所有訂單時(shí),就可以只通過一次查詢做到將用戶的信息、所有的訂單都獲取到,而不像關(guān)系型數(shù)據(jù)庫需要先在User表里查用戶的信息,再根據(jù)用戶的id去查所有訂單。
同樣一篇文章會(huì)有N個(gè)用戶去評論產(chǎn)生N條評論數(shù)據(jù),而這N條評論是只屬于這一篇文章的,不存在評論既屬于A文章,又屬于B文章的情況。這種我們還是可以采用反范式化設(shè)計(jì),將與該文章相關(guān)的評論都嵌入到這篇文章里:
{
"title": "為什么要學(xué)習(xí)云開發(fā)",
"content": "云開發(fā)是騰訊云為移動(dòng)開發(fā)者提供的一站式后端云服務(wù)",
"comments": [{
"name": "李東bbsky",
"created_on": "2020-03-21T10:01:22Z",
"comment": "云開發(fā)是微信生態(tài)下的最推薦的后臺(tái)技術(shù)解決方案"
}, {
"name": "小明",
"created_on": "2020-03-21T11:01:22Z",
"comment": "云開發(fā)學(xué)起來太簡單啦"
}]
}
在我們要進(jìn)入文章的詳情頁時(shí),除了需要獲取文章的信息,還要一次性把評論都讀取出來,這種反范式化內(nèi)嵌文檔就能做到,也就是可以通過一次查詢就能獲取到所有需要的數(shù)據(jù)。但是如果文章都是屬于大V一樣的熱點(diǎn),經(jīng)常會(huì)有幾千條幾萬條的評論,將所有的評論都內(nèi)嵌到文章記錄里可能會(huì)存在記錄溢出(比如超過16M)、增刪改查效率也會(huì)下降,這個(gè)時(shí)候就不適合用內(nèi)嵌的方式,而是引用。
有時(shí)候數(shù)據(jù)與數(shù)據(jù)之間的關(guān)系會(huì)比較復(fù)雜,不再是一對一或者一對多的關(guān)系,比如共享協(xié)作時(shí),一個(gè)用戶可以發(fā)N個(gè)文檔,而一個(gè)文檔又有N個(gè)作者(用戶),這種N對N的復(fù)雜關(guān)系,使用內(nèi)嵌文檔就不那么好處理了。
試想一下如果你只創(chuàng)建一個(gè)用戶表,把A所參與編輯的文檔都內(nèi)嵌到相應(yīng)記錄的字段里,B用戶的也是,如果A,B用戶都參與編輯過同一份文檔,那么一份文檔就被內(nèi)嵌到了連個(gè)用戶的記錄了,如果這個(gè)文檔有N個(gè)作者,就會(huì)被重復(fù)內(nèi)嵌N次。如果我們只需要查用戶編輯過哪些文檔,這種方式就沒有問題,但是如果要查一份文檔被多少個(gè)作者編輯過,就比較困難了;如果文檔更新比較頻繁,那操作起來就更加復(fù)雜了,這時(shí)內(nèi)嵌文檔顯然不合適,應(yīng)該采用范式化的設(shè)計(jì)。
比如我們將用戶存儲(chǔ)到user集合里,將所有的文檔存儲(chǔ)到file集合里,集合與集合的會(huì)通過唯一的_id
來連接,下面user集合主要存儲(chǔ)用戶的信息,而把需要引用的files集合記錄的_id
也寫到user集合里,
{
"_id": "author10001",
"name": "小云",
"male":"female",
"file": ["file200001","file200002","file200003"]
}
{
"_id": "author10002",
"male":"male",
"name": "小開",
"books": ["file200001","file200004"]
}
而在files集合里,則存儲(chǔ)所有文檔的信息,在files集合里只需要有user集合引用的_id
即可:
{
"_id": "file200001",
"title": "云開發(fā)實(shí)戰(zhàn)指南.pdf",
"categories": "PDF文檔",
"size":"16M"
}
{
"_id": "file200002",
"title": "云數(shù)據(jù)庫性能優(yōu)化.doc",
"categories": "Word文檔",
"size":"2M"
}
{
"_id": "file200003",
"title": "云開發(fā)入門指南.doc",
"categories": "Word文檔",
"size":"4M"
}
{
"_id": "file200004",
"title": "云函數(shù)實(shí)戰(zhàn).doc",
"categories": "Word文檔",
"size":"4M"
}
如果我們想一次性查詢用戶參與編輯了哪些文件以及相應(yīng)的文件信息,可以在云函數(shù)端使用聚合的lookup,這樣相當(dāng)于兩個(gè)集合整合到一個(gè)集合里面了。
const cloud = require('wx-server-sdk')
cloud.init({
env: cloud.DYNAMIC_CURRENT_ENV,
})
const db = cloud.database()
const _ = db.command
const $ = db.command.aggregate
exports.main = async (event, context) => {
const res = db.collection('user').aggregate()
.lookup({
from: 'files',
localField: 'file',
foreignField: '_id',
as: 'bookList',
})
.end()
return res
}
而如果我們要修改某個(gè)指定文檔的信息,直接根據(jù)files集合的_id來查詢就可以了。文檔更新一次,所有參與編輯該文檔的信息都會(huì)更新,保證了文件內(nèi)容的一致性。
值得一提的是,盡管我們將復(fù)雜的關(guān)系通過范式化設(shè)計(jì)把數(shù)據(jù)分散到了不同的集合,但是和關(guān)系型數(shù)據(jù)庫、Excel一個(gè)字段一列還是不一樣,我們還是可以把關(guān)系不那么復(fù)雜的數(shù)據(jù)用數(shù)組、對象的方式內(nèi)嵌。
如果每個(gè)用戶參與編輯的文檔特別多而每個(gè)文檔參與共同編輯的用戶又相對比較少,把file都內(nèi)嵌到user集合里就比較耗性能了,這時(shí)候可以反過來,把user的id嵌入files集合里,所以數(shù)據(jù)庫的設(shè)計(jì)與實(shí)際業(yè)務(wù)有著很大的關(guān)系。
//由于file數(shù)組過大,user集合不再內(nèi)嵌file了
{
"_id": "author10001",
"name": "小云",
"male":"female",
}
//把用戶的id嵌入到files集合里,相當(dāng)于以文檔為主,作者為輔
{
"_id": "file200001",
"title": "云開發(fā)實(shí)戰(zhàn)指南.pdf",
"categories": "PDF文檔",
"size":"16M",
"author":["author10001","author10002","author10003"]
}
這里再說明一下,跨表查詢和聯(lián)表查詢是兩碼事,跨表查詢我們可以通過集合與集合之間有關(guān)聯(lián)的字段(意義相同的字段)多次查詢來查找結(jié)果;而聯(lián)表查詢則是通過關(guān)聯(lián)的字段將多個(gè)集合的數(shù)據(jù)整列整列的合并到一起處理。如果你不需要返回跨集合的整列數(shù)據(jù),就不建議用聯(lián)表查詢,更不要妄圖聯(lián)N張表,能跨表查詢就跨表查詢。
云開發(fā)數(shù)據(jù)庫的數(shù)據(jù)模式比較靈活,關(guān)系型數(shù)據(jù)庫要求你在插入數(shù)據(jù)之前必須先定義好一個(gè)表的模式結(jié)構(gòu),而云數(shù)據(jù)庫的集合 collection 則并不限制記錄 document 結(jié)構(gòu)。關(guān)系型數(shù)據(jù)庫對有什么字段、字段是什么類型、長度為多少等等,而云數(shù)據(jù)庫既不需要預(yù)先定義,而且記錄的結(jié)構(gòu)也沒有限制,同一個(gè)集合的記錄的字段可以有很大的差異。
這種靈活性讓對象和數(shù)據(jù)庫文檔之間的映射變得很容易。即使數(shù)據(jù)記錄之間有很大的變化,每個(gè)文檔也可以很好的映射到各條不同的記錄。當(dāng)然在實(shí)際使用中,同一個(gè)集合中的文檔最好都有一個(gè)類似的結(jié)構(gòu)(相同的字段、相同的內(nèi)嵌文檔結(jié)構(gòu))方便進(jìn)行批量的增刪改查以及進(jìn)行聚合等操作。
隨著應(yīng)用程序使用時(shí)間的增長和需求變化,數(shù)據(jù)庫的數(shù)據(jù)模式可能也需要相應(yīng)地增長和改變。最簡單的方式就是在原有的數(shù)據(jù)模式基礎(chǔ)之上進(jìn)行添加字段,這樣就能保證數(shù)據(jù)庫支持所有舊版的模式。比如用戶信息表,由于業(yè)務(wù)需要需要增加一些字段,比如性別、年齡,云數(shù)據(jù)庫可以很輕松添加,但是這會(huì)出現(xiàn)一些問題,就是以往收集的用戶信息性別、年齡這些字段是空的,而只有新添加的用戶才有。如果業(yè)務(wù)的數(shù)據(jù)變動(dòng)比較大,文檔的數(shù)據(jù)模式也會(huì)存在版本混亂的沖突,這個(gè)在數(shù)據(jù)庫設(shè)計(jì)之初也是要思考的。
如果已經(jīng)知道未來要用到哪些字段,在第一次插入的時(shí)候就將這些字段預(yù)填充了,以后用到的時(shí)候就可以使用更新指令進(jìn)行字段級(jí)別的更新,而不再需要再給集合來新增字段,這樣的效率就會(huì)高很多。
{
"_id":"user20200001",
"nickname": "小明",
"age": 27,
"address":"",
"school":[{
"middle":""
},{
"college":""
}]
}
比如簡歷網(wǎng)站的用戶信息表的address、school,用戶登錄的時(shí)候不必填,但是投遞簡歷前這些信息必填,如果沒有預(yù)先設(shè)置這些字段,收集這些信息時(shí)就需要使用doc對文檔進(jìn)行記錄級(jí)別的更新。
db.collection("user").doc("user20200001")
.update({
data:{
"address":"深圳",
"school":[{
"middle":"華中一附中"
},{
"college":"清華大學(xué)"
}]
}
})
但是如果預(yù)先設(shè)置了這些字段,就是使用更新操作符進(jìn)行字段級(jí)別的更新,當(dāng)集合越大,修改的內(nèi)容又比較少時(shí),使用更新操作符來更新文檔,性能會(huì)大大提升。
db.collection("user").doc("user20200001")
.update({
data:{
"address":_.set("深圳"),
"school.0.middle":_.set("華中一附中"),
"school.1.college":_.set("清華")
}
})
采用內(nèi)嵌文檔這種反范式化設(shè)計(jì)在查詢時(shí)是有很大的好處的,但是有一些文檔的更新操作,會(huì)在內(nèi)嵌文檔的數(shù)組里增加元素或者增加一個(gè)新字段,如果隨著業(yè)務(wù)的需求這類操作導(dǎo)致文檔的大小變大,比如我們?yōu)榱朔奖惆言u論內(nèi)置到內(nèi)嵌文檔里,早期這樣的設(shè)計(jì)是沒有問題的,但是如果評論常年累積的增加會(huì)導(dǎo)致內(nèi)嵌文檔過大,越是往后新增的評論會(huì)越是影響性能,而且云數(shù)據(jù)庫的一個(gè)記錄的上限是16M。如果出現(xiàn)這種數(shù)據(jù)增長的情況,也會(huì)影響到反范式化的設(shè)計(jì)模式,那么你可能要重新設(shè)計(jì)下數(shù)據(jù)模型,在不同文檔之間使用引用的方式而非內(nèi)嵌的數(shù)據(jù)結(jié)構(gòu)。
由于更新指令不僅可以對數(shù)據(jù)進(jìn)行字段級(jí)別的微操(增刪改),而且還是原子操作,因此它不僅性能優(yōu)異還支持高并發(fā)。更值得一提的是,通過反范式化設(shè)計(jì)內(nèi)嵌文檔的方式,更新指令的原子操作可以替代一部分事務(wù)的功能,這個(gè)在原子操作和事務(wù)章節(jié)會(huì)有介紹。
更多建議: