📺

SpannerのStale Readで過去のデータを読み出す

2023/09/22に公開

はじめに

通常Spannerの読み取りを行うとStrong Readとなりクエリー実行時点での最新データであることが保証されますが、オプションでStale Readを指定すると一定の範囲で過去時点でのデータを取得できます。

これによりある時点での厳密なデータを元にした処理を実装したり、強い整合性が不要な場合にレイテンシーを改善したりできます。

今回仕事で実用する機会があったので振る舞いの検証を含めて記事に残したいと思います。

使い方

bouded-staleness

少なくとも指定したDurationより新しいことが保証されるクエリーです。言い換えると指定した範囲内に更新がある場合、クエリーの度に違う結果が取得される可能性があります。

以下の例では最も古い場合でも15秒前のデータを取得します。

tb := spanner.MaxStaleness(15 * time.Second)

これを WithTimestampBound() でオプションとして付与します。
それ以外は通常のReadOnlyTransactionと変わりありません。

iter := cli.ReadOnlyTransaction().WithTimestampBound(tb).Query(ctx, stmt)

exact-staleness

指定した時刻もしくはDuration丁度の時点のデータを取得することが保証されるクエリーです。ある時点でのデータは当然1つだけなので、こちらは何度投げても同じ結果が取得されます。

時刻指定

日本時間の 2023-01-01 00:00:00.0 丁度時点のデータを取得

jst := time.FixedZone("Asia/Tokyo", 9*60*60)
t := time.Date(2023, 1, 1, 0, 0, 0, 0, jst)
tb := spanner.ReadTimestamp(t)

Duration指定

15秒前丁度時点のデータを取得

tb := spanner.ExactStaleness(15 * time.Second)

Strong Read

前述の通り特に意識せずクエリーを実行すると最新を読み取りますが、明示的に指定することも可能です。

tb := spanner.StrongRead()

レイテンシー削減のためにStale Readを使う

データがある程度古くてもよく且つレイテンシー要件が求められる場合は15秒以上過去を指定することでレイテンシーを改善できることが公式ドキュメントで示されていますが、これはSpannerの仕組みに根ざしています。

SpannerはデータをSplitという単位に分割し複数のzoneで分散配置とレプリケーションを行う事により高可用性とスケーラビリティを実現していますが、各Splitはいずれかのzoneのレプリカがリーダーとなり書き込みはリーダーのみが担当できます。

これは最新のデータであると確定できるのはリーダーだけであることを意味します。そして読み取りを行う際にクエリーがリーダーでないzoneで試みられた場合、リーダーに対し最新であるか否か問い合わせが行われ更に必要に応じてデータの更新を待った上でレスポンスするためレイテンシーが悪化する場合があります。

各レプリカが最新状態であるかのチェックは10秒毎に行われていますが、これはつまり理屈の上においては少なくとも10秒以上過去の時刻であればリーダーでなくともその指定時点においての最新であると確定させられることになります。

リーダーでないレプリカが単独で最新のデータを確定できるということはリーダーとの通信が不要であり、ある程度過去の時刻を指定することによりレイテンシーの削減が期待できます。仕組み上マルチリージョン構成の場合はこの影響が更に大きくなると思われます。

exact-stalenessを使うと更に早い(かもしれない)

exact-stalenessではある1点の時刻を指定するため返すべきデータは確実に1つだけです。それに対しbounded-stalenessでは返すデータの候補が複数あり得るため、その選定のために僅かにレイテンシーが大きくなることがあります。

このとき、候補になったデータの中からなるべく新しいデータを返却するようです。

遡れる時間の制限

無限に遡れるというわけではなく、デフォルトでは1時間前までが設定されておりオプション指定で最大1週間までです。それを過ぎると古いバージョンのデータはGCされていき、一定以上過去を指定するとFailedPreconditionが返されます。

spanner: code = "FailedPrecondition", desc = "Read-only transaction timestamp 2023-09-21T09:24:59.883702Z has exceeded the maximum timestamp staleness"

ちなみに手元の環境で指定時間を1分ずつ増やして試してみたところ、デフォルトの1時間を過ぎてもエラーにはならず1時間30分前を指定した時点でエラーになりました。

必要以上に長い期間を選択するとGCが遅くなることでストレージ容量を圧迫するため要件によって調節する必要がありますが、ALTER DATABASEで後から設定変更が可能なので然程深く悩む必要は無いでしょう。

spanner> ALTER DATABASE `xxx` SET OPTIONS (version_retention_period = '7d');
Query OK, 0 rows affected (15.47 sec)

動作検証

以下の流れで確認します。

  1. 名前が aaa のユーザーを作成する
  2. 3秒間待つ
  3. 名前を bbb に更新する
  4. Strong Readで読み出す
     → 期待結果: 更新後のbbbを取得するはず
  5. レコード作成時刻を指定して読み出す
     → 期待結果: 更新前のaaaを取得するはず
  6. レコード作成前の時刻(作成の1時間前)を指定
     → 期待結果: データが存在しない
  7. version_retention_period を超過する時刻(作成の1.5時間前を指定)を指定
     → 期待結果: FailedPreconditionエラー

環境構築

下記リポジトリにスキーマやスクリプトその他一式全て入っています。

https://github.com/ryo-yamaoka/samples/tree/main/spanner-stale-read

.envrc に既存のプロジェクトIDと作成したいSpannerインスタンス名・データベース名を入れてmake spanner-create を叩くと実インスタンスが100PUでサクッと作成されスキーマも入ります。

既存インスタンスを使いたい場合やリセットしたい場合は spanner-reset を叩くと一旦データベースを破棄し再作成されます。

確認が終わったら忘れずに spanner-delete しておきましょう。最小構成の100PUといえど1ヶ月つけっぱにした場合多くの人にとって端金ではないはず(個人の感想です)。

実行結果

$ go run main.go 
StrongRead:
{UserID:4529a530-49dc-49c0-b1fd-4224c42b80ed Name:bbb CreatedAt:2023-09-21 10:54:59.883702 +0000 UTC UpdatedAt:2023-09-21 10:55:02.937215 +0000 UTC}

Timestamp CreatedAt:
{UserID:4529a530-49dc-49c0-b1fd-4224c42b80ed Name:aaa CreatedAt:2023-09-21 10:54:59.883702 +0000 UTC UpdatedAt:2023-09-21 10:54:59.883702 +0000 UTC}

Timestamp too Old(-1h):

Timestamp too Old(-1.5h):
spanner: code = "FailedPrecondition", desc = "Read-only transaction timestamp 2023-09-21T09:24:59.883702Z has exceeded the maximum timestamp staleness"
exit status 1

終わりに

何らかのアプリケーションの実装において厳密に日付が変わった瞬間でのデータを元に処理を行いたい、等というニーズはそれなりにあると思うので色々と使い手がありそうな機能だと思います。

また整合性を対価にレイテンシーを減らせるという振る舞いもSpannerの仕組みを調べていくと腹落ちでき、知的好奇心が満たされる喜びがありました。

参考文献

Discussion