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