DocumentDB のインデックス設定によるRUの変動について

DocumentDBのインデックスについて調べてのでブログに記しておこうと思います。
DocumentDBではデフォルト設定でも結構いい感じに動くようにインデックス設定が行われているのですが、カスタマイズ可能であり、これを最適化するとRUや(インデックス用)データサイズの節約につながります。

※RU = RequestUnit。DocumentDBにおける「単位時間(秒)あたりの処理数の抽象単位」。課金に大きく影響する数値です。

1. デフォルトのインデックス設定

DocumentDBでは、インデックスはコレクションに対して設定可能です。
またコレクションを作成すると、デフォルトでは、以下のようなインデックスポリシーが設定されます。

{
  "indexingMode": "consistent",
  "automatic": true,
  "includedPaths": [
    {
      "path": "/*",
      "indexes": [
        {
          "kind": "Range",
          "dataType": "Number",
          "precision": -1
        },
        {
          "kind": "Hash",
          "dataType": "String",
          "precision": 3
        }
      ]
    }
  ],
  "excludedPaths": []
}

これは、Azureポータル上で「対象DocumentDBアカウント → DataExploere → 対象データベース → 対象コレクション → Scale & Settings」で確認することができます。

f:id:daigo-knowlbo:20170505004326p:plain

「includedPaths」はインデックス適用対象のパス、「excludedPaths」はインデックス所帯対象のパス、という意味になります。
「path: “/*"」とは、「格納されたJSONデータ要素のすべて(配下の階層を含めてすべて)に対して」という意味を持ちます。
そして ”/*" に対して、「Range」「Hash」の2種類のインデックスが設定されています。
それぞれのインデックス種類の意味は、Azure Cosmos DB indexing policies | Microsoft Docsに記述されており、以下が対象箇所の引用になります。

Hash:
 Hash を /prop/? (または/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT FROM collection c WHERE c.prop = “value”
 Hash を /props/[]/? (または / または /props/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT tag FROM collection c JOIN tag IN c.props WHERE tag = 5

Range:
 Range を /prop/? (または/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT FROM collection c WHERE c.prop = “value”
 SELECT FROM collection c WHERE c.prop > 5
 SELECT FROM collection c ORDER BY c.prop

インデックスの種類にはもう1つ「Spatial」があります。これはGEOクエリーを行う為のインデックスとなります。

つまり、コレクションに対するデフォルトのインデックス設定は、一般的な「文字列比較」や「数値比較」による抽出、また並べ替えのためのインデックスが、すべてのドキュメント要素に適用されることになります。

2. 下準備

さて、本投稿では「インデックスの最適化によるRUの変動」を調べるのですが、そのためのデータを準備したいと思います。
以下のPhotoItemクラスをコレクションに保存するドキュメントエンティティとします。

public class PhotoItem
{
  [JsonProperty(PropertyName = "id")]
  public string Id { get; set; }

  // タイトル
  public string Title { get; set; }

  // 「いいね」数
  public int LikeCount { get; set; }

  // 写真のURI
  public string PhotoUri { get; set; }

  // カテゴリ
  public string Category { get; set; }
}

データベース・コレクションは以下のDocumentManagerクラスのInitializeDocumentClient()メソッドで作成します。
データベース名は ExampleDB1、コレクション名は ExampleCollection1、パーティションキーとして Category を設定します。
CreateExampleDocument(PhotoItem photoItem)メソッドは、PhotoItemドキュメントを作成します。

public class DocumentManager
{
  private const string EndpointUrl = "https://rddocdb.documents.azure.com:443/";
  private const string PrimaryKey = "[プライマリキー]";

  private const string DatabaseID = "ExampleDB1";
  private const string CollectionID = "ExampleCollection1";

  private DocumentClient client;

  // データベースとコレクションを作成
  public async Task<bool> InitializeDocumentClient()
  {
    this.client = new DocumentClient(new Uri(DocumentManager.EndpointUrl), DocumentManager.PrimaryKey,
      new ConnectionPolicy
      {
        ConnectionMode = ConnectionMode.Gateway,
        ConnectionProtocol = Protocol.Https
      });

    // ExampleDB1 データベースを作成
    await this.client.CreateDatabaseIfNotExistsAsync(
      new Database { Id = DocumentManager.DatabaseID });

    // ExampleCollection1 コレクションを作成(パーティションキーはCategory)
    Collection<string> partitionPaths = new Collection<string>();
    partitionPaths.Add("/Category");
    var collection = new DocumentCollection { 
                Id = DocumentManager.CollectionID, 
                PartitionKey = new PartitionKeyDefinition() { Paths = partitionPaths } };
    collection = await this.client.CreateDocumentCollectionIfNotExistsAsync(
      UriFactory.CreateDatabaseUri(DocumentManager.DatabaseID),
      collection);

    return true;
  }
  
  // ドキュメントを作成
  public async Task<Document> CreateExampleDocument(PhotoItem photoItem)
  {
    // ドキュメント作成
    var doc = await this.client.CreateDocumentAsync(
      UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID),
      photoItem);
    
    return doc;
  }

  ...省略

}

以下のコードで DocumentManagerクラス を呼び出してデータベース・コレクション・10,000件のドキュメントを作成します。

// DocumentManagerクラスを呼び出してデータベースを初期化
Random random = new Random();

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

string[] categories = { "sky", "sea", "mountain", "people" };

for (int i = 0; i < 10000; i++)
{
  PhotoItem item = new PhotoItem()
  {
    Id = i.ToString(),
    Title = string.Format("SampleTitle{0}", i.ToString()),
    LikeCount = random.Next(1000),
    PhotoUri = string.Format("https://xxxxxxx/{0}", i.ToString()),
    Category = categories[i%4]
  };
  var ret = await manager.CreateExampleDocument(item);
  System.Threading.Thread.Sleep(50); // 429しないように緩やかに追加する
  Console.WriteLine(i);
}

3. デフォルト状態での書き込みと読み込み

デフォルトのインデックス状態で書き込みと読み込みを確認してみます。

書き込み

10件のドキュメントを書き込んでみます。

Random random = new Random();

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

{
  string[] categories = { "sky", "sea", "mountain", "people" };
  for (int id = 10000; id < 10010; id++)
  {

    PhotoItem item = new PhotoItem()
    {
      Id = id.ToString(),
      Title = string.Format("SampleTitle{0}", id.ToString()),
      LikeCount = random.Next(1000),
      PhotoUri = string.Format("https://xxxxxxx/{0}", id.ToString()),
      Category = categories[id % 4]
    };
    var ret = await manager.CreateExampleDocument(item);
  }
}

上記ソースにより送信されたHTTPリクエストは以下のようになります(ここでは1件のみ抽出。続けて実施した10件の書き込みのRUは同一であった為)。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-documentdb-partitionkey: ["sea"]
x-ms-date: Thu, 04 May 2017 16:46:29 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3deHQp5YAx4oaQAkFvxYn0vh9sMbLFqPliTss6TqfrT1Y%3d
x-ms-session-token: 0:10043
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"id":"10001","Title":"SampleTitle10001","LikeCount":469,"PhotoUri":"https://xxxxxxx/10001","Category":"sea"}

そして、HTTPレスポンスは以下の通りです。

HTTP/1.1 201 Created
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:05.215 GMT
etag: "00003133-0000-0000-0000-590b5ae50000"
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=3912;documentsCount=10031;collectionSize=6560;
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-quorum-acked-lsn: 10043
x-ms-current-write-quorum: 3
x-ms-current-replica-set-size: 4
x-ms-xp-role: 1
x-ms-request-charge: 6.1
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 6f53bfbf-d4b1-4ad4-9bf1-b4ce7367636b
x-ms-session-token: 0:10044
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 16:46:28 GMT

...省略...

消費RUは・・・「x-ms-request-charge: 6.1」。
1件の書き込みRUは 6.1 でした。

読み込み

ドキュメントを読み込みを行います。
DocumentManagerクラスに検索用メソッドQueryPhotoItems()を追加します。
QueryPhotoItems()はかなり固定的な実装で、LikeCountが100より大きく、Categoryが sky のドキュメントを50件取得します。

public class DocumentManager {
 ...省略
  public async Task<List<PhotoItem>> QueryPhotoItems()
  {
    List<PhotoItem> result = null;

    var feedOption = new FeedOptions() { MaxItemCount = 50 };

    var query = this.client.CreateDocumentQuery<PhotoItem>(
      UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID), feedOption)
      .Where(i => i.LikeCount > 100 && 
             i.Category == "sky")
      .Take(50);
    result = query.ToList();

    return result;
  }
}

DocumentManager.QueryPhotoItems()を呼び出す実装は以下の通り。

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();
// LikeCountが>100のドキュメントを50件検索
var result = await manager.QueryPhotoItems();

上記で実行されるHTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-continuation: 
x-ms-documentdb-isquery: True
x-ms-max-item-count: 50
x-ms-documentdb-query-enablecrosspartition: False
x-ms-documentdb-query-iscontinuationexpected: False
x-ms-date: Thu, 04 May 2017 16:59:15 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dKF%2fbc5aVHlTffYtoHF4k5qzALl727%2bNdHlERf3plj7U%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Content-Type: application/query+json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"query":"SELECT TOP 50 * FROM root WHERE ((root[\"LikeCount\"] > 100) AND (root[\"Category\"] = \"sky\")) "}

HTTPレスポンスは以下の通りです。

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:13.755 GMT
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=4298;documentsCount=10040;collectionSize=6964;
x-ms-item-count: 50
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-xp-role: 2
x-ms-request-charge: 20.32
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 2f1f663c-09e2-4913-b20d-8ef5ac3d63c2
x-ms-session-token: 0:10052
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 16:59:15 GMT

...省略...

x-ms-request-charge: 20.32。
消費RUは 20.32 でした。

4. LikeCount/Categoryのみのインデックス設定で書き込みと読み込み

インデックスを以下のように変更します。
LikeCount / Categoryのみにインデックスを適用するようにカスタマイズします。

{
  "indexingMode": "consistent",
  "automatic": true,
  "includedPaths": [
    {
      "path": "/LikeCount/*",
      "indexes": [
        {
          "kind": "Range",
          "dataType": "Number",
          "precision": -1
        }
      ]
    },
    {
      "path": "/Category/*",
      "indexes": [
        {
          "kind": "Hash",
          "dataType": "String",
          "precision": 3
        }
      ]
    }
  ],
  "excludedPaths": [
    {
      "path": "/*"
    }
  ]
}

設定はAzureポータルで「設定 → インデックス作成ポリシー」から変更することができます。

f:id:daigo-knowlbo:20170505020619p:plain

書き込み

先程と同様に10件のドキュメントを書き込んでみます。
HTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-documentdb-partitionkey: ["mountain"]
x-ms-date: Thu, 04 May 2017 17:03:09 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3deHdnuMnm8pSUdfK4T0ItZPjQzGvmyzqBOEExfbDCuUw%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 114
Expect: 100-continue

{"id":"10010","Title":"SampleTitle10010","LikeCount":782,"PhotoUri":"https://xxxxxxx/10010","Category":"mountain"}

HTTPレスポンスは以下の通りです。

HTTP/1.1 201 Created
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:05.215 GMT
etag: "00004e33-0000-0000-0000-590b5ecd0000"
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=4317;documentsCount=10040;collectionSize=6965;
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-quorum-acked-lsn: 10053
x-ms-current-write-quorum: 3
x-ms-current-replica-set-size: 4
x-ms-xp-role: 1
x-ms-request-charge: 5.52
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: fedec436-d43f-479c-b8c7-8ce095d9dd07
x-ms-session-token: 0:10054
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 17:03:08 GMT

...省略...

消費RUは・・・「x-ms-request-charge: 5.52」。
1件の書き込みRUは 5.52 でした。
デフォルトインデックス状態の 6.1 よりも小さな値になりました。

読み込み

ドキュメントを読み込みを行います。
先程と同様の読み込みを行います。

HTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-continuation: 
x-ms-documentdb-isquery: True
x-ms-max-item-count: 50
x-ms-documentdb-query-enablecrosspartition: False
x-ms-documentdb-query-iscontinuationexpected: False
x-ms-date: Thu, 04 May 2017 17:10:13 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dvO5eIEtwI7FZ3%2bD8d8ezEIvH3Hp9EuY1EJ8ZR0Rm2WE%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Content-Type: application/query+json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"query":"SELECT TOP 50 * FROM root WHERE ((root[\"LikeCount\"] > 100) AND (root[\"Category\"] = \"sky\")) "}

HTTPレスポンスは以下の通りです。

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:13.755 GMT
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=3929;documentsCount=10050;collectionSize=6597;
x-ms-item-count: 50
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-xp-role: 2
x-ms-request-charge: 20.32
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 2be70cc0-d86c-40ce-8f72-a2542c954ad9
x-ms-session-token: 0:10063
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 17:10:13 GMT

...省略...

消費RUは 20.32 でした。
検索条件(LikeCount・Category)が、デフォルトインデックス時も、その後のカスタムインデックス時も共に対象となっている為に、読み込み時の消費RUが変化しませんでした。

4. 読み込みインデックスの効きを確認する

前述の確認では デフォルトインデックス時・カスタムインデックス時 共にインデックス対象の項目で検索してしまっていました。
検索条件を以下のようにした場合はどうでしょうか?

var query = this.client.CreateDocumentQuery<PhotoItem>(
  UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID), feedOption)
  .Where(i => i.LikeCount > minLikeCount && 
              i.Category == "sky" && 
              i.Title == "SampleTitle0")
  .Take(50);

Title項目も検索対象としました(このクエリー条件は現実のビジネス的にはちょっと変な条件指定ですが・・・技術的検証としてとらえてください)。
Titleは「デフォルトインデックス時にはインデックス対象」「カスタムインデックス時にはインデックス対象外」となる項目です。

実施結果は以下のようになります。

デフォルトインデックス時(インデックス対象) : x-ms-request-charge: 5.96
カスタムインデックス時(インデックス対象外) : x-ms-request-charge: 204.98

RUに大きな違いが発生しました。
インデックス外の項目での検索の負荷の高さを確認することができます。

まとめ

DocumentDBではデフォルト状態で「すべての項目に対して Hash と Range のインデックスが設定されている」。
GEOクエリーを行わなければ、何もしないでもある程度いい感じに動いてくれる。
インデックスの最適化で消費RUの最適化が図れる。ただし、適切に実施しないと、消費RUに大きな差が生まれるので注意しなければならない。
といった感じでしょうか。