はじめに
こんにちは、Technology Innovation Group所属 DBチームの岩崎です。
私はDBチームとして様々なプロジェクトでOracleやPostgreSQLなどRDBの設計・構築に携わってきました。
現在は、Cassandraの導入とデータモデルを設計しています。
本稿ではDBネタとしてRDB脳から脱却して、KVSならではのテーブル設計の勘所をお伝えいたします。
Cassandraはどのようなデータベースなのか
CassandraはKVS(Key Value Store)と呼ばれ、KeyとValueを組み合わせる単純な構造からなるDBです。
データアクセスはkeyに対して行い、Keyに関連付けられたValueが呼び出されます。
KVSは一般的にスキーマレスを採用することが多いと思いますが、Cassandraではアプリケーションの観点から見て、データは構造的に扱える方が開発・運用・管理していく上でメリットがあるとのスタンスを取っているため、スキーマレスではなくスキーマ定義を必要としています。
また、keyに対して複数のカラムを定義することが可能で、カラム型にはListやMapのようなコレクション型、いわゆるオブジェクト型のようなユーザ定義型(UDT:User Defined Type)の使用に対応しているため、JSONのような階層的なデータをスキーマ定義して柔軟に扱うことができるのが特徴です。
その他、本稿では触れませんが下記のような特徴を持っており、スケーラビリティ・アベイラビリティに重きを置いたデータベースと言えます。
- データをクラスタ内の複数ノードで分散保持しているため、性能・容量のリニアにスケール可能
- マスタレスアーキテクチャで、単一障害点がなくノード障害時のマスタ切り替え不要で可用性を厳格に保証
- データセンターを跨ぐクロスリージョン構成を取ることができ、広域災害時の高いBCP要求を満たすことが可能
Cassandraのデータモデルを理解する
Cassandraにおいてデータをどのように定義して扱うことができるのかということを説明していきます。
CassandraはCQLというSQLライクなクエリ言語を用いて記述できるためRDB脳でも直感的に扱えます。
なお、本項ではCassandraのバージョンは3.11.4を利用します。
テーブル作成
CassandraではRDBと同様にデータをテーブルという単位で管理を行います。
KVSではデータの管理、アクセスはkeyに対して行うため、PRIMARY KEYとして定義する必要があります。
Valueに相当するカラムは複数定義可能で配列やMapなどのコレクション型もデータ型として定義することが可能です。
テーブル定義
PRIMARY KEYに指定したカラムがKVSにおけるkey項目です。
CREATE TABLE test_table ( |
レコード作成
INSERT INTO test_table ( |
このようにCassandraでは扱うデータをテーブルとしてスキーマ定義して管理します。
次にデータアクセス方法について見ていきましょう。
データアクセス(PRIMARY KEY)
データベース内に作成したテーブルにアクセスするにはkeyをWHERE句に指定してアクセスを行います。
KVSの特徴は基本的にはkey以外をWHERE句の絞り込み条件に指定できないという点です。
keyを指定してデータアクセス
select * from test_table where id = '01'; |
key以外を指定してデータアクセス
select * from test_table where body = 'Cassandraデータモデリングの紹介'; |
このようにRDBでは当たり前のようにできたWHERE句による絞り込みが、KVSではできないということを念頭にデータモデルを設計する必要があります。
実際は全くできないというわけではないものの制限があるため、できないというスタンスにたって設計したほうが無難と考えられます。
データアクセス(PARTITION KEY)
CassandraはKVSのため基本的には上記のPRIMARY KEYによるデータアクセスになりますが、もう1つの特徴としてPARTITION KEYによるデータアクセスが可能です。
PARTITION KEYとはレコードをカラム単位で集約するキーのことを意味します。
先ほど作成したtest_tableに対して日付単位で集約できるようにPARTITION KEYを追加してみましょう。
テーブル定義
PRIMARY KEYの先頭項目がPARTITION KEYと認識されます。
下記の例ではPARTITION KEYがdate、PRIMARY KEYはdateとidの複合になります。
CREATE TABLE test_part_table ( |
レコード作成
INSERT INTO test_part_table ( |
PARTITION KEYを指定してデータアクセス
PARTITION KEYのdateをWHERE句の条件に指定してデータアクセスしてみると、PARTITION KEY単位で集約されたデータを取得できます。
select * from test_part_table where date = '2019-07-01'; |
このようにCassandraではPARTITION KEY単位による列ごとに集約されたデータアクセスが可能なため、KVSの中でもワイドカラムストア(列指向型)と呼ばれることがあります。
CassandraではPARTITION KEYのハッシュ値を基に物理的なデータ配置箇所を決定しています。
Cassandraは複数ノードでクラスタ構成を取ることが一般的ですが、同一PARTITION KEYのデータは、(複数の)特定ノード内に集約されてデータが格納されます。
そのため、検索条件にPARTITION KEYを指定することで対象のキーのデータをどのノードが保持しているかということが分かるため、サーバーの並列数やデータ量が増えても高速にデータアクセスすることが可能になります。
言い換えれば、PARTITION KEYが無いと全ノードの全レコードを舐めないと条件に一致するかを評価できないので、KVSはキーアクセスしか行えないということになります。
ちなみに複合PRIMARY KEYを定義する場合、特に指定がなければ先頭のキーがPARTITION KEYとなり、単一PRIMARY KEYの場合はPRIMARY KEY = PARTITION KEYとして認識されています。
また、PARTITION KEY以外のPRIMARY KEYはCLUSTERING KEYと呼ばれ、パーティション内のデータのソートキーとなっています。
PARTITION KEYのみを指定してデータを取得する際の動作は、RDB脳にはなじみ深いB*TreeインデックスをRANGE SCANする動作と同様です。
本記事のなかではあまりふれませんが、CLUSTERING KEYによるソート順に従って先頭の〇〇件を取得する、といったことも可能です。
データ更新
Cassandraはテーブル構造を事前に定義しているため、RDB同様に特定のカラムに対する更新が可能です。
また、配列やMapで定義した項目に対しても要素の追加や削除など柔軟に更新を行うことができます。
Map型のデータ更新
先ほど作成したtest_tableのMap型で定義したkeywordに対して要素の追加や更新を行ってみましょう。
select keyword from test_table where id = '01'; |
Mapのkey要素であるauthorを指定してvalueを書き換えてみます。
UPDATE test_table SET keyword['author'] = 'Yuta' WHERE id = '01'; |
Mapの要素を足してみます。
UPDATE test_table SET keyword = keyword + {'category': 'db'} WHERE id = '01'; |
Cassandraのデータモデリングについて考える
なんとなくCassandraでデータをどのように扱うか理解できましたでしょうか。
ここからはどのようにデータモデルを考えていくべきかをチャットテーブルを例に説明していきます。
チャットのデータモデル概要
チャット管理に関するリレーショナルモデルのテーブル設計を元に考えてみましょう。
- チャットルーム
- ユーザ、グループ毎にチャットを管理するテーブル。
- チャット
- チャットの投稿内容の詳細を管理するテーブル
- リアクション
- チャットの投稿に対するスタンプなどのリアクションを管理するテーブル
例えばRDBであれば上記のままの3つのテーブルを作成することが考えられます。
しかし、KVSは単一テーブルに対するキーアクセスのみを可能にしたデータベースであるため、RDBのように複数テーブル間を結合できないという点がKVSとRDBとの最大の違いです。
KVSにおけるテーブル設計では、ある機能を実現するためのテーブルは基本的に1つで足りる(であろう)というスタンスに立ち、まず上記のテーブルを1テーブルに集約できないかを考えます。
また、KVSでは結合だけでなくトランザクションも存在しないため(単一レコードに対する軽量トランザクションのみ)テーブルを分割すると、アプリケーションの作りが複雑化してしまうという問題もあります。
そのため、まずはテーブルを集約することを意識していきましょう。
テーブル集約のポイント
RDBのようにフラットな階層でデータモデルを表現するためには結合が必須です。
しかし、KVSは配列やMapのようなコレクション型を扱えるという点が集約のポイントになります。
例えばチャットとリアクションの関係をコレクション型を利用して1つのテーブルとして表現してみましょう。
チャットに対してリアクションをコレクション型で定義することで1チャットに対してN数のリアクションを表現できます。
Map型を利用した集約
reactionをMap型で定義することでユーザ別のリアクションを表現できるようになりました。
更新時もMap要素を指定して行えるため追加や削除を柔軟に行えます。
CREATE TABLE chat ( |
次にチャットルームとチャットの関係を1つのテーブルとして表現してみましょう。
チャットルームには最終投稿日付やチャットルーム参加者などチャットにとっての付随情報を管理をするため、RDBではチャットルームIDをFKとしてチャット側に持たせるという考え方になると思います。
ここもコレクションを利用してチャットルームにチャット情報を集約してみましょう。
chatをMap型で定義することでチャットテーブルにチャットの情報を持たせることができます。
CREATE TABLE chatroom ( |
しかし、上記のようなMap型(map<text, text>)では単一項目のvalueしか持てないためチャットの情報を表現しきることができません。
そこでユーザ定義型(UDT)という複数のフィールドと型を定義して利用します。
UDT型を利用した集約
chatの情報をUDTとして定義してみましょう。
UDTはテーブルではなくタイプとして定義(CREATE)されます。タイプにはPKを指定する必要はありません。
UDTのフィールドにMapなどのコレクション型を定義することもできます。
CREATE TYPE chat_type ( |
上記で作ったUDTを利用してチャットルームとチャットを集約してみましょう。
先ほどの作ったUDTをデータ型として指定できます。
ただし注意しなければならない点は、コレクション型の中でUDTを定義する場合、frozenという指定が必要になります。frozenの制約事項は後述します。
CREATE TABLE chatroom ( |
INSERT INTO chatroom JSON |
UDTを利用することでチャットルームとチャットを集約できました。
しかし、上記の集約例だと2つの問題点が存在します。
問題点1. 投稿数 = 要素数となるためコレクションサイズが肥大化し過ぎる
Cassandraのドキュメントによると、mapコレクションのキーの最大数は65,535しか持てないと記述されています。
また、コレクション要素全てを読み込んでしまうのでアクセス効率も悪くなります。
つまり、チャットルームにチャットの情報をコレクション型で集約してしまうと1チャットルーム内で投稿できるチャット数が制限されてしまい、要件を満たせなくなる可能性があります。
チャットとリアクションのようにそこまでコレクションの要素が肥大化しない関係であれば積極的に集約していく価値がありますが、上記のパターンには注意が必要です。
問題点2. chatの情報をfrozenで定義しているため柔軟な更新が行えなくなる
Cassandraではコレクション型の中でさらにコレクション型を定義するようなネスト構造を表現する際にfrozenを利用して定義します。
frozenを利用することで深い階層のデータを定義していくことができますが、frozenされた項目の値はBLOBと同様に処理されるようになるため、frozenされた項目に対して部分更新ができなくなります。
つまり先ほどのchatroomの例だと、chatがfrozenで定義されているためchatの項目を更新するためには、下記のようにchatの全項目を指定して更新する必要があります。
frozen項目の更新
chatのbodyだけ更新するとbody以外の項目は全てnullとして扱われるのでデータロストしてしまいます。
UPDATE chatroom SET chat['01'] = fromJson( |
下記のようにfrozen項目全体を含めて更新する必要があります。
UPDATE chatroom SET chat['01'] = fromJson( |
このように一見便利なfrozenですが安易に利用してネストを深くすると更新要件を満たすことができなくなる可能性があるので、注意が必要です。
staticカラムを利用してテーブルを集約する
コレクション型を利用してチャットルームとチャットを集約するのは制約事項があることがわかりました。
Cassandraにおいてテーブル集約を考える際にもう1つ有効な手法としてstaticカラムというものがあります。
staticカラムとして定義することで対象のカラムはパーティション単位で同じデータが保持されるようになります。
create table static_test( |
staticカラムを更新する
staticとして定義したstatic_dataを更新すると同一パーティション内のstatic_dataも更新されます。
update static_test set static_data = 'static_test_1' where id = '1'; |
staticで定義すると下記のように同一パーティション内のデータはstaticカラムを参照するようなデータ構造になるため、static項目を更新すると同一パーティション内のデータは全て更新されたように見えていたわけです。
{ "id" : 1, "static_data" : "static_test_1" { |
このstaticカラムを利用することで同一パーティションキー間でテーブル結合することなく共有できます。
チャットとチャットルームにおける関係にも利用できそうです。
staticカラムを利用してテーブルを集約する
チャットにチャットルームIDをFKとして持たせてチャットルームの情報を引っ張ってくるRDB的な結合を、チャットテーブルにstatcカラムとしてチャットルームの情報を定義することでテーブル集約を実現できます。
CREATE TABLE chat ( |
staticカラムでテーブル集約したことにより、コレクション型で集約した時に比べて、投稿数 = レコード数となるため、投稿数の制約が無くなります。
また、レコード更新時もネストが1段階浅くなりfrozenを利用せずチャットの情報を保持できるため、個別更新にも対応できる柔軟なデータモデルになったと言えます。
まとめ
Cassandraのデータモデルを考える際にはついついRDB的な思考で結合ありきのデータモデルでテーブル設計を行ってしまいますが、まずはテーブルを集約できるかを念頭に見つめ直してみましょう。
最後にデータモデルを考える上での考慮点をまとめてみました。
- 結合ありきのテーブル設計になっていないか
- コレクション型を利用してテーブル間の関係を集約できるか
- 集約時にネストが深くなりすぎないか、また更新要件を満たすことができるか
- staticカラムを利用してパーティション単位の情報を共有させることで集約できるか
他にもデータモデル設計時に検討した項目はありますが、それはまた別の機会に紹介できれば幸いです。
ぜひ皆さんもRDB脳から脱却してKVSを使いこなしてみましょう!!