Go言語で検証!Bigtableのアクセスパターンとパフォーマンス
はじめに
クラウドネイティブなアプリケーション開発において、スケーラビリティとパフォーマンスに優れたデータベースの選定は非常に重要です。
Google Cloudが提供するNoSQLデータベースであるCloud Bigtableは、ペタバイト級のデータを扱う大規模なアプリケーションで多くの実績があります。
しかし、Bigtableの性能を最大限に引き出すには、その特性を理解したスキーマ設計、特に行キー(Row Key)の設計が鍵となります。
今回、私たちのチームではBigtableの性能評価を行い、キー設計やアクセス方法がパフォーマンスに与える影響についての知見を得ました。
この記事では、その評価で用いたGo言語のサンプルコードを交えながら、Bigtableの基本的な性能特性とキー設計の勘所をご紹介します。
この記事が、これからBigtableを使おうとしている方や、すでに利用しているがパフォーマンスに課題を感じている方の助けになれば幸いです。
検証の概要
Bigtableに対して、いくつかの異なるパターンでデータの読み書きを行い、その挙動を確認しました。
検証に用いたアクセスパターン
評価は、以下の5つのパターンで行いました。
- 単一行の読み書き(シーケンシャルキー): 連番のような予測可能なキーで1行ずつ読み書き
- 単一行の読み書き(ランダムキー): UUIDv4のようなランダムなキーで1行ずつ読み書き
- 複数行のバルク読み書き(シーケンシャルキー): 連番のようなキーで複数行をまとめて読み書き
- 複数行のバルク読み書き(ランダムキー): ランダムなキーで複数行をまとめて読み書き
- フルスキャン: テーブル全体をスキャン
これらのコードはGo言語と公式のクライアントライブラリ cloud.google.com/go/bigtable
を用いて実装しました。
キー設計のポイント
Bigtableでは、データは行キーによって辞書順にソートされて保存されます。この特性がパフォーマンスに大きく影響します。
-
シーケンシャルキー:
0001
,0002
,0003
... のようなキー。タイムスタンプをキーの接頭辞にする場合もこれに含まれます。 -
ランダムキー:
f47ac10b-58cc-4372-a567-0e02b2c3d479
のようなUUIDや、ハッシュ値を接頭辞にしたキー。
それでは、各パターンの実装と考察を見ていきましょう。
各パターンの実装と考察
1. シーケンシャルキーによるアクセス
キーの生成方法
書き込みのループ回数 j
を使って、以下のようにキーを生成しています。これにより、キーは辞書順に並びやすい、シーケンシャルな値になります。
// bulk-sequencial.go
rowKeys[i] = fmt.Sprintf("%d#%s#%d#%d", j, columnName, i, bigtable.Now().Time().Unix())
考察:ホットスポットの問題
シーケンシャルなキーで大量の書き込みを行うと、特定のノード(タブレット)にアクセスが集中する「ホットスポット」という現象が発生しやすくなります。
Bigtableは負荷に応じて自動でタブレットの分割を行いますが、トラフィックの急増に追いつかない場合、書き込み性能のボトルネックになる可能性があります。
一方で、特定の範囲のデータをまとめて読み取る(レンジスキャン)際には、データが物理的に近い場所に格納されているため、非常に高速に読み取りができます。
時系列データを扱う場合など、この特性が有利に働くケースも多くあります。
2. ランダムキーによるアクセス
キーの生成方法
github.com/google/uuid
を用いて生成したUUIDをキーの接頭辞にしています。
これにより、キーはランダムな値となり、テーブル全体に分散して格納されます。
// bulk-random.go
uuid, err := uuid.NewRandom()
// ...
rawKey := fmt.Sprintf("%s#%d#%d#%d", uuid.String(), i, j, bigtable.Now().Time().Unix())
考察:書き込み性能の向上
キーがランダムになることで、書き込みリクエストはBigtableクラスタ内の複数のノードに分散されます。
これによりホットスポットを回避でき、非常に高いスループットで安定した書き込み性能を実現できます。
大規模なデータ収集基盤など、書き込み性能が重視されるシステムでは、ランダムキーの採用が基本戦略となります。
しかし、シーケンシャルキーとは逆に、特定の範囲のデータをまとめて読み出すようなユースケースは苦手です。
3. バルク(一括)処理 vs. 個別処理
今回の検証では、1行ずつ読み書きする Apply
(Write) / ReadRow
(Read) と、複数行をまとめて処理する ApplyBulk
/ ReadRows
を比較しました。
// 単一行書き込み (single-*)
err = tbl.Apply(ctx, rawKey, m)
// バルク書き込み (bulk-*)
rowErrs, err := tbl.ApplyBulk(ctx, rowKeys, muts)
一般的に、Bigtableへのリクエストはネットワークラウンドトリップを伴います。
バルク処理は、このオーバーヘッドを削減し、特に多数の行を扱う場合にトータルのスループットを大幅に向上させることができます。
小さな書き込みや読み込みが大量に発生する場合は、クライアント側でリクエストをバッファリングし、バルクAPIを呼び出すのが効果的です。
4. フルスキャン
テーブル全体をスキャンする ReadRows
に bigtable.AllRows()
を渡すことで実現できますが、今回の検証コードでは PrefixRange
を用いて特定のプレフィックスを持つ行をスキャンしています。
// fullscan.go
err = tbl.ReadRows(ctx, bigtable.PrefixRange(columnName), func(row bigtable.Row) bool {
// ...
return true
}, bigtable.RowFilter(bigtable.ColumnFilter(columnName)))
本番環境で巨大なテーブルに対してフルスキャンを実行すると、多大なコストと時間がかかるだけでなく、クラスタ全体に高い負荷をかける可能性があります。
スキャンは、できるだけ小さな範囲に限定するか、バッチ処理基盤(Dataflowなど)と連携して計画的に実行することが推奨されます。
実装サンプル
単一行処理(シーケンシャルキー)
package main
import (
"context"
"fmt"
"log"
"cloud.google.com/go/bigtable"
)
func main() {
// クライアントの初期化
ctx := context.Background()
client, err := bigtable.NewClient(ctx, projectID, instanceID)
if err != nil {
log.Fatalf("Could not create client: %v", err)
}
defer client.Close()
tbl := client.Open(tableName)
// 10,000行の書き込み・読み取りテスト
for i := 0; i < 10000; i++ {
// 書き込み
m := bigtable.NewMutation()
m.Set(columnFamilyName, columnName, bigtable.Now(), []byte("Hello World!"))
rawKey := fmt.Sprintf("%s#%d#%d", columnName, i, bigtable.Now().Time().Unix())
err = tbl.Apply(ctx, rawKey, m)
if err != nil {
log.Fatalf("Could not apply mutation: %v", err)
}
// 即座に読み取り
row, err := tbl.ReadRow(ctx, rawKey, bigtable.RowFilter(bigtable.ColumnFilter(columnName)))
if err != nil {
log.Fatalf("Could not read row: %v", err)
}
if i%1000 == 0 {
log.Printf("%d\t%s = %s\n", i, rawKey, string(row[columnFamilyName][0].Value))
}
}
}
バルク処理(ランダムキー)
package main
import (
"context"
"fmt"
"log"
"github.com/google/uuid"
"cloud.google.com/go/bigtable"
)
func main() {
// クライアントの初期化は省略...
tbl := client.Open(tableName)
greetings := []string{"Hello World!", "Hello Cloud Bigtable!", "Hello golang!"}
for j := 0; j < 10000; j++ {
muts := make([]*bigtable.Mutation, len(greetings))
rowKeys := make([]string, len(greetings))
// UUIDを生成
uuid, err := uuid.NewRandom()
if err != nil {
panic(err)
}
// バッチ分のデータを準備
for i, greeting := range greetings {
muts[i] = bigtable.NewMutation()
muts[i].Set(columnFamilyName, columnName, bigtable.Now(), []byte(greeting))
rowKeys[i] = fmt.Sprintf("%s#%d#%d#%d", uuid.String(), i, j, bigtable.Now().Time().Unix())
}
// バルク書き込み
rowErrs, err := tbl.ApplyBulk(ctx, rowKeys, muts)
if err != nil {
log.Fatalf("Could not apply bulk mutation: %v", err)
}
if rowErrs != nil {
log.Fatalf("Could not write some rows")
}
// 書き込んだデータをスキャンで読み取り
err = tbl.ReadRows(ctx, bigtable.PrefixRange(fmt.Sprintf("%s#", uuid.String())),
func(row bigtable.Row) bool {
item := row[columnFamilyName][0]
if j%1000 == 0 {
log.Printf("%d\t%s = %s\n", j, item.Row, string(item.Value))
}
return true
}, bigtable.RowFilter(bigtable.ColumnFilter(columnName)))
}
}
まとめ:ユースケースに合わせたキー設計を
今回の検証から、以下のことが改めて確認できました。
- 書き込み性能を重視するならランダムキー: ホットスポットを回避し、スループットを最大化できます。
- 範囲読み取りを多用するならシーケンシャルキー: 時系列データなど、連続したデータを高速に読み取れますが、書き込み時のホットスポットに注意が必要です。
- 処理効率を上げるならバルクAPI: ネットワークオーバーヘッドを削減し、スループットを向上させます。
Bigtableのパフォーマンスは、行キーの設計に大きく依存します。「銀の弾丸」はなく、アプリケーションの主要な読み書きパターン(ユースケース)に基づいてキーを設計することが最も重要です。
例えば、「ユーザーごとの最新のイベントを取得する」という要件であれば、<userID>/<timestamp>
のようなキーが考えられますが、これでは特定のユーザーに書き込みが集中する可能性があります。これを避けるために、<hashed_userID>/<userID>/<timestamp>
のようにハッシュ値をプレフィックスに加えるといった工夫も有効です。
参考資料
最後までお読みいただき、ありがとうございました。
Discussion