2022.01.11

Google SpannerとMySQLのトランザクションの違い

こんにちは,S.T.です。今回はGoogle Spannerを検証します。Spannerは関係データベースのデータ構造を持ちトランザクション処理に対応した分散データベースです。リアルタイムにトランザクション処理を行うようなアプリケーションのデータストアとしての利用も考慮されています。また,PostgreSQLのインタフェースの実装も進められており,アプリケーションから一般的なRDBと同様に使えるようになる日も近そうです。

そこで,今回はSpannerのトランザクションの挙動をMySQLのInnoDBと比較し,挙動の違いによる落とし穴がないかどうかを確かめます。具体的には,トランザクション分離レベルやロックの範囲について検証します。結論から言うと,何も考えずに単にRDB置き換えるだけでは大怪我をしそうだが,特性を理解していればそう大差ない実装でいけると言えそうです。また,Spannerの肝である「分散」については,次回検証する予定です。

1.Spannerとは

先にも述べたように,Spannerは関係データベースのようなデータ構造でトランザクション処理に対応したデータベースです。Google Cloud Platform(GCP)上で提供されています。トランザクションを用いて一貫性を持った処理を実現可能ですが,複数ノードに分散してデータを持つという点がユニークです。分散が前提のアーキテクチャですから,スケーラビリティやアベイラビリティの面では一般的なRDBにはない利点を備えています。

2.トランザクションに関わるアーキテクチャ

トランザクションの実現方法

Spannerは複数ノードで分散して処理を行うアーキテクチャでした。複数ノードで一貫性を持った処理を実現するのは,シンプルな方法では困難です。Spannerがトランザクションをサポートするために実装しているデータモデルや整合性を保つための工夫を簡単に紹介します。

Spannerは各レコードのデータにタイムスタンプを付与して,過去のデータの履歴も保持・管理しています。もし,トランザクションに正確なタイムスタンプが付与されていれば,あるトランザクションが見ることができるデータを判断し,適切に返却することができます。また,ロックについても最も早い時間にそのデータのロックを獲得したトランザクションにロックを渡すというシンプルな方法で解決できます。

単一のノードで動作していれば,自身の時計を使えば(その時刻が正確でなくても)一貫性のある処理が可能です。しかし,Spannerはマルチノードに分散しており,それぞれのノードには必ず時間のズレが生じます。SpannerはTrueTime APIを用いて,ノードの間の時刻のずれを一定の範囲内に収めるような仕組みを導入し,この問題に対処しています。この仕組みを用いると,図1に示すように複数のトランザクションを実行するノードの時刻がずれていても,トランザクションに付与されるタイムスタンプは正しい順序を保つことができます。
図1:Spannerのトランザクションのタイムスタンプ

Trx1を実行するノードは,Trx2を実行するノードよりも時刻が2ε遅れています。単にノードの時刻をタイムスタンプとして採用すると,Trx1が実時間上ではTrx2によりも先に開始していたにもかかわらず,Trx2が先に実行されたことになってしまいます。ノード間の時刻のズレが±2ε以内に収まっていることが保証されていれば,タイムスタンプに2εを足すことで,自分の時計がノードの中で一番遅れていたとしても,必ず正しい実行順序になります。これを実現するために,時刻のズレの範囲を保証するものがTrueTime APIです。

さらに,コミットする前にトランザクションの開始時刻から+2ε経過したことを保証(つまり,そうなるように待機する)することで,全ノードがデータのロックを認識することができ,一貫性が保証できます。

Spannerには,読み書きを行うRead-Write TransactionとRead-Only Transactionが存在します。Read-Write Transactionは,データのロックを取りながら更新処理を行う,一般的なトランザクションです。Read-Only Transactionは,データのロックには触れず,一貫性を持ってデータの読み出しのみを行うトランザクションです。過去のデータにタイムスタンプを付けて保持しているため,Read-Only Transactionの開始時刻が確定すれば,その時刻に基づいたデータを読み出すことで,ロックとは無関係に一貫性のある読み出しを実現することができます。

ここで紹介した内容は,以降の記事の理解を助けとなる最小限のものです。より詳細な内容は,本記事の執筆の参考にした文献を末尾に記載していますので,そちらをご覧ください。

3.トランザクションの検証

実際にGCP上にSpannerのインスタンスを立ち上げ,トランザクションを発行して動作を確認してみます。

下準備

WebコンソールからGCP上に適当なサイズのインスタンスとDBを作成し,spanner-cliを用いてDBに接続します。今回は以下のusersというテーブルをサンプルとして使用します。PKとなるIDと名前,何らかのスコアの3つのカラムを持っています。
spanner> show create table users;
+-------+-----------------------------+
| Table | Create Table                |
+-------+-----------------------------+
| users | CREATE TABLE users (        |
|       |   id INT64 NOT NULL,        |
|       |   name STRING(32) NOT NULL, |
|       |   score INT64 NOT NULL,     |
|       | ) PRIMARY KEY(id)           |
+-------+-----------------------------+
1 rows in set (0.62 sec)
このテーブルに,以下のようなデータをINSERTしておきます。
spanner> select * from users;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   |
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   |
+----+--------+-------+
4 rows in set (1.27 msecs)
Spannerでは効率的に分散できるようなPK設計が推奨されていますが,今回のデータはそれに沿っていません。トランザクション処理のみに着目するため,他のRDBでよく使うようなAUTO INCREMENTなIDをイメージしたデータを使用します。

REPEATABLE READ的な動作の確認

私が普段使用するMySQL(InnoDB)のトランザクション分離レベルは,デフォルトのREPEATABLE READです。おそらくMySQLを使う多くのプロジェクトも同じではないかと思います。Spannerのトランザクションが,REPEATABLE READ相当のトランザクション分離レベルであれば,既存のアプリロジックの大筋を変えずにSpannerに移行しても,ひとまずデータの不整合が起きることはなさそうです。まずはこれを確かめます。具体的には,ダーティリード,ファジーリード,ファントムリードが発生しないことを確認します。また,以降MySQLの動作を比較として取り上げることがありますが,全てREPEATABLE READのInnoDBの挙動となります。

ダーティリードが発生しないことの確認

ダーティリードは,あるトランザクションでコミットする前のデータを他のトランザクションで読んでしまう現象です。トランザクション分離レベルがREPEATABLE READでは発生しません。この現象が発生しないことを確認します。spanner-cliを2つ起動し,2つのトランザクションを経行して実行します。
# Trx 1
spanner> begin;
Query OK, 0 rows affected (0.10 sec)

# Trx 2
spanner> begin;
Query OK, 0 rows affected (0.02 sec)

# Trx 1
spanner(rw txn)> update users set score = 200 where id = 1;
Query OK, 1 rows affected (0.04 sec)

# Trx 2
spanner(rw txn)> select * from users;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   | # コミット前なのでtaroのscoreは古い100が見えている
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   |
+----+--------+-------+
4 rows in set (1.18 msecs)
このようにダーティリードは発生しませんでした。先に紹介したように,タイムスタンプベースで過去のデータも保持・管理しているため,トランザクションごとに適切なデータを読み出すことができるわけです。

ファジーリードが発生しないことの確認

ファジーリードは,あるトランザクションが同じデータを2回読み出す際に,1回目と2回目の間で別のトランザクションが値を更新してコミットしたため,1回目と2回目で異なるデータ(更新されたデータ)を読み出してしまう現象です。これもREPEATABLE READでは発生しません。同じように検証してみます。
# Trx 1
spanner> begin;
Query OK, 0 rows affected (0.14 sec)

spanner(rw txn)> select * from users;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   |
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   |
+----+--------+-------+
4 rows in set (1.39 msecs)

# Trx 2
spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> update users set score = 200 where id = 1;
Query OK, 1 rows affected (0.02 sec)

spanner(rw txn)> commit;
# ここでブロックされる
今回は,MySQLとは異なる結果となりました。Trx1でSELECTするとTrx2のコミットがブロックされるため,そもそもファジーリードが発生する別のトランザクションによる変更自体を行うことができません。MySQLはコミットをブロックせず,Trx1でSELECTを実行しても同じ結果が得られます。

ファジーリードは発生しませんでしたが,コミットがブロックされるため,リアルタイムにリクエストを処理するアプリケーションの場合,この処理がボトルネックとなる可能性があります。これを解消するために活躍するのが,先に紹介したRead-Only Transactionです。Read-Only Transactionはロックを取得しないため,Trx1をRead-OnlyとすることでTrx2のコミットはブロックされなくなります。また,前述の通り過去のデータも保持されているため,Trx1では更新前のデータを読み出すことができます。試してみましょう。
# Trx 1
spanner> begin ro; # roを指定する
Query OK, 0 rows affected (0.03 sec)

spanner(ro txn)> select * from users;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   |
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   |
+----+--------+-------+
4 rows in set (1.55 msecs)

# Trx 2
spanner> begin;
Query OK, 0 rows affected (0.12 sec)

spanner(rw txn)> update users set score = 200 where id = 1;
Query OK, 1 rows affected (0.03 sec)

spanner(rw txn)> commit;
Query OK, 0 rows affected (0.01 sec)
# ブロックされずにコミット成功

# Trx 1

spanner(ro txn)> select * from users;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   | # taroは更新前の値が読める
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   |
+----+--------+-------+
4 rows in set (1.44 msecs)
MySQLと同等の挙動となりました。上記のような挙動の違いがあるため,もしMySQLを前提としたアプリケーションをSpannerに繋ぎ変える場合,アプリケーション側でトランザクションを作る際に,Read-Onlyにするべきか検討しなおすという作業が必要になります。

ファントムリードが発生しないことの確認

ファントムリードは,あるトランザクションで何らかの条件でレコードを繰り返し取得するとき,1回目と2回目の間に別のトランザクションがその条件にマッチするレコードを追加したことによって,読み出す結果が変わってしまう現象です。REPEATABLE READの定義ではファントムリードは発生しますが,MySQL(InnoDB)のREPEATABLE READでは対策されており,発生しません。Spannerではどうでしょうか。
# Trx 1
spanner> begin;
Query OK, 0 rows affected (0.01 sec)

spanner(rw txn)> select * from users where id >= 1 and id <= 5;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   |
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   |
+----+--------+-------+
4 rows in set (1.86 msecs)

# Trx 2
spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> insert into users(id, name, score) values(5, 'phantom', 100);
Query OK, 1 rows affected (0.02 sec)

spanner(rw txn)> commit;
# ここでブロックされる
ファジーリードの検証と同じく,コミットがブロックされました。MySQLでは明示的にこの範囲に対してロックを獲得しない限りコミットはブロックされず,ファントムリードも発生しません。ここでも細かい挙動の差があります。

この問題も,Trx1をRead-Only Transactionにすることで対処可能です。
# Trx 1
spanner> begin ro;
Query OK, 0 rows affected (0.02 sec)

spanner(ro txn)> select * from users where id >= 1 and id <= 5;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   |
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   |
+----+--------+-------+
4 rows in set (1.69 msecs)

# Trx 2

spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> insert into users(id, name, score) values(5, 'phantom', 100);
Query OK, 1 rows affected (0.02 sec)

spanner(rw txn)> commit;
Query OK, 0 rows affected (0.02 sec)
# ブロックされずに成功する

# Trx 1

spanner(ro txn)> select * from users where id >= 1 and id <= 5;
+----+--------+-------+
| id | name   | score |
+----+--------+-------+
| 1  | taro   | 100   |
| 2  | jiro   | 200   |
| 3  | hanako | 150   |
| 4  | saburo | 150   | # id = 5 はINSERTされているが,Trx1からは見えない
+----+--------+-------+
4 rows in set (1.91 msecs)

トランザクションの挙動まとめ

このように,MySQL(InnoDB)のトランザクションとは似て非なる挙動となりました。特に,コミットがブロックされる挙動を理解していないとパフォーマンス面で問題が発生することもありそうです。

SpannerのRead-Write Transactionでは,読みる行に共有ロックがかかります。これはInnoDBでトランザクション分離レベルをSerializableに設定した場合と近い挙動です。Serializebleでは,もちろんダーティリード,ファジーリード,ファントムリードが発生しません。かわりに,ロックの獲得待ちで処理がブロックされるため,性能は低下します。SpannerではRead-Only Transactionを導入することで,性能の低下を防ぎつつトランザクションの高い分離性を担保しています。MySQLを前提としたトランザクション設計をそのままRead-Write Transactionにしても整合性の面では問題なさそうですが,Spannerの性能を最大限引き出すためには,Read-Only Transactionを活用できるような設計・実装を意識する必要があります。

ロックの動作の確認

更新を伴う処理では,整合性を担保するためにロックを獲得して処理を行うことも多くあります。Spannerでそのような処理を行った際の挙動を確認します。といっても,Read-Write Transctionは読み取る行に共有ロックを置く,という事実を知っていれば,大抵は結果が予想できてしまいます。今回はSerializeble的な特性を活かした例として,SELECTしてなければINSERTの挙動を見てみましょう。
# Trx 1
spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> select * from users where name = 'newuser';
Empty set (1.83 msecs)

# Trx 2

spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> select * from users where name = 'newuser';
Empty set (1.53 msecs)

spanner(rw txn)> insert into users(id, name, score) values(5, 'newuser', 100);
Query OK, 1 rows affected (0.03 sec)

spanner(rw txn)> commit;

# ここでブロックされる

# Trx 1

spanner(rw txn)> insert into users(id, name, score) values(5, 'newuser', 100);
Query OK, 1 rows affected (0.05 sec)

spanner(rw txn)> commit;
Query OK, 0 rows affected (0.01 sec)
# 成功する

# Trx 2
ERROR: transaction was aborted
このように,SELECTしてなければINSERTのトランザクションが同時に実行された場合,後から実行したトランザクションが失敗し,整合性が保たれます。MySQLではLOCK IN SHARE MODEを使うことで同じような挙動となりますが,空打ちによるギャップロックが問題となるため,使う場面を選びます。Spannerも同様で,例えば「insert into users(id, name, score) values(5, ‘useruser’, 100)」というクエリをTrx2で実行してコミットしてもブロックされます。これはusers.nameにINDEXを作成することでロックの範囲を限定でき,回避可能です。このあたりはMySQLと似ていますね。

ただし,MySQLのギャップロックとは挙動が異なります。例えば,scoreに対してINDEXをはり, score=500をSELECTします。usersテーブルにはscoreが500のレコードが存在しないため空打ちとなります。MySQLであれば,score=500から直前のINDEXの値である200までの間にロックがかかり,INSERTがブロックされます。別のトランザクションからscore=300のレコードは挿入できず,score=600のレコードは挿入できる,という状態になります。Spannerで試してみましょう。
# Trx 1
spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> select * from users where score = 500;
Empty set (1.91 msecs)

# Trx 2
spanner> begin;
Query OK, 0 rows affected (0.01 sec)

spanner(rw txn)> insert into users(id, name, score) values(5, '300user', 300);
Query OK, 1 rows affected (0.02 sec)

spanner(rw txn)> commit;
# ここでブロックされる

# Trx 3
spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> insert into users(id, name, score) values(5, '600user', 600);
Query OK, 1 rows affected (0.03 sec)

spanner(rw txn)> commit;
# ここでブロックされる
MySQLのギャップロックとは異なり,score=600のINSERTもブロックされました。このように,ロックされる範囲がことなることに注意する必要がありそうです。

最後に排他ロックです。MySQLではFOR UPDATEを使用してロックを獲得して値を更新する,といった処理を行います。Spannerでは,FOR UPDATEの操作はできず,同じレコードを更新したトランザクションの片方が失敗するという挙動になります。
# Trx 1
spanner> begin;
Query OK, 0 rows affected (0.02 sec)

spanner(rw txn)> select * from users where id = 1;
+----+------+-------+
| id | name | score |
+----+------+-------+
| 1  | taro | 100   |
+----+------+-------+
1 rows in set (1.92 msecs)

# Trx 2
spanner> begin;
Query OK, 0 rows affected (0.01 sec)

spanner(rw txn)> select * from users where id = 1;
+----+------+-------+
| id | name | score |
+----+------+-------+
| 1  | taro | 100   |
+----+------+-------+
1 rows in set (1.81 msecs)

# Trx 1
spanner(rw txn)> update users set score = 120 where id = 1;
Query OK, 1 rows affected (0.02 sec)

#Trx 2
spanner(rw txn)> update users set score = 120 where id = 1;
Query OK, 1 rows affected (0.03 sec)

# Trx 1
spanner(rw txn)> commit;
Query OK, 0 rows affected (0.36 sec)

# Trx 2
spanner(rw txn)> commit;
ERROR: transaction was aborted
整合性は担保されますが,トランザクションが失敗したことを検知してエラーハンドリングを行ったり,リトライしてリードした結果を再確認するなど,アプリケーション側での対応が必要になります。

4.まとめ

SpannerはSQLのインタフェースを備えておりトランザクションをサポートしているため,シンプルな処理ではMySQLのようなRDBをそのまま置き換える形でも使うことができるでしょう。また,トランザクションの分離性も高いため,考慮不足により不整合が発生する危険性も低そうです。

ただし,分離性を担保するためにRead-Write Transactionは必ず共有ロックを取ったり,ロックの範囲が広かったりといった弱点もあります。そのため,MySQL向けに設計したトランザクションをそのまま使用すると思わぬパフォーマンス低下を招く危険性があります。単なるスケーラビリティ,アベイラビリティに優れたRDBの上位互換ではありません。Spannerのトランザクションの特性をよく理解した上で,Spannerに合わせたトランザクション設計を行う必要があります。

また,今回の検証によって,1つのレコードのロック獲得が集中するようなアプリケーションにうまく適用するのは少し厳しいのではないかと感じています。何事にも言えることですが,やはり本質の理解と使いどころの見極めが重要です。

最後に

次世代システム研究室では,データサイエンティスト/機械学習エンジニアを募集しています。ビッグデータの解析業務など次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら,ぜひ募集職種一覧からご応募をお願いします。皆さんのご応募をお待ちしています。

 

参考

Cloud Spanner とはhttps://cloud.google.com/blog/ja/topics/developers-practitioners/what-cloud-spanner
Spanner: Google’s Globally-Distributed Database https://research.google/pubs/pub39966/
Spanner – Qiita https://qiita.com/kumagi/items/7dbb0e2a76484f6c522b
Cloud Spanner を使って様々な Anomaly に立ち向かう https://medium.com/google-cloud-jp/cloud-spanner-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E6%A7%98%E3%80%85%E3%81%AA-anomaly-%E3%81%AB%E7%AB%8B%E3%81%A1%E5%90%91%E3%81%8B%E3%81%86-5132f691ccf4

※記事中で参照しているWeb上の文献は記事公開日時点の情報です。

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事