移動建模平臺元數據存儲架構演進

源寶導讀:明源雲天際-移動建模平臺是一個快速生成多端移動應用的PaaS平臺,元數據是移動應用設計與運行的核心數據結構,本文將從元數據存儲這個視角分享我們的技術思考與實踐。

一、什麼是元數據(Metadata)?

    這個問題要先從移動建模平臺的定位說起。移動建模平臺是一個高效的應用搭建、管理平臺,用戶可以通過拖拉拽的方式,自定義快速生成多端移動應用的PaaS平臺。

    目前主流的移動應用開發大都是基於H5爲主的前端技術,元數據是對移動應用內部結構的一種數據抽象,用於描述應用所使用的組件和配置,是整個移動應用設計階段和運行階段的核心數據,也是移動建模平臺生成的重要產物之一。本文主要從元數據這個視角去討論移動建模平臺在元數據存儲方面的一些實踐。

    如果把移動建模平臺比作一個汽車生產線的話,那麼移動應用就好比這條生產線生產的汽車,元數據就好比汽車的配置,消費者可以基於汽車的原廠配置進行個性化改裝,也就有了個性化元數據,改裝完成最終驗車上牌也就有了運行時元數據。

    設計階段通過一個Web版的在線設計器,設計器初始化會加載元數據進行頁面渲染,元數據數據結構如下:

    設計器加載完成後可以通過設計器進行應用的設計和頁面配置,保存設計就會產生新的元數據:

二、元數據存儲架構演進過程概覽

    移動建模平臺元數據存儲的演進過程大致可以分爲三個階段:

2.1、單體應用階段

    這個階段元數據表和其他業務數據表在同一個數據庫中,按照上圖的邏輯結構主要分成四張表來存儲:

    在項目初期數據量並不大,這種結構也是最容易實現和最容易想到的。但隨着業務的發展,各種組件越來越豐富,單個應用的元數據也由最初的普遍幾十KB發展到幾M,同時伴隨着頁面增多,頁面之間的拷貝、複製、更新等操作也變得越來越緩慢。

    從上圖的表結構可以看出,metadata字段是使用字符串來存儲json的,並且設計時和運行時元數據存儲在同一張表中。很快這種設計方案的弊端就顯現出來,主要有幾方面問題:

  1. 元數據可能會有幾M,對元數據的每次讀寫操作都需要對元數據進行序列化和反序列化,網絡IO和內存消耗大,程序執行時間過長

  2. 即使要修改元數據中很小的一部分內容也必須將元數據全部取出,修改後再序列化爲字符串存入數據庫。由於元數據的特殊性,緩存方案也無法使用

  3. 頁面、文件夾數量比較多時對頁面的複製、刪除等操作需要涉及到多表事務,事務執行效率低。一個租戶升級操作可能需要十幾分鍾。

  4. 當PHP按照數組方式來處理後導致空對象和空數組轉換問題,會導致元數據損壞無法還原,前端頁面渲染出錯

  5. 由於涉及到多張表的操作,多表查詢會讓業務邏輯變得極其複雜,程序很難維護

2.2、服務拆分階段

針對以上出現的一些問題,開始採取一些局部的優化手段,主要有幾下幾方面:

  1. 採用服務化的方式將原有的元數據操作相關的邏輯從單體應用中剝離出來,有了元數據服務。

  2. 數據表結構增加了一些冗餘字段,並針對索引進行了相應調整,提高了查詢性能。

  3. 在寫入操作比較多的地方將以前的單條insert改爲了一次性多條insert插入,優化寫入性能。

  4. 對元數據的結構進行優化,精簡冗餘部分,減小元數據的體積。

  5. PHP操作元數據禁止使用數組方式來處理,統一轉爲對象。

    這個階段採用了以上一些優化方法,雖然性能得到一些改善,但是都沒有從根本上解決問題,根本問題出在存儲層,團隊也有討論過使用NOSQL,比如MongoDB,但是由於元數據和其他模塊嚴重耦合,數據層的拆分難度很大。加之如果改爲MongoDB,新的數據模型如何設計,舊的數據如何遷移等問題還沒最佳實踐。所以這個階段的一些改進僅限於應用層的拆分,不過對於後續重構提供了參考。

2.3、微服務化階段

    這個階段也是移動PaaS2.0階段,在2.0中元數據相關的能力完全抽離出來成爲單獨的服務,並且使用golang進行重構,數據庫也獨立出來,使用MongoDB進行重新建模設計。爲什麼要選用MongoDB來作爲數據庫存儲,主要基於以下幾個方面:

  1. 元數據本來就是json結構,而MongoDB的使用BSON作爲數據交換格式,以文檔方式組織數據,非常符合元數據的結構特點。

  2. MongoDB4.0之後同樣支持事務操作,在一些需要事務的場景下依然能夠保證數據的一致性。

  3. 通過性能對比,MongoDB在讀寫性能上有明顯優勢。

  4. JSON 格式存儲最接近真實對象模型,對開發者友好,方便快速開發迭代。對於測試人員來說,可以直觀的看到元數據的數據結構,對測試更加友好。

  5. 能夠極大的簡化目前的應用層開發,減少大量的多表查詢操作。

  6. 可以按需修改元數據文檔的某個節點,而不需要讀取整個元數據文檔。

  7. 高可用複製集滿足數據高可靠、服務高可用的需求,運維簡單,故障自動切換。

  8. 可擴展分片集羣,面對未來海量元數據存儲,可以很方便的支持水平擴展。

  9. 強大的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創建索引:

xxxxxxxxxx
db.metadata.createIndex({
    "configs.ancestors": 1
})

    如圖所示,在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{}) error
  Edit(metadataId, configId string, data interface{}) error
  Delete(metadataId, configId string) error
  GetType() string
}

    解決了存儲問題後,需要返回樹狀結構給前端,這就需要應用端重新組裝數據。

type PageListResponse []TreeNode


//統一定義菜單樹的數據結構
type TreeNode struct {
  Id        string                 `json:"id"`                  //節點id
  ParentId  string                 `json:"-"`                   //父id
  Type      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 []TreeNode
  for _, 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 := &v
      recursiveTree(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設計模式方面的內容,總結一下從開發選型角度大致有以下幾點實踐經驗:

  1. 使用MySQL和MongoDB同時進行數據建模,對比兩者之間的優劣,在表關係比較複雜時可能涉及到多表關聯查詢較多的場景下利用MongoDB內嵌文檔、內嵌數組等靈活的文檔數據結構往往能設計出結構更清晰、性能更好的存儲方案。

  2. 小心MongoDB單個文檔16M的存儲限制,對於那種可能無限增長的數據不適合直接使用內嵌方式存儲,可改爲內嵌引用方式。

  3. 儘量不要使用ORM框架來操作MongoDB,往往會誤把MongoDB當成MySQL來使用,同時不能很好的使用MongoDB強大的API。

  4. Golang和MongoDB的結合能在提升性能的同時,帶來開發上的便利。

  5. MongoDB 4.0以後已經支持多文檔事務,擴展了MongoDB的使用場景,越來越多的場景其實是可以使用MongoDB代替MySQL。如果沒有特別的必要和限制,採用MongoDB往往會給程序設計帶來更大的靈活性,提高數據庫開發效率,更好的滿足快速迭代開發的需求。

  6. MongoDB不能簡單理解爲一個json文檔存儲所有數據,同時要結合具體的業務場景考慮讀寫操作是否方便來設計文檔模型。

------ END ------

作者簡介

段同學: 研發工程師,目前負責天際-移動平臺產品的研發工作。

也許您還想看

基於 Go 的微服務運行情況監控實踐

在明源雲客,一個全新的服務會經歷什麼?

雲客後臺優化的“前世今生”(一)

雲客後臺優化的“前世今生”(二)

迴歸統計在DMP中的實戰應用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章