イミュータブル エンティティを Cloud Datastore 上に実装する
2016年9月15日木曜日
* この投稿は米国時間 8 月 3 日、Streak.com の Co-Founder である Aleem Mawani 氏によって投稿されたもの(投稿はこちら)の抄訳です。
注 : 今回の執筆者は Streak.com の共同創業者 Aleem Mawani 氏です。Streak.com は Google Cloud Platform のお客様で、Google Apps に対応した Streak の CRM ソリューションは全面的に Google プロダクト(Gmail、Google App Engine、Google Cloud Datastore)上に構築されています。この記事では、Cloud Datastore のオブジェクト ストレージ システムに Streak がどのように高度な機能を追加したかを知ることができます。
Streak は、Gmail に直接組み込まれるフル機能の CRM ソリューションです。Google Cloud Platform 上に(Google App Engine を最も活用して)構築されており、Google Cloud Datastore にテラバイト(TB)規模のユーザー データを保存しています。
Cloud Datastore は私たちのプライマリ データベースです。そのスケーラビリティや一貫したパフォーマンス、フルマネージド管理にとても満足しています。
しかし、私たちはいくつかの分野で機能が物足りなくなりました。ユーザーがデータを更新するたびに、データベース エンティティを新しいコンテンツで上書きするのではなく、これらのバージョンをすべて保存し、それらに簡単にアクセスできるようにしたかったのです。つまり、すべてのデータをイミュータブル(不変)にする方法が欲しいと考えたわけです。
この投稿では、どのような場合にイミュータブル エンティティを使いたくなるのか、私たちがどのようなアプローチでそれらを Cloud Datastore 上に実装したのかを説明します。
私たちがイミュータブル エンティティを重要と考えた理由は次のとおりです。
- 私たちはニュースフィード スタイルの UI を簡単に実装できる方法を求めていました。一般的なニュースフィードは、エンティティが時間の経過とともにどのように変化したかを、グラフィカルなフォーマットでユーザーに表示します。従来、私たちは 1 つのエンティティの異なるバージョン間の差分を記録するために、独立したサイド エンティティを保存していました。こうしたサイド エンティティをクエリして、ニュースフィードをレンダリングしていたのです。しかし、このようなサイド エンティティは設計上、エラーが起こりやすく、メンテナンスが面倒でした。たとえば、エンティティに新しいプロパティを追加したときは、サイド エンティティにも忘れずに追加する必要がありました。特定のデータをサイド エンティティに追加し忘れると、後で必要になったときにそのデータを再構築する方法はなく、そのデータは永久に失われました。
エンティティ “Contact” は、ユーザーの連絡先に関するデータを保存します。イミュータブル エンティティとして実装されているので、連絡先が時間とともにどのように変わってきたかを示す履歴を簡単に生成できます。 - イミュータブル エンティティを持つことで、ユーザー エラーからのリカバリが非常に簡単になります。ユーザーはデータを前のバージョンにロールバックでき、うっかり削除してしまったようなデータをリカバリすることもできます(私たちが削除操作をどのように実装したかについては後述します)1。
- デバッグが容易になる可能性が高まります。エンティティが時間とともにどのように変化し、現在の状態になったのかがわかると、便利な場合がよくあります。エンティティに対する多数の変更の履歴に対してクエリを実行することもでき、これはユーザー行動分析やパフォーマンス最適化に役立ちます。
コンテキスト
Cloud Datastore でのイミュータブル エンティティの実装について見る前に、このデータストアの動作の基本的な仕組みをある程度理解しておく必要があります。なお、Cloud Datastore に精通している方は、このセクションを飛ばしていただいてかまいません。
Cloud Datastore はキー バリュー ストアと考えることができます。バリュー(データストア内のエンティティ)はキーで特定され、エンティティ自体は 1 つまたは複数のプロパティを持ちます。テーブル内のすべてのエンティティにスキーマが強制されることはありません。このため、2 つのエンティティのプロパティが同じである必要はありません。
このデータベースは 1 つのテーブルに対する基本的なクエリもサポートします。ジョインやアグリゲーションは行われず、単純なテーブル スキャンだけが行われ、そのためのインデックスが作成できます。これは制限のように思われるかもしれませんが、この特徴のおかげで、高速で一貫したクエリ パフォーマンスが得られます。通常、データの非正規化が行われるからです。
今回のイミュータブル エンティティの実装では、Cloud Datastore の最も重要な特徴は “エンティティ グループ” にあります。エンティティ グループはエンティティのグループであり、このグループでは以下の 2 つのことが保証されます。
この 2 つのことは、いずれも私たちの実装にとって重要です。Cloud Datastore 自体の詳細についてはドキュメントをご覧ください。
私たちは、1 つのエンティティに対して行った変更をすべて保存するとともに、エンティティの一般的な操作(取得、削除、更新、作成、クエリ)をサポートする方法を必要としていました。私たちが選択した全体的な戦略は、2 つのレベルの抽象化により、“データストア エンティティ” と “論理エンティティ” を使用することでした。個々の “データストア エンティティ” で “論理エンティティ” の個々のバージョンを表すようにしたのです。
私たちの API のユーザーは論理エンティティだけを操作し、それぞれの論理エンティティはデータストア エンティティを特定するキーを持ち、一般的な取得、作成、更新、削除、クエリの各操作をサポートします。これらの論理エンティティは、その論理エンティティのさまざまなバージョンを構成する実際のデータストア エンティティにバッキングされるわけです。最新の、あるいは最も Tip(先頭)のバージョンのデータストア エンティティが、論理エンティティの現在の値を表します。
まず、データ モデルがどのようなものかを見てみましょう。私たちは以下のようにエンティティを設計しました。
ユーザーがエンティティに変更を加えようとするたびに、必ず新しいデータストア エンティティが保存される仕組みになっています。最新のデータストア エンティティは isTip の値が true に設定され、他のデータストア エンティティは false に設定されます。
後でこのフィールドを使ってクエリを実行し、最新のデータストア エンティティを特定することで、特定の論理エンティティを取得します。このクエリはデータストアで高速に実行されます。すべてのクエリがインデックスを持つ必要があるからです。また私たちは、各データストア エンティティが作成された日時のタイムスタンプも保存します。
versionId フィールドは、各データストア エンティティのグローバル一意識別子(GUID)です。この ID は、エンティティを保存するときに Cloud Datastore によって自動的に割り当てられます。
consistentId は論理エンティティを特定します。これは、私たちがこの API のユーザーに提供できる ID です。1 つの論理エンティティ内のデータストア エンティティは、すべて同じコンシステント ID を持ちます。
私たちは、論理エンティティのコンシステント ID を、チェーンの最初のデータストア エンティティの ID と同じにしました。コンシステント ID はある程度任意に決めることができ、どのような一意識別子を選んでもかまいません。しかし、低レベルの Cloud Datastore API がどのデータストア エンティティにもユニークな ID を割り当てるので、私たちはコンシステント ID として、最初のデータストア エンティティの ID を使うことにしたのです。
このデータモデルには、興味深い点がもう 1 つあります。それは firstEntityInChain フィールドです。
上の図には示されていませんが、すべてのデータストア エンティティは親を持ちます(親を基準にエンティティ グループが決まります)。チェーン内の最初のデータストア エンティティが親として設定されます。
重要なのは、チェーン内のすべてのデータストア エンティティ(最初のものを含む)が同じ親を持ち、それゆえ同じエンティティ グループに属することです。そのおかげで、これらに対して整合性を持つクエリを実行できます。以上のものが必要な理由については後述します。
コードで定義された同じイミュータブル エンティティを以下に示します。私たちは素晴らしい Objectify ライブラリと Cloud Datastore を使っており、以下ではこれらを活用したコード例を随時紹介します。
それでは、論理エンティティに対する一般的な操作をどのように行うのかを見ていきましょう。論理エンティティがデータストア エンティティでバッキングされることを念頭に置いてください。
また、親キー(firstEntityInChain)として自身を指定します。後でこのエンティティをクエリできるように、isTip を true に設定する必要もあります。さらに、タイムスタンプとデータストア エンティティの作成者を設定し、このエンティティを Cloud Datastore に永続的に保存します。
次に、新しいデータストア エンティティを作成し、consistentId と firstEntityInChain を、フェッチしたデータストア エンティティのものに設定します。新しいデータストア エンティティの isTip を true に設定し、フェッチしたデータストア エンティティのこのフィールドを false に設定します(既存エンティティの中で、このインスタンスだけを変更することに注意してください。つまり、100 % イミュータブルなわけではないということです)。
このエンティティが論理エンティティを表します。クエリを整合性のあるものにしたいので、“祖先クエリ” を実行する必要があります(つまり、特定のエンティティ グループに対してのみクエリを実行するように Cloud Datastore に指示する必要があります)。このクエリが動作するのは、特定の論理エンティティのすべてのデータストア エンティティが、同じエンティティ グループに属するようにした場合に限られます。
このクエリが返す結果は 1 つだけ、つまり論理エンティティを表すデータストア エンティティだけでなければなりません。
- 1 つのエンティティ グループのみに対するクエリからは整合性のある結果が得られます。これは、書き込みの直後にクエリを行った場合、その結果は、その書き込みによる変更を反映することが保証されているということです。逆に、クエリの対象が 1 つのエンティティ グループに限定されていなければ、整合性のある結果は得られないかもしれません(データの陳腐化のため)。
- マルチエンティティ トランザクションは、1 つのエンティティ グループ内にのみ適用できます(この機能は最近、改良されました。Cloud Datastore は現在、エンティティ グループ間のトランザクションをサポートしています。ただし、トランザクションに含まれるエンティティ グループの数は 25 までに制限されています)。
この 2 つのことは、いずれも私たちの実装にとって重要です。Cloud Datastore 自体の詳細についてはドキュメントをご覧ください。
どのようにイミュータブル エンティティを実装したか
私たちは、1 つのエンティティに対して行った変更をすべて保存するとともに、エンティティの一般的な操作(取得、削除、更新、作成、クエリ)をサポートする方法を必要としていました。私たちが選択した全体的な戦略は、2 つのレベルの抽象化により、“データストア エンティティ” と “論理エンティティ” を使用することでした。個々の “データストア エンティティ” で “論理エンティティ” の個々のバージョンを表すようにしたのです。
私たちの API のユーザーは論理エンティティだけを操作し、それぞれの論理エンティティはデータストア エンティティを特定するキーを持ち、一般的な取得、作成、更新、削除、クエリの各操作をサポートします。これらの論理エンティティは、その論理エンティティのさまざまなバージョンを構成する実際のデータストア エンティティにバッキングされるわけです。最新の、あるいは最も Tip(先頭)のバージョンのデータストア エンティティが、論理エンティティの現在の値を表します。
まず、データ モデルがどのようなものかを見てみましょう。私たちは以下のようにエンティティを設計しました。
ユーザーがエンティティに変更を加えようとするたびに、必ず新しいデータストア エンティティが保存される仕組みになっています。最新のデータストア エンティティは isTip の値が true に設定され、他のデータストア エンティティは false に設定されます。
後でこのフィールドを使ってクエリを実行し、最新のデータストア エンティティを特定することで、特定の論理エンティティを取得します。このクエリはデータストアで高速に実行されます。すべてのクエリがインデックスを持つ必要があるからです。また私たちは、各データストア エンティティが作成された日時のタイムスタンプも保存します。
consistentId は論理エンティティを特定します。これは、私たちがこの API のユーザーに提供できる ID です。1 つの論理エンティティ内のデータストア エンティティは、すべて同じコンシステント ID を持ちます。
私たちは、論理エンティティのコンシステント ID を、チェーンの最初のデータストア エンティティの ID と同じにしました。コンシステント ID はある程度任意に決めることができ、どのような一意識別子を選んでもかまいません。しかし、低レベルの Cloud Datastore API がどのデータストア エンティティにもユニークな ID を割り当てるので、私たちはコンシステント ID として、最初のデータストア エンティティの ID を使うことにしたのです。
このデータモデルには、興味深い点がもう 1 つあります。それは firstEntityInChain フィールドです。
上の図には示されていませんが、すべてのデータストア エンティティは親を持ちます(親を基準にエンティティ グループが決まります)。チェーン内の最初のデータストア エンティティが親として設定されます。
重要なのは、チェーン内のすべてのデータストア エンティティ(最初のものを含む)が同じ親を持ち、それゆえ同じエンティティ グループに属することです。そのおかげで、これらに対して整合性を持つクエリを実行できます。以上のものが必要な理由については後述します。
コードで定義された同じイミュータブル エンティティを以下に示します。私たちは素晴らしい Objectify ライブラリと Cloud Datastore を使っており、以下ではこれらを活用したコード例を随時紹介します。
public class ImmutableDatastoreEntity {@IdLong versionId;@ParentKey<T> firstEntityInChain;protected Long consistentId;protected boolean isTip;Key<User> savedByUser;}
それでは、論理エンティティに対する一般的な操作をどのように行うのかを見ていきましょう。論理エンティティがデータストア エンティティでバッキングされることを念頭に置いてください。
作成を実行
論理エンティティを作成するときは、1 つの新しいデータストア エンティティを作成し、Cloud Datastore の ID 割り当てを利用して versionId フィールドを設定し、consistentId フィールドに同じ値を設定するだけです。また、親キー(firstEntityInChain)として自身を指定します。後でこのエンティティをクエリできるように、isTip を true に設定する必要もあります。さらに、タイムスタンプとデータストア エンティティの作成者を設定し、このエンティティを Cloud Datastore に永続的に保存します。
ImmutableDatastoreEntity entity = new ImmutableDatastoreEntity();entity.setVersionId(DAO.allocateId(this.getClass()));entity.setConsistentId(entity.getVersionId());entity.setFirstEntityInChain((Key<T>) Key.create(entity.getClass(), entity.versionId));entity.setTip(true);
更新を実行
論理エンティティを新しいデータで更新するには、まずチェーン内の最新のデータストア エンティティをフェッチする必要があります(手順については、下の “取得” のセクションで説明します)。次に、新しいデータストア エンティティを作成し、consistentId と firstEntityInChain を、フェッチしたデータストア エンティティのものに設定します。新しいデータストア エンティティの isTip を true に設定し、フェッチしたデータストア エンティティのこのフィールドを false に設定します(既存エンティティの中で、このインスタンスだけを変更することに注意してください。つまり、100 % イミュータブルなわけではないということです)。
最後に、タイムスタンプとユーザー キーのフィールドを埋めます。これで、新しいデータストア エンティティを保存する準備が整いました。
ここで重要なポイントが 2 つあります。1 つは、新しいデータストア エンティティについては、保存時に Cloud Datastore が ID を自動的に割り当てるようにすればよいということです(ID を他の用途で使う必要はないからです)。
そしてもう 1 つのポイントは極めて重要です。それは、既存データストア エンティティのフェッチと、新旧のデータストア エンティティの保存は、同一のトランザクションで実行するということです。そうしないと、データの内部整合性がなくなるおそれがあります。
ここで重要なポイントが 2 つあります。1 つは、新しいデータストア エンティティについては、保存時に Cloud Datastore が ID を自動的に割り当てるようにすればよいということです(ID を他の用途で使う必要はないからです)。
そしてもう 1 つのポイントは極めて重要です。それは、既存データストア エンティティのフェッチと、新旧のデータストア エンティティの保存は、同一のトランザクションで実行するということです。そうしないと、データの内部整合性がなくなるおそれがあります。
// start transactionImmutableDatastoreEntity oldVersion = getImmutableEntity(immutableId)oldVersion.setTip(false);ImmutableDatastoreEntity newVersion = oldVersion.clone();// make the user edits needednewVersion.setVersionId(null);newVersion.setConsistentId(this.getConsistentId());newVersion.setFirstEntityInChain(oldVersion.getFirstEntityInChain());// .clone also performs the last two lines but just to be explicit this, just fyinewVersion.setTip(true);ofy().save(oldVersion, newVersion).now();// end transaction
取得を実行
取得を実行するには、Cloud Datastore に対してクエリ操作を行わなければなりません。特定のconsistentId
を持ち、かつ isTip が true に設定されているデータストア エンティティを検索する必要があるからです。このエンティティが論理エンティティを表します。クエリを整合性のあるものにしたいので、“祖先クエリ” を実行する必要があります(つまり、特定のエンティティ グループに対してのみクエリを実行するように Cloud Datastore に指示する必要があります)。このクエリが動作するのは、特定の論理エンティティのすべてのデータストア エンティティが、同じエンティティ グループに属するようにした場合に限られます。
このクエリが返す結果は 1 つだけ、つまり論理エンティティを表すデータストア エンティティだけでなければなりません。
Key ancestorKey = KeyFactory.createKey(ImmutableDatastoreEntity.class, consistentId);ImmutableDatastoreEntity e = ofy().load().kind(ImmutableDatastoreEntity.class).filter("consistentId", consistentId).filter("isTip", true).ancestor(ancestorKey) // this limits our query to just the 1 entity group.list() .first();
削除を実行
論理エンティティを削除するには、最新のデータストア エンティティの isTip を false に設定するだけです。こうすることで、上に述べた “取得” の操作を行っても、結果が返されなくなります。その一方で、以下に述べるようなクエリは引き続き動作します。// wrap block in transactionImmutableDatastoreEntity oldVersion = getImmutableEntity(immutableId);oldVersion.setTip(false);ofy().save(oldVersion, newVersion).now();
クエリを実行
すべての論理エンティティに対してクエリを実行できる必要があります。ただし、どのデータストア エンティティをクエリするときも、各論理エンティティの最新のデータストア エンティティだけをクエリの対象とするように、クエリを設定する必要があります(データの古いバージョンを明示的に検索したいのでなければ)。そのためには、クエリの対象を最新のエンティティに限定する特別なフィルタをクエリに追加することが必要です。ここで重要な注意点があります。この場合、整合性を持つクエリを実行することはできないということです。すべてのクエリ結果が同じエンティティ グループに属することを保証できないからです(実は、結果が複数の場合は、それらが同じエンティティ グループに属さないことは自明です)。
List<ImmutableDatastoreEntity> results = ofy().load().kind(ImmutableDatastoreEntity.class).filter("isTip", true).filter(/** apply other filters here */).list();
ニュースフィード クエリを実行
私たちの目標の 1 つは、論理エンティティが時間とともにどのように変わってきたかを示すことでした。そのためには、チェーン内のすべてのデータストア エンティティをクエリできなければなりません。
これはかなりシンプルなクエリです。
consistentId
でクエリし、結果をタイムスタンプで並べるだけです。そうすれば、論理エンティティのすべてのバージョンが得られます。各データストア エンティティについて、前のデータストア エンティティとの差分を取れば、ニュースフィードに必要なデータを生成できます。
Key ancestorKey = KeyFactory.createKey(ImmutableDatastoreEntity.class, consistentId);List<ImmutableDatastoreEntity> versions = ofy().load().kind(ImmutableDatastoreEntity.class).filter("consistentId", consistentId).ancestor(ancestorKey).list();
課題
以上の設計により、私たちは、デバッグしやすく、ニュースフィード風の機能を開発しやすい、ほぼイミュータブルなエンティティを実装するという目標を達成できました。しかし、この方法にはいくつか課題もあります。- エンティティを取得するには必ずクエリを実行する必要がある : 特定の論理エンティティを取得するには、前述したようにクエリを実行しなければなりません。Cloud Datastore では、これはキーによる従来の “取得” よりも時間のかかる操作です。さらに、Objectify はビルトイン キャッシング機能を提供しますが、イミュータブル エンティティの 1 つを取得しようとするときは、この機能は使えません(Objectify はクエリをキャッシュできないからです)。この課題の対策としては、パフォーマンス上の問題が発生したら独自のキャッシングを memcache で実装することです。
- エンティティの取得をバッチで実行する方法がない : 各クエリは、整合性を確保するために 1 つのエンティティ グループを対象にしなければならないので、複数の論理エンティティの最新のデータストア エンティティを、1 回のデータストア操作でフェッチすることはできません。この問題に対処するため、私たちは複数の非同期クエリを実行し、それらがすべて完了するのを待ちます。この方法は理想的でもクリーンでもありませんが、実用上はかなりうまくいきます。ただし、App Engine で RPC を同時に呼び出す場合、RPC の発行数は 30 までに制限されていることに留意する必要があります。そのため、この対処方法は暫定的なものと言わざるをえません。
- エンティティを実装する初期コストが高い : 私たちは、上で説明した設計の大部分を抽象化し、イミュータブル エンティティを今後は低コストで実装できるようにしました。しかし、エンティティを最初に実装するのは容易なことではありませんでした。さまざまな問題をすべて解決するにはかなりの時間を要しました。こうした手間のかかる実装を行う価値があるのは、イミュータビリティを切実に必要としている場合か、もしくは実装をさまざまなユースケースに活用し、その多くの受益者に実装コストを “広く薄く” 負担してもらう場合に限られます。
- エンティティが実際には削除されない : 設計上、私たちはイミュータブル エンティティを削除しません。しかし、ユーザーの側では、私たちのアプリで何かを削除したら、私たちがそのデータを実際に削除することを期待するかもしれません。一部の規制対象業種(ヘルスケアなど)でも、そうしたことが求められる可能性があります。私たちのユースケースではそうした配慮は不要でしたが、お客様によっては、データセットを監視し、論理エンティティが削除されたのを見つけたら、バッチ タスクで定期的に、それらを表すデータストア エンティティをすべて削除するシステムを開発したほうがよいかもしれません。
次のステップ
私たちは、イミュータブル エンティティを本番環境で短期間しか運用していません。そのため、これから未知の問題に直面することもあるでしょう。また、イミュータブル エンティティとして実装するデータセットがさらにいくつか増えれば、実装のコストや労力に見合った効果が出ているかどうかを明確に判断できるようになるはずです。最新情報をお知りになりたい方は、ぜひ私たちのブログをご覧ください。
今回紹介したようなデータ インフラストラクチャに興味を持たれた方は、ぜひご連絡ください。なお、私たちはバックエンド チームのメンバーも募集しています。私たちの人材募集ページで詳しい情報をご覧ください。
ディスカッションは Hacker News でどうぞ。
1 これは MVCC(https://en.wikipedia.org/wiki/MultiVersion_Concurrency_Control)の考え方と非常に似ています。MVCC は、多くの現代的なデータベースでトランザクションやロールバックの実装に使用されています。
- Posted by Aleem Mawani, Co-Founder, Streak.com
0 件のコメント :
コメントを投稿