このドキュメントでは、データベース管理者、アプリケーション デベロッパー向けに、Spanner を使用するアプリケーションで一意の数値シーケンスを生成する方法について説明します。
はじめに
従業員番号や請求書番号など、ビジネスにおいてシンプルで一意の数値 ID が必要となるケースは頻繁に発生します。従来のリレーショナル データベースには、単調に増加する一意の数列を生成する機能が含まれていることが多くあります。これらのシーケンスは、データベースに格納されたオブジェクトの一意の識別子(行キー)を生成するために使用されます。
ただし、行キーとして単調に増加(または減少)する値を使用する方法はベスト プラクティスではありません。データベースにホットスポットが生じ、パフォーマンスが低下するためです。このドキュメントでは、Spanner データベース テーブルとアプリケーション レイヤ ロジックを使用してシーケンス ジェネレータを実装するメカニズムを提案します。
また、Spanner は組み込みのビット反転シーケンス ジェネレータをサポートしています。Spanner シーケンス ジェネレータの詳細については、シーケンスの作成と管理をご覧ください。
シーケンス ジェネレータの要件
すべてのシーケンス ジェネレータは、トランザクションごとに一意の値を生成する必要があります。
シーケンス ジェネレータでは、ユースケースに応じて次の特性を持つシーケンスを作成することが必要な場合もあります。
- 順序: シーケンスで低い値を高い値の後に生成することはできません。
- ギャップなし: シーケンスでギャップを発生させてはいけません。
また、シーケンス ジェネレータには、アプリケーションが要求する頻度で値を生成することも求められます。
特に分散システムでは、これらの要件をすべて満たすことは困難です。パフォーマンス目標を達成するために必要な場合は、シーケンスを順序付けして、かつギャップのないようにするという要件に対して妥協することもできます。
他のデータベース エンジンには、これらの要件を処理する方法があります。たとえば、PostgreSQL と MySQL の AUTO_INCREMENT 列のシーケンスは、トランザクションごとに一意の値を生成できますが、トランザクションがロールバックされた場合にギャップのない値を生成することはできません。詳細については、PostgreSQL ドキュメントの注意事項と MySQL での AUTO_INCREMENT の実装をご覧ください。
データベース テーブル行を使用したシーケンス ジェネレータ
アプリケーションでは、データベース テーブルを使用してシーケンス名とシーケンス内に次の値を保存することで、シーケンス ジェネレータを実装できます。
データベース トランザクション内でシーケンスの next_value
セルを読み取り、増分すると一意の値が生成され、アプリケーション プロセス間でそれ以上の同期が不要になります。
まず、次のようにテーブルを定義します。
CREATE TABLE sequences (
name STRING(64) NOT NULL,
next_value INT64 NOT NULL,
) PRIMARY KEY (name)
シーケンスを作成するには、テーブルに新しいシーケンス名と開始値(("invoice_id", 1)
など)を含む行を挿入します。ただし、next_value
セルは、生成されるシーケンス値ごとに増分されますが、そのパフォーマンスは行の更新頻度によって制限されます。
Spanner クライアント ライブラリは、再試行可能なトランザクションを使用して競合を解決します。読み取り / 書き込みトランザクション中に読み取られたセル(列の値)が他の場所で変更された場合、そのトランザクションは他のトランザクションが完了するまでブロックされ、その後、中止されて再試行され、更新された値を読み取ります。これにより、書き込みロックの期間が最小限に抑えられますが、正常に commit される前にトランザクションが複数回試行される可能性があります。
行で一度に発行できるトランザクションは 1 つのみであるため、シーケンス値を発行する最大頻度は、トランザクションの合計レイテンシに反比例します。
このトランザクションの合計レイテンシは、クライアント アプリケーションと Spanner ノード間のレイテンシ、Spanner ノード間のレイテンシ、TrueTime の不確実性など、いくつかの要因によって決まります。たとえば、マルチリージョン構成では、異なるリージョンのノードからの書き込み確認が完了するまで待機する必要があるため、トランザクション レイテンシが高くなります。
たとえば、1 つのセル(1 行に 1 列)の読み取り更新トランザクションのレイテンシが 10 ミリ秒(ms)の場合、1 秒あたりの理論上のシーケンス値の最大発行頻度は 100 です。この最大値は、クライアント アプリケーションのインスタンス数やデータベース内のノード数に関係なく、データベース全体に適用されます。これは、単一の行が常に 1 つのノードで管理されるためです。
次のセクションでは、この制限を回避する方法について説明します。
アプリケーション側の実装
アプリケーション コードは、データベース内の next_value
セルを読み取り、更新する必要があります。これを行う方法は複数あり、それぞれ異なるパフォーマンス特性と欠点があります。
シンプルなトランザクション内シーケンス ジェネレータ
最も簡単な方法は、アプリケーションが新しい連続値を必要とするときに、トランザクション内の列値を増分することです。
1 回のトランザクションで、アプリケーションは次の処理を行います。
- アプリケーションで使用されるシーケンス名の
next_value
セルを読み取ります。 - シーケンス名の
next_value
セルを増分して更新します。 - 取得した値を、アプリケーションで必要な列の値に使用します。
- アプリケーションの残りのトランザクションを完了します。
このプロセスでは、順番通りの、ギャップのないシーケンスが生成されます。データベース内の next_value
セルを低い値に更新しない場合、シーケンスも一意になります。
シーケンス値は広範なアプリケーションのトランザクションの一部として取得されるため、シーケンス生成の最大頻度はアプリケーションのトランザクション全体の複雑さによって異なります。複雑なトランザクションはレイテンシが高くなるため、許容される頻度の最大値は低くなります。
分散システムでは、多数のトランザクションが同時に試行され、シーケンス値に対して高い競合が発生する可能性があります。next_value
セルはアプリケーションのトランザクション内で更新されるため、next_value
セルを同時に増分しようとするトランザクションは最初のトランザクションによってブロックされ、再試行されます。これにより、アプリケーションがトランザクションを正常に完了するまでに必要な時間が大幅に増加し、パフォーマンスの問題が発生する可能性があります。
次のコードは、トランザクションごとに 1 つのシーケンス値のみを返す簡単なトランザクション内シーケンス ジェネレータの例を示しています。この制限が存在するのは、Mutation API を使用したトランザクション内の書き込みは、同じトランザクションを読み取る場合でも、トランザクションが commit されるまで表示されないためです。したがって、同じトランザクションでこの関数を複数回呼び出すと、常に同じシーケンス値が返されます。
次のサンプルコードは、同期 getNext()
関数を実装する方法を示しています。
次のサンプルコードは、トランザクションで同期 getNext()
関数がどのように使用されるかを示しています。
改善されたトランザクション内同期シーケンス ジェネレータ
トランザクション内で発行されたシーケンス値を追跡することで、単一のトランザクション内で複数の値を生成するように上記の抽象化を変更できます。
1 回のトランザクションで、アプリケーションは次の処理を行います。
- アプリケーションで使用するシーケンス名の
next_value
セルを読み取ります。 - この値を変数として内部に保存します。
- 新しいシーケンス値がリクエストされるたびに、格納されている
next_value
変数を増分し、更新されたセル値をデータベースに設定する書き込みをバッファします。 - アプリケーションの残りのトランザクションを完了します。
抽象化を使用する場合は、この抽象化のオブジェクトをトランザクション内で作成する必要があります。最初の値がリクエストされると、オブジェクトは 1 回の読み取りを実行します。オブジェクトは next_value
セルの内部で追跡されるため、複数の値を生成できます。
以前のバージョンに適用されていたレイテンシと競合に関する注意事項は、このバージョンにも適用されます。
次のサンプルコードは、同期 getNext()
関数を実装する方法を示しています。
次のサンプルコードは、2 つのシーケンス値のリクエストで同期 getNext()
関数を使用する方法を示しています。
トランザクション外の(非同期の)シーケンス ジェネレータ
前の 2 つの実装では、ジェネレータのパフォーマンスはアプリケーションのトランザクションのレイテンシに依存します。別のトランザクションでシーケンスを増分することにより、シーケンスのギャップを許容する代わりに最大頻度を改善できます(これは PostgreSQL で使用されるアプローチです)。アプリケーションがトランザクションを開始する前に、最初に使用するシーケンス値を取得する必要があります。
アプリケーションは次の処理を行います。
- シーケンス値を取得して更新するための最初のトランザクションを作成します。
- アプリケーションで使用するシーケンス名の
next_value
セルを読み取ります。 - この値を変数として格納します。
- シーケンス名のデータベース内の
next_value
セルを増分および更新します。 - トランザクションを完了します。
- アプリケーションで使用するシーケンス名の
- 返された値を別のトランザクションで使用します。
この個別のトランザクションのレイテンシは最小レイテンシに近く、パフォーマンスは理論上の最大頻度である 1 秒あたりの値数 100 に近づきます(10 ミリ秒のトランザクション レイテンシを想定)。シーケンス値は個別に取得されるため、アプリケーションのトランザクション自体のレイテンシは変更されず、競合が最小限に抑えられます。
ただし、シーケンス値がリクエストされ、使用されていない場合、リクエストされたシーケンス値をロールバックできないため、シーケンスにギャップが残ります。これは、シーケンス値を要求した後にトランザクション中にアプリケーションが停止または失敗した場合に発生します。
次のサンプルコードは、データベースの next_value
セルを取得して増分する関数を実装する方法を示しています。
次の非同期 getNext()
関数の実装に示すように、この関数を使用して単一の新しいシーケンス値を簡単に取得できます。
次のサンプルコードは、2 つのシーケンス値のリクエストで非同期の getNext()
関数を使用する方法を示しています。
上記のコード例では、シーケンス値がアプリケーションのトランザクション外でリクエストされていることがわかります。これは、Cloud Spanner が同じスレッド内の別のトランザクション内でトランザクションを実行(ネストされたトランザクション)することをサポートしていないためです。
この制限を回避するには、バックグラウンド スレッドを使用してシーケンス値をリクエストし、結果を待機します。
バッチ シーケンス ジェネレータ
シーケンス値を順番に指定する必要があるという要件を無視すると、パフォーマンスを大幅に改善できます。このようにすることで、一連のシーケンス値をアプリケーションで予約して、内部で発行できるようになります。個別のアプリケーション インスタンスには独自の値のバッチがあるため、発行される値は順序どおりではありません。さらに、アプリケーション インスタンスが停止している場合など、値のバッチ全体を使用しないアプリケーション インスタンスでは、使用されない値がギャップとしてシーケンス内に残されます。
アプリケーションは次の処理を行います。
- バッチの開始値とサイズ、使用可能な次の値を含む各シーケンスの内部状態を維持します。
- バッチからのシーケンス値を要求します。
- バッチに残りの値がない場合は、次のようにします。
- シーケンス値を読み取り、更新するトランザクションを作成します。
- シーケンスの
next_value
セルを読み込みます。 - この値を新しいバッチの開始値として内部に保存します。
- データベースの
next_value
セルをバッチサイズに等しい量だけ増分します。 - トランザクションを完了します。
- 次の使用可能な値を返し、内部の状態を増分します。
- トランザクションで返された値を使用します。
この方法では、シーケンス値を使用するトランザクションにおいては、シーケンス値の新しいバッチを予約する必要がある場合にのみ、レイテンシが増加します。
制限の要件が 1 秒あたりに発行されるバッチ数になるため、バッチサイズを増やすことでパフォーマンスを任意のレベルまで上げることができるのが利点です。
たとえば、バッチサイズが 100 で、新しいバッチを取得するために 10 ミリ秒のレイテンシがあり、1 秒間に最大 100 のバッチが発行される場合、1 秒あたり 10,000 のシーケンス値が発行されます。
次のサンプルコードは、バッチを使用して getNext()
関数を実装する方法を示しています。このコードでは、以前に定義した getAndIncrementNextValueInDB()
関数を再利用して、シーケンス値の新しいバッチをデータベースから取得します。
次のサンプルコードは、2 つのシーケンス値のリクエストで非同期の getNext()
関数を使用する方法を示しています。
ここでも、Spanner はネストされたトランザクションをサポートしていないため、トランザクションの外部で(またはバックグラウンド スレッドを使用して)値をリクエストする必要があります。
非同期バッチ シーケンス ジェネレータ
レイテンシの増加が許容されない高パフォーマンス アプリケーションの場合、現在の値のバッチがなくなったときに新しい値のバッチを準備することで、前述のバッチ ジェネレータのパフォーマンスを改善できます。
これは、バッチに残っているシーケンス値の数が過少になっていることを示すしきい値を設定することで実現できます。しきい値に達すると、シーケンス ジェネレータはバックグラウンド スレッドで新しい値のバッチのリクエストを開始します。
前のバージョンと同様に、値は順番に発行されず、トランザクションが失敗した場合やアプリケーション インスタンスがシャットダウンされた場合は、シーケンスに未使用の値のギャップが残ります。
アプリケーションは次の処理を行います。
- バッチの開始値と使用可能な次の値を含む、各シーケンスの内部状態を維持します。
- バッチからのシーケンス値を要求します。
- バッチ内の残りの値がしきい値よりも少ない場合は、バックグラウンド スレッドで次の操作を行います。
- シーケンス値を読み取り、更新するトランザクションを作成します。
- アプリケーションで使用するシーケンス名の
next_value
セルを読み取ります。 - この値を次のバッチの開始値として内部に保存します。
- データベースの
next_value
セルをバッチサイズに等しい量だけ増分します。 - トランザクションを完了します。
- バッチに残りの値がない場合は、バックグラウンド スレッドから次のバッチの開始値を取得し(必要に応じて完了するのを待って)、取得した開始値を次の値として使用して新しいバッチを作成します。
- 次の値を返し、内部の状態を増分します。
- トランザクションで返された値を使用します。
最適なパフォーマンスを得るには、現在のバッチでシーケンス値が不足する前に、バックグラウンド スレッドを開始して完了する必要があります。そうしないと、アプリケーションは次のバッチを待つ必要があり、レイテンシが増加します。したがって、発行されるシーケンス値の頻度に応じて、バッチサイズと低しきい値を調整する必要があります。
たとえば、値の新しいバッチを取得するトランザクション時間が 20 ミリ秒、バッチサイズが 1000、1 秒あたり 500 の値(2 ミリ秒あたりの値数が 1)の最大シーケンス発行頻度を想定します。値の新しいバッチが発行される 20 ms の間に、10 個のシーケンス値が発行されます。したがって、必要なときに次のバッチを使用できるように、残りのシーケンス値のしきい値は 10 より大きい必要があります。
次のサンプルコードは、バッチを使用して getNext()
関数を実装する方法を示しています。このコードでは、以前に定義した getAndIncrementNextValueInDB()
関数を使用し、バックグラウンド スレッドを使用してシーケンス値のバッチを取得します。
次のコード例は、トランザクションで使用する 2 つの値のリクエストで非同期バッチ getNext()
関数を使用する方法を示しています。
この場合、新しい値のバッチの取得はバックグラウンド スレッドで行われるため、トランザクション内で値をリクエストできます。
概要
次の表は、4 種類のシーケンス ジェネレータの特性を比較したものです。
同期 | 非同期 | バッチ | 非同期バッチ | |
---|---|---|---|---|
一意の値 | ○ | ○ | ○ | ○ |
グローバルに順序付けされた値 | ○ | ○ | × 負荷が高く、バッチサイズが十分に小さい場合、値は互いに近くなります。 |
× 負荷が高く、バッチサイズが十分に小さい場合、値は互いに近くなります。 |
ギャップなし | ○ | × | × | × |
パフォーマンス | 1/トランザクション レイテンシ、 (1 秒あたり最大 25 の値) |
毎秒 50 から 100 の値 | 1 秒あたり 50 から 100 の値のバッチ | 1 秒あたり 50 から 100 の値のバッチ |
レイテンシの増加 | 10 ミリ秒超 高い競合率で大幅に増加(トランザクションにかなりの時間がかかる場合) |
すべてのトランザクションで 10 ミリ秒 競合率が高い場合はかなり高い |
10 ミリ秒。ただし、値の新しいバッチを取得する場合のみ | ゼロ(バッチサイズと低しきい値が適切な値に設定されている場合) |
上記の表は、全体的なパフォーマンス要件を満たしながら、一意の値を生成するために、グローバルに順序付けされた値とギャップのない一連の値の要件について妥協することが必要な場合があることも示しています。
パフォーマンス テスト
上記のシーケンス生成クラスと同じ GitHub リポジトリにあるパフォーマンス テスト / 分析ツールを使用して、各シーケンス ジェネレータをテストし、パフォーマンスとレイテンシ特性を実証できます。このツールは、10 ミリ秒のアプリケーションのトランザクション レイテンシをシミュレートし、シーケンス値を要求する複数のスレッドを同時に実行します。
パフォーマンス テストでは、単一の行のみが変更されるため、単一ノードの Spanner インスタンスのみをテストする必要があります。
たとえば、次の出力は、10 スレッドの同期モードでのパフォーマンスとレイテンシの比較を示しています。
$ ITERATIONS=2000
$ MODE=SYNC
$ NUMTHREADS=10
$ java -jar sequence-generator.jar \
$INSTANCE_ID $DATABASE_ID $MODE $ITERATIONS $NUMTHREADS
2000 iterations (10 parallel threads) in 58739 milliseconds: 34.048928 values/s
Latency: 50%ile 27 ms
Latency: 75%ile 31 ms
Latency: 90%ile 1189 ms
Latency: 99%ile 2703 ms
次の表は、1 秒あたりに発行できる値の数、50 パーセンタイル、90 パーセンタイル、99 パーセンタイルにおけるレイテンシなど、さまざまなモードと並列スレッド数の結果を比較しています。
モードとパラメータ | スレッド数 | 値 / 秒 | 50 パーセンタイル レイテンシ(ミリ秒) | 90 パーセンタイル レイテンシ(ミリ秒) | 99 パーセンタイル レイテンシ(ミリ秒) |
---|---|---|---|---|---|
SYNC | 10 | 34 | 27 | 1189 | 2703 |
SYNC | 50 | 30.6 | 1191 | 3513 | 5982 |
ASYNC | 10 | 66.5 | 28 | 611 | 1460 |
ASYNC | 50 | 78.1 | 29 | 1695 | 3442 |
BATCH (サイズ 200) |
10 | 494 | 18 | 20 | 38 |
BATCH(バッチサイズ 200) | 50 | 1195 | 27 | 55 | 168 |
ASYNC BATCH (バッチサイズ 200、しきい値 50) |
10 | 512 | 18 | 20 | 30 |
ASYNC BATCH (バッチサイズ 200、しきい値 50) |
50 | 1622 | 24 | 28 | 30 |
同期(SYNC)モードでは、スレッド数が増えて競合が増えることがわかります。これにより、トランザクションのレイテンシが大幅に増加します。
非同期(ASYNC)モードでは、シーケンスを取得するトランザクションが小さく、アプリケーションのトランザクションとは異なるため、競合が少なくなり、頻度が高くなります。ただし、競合が発生して、90 パーセンタイルのレイテンシが高くなる可能性があります。
バッチ(BATCH)モードでは、データベースからシーケンス値の別のバッチを同期的に要求する必要がある場合に 99 パーセンタイル値を除いて、レイテンシが大幅に減少します。パフォーマンスは、ASYNC モードに比べ BATCH モードで何倍も高くなっています。
50 スレッドのバッチモードでは、シーケンスが非常に迅速に発行されるため、制限要因が仮想マシン(VM)インスタンスの能力である(この場合、4 vCPU マシンはテスト中に 350% CPU で実行されていた)ため、レイテンシが高くなります。複数のマシンとプロセスを使用すると、全体的な結果は 10 スレッドのバッチモードと同様に表示されます。
ASYNC BATCH モードでは、データベースから新しいバッチをリクエストする際のレイテンシはアプリケーションのトランザクションから完全に独立しているため、レイテンシの変動は最小限であり、スレッド数が多くてもパフォーマンスは高くなります。
次のステップ
- Spanner のスキーマ設計のベスト プラクティスについて確認する。
- Spanner テーブルのキーとインデックスの選択方法を確認する。
- Google Cloud に関するリファレンス アーキテクチャ、図、ベスト プラクティスを確認する。Cloud アーキテクチャ センター をご覧ください。