如何使用真實範例在 Azure Cosmos DB 上將資料模型化和分割

適用於:NoSQL

本文根據數個 Azure Cosmos DB 概念而撰寫 (例如資料模型化分割佈建的輸送量),以示範如何進行實際資料的設計演練。

如果您經常使用關聯式資料庫,您可能已有設計資料模型的習慣和觀念。 基於特定的條件約束,以及 Azure Cosmos DB 的獨特功能,這些最佳做法大多無法發揮實際功效,而可能迫使您選擇次佳的解決方案。 本文的目的,是要引導您在 Azure Cosmos DB 中完成為實際使用案例建立模型的程序,從建立項目模型、實體共置到容器分割,逐步完成。

下載或檢視社群產生的原始程式碼,其闡述了本文的概念。

重要

社群參與者提供此程式碼範例,但 Azure Cosmos DB 小組不支援其維護。

案例

在此練習中,我們將考量「使用者」可建立「貼文」的部落格平台領域。 使用者也可對這些貼文按讚及新增留言

提示

某些字詞會以斜體醒目提示;這些字詞表示我們的模型必須操作的某些「事物」。

對我們的規格新增更多需求:

  • 首頁會顯示最近建立貼文的摘要,
  • 我們可以擷取某個使用者的所有貼文、某篇貼文的所有留言,以及某篇貼文所有的讚,
  • 會連同貼文傳回作者的使用者名稱,以及貼文的留言數和按讚數,
  • 此外也會連同留言和按讚數傳回留言和按讚者的使用者名稱,
  • 以清單形式顯示時,貼文只需要顯示其內容的截斷摘要。

識別主要存取模式

一開始,我們將藉由識別解決方案的存取模式給予初始規格某種結構。 設計 Azure Cosmos DB 的資料模型時,務必了解模型必須因應的要求,以確定模型可有效因應這些要求。

為了讓整體程序更容易執行,我們從 CQRS 借用某個詞彙,以將這些不同的要求分類為命令或查詢。 在 CQRS 中,命令是寫入要求 (也就是更新系統的意圖),而查詢是唯讀要求。

以下是我們平台所公開的要求清單:

  • [C1] 建立/編輯使用者
  • [Q1] 擷取使用者
  • [C2] 建立/編輯貼文
  • [Q2] 擷取貼文
  • [Q3] 以簡短形式列出使用者的貼文
  • [C3] 建立留言
  • [Q4] 列出貼文的留言
  • [C4] 對貼文按讚
  • [Q5] 列出貼文的讚
  • [Q6] 以簡短形式列出最近建立的 x 篇貼文 (摘要)

在此階段,我們尚未考慮每個實體 (使用者、貼文等) 所包含內容的詳細資料。 在針對關聯式存放區進行設計時,此步驟通常是我們必須先處理的步驟。 我們會先從此步驟開始,因為我們必須釐清這些實體在資料表、資料行、外部索引鍵等方面將如何轉譯。針對在寫入時不會強制執行任何結構描述的文件資料庫,這方面的顧慮就少得多。

之所以要在一開始就找出存取模式,主要是因為這份要求清單將成為我們的測試套件。 我們在每次反覆執行資料模型時,都會查看每個要求,並檢查其效能和可擴縮性。 我們會計算每個模型所耗用的要求單位,並將其最佳化。 所有這些模型都使用預設的編製索引原則,您可以透過對特定屬性編製索引來加以覆寫,以進一步改善 RU 耗用量和延遲。

V1:第一個版本

我們一開始有兩個容器:usersposts

使用者容器

此容器只會儲存使用者項目:

{
    "id": "<user-id>",
    "username": "<username>"
}

我們依 id 來分割此容器,這表示該容器內的每個邏輯分割區都只會包含一個項目。

貼文容器

此容器裝載貼文、留言和讚這類實體:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "title": "<post-title>",
    "content": "<post-content>",
    "creationDate": "<post-creation-date>"
}

{
    "id": "<comment-id>",
    "type": "comment",
    "postId": "<post-id>",
    "userId": "<comment-author-id>",
    "content": "<comment-content>",
    "creationDate": "<comment-creation-date>"
}

{
    "id": "<like-id>",
    "type": "like",
    "postId": "<post-id>",
    "userId": "<liker-id>",
    "creationDate": "<like-creation-date>"
}

我們依 postId 來分割此容器,這表示該容器內的每個邏輯分割區都會包含一篇貼文、該貼文的所有留言,以及該貼文所有的讚。

我們已在此容器中所儲存的項目中引進 type 屬性,用以區分此容器所裝載的三種實體。

此外,我們也選擇要參考相關資料,而非加以內嵌 (如需這些概念的詳細資訊,請查看本節),原因是:

  • 使用者可建立的貼文並沒有數量上限,
  • 貼文沒有長度限制,
  • 一篇貼文可以有的留言數和按讚數並沒有上限,
  • 我們想要能夠直接對貼文新增留言或按讚,而無須更新貼文本身。

模型的執行成效如何?

現在我們將評估第一個版本的效能和延展性。 針對先前所識別的每個要求,我們將測量其延遲及其耗用的要求單位數量。 這項測量將對一個虛擬資料集執行,其中包含 100,000 名使用者,每個使用者有 5 到 50 篇貼文,而每篇貼文最多有 25 個留言和 100 個讚。

[C1] 建立/編輯使用者

此要求很容易實作,因為我們剛剛才在 users 容器中建立或更新項目。 憑藉 id 分割區索引鍵的效用,要求會妥善分散到所有分割區。

Diagram of writing a single item to the users' container.

延遲 RU 費用 效能
7 毫秒 5.71 RU

[Q1] 擷取使用者

擷取使用者的作業會藉由從 users 容器中讀取對應的項目來完成。

Diagram of retrieving a single item from the users' container.

延遲 RU 費用 效能
2 毫秒 1 RU

[C2] 建立/編輯貼文

類似於 [C1],我們只需寫入 posts 容器即可。

Diagram of writing a single post item to the posts container.

延遲 RU 費用 效能
9 毫秒 8.76 RU

[Q2] 擷取貼文

首先我們從 posts 容器中擷取對應的文件。 但這樣還不夠,根據我們的規格,我們還必須彙總貼文作者的使用者名稱、貼文的留言數,以及貼文的按讚數。 列出的彙總需要再發出 3 個 SQL 查詢。

Diagram of retrieving a post and aggregating additional data.

這些額外的查詢分別會依其各自容器的分割區索引鍵進行篩選,而這正是我們想盡可能提高效能和可擴縮性所需要的。 但我們終究還是需要執行四項作業才能傳回單一貼文,因此我們將下次反覆執行時加以改善。

延遲 RU 費用 效能
9 毫秒 19.54 RU

[Q3] 以簡短形式列出使用者的貼文

首先,我們必須使用會擷取該名使用者對應貼文的 SQL 查詢,來擷取所需的貼文。 但我們也必須發出其他查詢來彙總作者的使用者名稱以及留言數和按讚數。

Diagram of retrieving all posts for a user and aggregating their additional data.

此實作有許多缺點:

  • 必須針對第一個查詢所傳回的每篇貼文發出會彙總留言數和按讚數的查詢,
  • 主要查詢不會依 posts 容器的分割區索引鍵進行篩選,而導致整個容器的展開傳送和分割區掃描。
延遲 RU 費用 效能
130 毫秒 619.41 RU

[C3] 建立留言

留言可藉由在 posts 容器中寫入對應的項目而建立。

Diagram of writing a single comment item to the posts container.

延遲 RU 費用 效能
7 毫秒 8.57 RU

[Q4] 列出貼文的留言

首先,我們以查詢擷取該貼文的所有留言,且同樣地,我們也必須個別彙總每個留言的使用者名稱。

Diagram of retrieving all comments for a post and aggregating their additional data.

雖然主要查詢會依容器的分割區索引鍵進行篩選,但個別彙總使用者仍會導致整體效能下降。 我們稍後會加以改善。

延遲 RU 費用 效能
23 毫秒 27.72 RU

[C4] 對貼文按讚

如同 [C3],我們在 posts 容器中建立對應的項目。

Diagram of writing a single (like) item to the posts container.

延遲 RU 費用 效能
6 毫秒 7.05 RU

[Q5] 列出貼文的讚

如同 [Q4],我們查詢該貼文的讚,然後彙總其使用者名稱。

Diagram of retrieving all likes for a post and aggregating their additional data.

延遲 RU 費用 效能
59 毫秒 58.92 RU

[Q6] 以簡短形式列出最近建立的 x 篇貼文 (摘要)

我們查詢依遞減的建立日期排序的 posts 容器以擷取最新的貼文,然後彙總每篇貼文的使用者名稱以及留言數和按讚數。

Diagram of retrieving most recent posts and aggregating their additional data.

同樣地,我們的初始查詢並未依 posts 容器的分割區索引鍵進行篩選,這會觸發高成本的展開傳送。但這次情況更糟,因為我們以更大的結果集作為目標,並且以 ORDER BY 子句排序結果,因而導致要求單位的成本更加昂貴。

延遲 RU 費用 效能
306 毫秒 2063.54 RU

V1 效能的反映

回顧我們在上一節中面臨的效能問題,我們主要可歸納出兩種類別的問題:

  • 對於某些要求,必須要發出多個查詢,才能收集我們需要傳回的所有資料,
  • 某些查詢不會依其目標容器的分割區索引鍵進行篩選,導致會妨礙延展性的展開傳送。

我們將從第一個問題開始逐一解決這些問題。

V2:導入反正規化以最佳化讀取查詢

我們之所以必須在某些情況下發出其他要求,是因為初始要求的結果未包含我們需要傳回的所有資料。 使用 Azure Cosmos DB 這類非關聯式資料存放區時,反正規化資料可跨資料集來解決這類問題。

在此範例中,我們將修改貼文項目,以新增貼文作者的使用者名稱、留言數和按讚數:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

我們也會修改留言和讚這兩個項目,以新增其建立者的使用者名稱:

{
    "id": "<comment-id>",
    "type": "comment",
    "postId": "<post-id>",
    "userId": "<comment-author-id>",
    "userUsername": "<comment-author-username>",
    "content": "<comment-content>",
    "creationDate": "<comment-creation-date>"
}

{
    "id": "<like-id>",
    "type": "like",
    "postId": "<post-id>",
    "userId": "<liker-id>",
    "userUsername": "<liker-username>",
    "creationDate": "<like-creation-date>"
}

反正規化留言數和按讚數

我們想要達到的結果是,每當我們新增留言或讚時,對應貼文中的 commentCountlikeCount 也會遞增。 因為 postId 會分割我們的 posts 容器,所以新的項目 (留言或讚) 以及其對應的貼文會位於相同的邏輯分割區中。 因此,我們可以使用預存程序來執行該作業。

當您建立留言 ([C3]) 時,除了在 posts 容器中新增項目以外,我們還會對該容器呼叫下列預存程序:

function createComment(postId, comment) {
  var collection = getContext().getCollection();

  collection.readDocument(
    `${collection.getAltLink()}/docs/${postId}`,
    function (err, post) {
      if (err) throw err;

      post.commentCount++;
      collection.replaceDocument(
        post._self,
        post,
        function (err) {
          if (err) throw err;

          comment.postId = postId;
          collection.createDocument(
            collection.getSelfLink(),
            comment
          );
        }
      );
    })
}

此預存程序會使用貼文的識別碼和新留言的本文作為參數,然後:

  • 擷取貼文
  • 增加 commentCount
  • 取代貼文
  • 新增留言

預存程序會以不可部分完成交易的形式執行,因此 commentCount 的值與留言的實際數目會隨時保持同步。

我們在增加新的讚時理當也呼叫了類似的預存程序,使 likeCount 遞增。

反正規化使用者名稱

使用者名稱需要不同的方法,因為使用者不只會位於不同的分割區,也位於不同的容器中。 當我們需要在分割區和容器間反正規化資料時,我們可以使用來源容器的變更摘要

在此範例中,我們使用 users 的變更摘要,以在使用者更新其使用者名稱時做出因應。 發生這種情況時,我們將對 posts 容器呼叫另一個預存程序,以傳播變更:

Diagram of denormalizing usernames into the posts container.

function updateUsernames(userId, username) {
  var collection = getContext().getCollection();
  
  collection.queryDocuments(
    collection.getSelfLink(),
    `SELECT * FROM p WHERE p.userId = '${userId}'`,
    function (err, results) {
      if (err) throw err;

      for (var i in results) {
        var doc = results[i];
        doc.userUsername = username;

        collection.upsertDocument(
          collection.getSelfLink(),
          doc);
      }
    });
}

此預存程序會以使用者的識別碼和使用者的新使用者名稱作為參數,然後:

  • 擷取所有符合 userId 的項目 (可以是貼文、留言或讚)
  • 針對這些項目
    • 取代 userUsername
    • 取代項目

重要

這項作業的成本高昂,因為它需要在 posts 容器的每個分割區上執行此預存程序。 我們假設大部分的使用者在註冊時都選擇適當的使用者名稱,且不會變更,因此這項更新將鮮少執行。

V2 有哪些效能提升?

讓我們來談談 V2 的一些效能提升。

[Q2] 擷取貼文

現在,反正規化作業已準備就緒,我們只需擷取單一項目來處理該要求即可。

Diagram of retrieving a single item from the denormalized posts container.

延遲 RU 費用 效能
2 毫秒 1 RU

[Q4] 列出貼文的留言

同樣地,我們不需執行額外的要求來擷取使用者名稱,而只需要依分割區索引鍵進行篩選的單一查詢。

Diagram of retrieving all comments for a denormalized post.

延遲 RU 費用 效能
4 毫秒 7.72 RU

[Q5] 列出貼文的讚

列出讚時的情況完全相同。

Diagram of retrieving all likes for a denormalized post.

延遲 RU 費用 效能
4 毫秒 8.92 RU

V3:確保所有要求都可調整

查看整體效能改進時,還是有兩個我們尚未完全最佳化的要求。 這些要求是 [Q3][Q6]。 這些要求涉及不會依目標容器的分割區索引鍵進行篩選的查詢。

[Q3] 以簡短形式列出使用者的貼文

此要求獲益於 V2 所引進的改進,因而不需要其他查詢。

Diagram that shows the query to list a user's denormalized posts in short form.

但其餘查詢仍未依 posts 容器的分割區索引鍵進行篩選。

對此情況的思考方向其實十分明確:

  1. 此要求「必須」userId 進行篩選,因為我們想要擷取特定使用者的所有貼文。
  2. 其執行效果不佳,因為執行依據為 posts 容器,但其分割依據不是 userId
  3. 顯而易見,我們會對分割依據為 userId 的容器執行此要求,以解決效能問題。
  4. 而其實我們已有這樣的容器:users 容器!

因此,我們藉由將所有貼文複製到 users 容器,來導入第二層反正規化。 這麼做,我們有效地取得以不同維度分割的貼文複本,使其能更有效地依 userId 擷取。

users 容器現在包含兩種項目:

{
    "id": "<user-id>",
    "type": "user",
    "userId": "<user-id>",
    "username": "<username>"
}

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

在此範例中:

  • 我們已在使用者項目中引進 type 欄位,用以區分使用者與貼文。
  • 我們也已在使用者項目中新增 userId 欄位,雖然這在已有 id 欄位時是多餘的,但因為 users 容器現在依 userId 進行分割 (而非先前的 id),所以成為必要欄位

為了完成此一反正規化,我們再次使用變更摘要。 這次,我們回應 posts 容器的變更摘要,以將任何新的或更新的貼文分派至 users 容器。 因為列出貼文並不需要傳回其完整內容,所以因此我們可以在處理時加以截斷。

Diagram of denormalizing posts into the users' container.

我們現在可以將查詢路由到依據容器的分割區索引鍵篩選的 users 容器。

Diagram of retrieving all posts for a denormalized user.

延遲 RU 費用 效能
4 毫秒 6.46 RU

[Q6] 以簡短形式列出最近建立的 x 篇貼文 (摘要)

在此我們必須處理類似的情況:即使已因 V2 中所引進的反正規化而不再需要進行其他查詢,其餘查詢還是不會依據容器的分割區索引鍵進行篩選:

Diagram that shows the query to list the x most recent posts created in short form.

依循相同的方式,要讓此要求達到最高的效能和延展性,要求必須僅在一個分割區中。 只達到單一分割區是可以想像的,因為我們只需要傳回有限數量的項目。 若要填入我們部落格平台的首頁,我們只需要取得 100 篇最新的貼文,而不需要將整個資料集分頁。

因此,為了將這最後一個要求最佳化,我們在設計中導入了第三個容器,完全用來處理此要求。 我們將貼文反正規化到這個新的 feed 容器:

{
    "id": "<post-id>",
    "type": "post",
    "postId": "<post-id>",
    "userId": "<post-author-id>",
    "userUsername": "<post-author-username>",
    "title": "<post-title>",
    "content": "<post-content>",
    "commentCount": <count-of-comments>,
    "likeCount": <count-of-likes>,
    "creationDate": "<post-creation-date>"
}

type 欄位會分割此容器,而此容器在我們的項目中一律為 post。 這麼做可確保此容器中的所有項目將位於相同的分割區中。

要完成反正規化,我們只需連結先前導入的變更摘要管線,以將貼文分派到這個新的容器即可。 需留意的一項重點,就是必須確定我們僅儲存了 100 篇最新的貼文;否則,容器的內容可能會超出分割區的大小上限。 每次在容器中新增文件時,呼叫後置觸發程序即可實作此限制:

Diagram of denormalizing posts into the feed container.

以下是會截斷集合的後續觸發程序主體:

function truncateFeed() {
  const maxDocs = 100;
  var context = getContext();
  var collection = context.getCollection();

  collection.queryDocuments(
    collection.getSelfLink(),
    "SELECT VALUE COUNT(1) FROM f",
    function (err, results) {
      if (err) throw err;

      processCountResults(results);
    });

  function processCountResults(results) {
    // + 1 because the query didn't count the newly inserted doc
    if ((results[0] + 1) > maxDocs) {
      var docsToRemove = results[0] + 1 - maxDocs;
      collection.queryDocuments(
        collection.getSelfLink(),
        `SELECT TOP ${docsToRemove} * FROM f ORDER BY f.creationDate`,
        function (err, results) {
          if (err) throw err;

          processDocsToRemove(results, 0);
        });
    }
  }

  function processDocsToRemove(results, index) {
    var doc = results[index];
    if (doc) {
      collection.deleteDocument(
        doc._self,
        function (err) {
          if (err) throw err;

          processDocsToRemove(results, index + 1);
        });
    }
  }
}

最後一個步驟是將查詢重新路由到新的 feed 容器:

Diagram of retrieving the most recent posts.

延遲 RU 費用 效能
9 毫秒 16.97 RU

推論

讓我們看看不同版本的設計所產生的整體效能和可擴縮性改進。

V1 V2 V3
[C1] 7 毫秒/5.71 RU 7 毫秒/5.71 RU 7 毫秒/5.71 RU
[Q1] 2 毫秒/1 RU 2 毫秒/1 RU 2 毫秒/1 RU
[C2] 9 毫秒/8.76 RU 9 毫秒/8.76 RU 9 毫秒/8.76 RU
[Q2] 9 毫秒/19.54 RU 2 毫秒/1 RU 2 毫秒/1 RU
[Q3] 130 毫秒/619.41 RU 28 毫秒/201.54 RU 4 毫秒/6.46 RU
[C3] 7 毫秒/8.57 RU 7 毫秒/15.27 RU 7 毫秒/15.27 RU
[Q4] 23 毫秒/27.72 RU 4 毫秒/7.72 RU 4 毫秒/7.72 RU
[C4] 6 毫秒/7.05 RU 7 毫秒/14.67 RU 7 毫秒/14.67 RU
[Q5] 59 毫秒/58.92 RU 4 毫秒/8.92 RU 4 毫秒/8.92 RU
[Q6] 306 毫秒/2063.54 RU 83 毫秒/532.33 RU 9 毫秒/16.97 RU

我們已將大量讀取情節最佳化

您可能已注意到我們著重於改善讀取要求 (查詢) 的效能,而犧牲寫入要求 (命令) 的效能。 在許多情況下,目前的寫入作業會透過變更摘要觸發後續的反正規化,而這會加重運算成本並拉長具體化的所需時間。

我們憑藉部落格平台 (例如大部分的社交應用程式) 大量讀取的事實來證明此著重於讀取效能。 大量讀取工作負載指出其所需處理的讀取要求數量通常遠高於寫入要求數量 (呈指數性的比例)。 因此,為了讓讀取要求以較低成本和較高的效率執行,而讓寫入要求的成本較為昂貴,是合理的做法。

在我們完成的最佳化之中,[Q6] 是最極致的一個,其從 2000 多個 RU 陡降到 17 個 RU;這是我們以每個項目約 10 個 RU 的成本將貼文反正規化所達到的成果。 由於我們處理的摘要要求量遠高於建立或更新貼文的數量,考量到整體的節省效果,這項反正規化的成本是可忽略的。

反正規化可以累加方式套用

我們在本文中探討的延展性改進,牽涉到整個資料集內的資料反正規化和複製。 應注意的是,這些最佳化並非一開始就必須具備的。 依分割區索引鍵進行篩選的查詢在大規模執行時的效能較佳,但跨分割區查詢若鮮少呼叫或僅針對有限的資料集執行也是可接受的。 如果您剛剛建置原型,或啟動了使用者群體較小或受控制的產品,您應該可以後續再進行這些改進。 重要的是監視模型效能,以決定進行改進的必要性和時機。

我們用來將更新散佈到其他容器的變更摘要,會持續完整地儲存這些更新。 即使您的系統已有許多資料,此持續性還是可讓您要求容器建立後的所有更新,以及以一次性追趕作業的形式啟動反正規化檢視。

下一步

在看完這些關於實際資料模型化和分割的簡介之後,您可以查看下列文章,以檢閱我們所說明的概念: