売れない俳優 海老原雄一郎です。
売れない俳優は売れていないので、食い扶持を稼ぐためにバイトをせざるを得ません。しかし俳優の仕事は不規則で、特に日中の予定は直前まで分からないことが多いので、普通の仕事は難しいです。周囲の俳優仲間からは、深夜の居酒屋やバーの店員、Uber Eats の配達員などがやりやすいと聞きました。
しかし自分にはもはやそんな仕事ができるほどの体力がないので、昔取った杵柄というやつで、ITエンジニアのバイトをすることになりました。
その関係で今 ScalarDB というものを触っているので、とりあえず勉強したことや一連の作業を備忘録代わりに記録しておきます(バイト先公認)。
ScalarDBとは
株式会社Scalar が開発している分散トランザクションマネージャー。
Cassandra, DynamoDB などの NoSQL DB に ACID 特性を追加したり、Oracle、MySQL などの従来型 RDBMS も含む異種分散データベース環境におけるグローバルトランザクションを実現する。
その動作原理、アーキテクチャーは論文化され、VLDB というデータベース分野の著名な国際カンファレンス (VLDB’23) でも採択された。
これ以上の詳細については中の人が書いてくれた記事等を参照されたし。
ソースコードはオープンソースで公開されている。
ただしオープンソース版では ScalarDB Cluster という機能、というか構成を利用することができない。
ドキュメントも公開されている。
英語版しかないが、ページに組み込まれたGoogle翻訳で日本語を読むこともできる。しかし、翻訳精度はイマイチ。
チュートリアルに挑戦
前述のドキュメントサイトに Getting Started というチュートリアルがあるので、まずはこれをひと通りやってみる。
このチュートリアルでは、単一のバックエンドDBに対して ScalarDB を利用して ACID トランザクションを実行する手順を確認することができる。
なお今回使うマシンは結構まっさらな状態の Mac。
1. JDK のインストール
最新のLTSは22だが、ScalarDB が対応しているバージョンは 8, 11, 17 らしいので、今回は JDK 17 を asdf でインストールすることに。
asdf のインストールまではつい最近の別件でやったときにメモを残しておいたので、それを見返しながら作業(記事に残しておいてよかった)。
asdf のインストールが終わったら Java プラグインもインストール。
$ asdf plugin-add java https://github.com/halcyon/asdf-java.git
$ asdf list all java
JDK 17系を探すと OpenJDK 17.0.2 が見つかったので、それをインストール。
$ asdf install java openjdk-17.0.2
~/.tool-versions
ファイルを作成し、OpenJDK 17.0.2 が使われるよう設定。
java openjdk-17.0.2
2. scalardb リポジトリをクローン
ターミナルを開き、適当なディレクトリに移動したら、以下のコマンドで ScalarDB の GitHub リポジトリをクローン。
$ git clone https://github.com/scalar-labs/scalardb
今日時点の ScalarDB バージョンは 3.12.3 相当のようだ。
クローンできたら Getting Started に必要なリソースがあるディレクトリに移動。
$ cd scalardb/docs/getting-started
3. ScalarDB のバックエンドデータベースを用意
ScalarDB は言ってみれば、異なる種類のバックエンドDBに対して論理層として機能し、統一的なAPIを提供するもの。
そのバックエンドDBとしてサポートされるデータベースは以下のページにまとまっている。
今回はバックエンド DB に MySQL を選択する。
MySQL は元々 ACID トランザクションを(概ね)サポートしているので、MySQL 単体に ScalarDB をかぶせる意味はあまりないのかもしれないが、MySQL は個人的にさわり慣れており、新しいものを複数いっぺんに試して余計な苦労をしたくないので、今回はこうすることにした。
あまりローカル環境を汚したくないので、MySQL は Docker で動かす。
まずは Docker Desktop をインストール。
そして MySQL 8.0.38 を起動。
$ docker run -d --rm -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes --name mysql mysql:8.0.38
MySQL 標準の 3306 番ポートでアクセスできるようにしておく。また、テスト用なので ROOT パスワードは設定しない(MYSQL_ALLOW_EMPTY_PASSWORD=yes
)。
コマンドラインから接続してみる。
まずは MySQL クライアントを Homebrew でインストール。データベースの状態を確認するのにいちいちコンテナにログインするのは面倒だし、クライアントは複数の MySQL バージョンに対応しているのでホストOSに直接インストール。
$ brew info mysql-client
では接続。
$ mysql -h 127.0.0.1 -P 3306 -u root
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 12
Server version: 8.0.38 MySQL Community Server - GPL
Copyright (c) 2000, 2024, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql>show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
+--------------------+
4 rows in set (0.01 sec)
MySQL データベースに接続できることが確認できたら、続いて ScalarDB の接続情報を設定。
docs/getting-started
ディレクトリに scalardb.properties
というファイルがあるので、その中身を以下のように書き換える。
scalar.db.storage=jdbc
scalar.db.contact_points=jdbc:mysql://127.0.0.1:3306/
scalar.db.username=root
これで MySQL を ScalarDB のバックエンドDBとして使えるようになった(はず)。
4. データベーススキーマの作成
サンプルアプリケーションのためのテーブルを作成する。
docs/getting-started
ディレクトリに emoney.json
というファイルを以下の内容で作成する。
{
"emoney.account": {
"transaction": true,
"partition-key": [
"id"
],
"clustering-key": [],
"columns": {
"id": "TEXT",
"balance": "INT"
}
}
}
このスキーマ定義ファイルを元にテーブルを作成するには ScalarDB Schema Loader というツールが必要らしい。
ScalarDB Schema Loader は以下のページからダウンロードできる。
いま試している ScalarDB は v3.12.3 相当なので、ScalarDB Schema Loader も v3.12.3 を選択、Assets セクションにリストされている scalardb-schema-loader-3.12.3.jar
をダウンロードし、emoney.json
と同じディレクトリに配置する。
次のコマンドを実行すると MySQL にスキーマが作成される。
$ java -jar scalardb-schema-loader-3.12.3.jar --config scalardb.properties --schema-file emoney.json --coordinator
[main] INFO com.scalar.db.schemaloader.command.SchemaLoaderCommand - Config path: scalardb.properties
[main] INFO com.scalar.db.schemaloader.command.SchemaLoaderCommand - Schema path: emoney.json
[main] INFO com.scalar.db.schemaloader.SchemaOperator - Creating the table account in the namespace emoney succeeded
[main] INFO com.scalar.db.schemaloader.SchemaOperator - Creating the coordinator tables succeeded
成功。
MySQL の状態を確認してみる。
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| coordinator |
| emoney |
| information_schema |
| mysql |
| performance_schema |
| scalardb |
| sys |
+--------------------+
7 rows in set (0.00 sec)
coordinator
、emoney
、scalardb
という3つのデータベースが作成されている。
せっかくなので各データベースの中身を簡単に確認しておこう。
coordinator
データベース
mysql> use coordinator;
Database changed
mysql> show tables;
+-----------------------+
| Tables_in_coordinator |
+-----------------------+
| state |
+-----------------------+
1 row in set (0.00 sec)
state
というテーブルが1つだけある。
mysql> describe state;
+---------------+-------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+---------------+-------------+------+-----+---------+-------+
| tx_id | varchar(64) | NO | PRI | NULL | |
| tx_state | int | YES | | NULL | |
| tx_created_at | bigint | YES | | NULL | |
+---------------+-------------+------+-----+---------+-------+
3 rows in set (0.01 sec)
mysql> select * from state;
Empty set (0.01 sec)
レコードはまだ0件。
tx_xxx
というカラム名からして、トランザクションの状態を管理するテーブルっぽい。
emoney
データベース
mysql> use emoney;
Database changed
mysql> show tables;
+------------------+
| Tables_in_emoney |
+------------------+
| account |
+------------------+
1 row in set (0.00 sec)
mysql> describe account;
+------------------------+-------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+------------------------+-------------+------+-----+---------+-------+
| id | varchar(64) | NO | PRI | NULL | |
| balance | int | YES | | NULL | |
| tx_id | longtext | YES | | NULL | |
| tx_state | int | YES | | NULL | |
| tx_version | int | YES | | NULL | |
| tx_prepared_at | bigint | YES | | NULL | |
| tx_committed_at | bigint | YES | | NULL | |
| before_tx_id | longtext | YES | | NULL | |
| before_tx_state | int | YES | | NULL | |
| before_tx_version | int | YES | | NULL | |
| before_tx_prepared_at | bigint | YES | | NULL | |
| before_tx_committed_at | bigint | YES | | NULL | |
| before_balance | int | YES | | NULL | |
+------------------------+-------------+------+-----+---------+-------+
13 rows in set (0.00 sec)
mysql> select * from account;
Empty set (0.01 sec)
先ほど作った emoney.json
の内容が反映されている。スキーマ定義上は id
と balance
の2カラムしか定義していなかったが、ScalarDB が利用すると思われる多くのカラムが付加されている。
scalardb
データベース
mysql> use scalardb;
Database changed
mysql> show tables;
+--------------------+
| Tables_in_scalardb |
+--------------------+
| metadata |
+--------------------+
1 row in set (0.00 sec)
mysql> describe metadata;
+------------------+--------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+------------------+--------------+------+-----+---------+-------+
| full_table_name | varchar(128) | NO | PRI | NULL | |
| column_name | varchar(128) | NO | PRI | NULL | |
| data_type | varchar(20) | NO | | NULL | |
| key_type | varchar(20) | YES | | NULL | |
| clustering_order | varchar(10) | YES | | NULL | |
| indexed | tinyint(1) | NO | | NULL | |
| ordinal_position | int | NO | | NULL | |
+------------------+--------------+------+-----+---------+-------+
7 rows in set (0.01 sec)
mysql> select * from metadata;
+-------------------+------------------------+-----------+-----------+------------------+---------+------------------+
| full_table_name | column_name | data_type | key_type | clustering_order | indexed | ordinal_position |
+-------------------+------------------------+-----------+-----------+------------------+---------+------------------+
| coordinator.state | tx_created_at | BIGINT | NULL | NULL | 0 | 3 |
| coordinator.state | tx_id | TEXT | PARTITION | NULL | 0 | 1 |
| coordinator.state | tx_state | INT | NULL | NULL | 0 | 2 |
| emoney.account | balance | INT | NULL | NULL | 0 | 2 |
| emoney.account | before_balance | INT | NULL | NULL | 0 | 13 |
| emoney.account | before_tx_committed_at | BIGINT | NULL | NULL | 0 | 12 |
| emoney.account | before_tx_id | TEXT | NULL | NULL | 0 | 8 |
| emoney.account | before_tx_prepared_at | BIGINT | NULL | NULL | 0 | 11 |
| emoney.account | before_tx_state | INT | NULL | NULL | 0 | 9 |
| emoney.account | before_tx_version | INT | NULL | NULL | 0 | 10 |
| emoney.account | id | TEXT | PARTITION | NULL | 0 | 1 |
| emoney.account | tx_committed_at | BIGINT | NULL | NULL | 0 | 7 |
| emoney.account | tx_id | TEXT | NULL | NULL | 0 | 3 |
| emoney.account | tx_prepared_at | BIGINT | NULL | NULL | 0 | 6 |
| emoney.account | tx_state | INT | NULL | NULL | 0 | 4 |
| emoney.account | tx_version | INT | NULL | NULL | 0 | 5 |
+-------------------+------------------------+-----------+-----------+------------------+---------+------------------+
16 rows in set (0.00 sec)
metadata
というテーブルの中にこれまでに見てきた coordinator.state
テーブルとemoney.account
テーブルのカラム情報が保存されている。ScalarDB が内部的に利用するんだろう。
5. トランザクションの実行
サンプルの電子マネーアプリケーションを利用してトランザクションを実行してみる。
このサンプルアプリケーションでは次のことができる。
- 口座の作成
- 口座への入金
- 口座間送金
- 口座残高の照会
customer1 口座の作成
サンプルアプリケーションは Gradle というビルドツールを利用して実行できるらしい。Java は10数年ぶりなので Gradle のことはよく分からん。
customer1
という名前 (ID) の口座を初期残高500円で作成する。
(通貨単位について言及はないが、分かりやすいので円ということにしておく)
$ ./gradlew run --args="-action charge -amount 500 -to customer1"
...
BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed
emoney.account
テーブルの中身を確認してみようか。
mysql> select * from emoney.account;
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| id | balance | tx_id | tx_state | tx_version | tx_prepared_at | tx_committed_at | before_tx_id | before_tx_state | before_tx_version | before_tx_prepared_at | before_tx_committed_at | before_balance |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| customer1 | 500 | 1abca1fd-9fb9-4d7f-a041-e043eaf42935 | 3 | 1 | 1720404771375 | 1720404771408 | NULL | NULL | NULL | NULL | NULL | NULL |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------+-----------------+-------------------+-----------------------+------------------------+----------------+
1 row in set (0.00 sec)
ID が customer1
、残高 (balance) が500円のレコードができている。
merchant1 口座の作成
今度は残高0円の merchant1 口座を作成する。
$ ./gradlew run --args="-action charge -amount 0 -to merchant1"
...
BUILD SUCCESSFUL in 941ms
2 actionable tasks: 1 executed, 1 up-to-date
確認。
mysql> select * from emoney.account;
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| id | balance | tx_id | tx_state | tx_version | tx_prepared_at | tx_committed_at | before_tx_id | before_tx_state | before_tx_version | before_tx_prepared_at | before_tx_committed_at | before_balance |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| customer1 | 500 | 1abca1fd-9fb9-4d7f-a041-e043eaf42935 | 3 | 1 | 1720404771375 | 1720404771408 | NULL | NULL | NULL | NULL | NULL | NULL |
| merchant1 | 0 | 0feb0023-cfe0-417d-bdc8-cdc102594c92 | 3 | 1 | 1720405003122 | 1720405003155 | NULL | NULL | NULL | NULL | NULL | NULL |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------+-----------------+-------------------+-----------------------+------------------------+----------------+
2 rows in set (0.00 sec)
レコードができている。
口座に入金
customer1 の口座に500円を追加入金する。
$ ./gradlew run --args="-action charge -amount 500 -to customer1"
...
BUILD SUCCESSFUL in 882ms
2 actionable tasks: 1 executed, 1 up-to-date
確認。
mysql> select * from emoney.account;
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------------------------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| id | balance | tx_id | tx_state | tx_version | tx_prepared_at | tx_committed_at | before_tx_id | before_tx_state | before_tx_version | before_tx_prepared_at | before_tx_committed_at | before_balance |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------------------------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| customer1 | 1000 | b6490be0-6393-4950-881e-6443f5fb531b | 3 | 2 | 1720405120833 | 1720405120871 | 1abca1fd-9fb9-4d7f-a041-e043eaf42935 | 3 | 1 | 1720404771375 | 1720404771408 | 500 |
| merchant1 | 0 | 0feb0023-cfe0-417d-bdc8-cdc102594c92 | 3 | 1 | 1720405003122 | 1720405003155 | NULL | NULL | NULL | NULL | NULL | NULL |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------------------------------+-----------------+-------------------+-----------------------+------------------------+----------------+
2 rows in set (0.00 sec)
残高が1000円になった。
before_xxx
カラムにセットされている値が興味深い。が、今は深掘りしない。
口座間送金
customer1 から merchant1 に100円を送金する。
$ ./gradlew run --args="-action pay -amount 100 -from customer1 -to merchant1"
...
BUILD SUCCESSFUL in 892ms
2 actionable tasks: 1 executed, 1 up-to-date
確認。
mysql> select * from emoney.account;
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------------------------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| id | balance | tx_id | tx_state | tx_version | tx_prepared_at | tx_committed_at | before_tx_id | before_tx_state | before_tx_version | before_tx_prepared_at | before_tx_committed_at | before_balance |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------------------------------+-----------------+-------------------+-----------------------+------------------------+----------------+
| customer1 | 900 | c312fb7d-69f7-4a75-8e0b-3bfece62b945 | 3 | 3 | 1720405365784 | 1720405365831 | b6490be0-6393-4950-881e-6443f5fb531b | 3 | 2 | 1720405120833 | 1720405120871 | 1000 |
| merchant1 | 100 | c312fb7d-69f7-4a75-8e0b-3bfece62b945 | 3 | 2 | 1720405365784 | 1720405365831 | 0feb0023-cfe0-417d-bdc8-cdc102594c92 | 3 | 1 | 1720405003122 | 1720405003155 | 0 |
+-----------+---------+--------------------------------------+----------+------------+----------------+-----------------+--------------------------------------+-----------------+-------------------+-----------------------+------------------------+----------------+
2 rows in set (0.00 sec)
おぉ、customer1 の残高が100円減って、merchant1 の残高が100円増えた。
残高照会
まずは customer1 口座の残高を確認。
$ ./gradlew run --args="-action getBalance -id customer1"
> Task :run
The balance for customer1 is 900
...
BUILD SUCCESSFUL in 957ms
2 actionable tasks: 1 executed, 1 up-to-date
900円。
次に merchant1 口座。
$ ./gradlew run --args="-action getBalance -id merchant1"
> Task :run
The balance for merchant1 is 100
...
BUILD SUCCESSFUL in 858ms
2 actionable tasks: 1 executed, 1 up-to-date
100円。
チュートリアルはここまで。
coordinator.state
テーブルの状態
完全に興味本位で、ScalarDB がトランザクション管理のために内部的に利用していると思われる coordinator.state
テーブルの状態を確認してみる。
mysql> select * from coordinator.state;
+--------------------------------------+----------+---------------+
| tx_id | tx_state | tx_created_at |
+--------------------------------------+----------+---------------+
| 0feb0023-cfe0-417d-bdc8-cdc102594c92 | 3 | 1720405003141 |
| 1abca1fd-9fb9-4d7f-a041-e043eaf42935 | 3 | 1720404771395 |
| 55587c8a-d72c-4513-aa64-107a72d58d64 | 3 | 1720405500813 |
| 56065c57-789d-48ef-b3db-a8837ad5c9c5 | 3 | 1720405781308 |
| b6490be0-6393-4950-881e-6443f5fb531b | 3 | 1720405120856 |
| c312fb7d-69f7-4a75-8e0b-3bfece62b945 | 3 | 1720405365819 |
+--------------------------------------+----------+---------------+
6 rows in set (0.00 sec)
emoney.account
テーブルの tx_id
カラムで見かけた値だ。
おそらく coordinator.state
テーブルと、emoney.account
テーブルに自動作成された tx_xxx
や before_tx_xxx
カラムをうまく利用して独自のトランザクション管理を行い、NoSQL DB でも ACID 特性を実現できるのだろう。
サンプルアプリケーションのソースコード
ソースコードは docs/getting-started/src
以下にある ElectronicMoney.java
と ElectronicMoney.java
で、実際に ScalarDB を操作するコードは後者。
データの操作は Get
や Put
というコマンドで操作している。そうか、ScalarDB へのアクセスは SQL じゃないのか。
おわりに
この記事の最初の方で紹介した資料の「データモデル」の節では以下のように説明されている。
Scalar DBのデータモデルは、いわゆる多次元マップの構造になっています。
Scalar DBでは、データに対してget、scan、put、deleteのようなCRUD操作を行うことができます。
先に、ScalarDB は異なる種類のバックエンドDBに対して論理層として機能する、と紹介したが、その論理層のデータモデルが多次元マップであり、バックエンドDBの種類に関わらず、SQL とは異なる CRUD API で統一的にアクセスできるということだろう。
つまり、先ほどは mysql コマンドを利用して SQL でテーブルの中身をのぞき見していたが、本来的にはそれは正しくないアクセス方法で、アプリケーション開発時にそれを行うと不整合なデータを取得してしまう可能性があるし、ましてや SQL で直接データを更新したりすると ScalarDB が管理している整合性が崩れるのでご法度ということになるのだろう。
ユニークだ。
今回のチュートリアルの内容だけだと ScalarDB を利用するメリットはあまり感じられないとは思うが、NoSQL DB に ACID 特性を付加できたり、異種・複数のバックエンドDBでグローバルトランザクションを実現できるとなると、どこかにニーズはありそう。
あと、9月に池袋で上演される「ナビゲーション」という舞台に出ます。
主演・演出はポケモンでお馴染みの松本梨香さん、脚本は「ごくせん」「1リットルの涙」の江頭美智留さんという大物コンビ。
チケットは https://www.confetti-web.com/events/1918 でお買い求めいただけますが、残念ながら僕がデータベースやSQLについて話すことは一切ありませんので、そこのところ誤解なきようお願いいたします。
どうしてもそういう話を聞きたい方は、チケットを買うときに「推しメン」で海老原を選んでから個別にご連絡ください。