移动建模平台元数据存储架构演进
源寶導讀:明源云天際-移動建模平臺是一個快速生成多端移動應用的PaaS平臺,元數據是移動應用設計與運行的核心數據結構,本文將從元數據存儲這個視角分享我們的技術思考與實踐。
一、什么是元數據(Metadata)?
? ? 這個問題要先從移動建模平臺的定位說起。移動建模平臺是一個高效的應用搭建、管理平臺,用戶可以通過拖拉拽的方式,自定義快速生成多端移動應用的PaaS平臺。
? ? 目前主流的移動應用開發大都是基于H5為主的前端技術,元數據是對移動應用內部結構的一種數據抽象,用于描述應用所使用的組件和配置,是整個移動應用設計階段和運行階段的核心數據,也是移動建模平臺生成的重要產物之一。本文主要從元數據這個視角去討論移動建模平臺在元數據存儲方面的一些實踐。
? ? 如果把移動建模平臺比作一個汽車生產線的話,那么移動應用就好比這條生產線生產的汽車,元數據就好比汽車的配置,消費者可以基于汽車的原廠配置進行個性化改裝,也就有了個性化元數據,改裝完成最終驗車上牌也就有了運行時元數據。
? ? 設計階段通過一個Web版的在線設計器,設計器初始化會加載元數據進行頁面渲染,元數據數據結構如下:
? ? 設計器加載完成后可以通過設計器進行應用的設計和頁面配置,保存設計就會產生新的元數據:
二、元數據存儲架構演進過程概覽
? ? 移動建模平臺元數據存儲的演進過程大致可以分為三個階段:
2.1、單體應用階段
? ? 這個階段元數據表和其他業務數據表在同一個數據庫中,按照上圖的邏輯結構主要分成四張表來存儲:
? ? 在項目初期數據量并不大,這種結構也是最容易實現和最容易想到的。但隨著業務的發展,各種組件越來越豐富,單個應用的元數據也由最初的普遍幾十KB發展到幾M,同時伴隨著頁面增多,頁面之間的拷貝、復制、更新等操作也變得越來越緩慢。
? ? 從上圖的表結構可以看出,metadata字段是使用字符串來存儲json的,并且設計時和運行時元數據存儲在同一張表中。很快這種設計方案的弊端就顯現出來,主要有幾方面問題:
元數據可能會有幾M,對元數據的每次讀寫操作都需要對元數據進行序列化和反序列化,網絡IO和內存消耗大,程序執行時間過長。
即使要修改元數據中很小的一部分內容也必須將元數據全部取出,修改后再序列化為字符串存入數據庫。由于元數據的特殊性,緩存方案也無法使用。
頁面、文件夾數量比較多時對頁面的復制、刪除等操作需要涉及到多表事務,事務執行效率低。一個租戶升級操作可能需要十幾分鐘。
當PHP按照數組方式來處理后導致空對象和空數組轉換問題,會導致元數據損壞無法還原,前端頁面渲染出錯。
由于涉及到多張表的操作,多表查詢會讓業務邏輯變得極其復雜,程序很難維護。
2.2、服務拆分階段
針對以上出現的一些問題,開始采取一些局部的優化手段,主要有幾下幾方面:
采用服務化的方式將原有的元數據操作相關的邏輯從單體應用中剝離出來,有了元數據服務。
數據表結構增加了一些冗余字段,并針對索引進行了相應調整,提高了查詢性能。
在寫入操作比較多的地方將以前的單條insert改為了一次性多條insert插入,優化寫入性能。
對元數據的結構進行優化,精簡冗余部分,減小元數據的體積。
PHP操作元數據禁止使用數組方式來處理,統一轉為對象。
? ? 這個階段采用了以上一些優化方法,雖然性能得到一些改善,但是都沒有從根本上解決問題,根本問題出在存儲層,團隊也有討論過使用NOSQL,比如MongoDB,但是由于元數據和其他模塊嚴重耦合,數據層的拆分難度很大。加之如果改為MongoDB,新的數據模型如何設計,舊的數據如何遷移等問題還沒最佳實踐。所以這個階段的一些改進僅限于應用層的拆分,不過對于后續重構提供了參考。
2.3、微服務化階段
? ? 這個階段也是移動PaaS2.0階段,在2.0中元數據相關的能力完全抽離出來成為單獨的服務,并且使用golang進行重構,數據庫也獨立出來,使用MongoDB進行重新建模設計。為什么要選用MongoDB來作為數據庫存儲,主要基于以下幾個方面:
元數據本來就是json結構,而MongoDB的使用BSON作為數據交換格式,以文檔方式組織數據,非常符合元數據的結構特點。
MongoDB4.0之后同樣支持事務操作,在一些需要事務的場景下依然能夠保證數據的一致性。
通過性能對比,MongoDB在讀寫性能上有明顯優勢。
JSON 格式存儲最接近真實對象模型,對開發者友好,方便快速開發迭代。對于測試人員來說,可以直觀的看到元數據的數據結構,對測試更加友好。
能夠極大的簡化目前的應用層開發,減少大量的多表查詢操作。
可以按需修改元數據文檔的某個節點,而不需要讀取整個元數據文檔。
高可用復制集滿足數據高可靠、服務高可用的需求,運維簡單,故障自動切換。
可擴展分片集群,面對未來海量元數據存儲,可以很方便的支持水平擴展。
強大的aggregation & mapreduce,可以將復雜的查詢分解為一個個小的步驟。
? ? 下圖是在4核8G的同一臺虛擬機上做的一個MySQL和MongoDB的性能對比測試,可以看出隨著插入元數據的數量增加,MySQL和MongoDB所花費的時間的差距也越來越大。
? ? 使用MongoDB重新設計后的元數據結構:
{"_id": ObjectId("5f3de7507cda70000e433ca2"),"workspaceId": "26043287605354496","common": {"style": {"globalBgColor": "#FFFFFF","primaryColor": "#FF543D","secondaryColor": "#FF6954"},"body": {"header": {"hide": false}}},"configs": [{"_id": ObjectId("5f3de7507cda70000e433ca3"),"type": "role","name": "游客","alias": "default","isGuest": true,"remark": "用戶未登錄時所使用...","position": 1.59789243277115e+18,"viewIds": [ObjectId("5f3e45c62ef1d50013b3303e")],"metadata": {"tabs": {"items": [{"isDefault": true,"text": "123","activeIcon": "appicon-house","href": {"name": "bde68663-6f93-2206-0b29-cf910711f71e"},"icon": "appicon-house","iconClass": "appicon"}]}}},{"_id": ObjectId("5f3de7507cda70000e433ca4"),"type": "role","name": "已登錄用戶","alias": "default-login","isGuest": false,"remark": "用戶登錄時所使用...","position": 1.59789243277116e+18,"viewIds": [ ],"metadata": { }},{"_id": ObjectId("5f470ced59221f0014d2a144"),"type": "page","ancestors": [ObjectId("5f3de7507cda70000e433ca4")],"name": "login","routeName": "ef214890-b3e6-9a24-9dd8-80d12343f76c","routePath": "/ef214890-b3e6-9a24-9dd8-80d12343f76c","remark": "","design": { },"metadata": {"name": "ef214890-b3e6-9a24-9dd8-80d12343f76c","path": "/ef214890-b3e6-9a24-9dd8-80d12343f76c","body": {"header": {"title": "login","items": [ ]},"content": {"items": [ ]}}},"position": 1.59849188522867e+18,"viewIds": [ ]}],"createdAt": ISODate("2020-08-20T03:00:32Z"),"updatedAt": ISODate("2020-08-20T03:00:32Z") }? ? 從新的結構可以看出之前的元數據中的配置變成了一個內嵌數組configs,configs下包含了角色配置、文件夾、頁面。三者之間的關系由以前的層次關系被打平后變成了并列關系。那么如何實現他們之前的那種上下級關系呢?仔細看就能發現configs中的每一個對象里都有一個ancestors字段,這個字段用于記錄祖先節點,也就是通過這個節點就可以輕松找到當前項有幾個上級,只需要增加一個索引字段就可以高效的得到一個樹狀結構。根據ancestors創建索引:
? ??如圖所示,在1.0中,如果想要按照箭頭所指的方向移動往往需要配合數據庫中的
? ? 這兩個字段,更新這兩個字段來標注頁面的位置。
? ? 在新的數據庫當中,由于頁面、文件夾、配置是平等關系,所以只需要一個 "position": 1.59849188522867e+18字段來記錄就行了,當需要移動上下頁面時候只需要取相鄰兩個元素的position的平均值,最后結果按照position來排序,性能得到很大提升。
? ? 通過前后數據結構的對比,可以很明顯發現,在使用MySQL存儲時,為了要保證元數據節點之間的關系,往往需要設計多張表,而使用MongoDB后,只要一個集合就能搞定設計時元數據的存儲,這樣帶來的直接好處就是性能提升和應用程序開發的簡化。
? ? 元數據服務端使用了golang代替之前的php,其實也是為了方便元數據的操作和提升性能,由于配置、文件夾和頁面的差異被抹平,三者被統一抽象為配置,于是就很方便的提供統一的元數據操作API,golang結構體可以完美的將元數據的結構映射到MongoDB的文檔模型中,開發者可以清楚的看到數據庫中元數據結構和代碼中是完全一致的,這對新人理解元數據結構會有很大幫助。
//元數據結構體 type Metadata struct {Model `bson:"-"`Id bson.ObjectId `bson:"_id" json:"id"`WorkspaceId string `bson:"workspaceId" comment:"工作區ID"`Common bson.M `bson:"common" comment:"公共配置"`Configs []Config `bson:"configs" comment:"配置信息"`IsPublished bool `bson:"isPublished" comment:"是否發布"`CreatedAt time.Time `bson:"createdAt"`UpdatedAt time.Time `bson:"updatedAt"` }type Config interface {Add(metadataId string, data interface{}) errorEdit(metadataId, configId string, data interface{}) errorDelete(metadataId, configId string) errorGetType() string }? ? 解決了存儲問題后,需要返回樹狀結構給前端,這就需要應用端重新組裝數據。
type PageListResponse []TreeNode//統一定義菜單樹的數據結構 type TreeNode struct {Id string `json:"id"` //節點idParentId string `json:"-"` //父idType string `json:"type"` //類型Name string `json:"name"` //節點名字RouteName string `json:"routeName,omitempty"` //標識RoutePath string `json:"routePath,omitempty"` //路徑Leaf bool `json:"leaf,omitempty"` //葉子節點IsGuest bool `json:"isGuest,omitempty"` //是否是游客配置IsLogin bool `json:"isLogin,omitempty"` //是否是登錄頁面Ancestors []string `json:"ancestors,omitempty"` //祖先節點Remark string `json:"remark,omitempty"` //備注Position string `json:"position"` //位置Design map[string]interface{} `json:"design,omitempty"` //組件屬性Metadata map[string]interface{} `json:"metadata,omitempty"` //元數據Children []TreeNode `json:"children,omitempty"` //子節點 }// GenerateTree 自定義的結構體實現 TreeNode 接口后調用此方法生成樹結構 // nodes 需要生成樹的節點 func GenerateTree(nodes []TreeNode) (trees []TreeNode) {trees = []TreeNode{}// 定義頂層根和子節點var roots, childs []TreeNodefor _, v := range nodes {if len(v.ParentId) <= 0 {// 判斷頂層根節點roots = append(roots, v)}childs = append(childs, v)}for _, v := range roots {childTree := &v// 遞歸recursiveTree(childTree, childs)// 遞歸之后,根據子節確認是否是葉子節點childTree.Leaf = (len(childTree.Children) == 0)trees = append(trees, *childTree)}return }// recursiveTree 遞歸生成樹結構 // tree 遞歸的樹對象 // nodes 遞歸的節點 func recursiveTree(tree *TreeNode, nodes []TreeNode) {for _, v := range nodes {if len(v.ParentId) <= 0 {// 如果當前節點是頂層根節點就跳過continue}if tree.Id == v.ParentId {childTree := &vrecursiveTree(childTree, nodes)// 遞歸之后,根據子節確認是否是葉子節點childTree.Leaf = (len(childTree.Children) == 0)tree.Children = append(tree.Children, *childTree)}} }? ? 這個階段golang結構體處理json的便利性凸顯出來,omitempty可以將空的節點數據忽略掉,這就有效的降低了元數據的體積,降低了網絡I/O。
? ? 設計時的元數據存儲性能和邏輯復雜的問題解決了,剩下的就是運行時元數據的問題了。元數據在運行時階段其實是不會變動的,在1.0當中,移動應用在運行時需要動態請求元數據的服務,從元數據服務接口中拉取運行時元數據來渲染頁面,顯然如果訪問量大后元數據服務會成為性能的瓶頸。針對這個問題結合元數據的業務特點,最終運行時元數據就采用靜態json文件的方式存儲在OSS上,不僅消除了后端服務訪問壓力問題,同時也提高了運行時元數據加載的穩定性。最終生成的路徑其實訪問的是一個真實存在的json問題。
xxxxxxxxxx https://xxxxxx.com/_assets/mobile_three/demo/exp/1.0.12/meta/default.json三、總結
? ? 好了,以上就是本次分享的移動建模平臺元數據存儲的演進過程,當然實際演進過程遠比本次講述的要復雜得多,分享的內容也是挑選幾個比較重要的場景展開,后續可以分享一些MongoDB設計模式方面的內容,總結一下從開發選型角度大致有以下幾點實踐經驗:
使用MySQL和MongoDB同時進行數據建模,對比兩者之間的優劣,在表關系比較復雜時可能涉及到多表關聯查詢較多的場景下利用MongoDB內嵌文檔、內嵌數組等靈活的文檔數據結構往往能設計出結構更清晰、性能更好的存儲方案。
小心MongoDB單個文檔16M的存儲限制,對于那種可能無限增長的數據不適合直接使用內嵌方式存儲,可改為內嵌引用方式。
盡量不要使用ORM框架來操作MongoDB,往往會誤把MongoDB當成MySQL來使用,同時不能很好的使用MongoDB強大的API。
Golang和MongoDB的結合能在提升性能的同時,帶來開發上的便利。
MongoDB 4.0以后已經支持多文檔事務,擴展了MongoDB的使用場景,越來越多的場景其實是可以使用MongoDB代替MySQL。如果沒有特別的必要和限制,采用MongoDB往往會給程序設計帶來更大的靈活性,提高數據庫開發效率,更好的滿足快速迭代開發的需求。
MongoDB不能簡單理解為一個json文檔存儲所有數據,同時要結合具體的業務場景考慮讀寫操作是否方便來設計文檔模型。
------ END ------
作者簡介
段同學:?研發工程師,目前負責天際-移動平臺產品的研發工作。
也許您還想看
基于 Go 的微服務運行情況監控實踐
在明源云客,一個全新的服務會經歷什么?
云客后臺優化的“前世今生”(一)
云客后臺優化的“前世今生”(二)
回歸統計在DMP中的實戰應用
總結
以上是生活随笔為你收集整理的移动建模平台元数据存储架构演进的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Visual Studio 即时窗口实用
- 下一篇: 浅谈AsyncLocal,我们应该知道的