Carpe Diem

備忘録

Bigtableで複数クラスタ構成におけるデータ整合性の保証

背景

Bigtableレプリケーションを有効にしたマルチクラスタ構成にすることで負荷分散、高可用性を保証することが可能です。

一方でマルチクラスタにすることで

といったデータ整合性での問題が発生します。

今回はその対策としてアプリプロファイルを用いた方法を紹介します。

プリプロファイルとは

cloud.google.com

プリプロファイルはリクエストのルーティングや単一行トランザクションの許可するかどうかを制御する設定です。

インスタンスであらかじめ作成しておき、SDKなどでアプリプロファイルを選択して使うことが可能です。

マルチクラスタルーティング

マルチクラスタ環境ではデフォルトでは以下のように近いクラスタにルーティングされます。

自動的にフェイルオーバーできる一方で、最初に述べたように単一行トランザクションは禁止されますし、レプリケーション遅延によるデータの不整合が発生します。

インスタンスが応答する場合は、レプリケーションの遅延は通常、数秒または数分

ref: https://cloud.google.com/bigtable/docs/replication-overview#consistency-model

とあるように多くのユースケースでは問題にならない程度ではありますが、書き込み後すぐに読み取りが発生するケースでは考慮が必要です。

シングルクラスタルーティング

プリプロファイルを設定すると、以下のように全てのリクエストを単一のクラスタにルーティングさせることが可能です。

こちらの場合はフェイルオーバーは手動になりますが、単一行トランザクションは利用可能同じクラスタにアクセスするのでレプリケーション遅延を考慮しなくて済みます

ルーティングの特徴を比較

ルーティングの比較をすると以下の通りになります。

特徴 Single-cluster routing Multi-cluster routing
リクエストのルーティング 全て1つのクラスタへルーティング 最も近い使用可能なクラスタにルーティング
フェイルオーバー ▲手動 自動
ユースーケース例 CPU使用率の高い負荷を分離したい 高可用性が求められる
リクエストを全般的に負荷分散したい
書き込み後読み取りの一貫性 あり ▲なし
単一行トランザクション あり ▲なし

ユースケースについては、例えば通常のアプリケーションサーバに加えて、バッチ処理でreadしかないが局所的にCPU負荷が上がるような要件があり、アプリケーション側に影響を与えないようクラスタを分離したいといったケースがあります。

方針

したがって

という2つのアプリプロファイルを併用することで、高可用かつデータ整合性も保証することができそうです。

実際にコードで検証してみる

今回は

の2つを検証します。

プリプロファイルの設定

あらかじめレプリケーションを有効にしたマルチクラスタ構成のインスタンスを用意します。

GCPでアプリプロファイルの作成

次にアプリプロファイルを作成します。

書き込み用

書き込み用のアプリプロファイルを作成します。

読み取り用

defaultと同じ設定ですが、読み取り用のアプリプロファイルも作成しておきます。

最終的にアプリプロファイルはこのようになります。

コード

次にGoでクライアントコードを用意します。

bigtable.ClientConfigで先ほど作成したアプリプロファイルIDを指定します。

ctx := context.Background()
writer, err := bigtable.NewClientWithConfig(ctx, projectID, instanceID, bigtable.ClientConfig{
        AppProfile: "writer",
})
if err != nil {
        panic(err)
}
defer writer.Close()
reader, err := bigtable.NewClientWithConfig(ctx, projectID, instanceID, bigtable.ClientConfig{
        AppProfile: "reader",
})
if err != nil {
        panic(err)
}
defer reader.Close()
client := &Client{
        writer: writer.Open(tableName),
        reader: reader.Open(tableName),
}

単一行トランザクションが有効か

writerreaderのそれぞれで条件付き書き込みが利用可能か確認します。

以下のようなコードを用意し、

mut := bigtable.NewMutation()
mut.Set(columnFamilyName, columnQualifier, bigtable.Timestamp(0), []byte(value))

filter := bigtable.ChainFilters(
        bigtable.FamilyFilter(columnFamilyName),
)
conditionalMutation := bigtable.NewCondMutation(filter, nil, mut)

if err := c.writer.Apply(context.Background(), rowKey, conditionalMutation); err != nil {
        return err
}

return nil

Apply()を呼び出す際にc.writerc.readerそれぞれで実行してみます。

動作検証

writer

問題なく実行できます。利用可能であることが分かります。

$ go run main.go
$

reader

以下のエラーが発生しました。アプリプロファイルの通り禁止されているようです。

$ go run main.go
panic: rpc error: code = FailedPrecondition desc = Single-row transactions are not allowed by this app profile

書き込み後読み取りの一貫性

writerreaderのそれぞれで書き込み後読み取ったデータが一致するか確認します。

func writeAndRead(client *Client) (bool, error) {
        rowKey := "key1"
        now := time.Now().UnixNano()
        in := strconv.FormatInt(now, 10)
        err := client.write(rowKey, columnFamilyName, in)
        if err != nil {
                return false, err
        }
        out, err := client.read(rowKey, columnFamilyName, false)
        if err != nil {
                return false, err
        }
        var match bool
        if in == out {
                match = true
        }
        return match, nil
}

再現性確認のため100回程度ループさせてみます。

var matched, unmatched int
for i := 0; i < 100; i++ {
        match, err := writeAndRead(client)
        if err != nil {
                panic(err)
        }
        if match {
                matched++
        } else {
                unmatched++
        }
}
fmt.Printf("matched: %d, unmatched: %d\n", matched, unmatched)

動作検証

writerで読み込みも

全試行で一致することが確認できます。一貫性を保証できていると言えるでしょう。

$ go run main.go
matched: 100, unmatched: 0

readerで読み込み

半分程度しか一致しないことが分かりました。一貫性がありません。

$ go run main.go
matched: 54, unmatched: 46

その他

手動フェイルオーバー方法

writerのようにシングルクラスタルーティングなアプリプロファイルを利用している場合、そのクラスタが落ちた際に手動でフェイルオーバーさせる必要があります。

ルーティング先を生きているクラスタに変更する方法としては

  • Webコンソールで編集する
  • gcloudコマンドで変更する
  • Terraformで変更する

があります。

gcloudコマンド

gcloudコマンドは以下です。

$ gcloud bigtable app-profiles update APP_PROFILE_ID \
    --instance=INSTANCE_ID \
    --route-to=CLUSTER_ID

Terraform

Terraformの場合はcluster_idignore_warningsを修正します。

resource "google_bigtable_app_profile" "writer" {
  instance       = "my-instance"
  app_profile_id = "writer"

  single_cluster_routing {
    cluster_id                 = "cluster01"  // here
    allow_transactional_writes = true
  }

  ignore_warnings = true // here
}

サンプルコード

今回のサンプルコードはこちら

github.com

まとめ

プリプロファイルを利用することで、マルチクラスタ構成においても

  • 単一行トランザクションの利用が可能
  • 書き込み後読み取りのデータ一貫性が保証される

といったことが実現できることが分かりました。

基本的な読み取りは負荷分散・高可用性の観点からマルチクラスタルーティングで十分ですが、上記のような要件が発生した場合はシングルクラスタルーティングも併用するのが良いでしょう。

参照