Cloud Spanner を使って様々な Anomaly に立ち向かう

Yuki Furuyama
google-cloud-jp
Published in
15 min readDec 3, 2018

本記事は Google Cloud Platform その2 Advent Calendar 2018 の4日目の記事です。

はじめに

Cloud Spanner はトランザクションの一貫性保証のレベルに External Consistency を採用しており、複数のトランザクションが一貫性のある状態で並行に走れるよう制御されています。
ではその一貫性保証とは、具体的にどのような問題 (Anomaly) を防いでくれるのでしょうか。

本記事では、一貫性保証のレベルが弱い時に起こりうる以下の様々な Anomaly を Cloud Spanner ではどのように防いでくれるか、実際に複数のトランザクションを実行して検証していきたいと思います。

  • Dirty Read
  • Lost Update
  • Non-repeatable Read
  • Phantom Read
  • Read Skew
  • Write Skew

これらの Anomaly については kumagi さんの「いろんなAnomaly」という記事がわかりやすいので参考にしてみてください。

下準備

実際にトランザクションを実行するためには mysql(1) のようなインタラクティブな CLI があるとわかりやすいので、拙作ですが spanner-cli という CLI ツールを使って見ていきます。spanner-cli は go get -u github.com/yfuruyama/spanner-cli でインストールできます (もしくはこちらからバイナリを落としてください)。

インストール後、検証のために使うテーブルと初期データを流し込みます。

まず以下のコマンドで Cloud Spanner のデータベースに接続後、

spanner-cli -p $PROJECT -i $INSTANCE -d $DATABASE

この DDL と DML をコピペして流し込んでください。

CREATE TABLE Accounts (
UserId INT64 NOT NULL,
Balance INT64 NOT NULL,
Type STRING(16) NOT NULL
) PRIMARY KEY (UserId);

CREATE TABLE Counters (
Id INT64 NOT NULL,
Value Int64 NOT NULL
) PRIMARY KEY (Id);

INSERT INTO Accounts (UserId, Balance, Type) VALUES (1, 1000, 'Checking'), (2, 1000, 'Checking'), (3, 1000, 'Checking');
INSERT INTO Counters (Id, Value) VALUES (1, 0);

Anomaly

では一つずつ Anomaly を見ていきましょう。コンソールを2つ開き、それぞれ spanner-cli で Cloud Spanner のデータベースに接続して試します。

Dirty Read

Dirty Read とはまだコミットしていない変更後のデータを、他のトランザクションから読めてしまうことを指します。
一般的には READ UNCOMMITTED というトランザクション分離レベルで発生します。

では Spanner でこの Dirty Read が起きないことを確認してみましょう。
以下の例は、tx1 が Balance を 2000 に変更していますが、tx1 がコミットする前は tx2 はオリジナルの値である 1000 が読めているのがわかります。

https://gist.github.com/yfuruyama/1d24d503ab0dfa1f16e0c2e62a479c4e

Cloud Spanner では DML (INSERT / UPDATE / DELETE) を使った変更は一度サーバ側にバッファリングされ、コミットが実行されるまでレコードには書き込まれません。
SELECT は実際にレコード本体から読み込むため、Dirty Read が発生することはありません。

Lost Update

Lost Update とは、あるトランザクションが変更した内容が他のトランザクションによって上書かれてしまい、なかったことにされてしまう現象です。
シンプルな例としてはカウンターをインクリメントしていく以下のようなトランザクションが考えられます。

  • 操作1: 現在のカウンター値を取得
  • 操作2: 新カウンター値 = 現カウンター値+1 で更新

このトランザクションが2つ並行して走った場合、本来であれば最終的に+2されるのが正しいですが、操作1が同時に行われた場合は最終的に+1で両方共上書きしてしまい、片方のトランザクションがなかったことにされてしまいます。

では Spanner でこの Lost Update が起きないことを確認してみましょう。

https://gist.github.com/yfuruyama/a2fef5cfabf7f94b8c4869943783242d

見てみると、tx1 のみコミットが成功し、tx2 のコミットが abort されているのがわかると思います。
これは Spanner が採用している wound-wait (参考) という Dead Lock の防止機構が働いているためです。

Spanner の Read-Write Transaction では、読み込みを行うレコードに対しては Shared Lock を、書き込みを行うレコードに対しては Exclusive Lock を取ろうとします。
Exclusive Lock を取得するためには別のトランザクションで Shared Lock が掛けられていないことが条件になりますが、別のトランザクションが既に Shared Lock を持っていて Exclusive Lock が取れない場合、トランザクションの開始時刻(正確には Shared Lock の取得時刻)を比較し、時刻が新しいトランザクションを abort させて、無理やり Exclusive Lock を取得するようになっています。要するに先勝ちです。

上記の例では、tx1 も tx2 も Shared Lock を持っていますが、 tx1 の方が先に SELECT を開始しているので、コミットする際に tx2 の方を abort させて Exclusive Lock を取得しています。

通常 MySQL 等では、Locking Read (SELECT FOR UPDATE) というアプローチでトランザクションの開始時点で開発者が明示的にロックをかけて Lost Update を防いだりしますが、Spanner では上に挙げたような内部的なロックの仕組みで Lost Update を防ぐようになっています。

Spanner のクライアントによっては abort された際にトランザクション全体をリトライするようになってるので(例. Goクライアント)、トランザクションの中身は何度実行されても問題ないよう冪等性を保つようにするのがいいでしょう。

Non-repeatable Read

Non-repeatable Read とは、同じレコードを繰り返し読んでる際に、別のトランザクションが値を更新した結果、取得できる値が途中から変わってしまう状況を指します。
一般的には READ COMMITTED というトランザクション分離レベルで発生します。

では Spanner で Non-repeatable Read が発生しないことを確認しましょう。
以下の例では tx1 が繰り返し読み込むレコードを tx2 が途中で更新しようとしている状況です。

https://gist.github.com/yfuruyama/ed7fc5fe3a6c16aaa644bbf93751017c

これを実行すると、そもそも tx2 のコミットのところで永遠にブロックされてしまうため、Non-repeatable Read の状況を作り出すことができない現象が発生します。これは Lost Update で説明した時と同じく wound-wait のデッドロック防止機能が働いているためです。

上記の例の場合、tx1 が先にレコードの Shared Lock を取得しているため、後からやってきた tx2 は書き込みを行うための Exclusive Lock を取得するために、tx1 が Shared Lock を解放するまで待たないといけません。
よって tx1 がトランザクションを終了するまで、tx2 をブロックすることとなります。

こういった時のために用意されているのが Read-Only Transaction というトランザクションです。Read-Only Transaction では読み込みしかできないという制約がある半面、一切ロックを取得しないため、他のトランザクションをブロックすることがありません。

では tx1 を Read-Only Transaction にした場合の例を見てみましょう。spanner-cli では BEGIN RO; でRead-Only Transaction が走るようになっています。

https://gist.github.com/yfuruyama/2ec4e05c7ed54aae787cbbc52fdd8271

確かにレコードの更新前と更新後で同じ値を読めており、Non-repeatable Read Anomaly を防げていることがわかります。

これは Read-Only Transaction では SELECT クエリを最初に実行した時のタイムスタンプを内部的に保持しておき、それ以降のクエリではそのタイムスタンプ以前のデータを読み込むようになっているからです。これが出来るのは Spanner が追記型のストレージを保持しており、過去のデータも遡って取得できることに起因しています。

Phantom Read

次に Phantom Read について見ていきましょう。
Phantom Read は Non-repeatable Read とも似てますが、同じレコードの集合を繰り返し読んでる際に、別のトランザクションがレコード追加した結果、取得できるレコードの集合が途中から変わってしまう状況を指します。

例えば、UserID=1〜3 のレコードのみ存在する状況で、あるトランザクションが SELECT * FROM Accounts WHERE UserId >= 1 && UserId <= 10 というクエリを繰り返し投げてる時に、別のトランザクションが UserId=4 のレコードを追加した場合、その追加したレコードが途中から読めてしまう状況を Phantom Read といいます。

では Spanner で Phantom Read が発生しないことを見てみましょう。先程と同じように、まずは Read-Write Transaction を使って試してみます。

https://gist.github.com/yfuruyama/cba2b942c89fc00d228511a01d3336d1

先程と同じく tx2 のコミット時点で永遠にブロックされてしまいました。
ここからわかることは、 WHERE UserID >= 1 AND UserId <= 10 に該当するレコードが UserID=1〜3 だけだとしても、UserID=4〜10 までのギャップに対しても Shared Lock がかかるということです (ドキュメントに記載がある訳ではないため推測です)。
これは MySQL(InnoDB) 等の Gap Lock に似た挙動となっていますね。

では tx1 の方を Read-Only Transaction にするとどうでしょうか。これは予想できるように、tx2 のコミットは問題なく成功し、tx1 の方で Phantom Read が起きることもありません。

https://gist.github.com/yfuruyama/3ac84e0c5d2a3a7a02cf8738b24e35ca

Read Skew

Read Skew とは複数のレコードを読み込むトランザクションの合間に、別のトランザクションで一部のレコードに更新が走り、最初のトランザクションの方で一貫した読み込みができない現象を指します。
現象としては Non-repeatable Read と似ていますが、Non-repeatable Read は同一レコードの読み込みが変わってしまうのに対して、Read Skew はまだ読んでいない別のレコードの読み込みが本来の値から変わってしまうことを指します。

例として、Balance=1000 のアカウント2つに対して、片方のトランザクションは両アカウントの読み込み、もう片方のトランザクションは両アカウントの Balance を更新するシナリオを考えます。
そのシナリオにおいて、読み込みを行うトランザクションで取得できる値が片方のアカウントは1000なのに、もう一方のアカウントは1500となってしまうのが Read Skew です。要するに並行して走っているトランザクションの影響を受けてしまい、一貫した読み込みができていないという現象です。

では Spanner で Read Skew が起きないことを確認してみましょう。以下の例では、tx1 が UserId=1 と UserId=2 のレコードを読み込む合間に、tx2 の方で両レコードの値を更新しています。

https://gist.github.com/yfuruyama/68b8f166ffba273c665482832cf9fc2e

tx1 の方は tx2 の更新をブロックしないよう Read-Only Transaction にしていますが、tx2 の更新に影響されることなく一貫性のある読み込みが出来ていることがわかると思います。

Write Skew

最後に Write Skew について見ていきます。
Write Skew は複数のトランザクションが同一のレコードを読み込んだ上で別々のレコードを更新する場面で、それぞれのトランザクションがオーバーラップした際に最終的な書き込みの結果が不整合になる現象を指します。

例を挙げると、上記 Accounts テーブルにおいて「Type=Saving なアカウントは1人しか認めない」というルールがあったとします。
その上で、どれか1つの Account を Saving に変えるという処理を

  • 操作1: SELECT COUNT(*) FROM Accounts WHERE type = “SAVING"; して現在の Saving アカウント数をチェック
  • 操作2: Saving アカウント数が0であれば、どれか一つのアカウントを Saving に変える

という一連の流れで実装した場合、2つのトランザクションが上記の操作1を同時に実行すると、2つのアカウントが同時に Saving になってしまうことが考えられます。

ではこのシナリオをもとに、Write Skew が Spanner では発生しないことを見てみましょう。

https://gist.github.com/yfuruyama/4516fe4c9b94c1ceae34f8332dc4f5cd

結果を見ると、tx1 は成功し、tx2 はコミットした際に abort されていることがわかると思います。
この理由も Lost Update で見たときと同じで、tx1 がコミットする際に必要な Exclusive Lock を取るために、既に Shared Lock を取得している tx2 を abort させるアルゴリズムが働いているためです。

この Write Skew に関しても、MySQL 等ではアプリケーション開発者が Locking Read (SELECT FOR UPDATE) を使ったクエリを書くか、トランザクション分離性を SERIALIZABLE にするといった対策が必要になります。

まとめ

今まで見てきたように、Cloud Spanner は External Consistency という非常に強力なトランザクション一貫性レベルを持っているため、上記に挙げた Anomaly を全て防いでくれることがわかりました。

通常の RDB では SELECT FOR UPDATE のような Locking Read を開発者が指定しないと Anomaly が発生する場面でも、Spanner はそのような指定をする必要がなく、トランザクションの一貫性を Spanner 側に任せることができます。

しかし、本記事で見てきたように Spanner の内部的なロック取得が他のトランザクションを abort することもあるため、

  • 読み込みしか行わない所は Read-Only Transaction にすること
  • Read-Write Transaction の中身はリトライされても問題のないよう冪等にすること

この二点を守ることは、沢山のトランザクションが並行して動いているケースでパフォーマンスを出すために重要だと思います。

Cloud Spanner を使って様々な Anomaly に立ち向かっていきましょう!

--

--

Yuki Furuyama
google-cloud-jp

Technical Solutions Engineer @Google Cloud. Opinions are my own and not the views of my employer.