Azure
CosmosDB

Cosmos DB のストアドプロシージャで Group By をやってみる

More than 1 year has passed since last update.

RU と格闘している皆さんお元気ですか。

私は一カ月前から RU やパーティションキーと戦っています。

ここ一カ月くらいの所感では、大規模フロントエンドで素早くデータを取得する手段として Cosmos DB は優れていると感じています。

SQL を使えるものは利点ですが、足りない部分もあります。


  • 集約関数は使えるが Group By でグループ化した項目ごとの集計はできない

  • JOIN はできるが同一ドキュメント内のネストした子データに階層を下げるという操作になる


    • いわゆる RDBMS の JOIN ではないため、複数ドキュメントにまたがる結合はできない



足りない部分はロジック側でクエリを複数回発行したり、データの持ち方を工夫したりして補います。

複数回クエリを発行するとクエリ発行回数分通信が発生するのでその分処理時間がかかります。

(といってもAzure 環境内から Cosmos DB につなぐ分にはそんなに気にならないとは思いますが)

ストアドプロシージャは Cosmos DB 側に Javascript を配置して一回の通信内で複数クエリの発行を行えます。さらにストアドプロシージャ内のクエリ発行は同一トランザクションとして扱われますので、複数更新はすべてが成功するか失敗するかのどちらかになります。

よってストアドプロシージャの主な用途はシビアな更新処理なのですが、参照系の処理でも使用可能です。

というわけで、複数回のクエリを一度にまとめるためにストアドプロシージャを作ってみます。


ドキュメントについて

次のような JSON を コレクションに登録します。

{

"id": "3e67a7a9-ac16-4917-80a0-51010fa33cb1",
"category": "0",
"price": 6
}

このうち category が同じものについて集計(今回はカウント)を取りたいとします。

category 1つ1つに対しては次の SQL でカウントが取れます。

SELECT VALUE count(c) FROM c where c.category = '1'

[ 300 ]

ただし GroupBy を使って category ごとにまとめて取得する SQL は Cosmos DB では書けませんので、ストアドプロシージャで行ってみます。


Javascript の作成

まずはストアドプロシージャを作ります。

言語は Javascript です。

http://azure.github.io/azure-documentdb-js-server/ などを参照に作っていきます。

function groubBy(categories) {

let context = getContext();
let collection = context.getCollection();
let response = context.getResponse();
let result = {}
categories.forEach(category => {
// カテゴリごとに、集計関数を実行する
collection.queryDocuments(collection.getSelfLink(),
`select VALUE count(c) from c where c.category = "${category}"`, {},
(err, documents, responseOptions) => {
result[category] = documents[0]

// check all query results finished.
if (Object.keys(result).length == categories.length) {
response.setBody(result)
}
});
});
}


ポイント


  • 関数名は何でもよいです。

  • プロシージャに引数を渡せます。(今回は 集計対象のカテゴリの配列を渡す想定)

  • 前述のカテゴリ集計の SQL を引数 categories の中身でループして実行しています。

  • クエリ発行後の結果はコールバックで受け取ります。


    • 最後のクエリのコールバックの場合に結果を集約して reponse.setBody でプロシージャの戻り値にしています。




ストアドプロシージャの登録

ストアドプロシージャは名前とソースコードのセットで Cosmos DB に登録する必要があります。 REST API でもできますが、今回は実行の検証と一緒に C# の Document DB の SDK を使って Xunit で実行します。

また DocumentDB の SDK をラップし簡単に使えるようにしてあります。

ソースの全量は https://github.com/kencharos/CosmosProcedure にありますので興味があったら見てみてください。

プロシージャの登録部分を抜粋すると次のようになります。

public async Task CreateProcedure(string name, string procedure)

{
await client.CreateStoredProcedureAsync(
UriFactory.CreateDocumentCollectionUri(database, collection),
new StoredProcedure { Id = name, Body = procedure });
}

StoredProcedure オブジェクトの Id にプロシージャの名前、 Body に関数の本体(前述のjavascriptソース) を設定します。

実際にこの関数を呼び出すときは、ファイルから javascript ソースを読み込んで渡してあげます。

登録したプロシージャは次のように呼び出します。

public async Task<(double, O)> CallProcedure<O>(string name, object input)

{
var res = await client.ExecuteStoredProcedureAsync<O>(
UriFactory.CreateStoredProcedureUri(database, collection, name), input);

return (res.RequestCharge, res.Response);
}

ExecuteStoredProcedureAsync の第一引数はプロシージャのURL (DB, コレクション, 上で登録したプロシージャの名前) です。

第二引数はプロシージャに与える引数で、前述の javascript の引数として設定されます。

戻り値は、プロシージャからの戻り値と一緒に確認のため、 RU/s も一緒に返すようにしました。

プロシージャを登録して呼び出すコードは次のようになります。

// ファイルからプロシージャの登録

await fixture.Client.CreateProcedure("groupBy",
new System.IO.StreamReader("groupBy.js").ReadToEnd());
// プロシージャの呼び出し
var result = await fixture.Client.CallProcedure<IDictionary<string, int>>(
"groupBy", new List<string> { "1", "3", "4", "5", "8" });

データ準備も含めテストにします。


Xunit テスト

Cosmos DB のローカルエミュレータでテストします。

データベース、コレクションはテストクラスごとに生成・破棄したいので、 ClassFixture を使います。

    // Cosmos ドキュメントと対になるオブジェクト

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class Sample
{
public String Id { get; set; }
public String Category { get; set; }
public long Price { get; set; }
}

public class Fixture : IAsyncLifetime
{
public CosmosClient Client { get; set; } = new CosmosClient();

public Fixture()
{
}

/// <summary>
/// 1000件データ登録
/// </summary>
/// <returns></returns>
public async Task InitializeAsync()
{
// DB コレクションの作成
await Client.CreateDBandCollection();
//3000件データ投入
await Task.WhenAll(Enumerable.Range(0, 3000).Select(i =>
{
// 1 カテゴリごとに 300件のデータを登録
return Client.AddDocument(new Sample { Category = (i / 300) + "", Price = i });
}));
}

public async Task DisposeAsync()
{
// テスト終了時はデータベースを破棄
await Client.DeleteDBandCollection();
}
}

1カテゴリに300件のデータ、合計3000件のデータを登録します。

次がテストクラスです。

    public class ProcedureTest : IClassFixture<Fixture>

{
Fixture fixture;
ITestOutputHelper output;

public ProcedureTest(Fixture fixture, ITestOutputHelper output)
{
this.fixture = fixture;
this.output = output;
}

[Fact]
public async Task TestCallProcedure()
{

await fixture.Client.CreateProcedure("groupBy",
new System.IO.StreamReader("groupBy.js").ReadToEnd());
// 5カテゴリのカウントを取得
var result = await fixture.Client.CallProcedure<IDictionary<string, int>>(
"groupBy", new List<string> { "1", "3", "4", "5", "8" });

output.WriteLine("RU/s:" + result.Item1);

Assert.Equal(5, result.Item2.Count);
Assert.Equal(300, result.Item2["1"]);
Assert.Equal(300, result.Item2["3"]);
Assert.Equal(300, result.Item2["4"]);
Assert.Equal(300, result.Item2["5"]);
Assert.Equal(300, result.Item2["8"]);
}
}

10カテゴリのうち、5カテゴリのカウントを取りました。

テストは成功します。


パフォーマンスについて

何度か実行したところ、5カテゴリ取得の場合にかかる RU/s は約 150 でした。

単発の SELECT VALUE count(c) FROM c where c.category = '1' にかかる RU/s は約 30 でしたので、取得したいカテゴリ数に RU/s は比例する結果になりました。

というわけでプロシージャで複数のクエリ発行をまとめたとしてもクエリ発行にかかる RU/s の総量は同じで、通信回数が減らせるだけという結果になりました。

複数クエリをまとめる用途でプロシージャを作るのはケースバイケースだと思いました。

クライアントサイドから単一カテゴリ取得の SQL を通信時間を短縮するために並列で投げる方法も考えられるためです。

EntityFramework と異なり、同時並列で SQL を投げても Cosmos DB は問題ないですし、 Azure Web Apps からクエリを発行する分には通信の遅延もほぼないですし。

加えてプロシージャもクエリ同様パーティションキーが同一の範囲でしかクエリが発行できないので、パーティションを使う場合は相当な制約があります。

あとプロシージャを Javascript で書くのはドキュメントやサンプルが少なく、結構つらいという面もあります。

また、更新頻度が少ないなら集計結果を直接保持するのが消費 RU/s を少なくするうえで一番効果的です。

Cosmos DB をうまく使うには非正規化は避けて通れないですね。

というわけで ストアドプロシージャの使い道はトランザクショナルな更新や取得(※)が必要な箇所だなと、あらためて思いました。

※ - 複数のクエリ発行で少しの時間差であっても更新が入るのが許容できない場合など。


まとめ

というわけで Cosmos DB のプロシージャの紹介でした。

サンプルが少ないのでお役に立てば幸いです。

Cosmos DB は色々気を付けないといけないけど、特性を理解して使えば非常に役に立つやつです。

RDBMS に飽きた人、なんとなく今のアプリに RDBMS は合わないと思っている人はお試しあれ。