Carpe Diem

備忘録

Bigtableに時系列データを保存する

概要

Bigtableで時系列データを保存する手段として

  1. 縦長のテーブルを使用する
  2. 横長のテーブルを使用する
  3. 列バージョンを利用する

の3種類あるのでそれぞれの特徴、使い方を紹介します。

環境

縦長のテーブルを使用する

各イベントを1行ずつ保持するやり方です。
例として庭の毎日の温度を保存する場合は以下のようになります。

行キー 列データ
garden#20150301 daily:temp:20.4
garden#20150302 daily:temp:21.2
garden#20150303 daily:temp:21.0
garden#20150304 daily:temp:25.1
garden#20150305 daily:temp:22.2
... ...
garden#20150331 daily:temp:20.4

※列データは列ファミリー、列修飾子、データを「:」で区切った形式で示しています

どんな時使うか

Bigtableで時系列データを扱う場合、基本的に縦長のテーブルを使うことが推奨されてます。 理由としては以下です。

  1. 1 行に 1 つのイベントを保存すると、データに対するクエリの実行が簡単になる
  2. 1 行に多数のイベントを保存すると、行の合計サイズが推奨最大値を超える可能性が高くなるため

横長のテーブルを使用する

christina04.hatenablog.com

こちらでも紹介したような、1行にイベントを追加していくパターンです。

行キー 列データ 列データ 列データ 列データ 列データ
garden#201503 temp:01:20.4 temp:02:21.2 temp:03:21.0 ... temp:31:20.4

どんな時使うか

推奨される縦長テーブルでなく、横長にする場合というのは以下の両方を満たしているときです。

  • クエリの最適化
    • 特定期間のイベント全体を一度に取得する必要があることが多い
  • 推測可能なデータ量
    • 1行に保存するイベント数が有限(=あらかじめ決まっている)である

列バージョンを利用する

Cloud Bigtable では、列にタイムスタンプ付きのバージョンを持たせることができます。
そのため、時系列を列の一連のバージョンとして保存することができます。
これは先程の列修飾子による横長なテーブルとは別の概念です。

f:id:quoll00:20200826082647p:plain

こんな感じで列データは1つなのですが、バージョン・リビジョンを持つイメージです。

どんな時使うか

誤った値入れてしまったので修正したい、かつその修正履歴が重要であるユースケースでは列バージョンが向いています。

それ以外のユースケースでは非推奨です。縦長テーブルや横長テーブルを使いましょう。

具体的な実装

bigtable emulatorとGoで、各パターンの具体的な実装を紹介します。

bigtable emulator

ローカル検証のためあらかじめbigtable emulatorを起動しておきます。

$ gcloud beta emulators bigtable start

cbtでテーブルを用意します。

$ export BIGTABLE_EMULATOR_HOST=localhost:8086
$ cbt -project my-project -instance emulator createtable my-table
$ cbt -project my-project -instance emulator ls
my-table

列ファミリーなども用意しておきます。

$ cbt -project my-project -instance emulator createfamily my-table daily
$ cbt -project my-project -instance emulator createfamily my-table temp

縦長テーブル(行キー)

書き込み

func writeRow(ctx context.Context, rowKey, columnFamilyName, columnQualifier string) error {
        client, err := bigtable.NewClient(ctx, projectID, instanceID)
        if err != nil {
                return fmt.Errorf("bigtable.NewClient: %v", err)
        }
        tbl := client.Open(tableName)

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

        if err := tbl.Apply(ctx, rowKey, mut); err != nil {
                return fmt.Errorf("Apply: %v", err)
        }

        return nil
}

各行を追加していきます。

func writeRows(prefix string) {
        ctx := context.Background()
        cfn := "daily"
        cq := "temp"
        writeRow(ctx, prefix+"#"+"20150301", cfn, cq)
        writeRow(ctx, prefix+"#"+"20150302", cfn, cq)
        writeRow(ctx, prefix+"#"+"20150303", cfn, cq)
        writeRow(ctx, prefix+"#"+"20150304", cfn, cq)
}

読み込み

行キーで指定します。

func readRow(rowKey string) error {
        ctx := context.Background()
        client, err := bigtable.NewClient(ctx, projectID, instanceID)
        if err != nil {
                return fmt.Errorf("bigtable.NewClient: %v", err)
        }
        tbl := client.Open(tableName)
        row, err := tbl.ReadRow(ctx, rowKey,
                bigtable.RowFilter(
                        bigtable.ChainFilters(
                                bigtable.FamilyFilter("daily"),
                                bigtable.ColumnFilter("temp"),
                        ),
                ),
        )
        if err != nil {
                return err
        }

        showRow(row)
        return nil
}

func showRow(row bigtable.Row) bool {
        for _, columns := range row {
                for _, column := range columns {
                        fmt.Printf("row: %s, column: %s, value: %s\n", column.Row, column.Column, string(column.Value))
                }
        }
        return true
}

結果

row: garden#20150301, column: daily:temp, value: 1

行キーをprefixで複数取得したい場合は以下のように bigtable.PrefixRange を使います。

func readRows(prefix string) error {
        ctx := context.Background()
        client, err := bigtable.NewClient(ctx, projectID, instanceID)
        if err != nil {
                return fmt.Errorf("bigtable.NewClient: %v", err)
        }
        tbl := client.Open(tableName)
        err = tbl.ReadRows(ctx, bigtable.PrefixRange(prefix),
                showRow,
                bigtable.RowFilter(
                        bigtable.ChainFilters(
                                bigtable.FamilyFilter("daily"),
                                bigtable.ColumnFilter("temp"),
                        ),
                ),
                bigtable.LimitRows(3),
        )
        if err != nil {
                return err
        }

        return nil
}

結果

row: garden#20150301, column: daily:temp, value: 1
row: garden#20150302, column: daily:temp, value: 9
row: garden#20150303, column: daily:temp, value: 9

データ

cbtで見ると以下のようになっています。

$ cbt -project my-project -instance emulator read my-table prefix=garden#
garden#20150301
  daily:temp                               @ 1970/01/01-09:00:00.000000
    "1"
----------------------------------------
garden#20150302
  daily:temp                               @ 1970/01/01-09:00:00.000000
    "9"
----------------------------------------
garden#20150303
  daily:temp                               @ 1970/01/01-09:00:00.000000
    "9"
----------------------------------------
garden#20150304
  daily:temp                               @ 1970/01/01-09:00:00.000000
    "4"

横長のテーブル(列修飾子)

書き込み

先程の関数で

  • 行キーは1つにする
  • 列修飾子を日付にする

という変更をします。

func appendColumn(prefix string) {
        ctx := context.Background()
        cfn := "temp"
        month := "201505"
        writeRow(ctx, prefix+"#"+month, cfn, fmt.Sprintf("%02d", 1))
        writeRow(ctx, prefix+"#"+month, cfn, fmt.Sprintf("%02d", 2))
        writeRow(ctx, prefix+"#"+month, cfn, fmt.Sprintf("%02d", 3))
        writeRow(ctx, prefix+"#"+month, cfn, fmt.Sprintf("%02d", 4))
}

ちなみに行も列もビッグ エンディアン順に並べられるので、列修飾子の値を0パディングしています。

読み込み

横長テーブルなので1行でデータをまとめて取得する形になります。

func readRow(rowKey string) error {
        ctx := context.Background()
        client, err := bigtable.NewClient(ctx, projectID, instanceID)
        if err != nil {
                return fmt.Errorf("bigtable.NewClient: %v", err)
        }
        tbl := client.Open(tableName)
        row, err := tbl.ReadRow(ctx, rowKey,
                bigtable.RowFilter(
                        bigtable.ChainFilters(
                                bigtable.FamilyFilter("temp"),
                                bigtable.CellsPerRowLimitFilter(3),
                        ),
                ),
        )
        if err != nil {
                return err
        }

        showRow(row)
        return nil
}

取得する列のうち、一部だけ欲しい場合は bigtable.CellsPerRowLimitFilter() で件数をフィルタできます。

データ

cbtで見ると以下のようになっています。

$ cbt -project my-project -instance emulator read my-table prefix=garden#201505
----------------------------------------
garden#201505
  temp:01                                   @ 1970/01/01-09:00:00.000000
    "25"
  temp:02                                   @ 1970/01/01-09:00:00.000000
    "11"
  temp:03                                   @ 1970/01/01-09:00:00.000000
    "18"
  temp:04                                   @ 1970/01/01-09:00:00.000000
    "20"

列バージョン

書き込み

先程まではmutationのtimestampを0にして、不要なバージョンを残さないようにしていましたが、今回はバージョンで管理したいのでtimestampを利用します。

func update(prefix string) {
        ctx := context.Background()
        cfn := "daily"
        cq := "temp"
        month := "201508"
        updateRow(ctx, prefix+"#"+month, cfn, cq)
        updateRow(ctx, prefix+"#"+month, cfn, cq)
        updateRow(ctx, prefix+"#"+month, cfn, cq)
        updateRow(ctx, prefix+"#"+month, cfn, cq)
}

func updateRow(ctx context.Context, rowKey, columnFamilyName, columnQualifier string) error {
        client, err := bigtable.NewClient(ctx, projectID, instanceID)
        if err != nil {
                return fmt.Errorf("bigtable.NewClient: %v", err)
        }
        tbl := client.Open(tableName)

        mut := bigtable.NewMutation()
        mut.Set(columnFamilyName, columnQualifier, bigtable.Now(), []byte(genTemp()))

        if err := tbl.Apply(ctx, rowKey, mut); err != nil {
                return fmt.Errorf("Apply: %v", err)
        }

        return nil
}

読み込み

1行しか取得しませんが、データ自体は横長テーブルのように複数返ってきます。

func readRow(rowKey string) error {
        ctx := context.Background()
        client, err := bigtable.NewClient(ctx, projectID, instanceID)
        if err != nil {
                return fmt.Errorf("bigtable.NewClient: %v", err)
        }
        tbl := client.Open(tableName)
        row, err := tbl.ReadRow(ctx, rowKey,
                bigtable.RowFilter(
                        bigtable.ChainFilters(
                                bigtable.FamilyFilter("daily"),
                                bigtable.ColumnFilter("temp"),
                                bigtable.LatestNFilter(3),
                        ),
                ),
        )
        if err != nil {
                return err
        }

        showRow(row)
        return nil
}

件数指定したい場合は bigtable.LatestNFilter() を使用します。

結果

row: garden#201508, column: daily:temp, value: 22, timestamp: 1598396705122000
row: garden#201508, column: daily:temp, value: 22, timestamp: 1598396705121000
row: garden#201508, column: daily:temp, value: 5, timestamp: 1598396705119000

データ

cbtで見ると以下のようになっています。
列修飾子もすべて同じですが、timestampが異なる別々に保存されています。

$ cbt -project my-project -instance emulator read my-table prefix=garden#201508
----------------------------------------
garden#201508
  daily:temp                               @ 2020/08/26-08:05:05.122000
    "22"
  daily:temp                               @ 2020/08/26-08:05:05.121000
    "22"
  daily:temp                               @ 2020/08/26-08:05:05.119000
    "5"
  daily:temp                               @ 2020/08/26-08:05:05.117000
    "19"

まとめ

bigtableで時系列データを保存する際に、ユースケースに応じて様々な手段があるということを紹介しました。

参考