フューチャー技術ブログ

— 脱RDB脳 — Cassandraのデータモデルについて考えてみる

はじめに

こんにちは、Technology Innovation Group所属 DBチームの岩崎です。

私はDBチームとして様々なプロジェクトでOracleやPostgreSQLなどRDBの設計・構築に携わってきました。
現在は、Cassandraの導入とデータモデルを設計しています。

本稿ではDBネタとしてRDB脳から脱却して、KVSならではのテーブル設計の勘所をお伝えいたします。

Cassandraはどのようなデータベースなのか

CassandraはKVS(Key-ValueStore)と呼ばれ、KeyとValueを組み合わせる単純な構造からなるDBです。
データアクセスはkeyに対して行い、Keyに関連付けられたValueが呼び出されます。

KVSは一般的にスキーマレスを採用することが多いと思いますが、Cassandraではアプリケーションの観点から見て、データは構造的に扱える方が開発・運用・管理していく上でメリットがあるとのスタンスを取っているため、スキーマレスではなくスキーマ定義を必要としています。

また、keyに対して複数のカラムを定義することが可能で、カラム型にはListやMapのようなコレクション型、いわゆるオブジェクト型のようなユーザ定義型(UDT:User Defined Type)の使用に対応しているため、JSONのような階層的なデータをスキーマ定義して柔軟に扱うことができるのが特徴です。

その他、本稿では触れませんが下記のような特徴を持っており、スケーラビリティ・アベイラビリティに重きを置いたデータベースと言えます。

  1. データをクラスタ内の複数ノードで分散保持しているため、性能・容量のリニアにスケール可能
  2. マスタレスアーキテクチャで、単一障害点がなくノード障害時のマスタ切り替え不要で可用性を厳格に保証
  3. データセンターを跨ぐクロスリージョン構成を取ることができ、広域災害時の高い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 (
id text
, body text
, tag list<text>
, keyword map<text, text>
, PRIMARY KEY(id)
);

レコード作成

レコード作成
INSERT INTO test_table (
id
, body
, tag
, keyword
) VALUES (
'01'
, 'Cassandraデータモデリングの紹介'
, ['Future','Tech Blog']
, {'author': 'Iwasaki', 'nosql':'cassandra'}
);

このようにCassandraでは扱うデータをテーブルとしてスキーマ定義して管理します。
次にデータアクセス方法について見ていきましょう。

データアクセス(PRIMARY KEY)

データベース内に作成したテーブルにアクセスするにはkeyをWHERE句に指定してアクセスを行います。
KVSの特徴は基本的にはkey以外をWHERE句の絞り込み条件に指定できないという点です。

keyを指定してデータアクセス

Key指定のデータアクセス
select * from test_table where id = '01';

id | body | keyword | tag
----+---------------------------------+---------------------------------------------+-------------------------
01 | Cassandraデータモデリングの紹介 | {'author': 'Iwasaki', 'nosql': 'cassandra'} | ['Future', 'Tech Blog']

key以外を指定してデータアクセス

Key以外を指定した場合のデータアクセス
select * from test_table where body = 'Cassandraデータモデリングの紹介';

-> key以外をWHERE句に指定したためクエリの実行に失敗する

このようにRDBでは当たり前のようにできたWHERE句による絞り込みが、KVSではできないということを念頭にデータモデルを設計する必要があります。

実際は全くできないというわけではないものの制限があるため、できないというスタンスにたって設計したほうが無難と考えられます。

データアクセス(PARTITION KEY)

CassandraはKVSのため基本的には上記のPRIMARY KEYによるデータアクセスになりますが、もう一つの特徴としてPARTITION KEYによるデータアクセスが可能です。

PARTITION KEYとはレコードをカラム単位で集約するキーのことを意味します。

先ほど作成したtest_tableに対して日付単位で集約できるようにPARTITION KEYを追加してみましょう。

テーブル定義

PRIMARY KEYの先頭項目がPARTITION KEYと認識されます。
下記の例ではPARTITION KEYがdate、PRIMARY KEYはdateとidの複合になります。

パーティションキー付きのテーブル定義
CREATE TABLE test_part_table (
date text
, id text
, body text
, tag list<text>
, keyword map<text, text>
, PRIMARY KEY((date), id)
);

レコード作成

レコード作成
INSERT INTO test_part_table (
date
, id
, body
, tag
, keyword
) VALUES (
'2019-07-01'
, '01'
, 'パーティションキーテスト1'
, ['Future','Tech Blog']
, {'author': 'Iwasaki', 'nosql':'cassandra'}
);

INSERT INTO test_part_table (
date
, id
, body
, tag
, keyword
) VALUES (
'2019-07-01'
, '02'
, 'パーティションキーテスト2'
, ['Future','Tech Blog']
, {'author': 'Iwasaki', 'nosql':'cassandra'}
);

INSERT INTO test_part_table (
date
, id
, body
, tag
, keyword
) VALUES (
'2019-07-02'
, '01'
, 'パーティションキーテスト3'
, ['Future','Tech Blog']
, {'author': 'Iwasaki', 'nosql':'cassandra'}
);

PARTITION KEYを指定してデータアクセス

PARTITION KEYのdateをWHERE句の条件に指定してデータアクセスしてみると、PARTITION KEY単位で集約されたデータを取得することができます。

パーティションキーを指定してのデータアクセス
select * from test_part_table where date = '2019-07-01';

date | id | body | keyword | tag
------------+----+---------------------------+---------------------------------------------+-------------------------
2019-07-01 | 01 | パーティションキーテスト1 | {'author': 'Iwasaki', 'nosql': 'cassandra'} | ['Future', 'Tech Blog']
2019-07-01 | 02 | パーティションキーテスト2 | {'author': 'Iwasaki', 'nosql': 'cassandra'} | ['Future', 'Tech Blog']

-> 2019-07-01のレコード2件が取得可能

select * from test_part_table where date = '2019-07-02';

date | id | body | keyword | tag
------------+----+---------------------------+---------------------------------------------+-------------------------
2019-07-02 | 01 | パーティションキーテスト3 | {'author': 'Iwasaki', 'nosql': 'cassandra'} | ['Future', 'Tech Blog']

-> 2019-07-02のレコード1件が取得可能

このように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';

keyword
---------------------------------------------
{'author': 'Iwasaki', 'nosql': 'cassandra'}

Mapのkey要素であるauthorを指定してvalueを書き換えてみます。

Mapの要素を書き換え
UPDATE test_table SET keyword['author'] = 'Yuta' WHERE id = '01';

select keyword from test_table where id = '01';

keyword
------------------------------------------
{'author': 'Yuta', 'nosql': 'cassandra'}

Mapの要素を足してみます。

Mapの要素を追加
UPDATE test_table SET keyword = keyword + {'category': 'db'} WHERE id = '01';

select keyword from test_table where id = '01';

keyword
------------------------------------------------------------
{'author': 'Yuta', 'category': 'db', 'nosql': 'cassandra'}

Cassandraのデータモデリングについて考える

なんとなくCassandraでデータをどのように扱うか理解できましたでしょうか。

ここからはどのようにデータモデルを考えていくべきかをチャットテーブルを例に説明していきます。

チャットのデータモデル概要

チャット管理に関するリレーショナルモデルのテーブル設計を元に考えてみましょう。

  • チャットルーム
    • ユーザ、グループ毎にチャットを管理するテーブル。
  • チャット
    • チャットの投稿内容の詳細を管理するテーブル
  • リアクション
    • チャットの投稿に対するスタンプなどのリアクションを管理するテーブル

例えばRDBであれば上記のままの3つのテーブルを作成することが考えられます。

しかし、KVSは単一テーブルに対するキーアクセスのみを可能にしたデータベースであるため、RDBのように複数テーブル間を結合することができないという点がKVSとRDBとの最大の違いです。

KVSにおけるテーブル設計では、ある機能を実現するためのテーブルは基本的に1つで足りる(であろう)というスタンスに立ち、まず上記のテーブルを1テーブルに集約できないかを考えます。

また、KVSでは結合だけでなくトランザクションも存在しないため(単一レコードに対する軽量トランザクションのみ)テーブルを分割すると、アプリケーションの作りが複雑化してしまうという問題もあります。

そのため、まずはテーブルを集約することを意識していきましょう。

テーブル集約のポイント

RDBのようにフラットな階層でデータモデルを表現するためには結合が必須です。
しかし、KVSは配列やMapのようなコレクション型を扱えるという点が集約のポイントになります。

例えばチャットとリアクションの関係をコレクション型を利用して1つのテーブルとして表現してみましょう。
チャットに対してリアクションをコレクション型で定義することで1チャットに対してN数のリアクションを表現できます。

Map型を利用した集約

reactionをMap型で定義することでユーザ別のリアクションを表現できるようになりました。
更新時もMap要素を指定して行えるため追加や削除を柔軟に行えます。

Map型を利用したチャットテーブル定義
CREATE TABLE chat (
id text -- チャットID
, body text -- チャット本文
, user text -- 投稿者
, send_date text -- 投稿日付
, reaction map<text, text> -- リアクション
, PRIMARY KEY(id)
)

次にチャットルームとチャットの関係を1つのテーブルとして表現してみましょう。

チャットルームには最終投稿日付やチャットルーム参加者などチャットにとっての付随情報を管理をするため、RDBではチャットルームIDをFKとしてチャット側に持たせるという考え方になると思います。

ここもコレクションを利用してチャットルームにチャット情報を集約してみましょう。

chatをMap型で定義することでチャットテーブルにチャットの情報を持たせることができます。

Map型を利用したチャットルームテーブル定義
CREATE TABLE chatroom (
id text -- チャットルームID
, last_update text -- 最終更新日付
, member set<text> -- 参加者
, chat map<text, text> -- チャット情報
, PRIMARY KEY(id)
)

しかし、上記のようなMap型(map<text, text>)では単一項目のvalueしか持てないためチャットの情報を表現しきることができません。

そこでユーザ定義型(UDT)という複数のフィールドと型を定義して利用します。

UDT型を利用した集約

chatの情報をUDTとして定義してみましょう。

UDTはテーブルではなくタイプとして定義(CREATE)されます。タイプにはPKを指定する必要はありません。

UDTのフィールドにMapなどのコレクション型を定義することもできます。

UDT型の定義
CREATE TYPE chat_type (
body text -- チャット本文
, user text -- 投稿者
, send_date text -- 投稿日付
, reaction map<text, text> -- リアクション
);

上記で作ったUDTを利用してチャットルームとチャットを集約してみましょう。
先ほどの作ったUDTをデータ型として指定することができます。

ただし注意しなければならない点は、コレクション型の中でUDTを定義する場合、frozenという指定が必要になります。frozenの制約事項は後述します。

UDT型を利用したチャットルーム定義
CREATE TABLE chatroom (
id text
, chatroom_name text
, last_update text
, member set<text>
, chat map<text, frozen<chat_type>>
, PRIMARY KEY(id)
);
INSERT INTO chatroom JSON
'{
"id": "1",
"chatroom_name": "DB Tech Blog",
"last_update": "2019-07-01:12:00",
"member": ["iwasaki","sugiyama","mano"],
"chat": {
"01": {
"body" : "chat message 1",
"user" : "iwasaki",
"send_date" : "2019-07-01:10:00",
"reaction" : { "sugiyama" : "reaction 1"}
},
"02": {
"body" : "chat message 2",
"user" : "iwasaki",
"send_date" : "2019-07-01:11:00"
},
"03": {
"body" : "chat message 3",
"user" : "sugiyama",
"send_date" : "2019-07-01:12:00",
"reaction" : { "iwasaki" : "reaction 1"}
}
}
}'
;

select chat from chatroom where id = '1';

chat
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{'01': {body: 'chat message 1', user: 'iwasaki', send_date: '2019-07-01:10:00', reaction: {'sugiyama': 'reaction 1'}}, '02': {body: 'chat message 2', user: 'iwasaki', send_date: '2019-07-01:11:00', reaction: null}, '03': {body: 'chat message 3', user: 'sugiyama', send_date: '2019-07-01:12:00', reaction: {'iwasaki': 'reaction 1'}}}

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(
'{ "body" : "chat message update 1"}'
)
WHERE id = '1'

cqlsh:test> select chat from chatroom where id = '1';

chat
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{'01': {body: 'chat message update 1', user: null, send_date: null, reaction: null}, '02': {body: 'chat message 2', user: 'iwasaki', send_date: '2019-07-01:11:00', reaction: null}, '03': {body: 'chat message 3', user: 'sugiyama', send_date: '2019-07-01:12:00', reaction: {'iwasaki': 'reaction 1'}}}

下記のようにfrozen項目全体を含めて更新する必要があります。

UPDATE chatroom SET chat['01'] = fromJson(
'{
"body" : "chat message update 1",
"user" : "iwasaki",
"send_date" : "2019-07-01:13:00",
"reaction" : { "sugiyama" : "reaction 1"}
}'
)
WHERE id = '1'

このように一見便利なfrozenですが安易に利用してネストを深くすると更新要件を満たすことができなくなる可能性があるので、注意が必要です。

staticカラムを利用してテーブルを集約する

コレクション型を利用してチャットルームとチャットを集約するのは制約事項があることがわかりました。

Cassandraにおいてテーブル集約を考える際にもう一つ有効な手法としてstaticカラムというものがあります。

staticカラムとして定義することで対象のカラムはパーティション単位で同じデータが保持されるようになります。

staticカラムを利用したテーブル定義
create table static_test(
id text,
no text,
static_data text static,
non_static_data text,
primary key((id),no)
);

insert into static_test json '{"id":"1","no":"1","static_data":"static_1","non_static_data":"non_static_1"}';
insert into static_test json '{"id":"1","no":"2","static_data":"static_1","non_static_data":"non_static_1"}';
insert into static_test json '{"id":"2","no":"1","static_data":"static_2","non_static_data":"non_static_2"}';
insert into static_test json '{"id":"2","no":"2","static_data":"static_2","non_static_data":"non_static_2"}';

select * from static_test ;

id | no | static_data | non_static_data
----+----+-------------+-----------------
1 | 1 | static_1 | non_static_1
1 | 2 | static_1 | non_static_1
2 | 1 | static_2 | non_static_2
2 | 2 | static_2 | non_static_2

staticカラムを更新する

staticとして定義したstatic_dataを更新すると同一パーティション内のstatic_dataも更新されます。

staticカラムの更新
update static_test set static_data = 'static_test_1' where id = '1';

select * from static_test;

id | no | static_data | non_static_data
----+----+---------------+-----------------
1 | 1 | static_test_1 | non_static_1
1 | 2 | static_test_1 | non_static_1
2 | 1 | static_2 | non_static_2
2 | 2 | static_2 | non_static_2

-> PARTITION KEY = '01'に対する更新だが、同パーティション内のstatic_dataも更新される

staticで定義すると下記のように同一パーティション内のデータはstaticカラムを参照するようなデータ構造になるため、static項目を更新すると同一パーティション内のデータは全て更新されたように見えていたわけです。

{ "id" : 1, "static_data" : "static_test_1"  {
"no" : 1 { "non_static_data" : "non_static_1"},
"no" : 2 { "non_static_data" : "non_static_1"}
},
},
{ "id" : 2, "static_data" : "static_2" {
"no" : 1 { "non_static_data" : "non_static_2"},
"no" : 2 { "non_static_data" : "non_static_2"}
}
}

このstaticカラムを利用することで同一パーティションキー間でテーブル結合することなく共有することができます。

チャットとチャットルームにおける関係にも利用できそうです。

staticカラムを利用してテーブルを集約する

チャットにチャットルームIDをFKとして持たせてチャットルームの情報を引っ張ってくるRDB的な結合を、チャットテーブルにstatcカラムとしてチャットルームの情報を定義することでテーブル集約を実現することができます。

staticカラムを利用したチャットテーブル定義
CREATE TABLE chat (
chatroom_id text -- チャットルームID
, chat_id text -- チャットID
, chatroom_name text static -- チャットルーム名
, last_update text static -- 最終更新日付
, member set<text> static -- チャット参加者
, body text -- チャット本文
, user text -- 投稿者
, send_date text -- 投稿日付
, reaction map<text, text> -- リアクション
, PRIMARY KEY((chatroom_id), chat_id)
);

staticカラムでテーブル集約したことにより、コレクション型で集約した時に比べて、投稿数 = レコード数となるため、投稿数の制約が無くなります。

また、レコード更新時もネストが1段階浅くなりfrozenを利用せずチャットの情報を保持することができるため、個別更新にも対応できる柔軟なデータモデルになったと言えます。

まとめ

Cassandraのデータモデルを考える際にはついついRDB的な思考で結合ありきのデータモデルでテーブル設計を行ってしまいますが、まずはテーブルを集約できるかを念頭に見つめ直してみましょう。

最後にデータモデルを考える上での考慮点をまとめてみました。

  • 結合ありきのテーブル設計になっていないか
  • コレクション型を利用してテーブル間の関係を集約できるか
  • 集約時にネストが深くなりすぎないか、また更新要件を満たすことができるか
  • staticカラムを利用してパーティション単位の情報を共有させることで集約できるか

他にもデータモデル設計時に検討した項目はありますが、それはまた別の機会に紹介できれば幸いです。
ぜひ皆さんもRDB脳から脱却してKVSを使いこなしてみましょう!!