この記事はEric Evans氏の記事「http://www.rackspacecloud.com/blog/2010/05/12/cassandra-by-example/」を、氏の許可を得て翻訳したものです。(原文公開日:2010年5月12日)
最近、Cassandraは注目を集めており、今まで以上に多くの人が組織で使おうと評価しています。こういった人々がCassandraについて詳しく知ろうとするにつれ、私たちのドキュメントが不足していることが明らかになってきました。その中で最たるものは、既存のリレーショナルデータベースのバックグラウンドを持つ人に対するデータモデルの説明です。
問題はCassandraのデータモデルが、伝統的なデータベースのデータモデルと比べて、混乱を引き起こしかねないほど異なっており、それを正そうとして行われた様々な説明が同じく誤解を生み出しているということです。
モデルについてマップのマップと説明されることがあります。これはスーパーカラムに対してはマップのマップのマップとなります。このような説明は、実例を示すためにJSON風の記法を用いて視覚的に行われることもあります。また、カラムファミリーがスーパーステーブルに喩えられることがあれば、カラムオブジェクトのコレクションを保持するコンテナに喩えられることもあります。時には、カラムが3タプルとされます。私から見るとこれらの説明はいずれも十分ではありません。
問題は、新しいものについて比喩を使わずに説明するのが難しいということです。しかし、比較に説得力がなければ混乱するだけです。Cassandraのデータモデルについて説明するための素晴らしい方法を誰か考えだしてくれないかと思っていますが、差し当たりはその価値に見合うような具体例を見つけることにします。
TwitterはCassandraの実際の利用例であると同時に、よく知られたサービスで、容易に概念化できることから議論の題材としても優れています。私たちは例えば次のようなことを知っています。ほとんどのサイトと同じようにユーザ情報(名前、パスワード、e-mailアドレスなど)が全員分あり、それらのエントリはフレンドとフォロワを紐づけるために相互にリンクされています。さらに、ツイートを保存することができなければTwitterとは呼べません。ツイートは140文字のテキストに加えて、タイムスタンプやURLに含まれるユニークなIDのようなメタデータに関連づけられています。
これをリレーショナルデータベース上でモデリングする場合、アプローチは比較的シンプルなものになります。まず、ユーザを保存するテーブルが必要です。
CREATE TABLE user ( id INTEGER PRIMARY KEY, username VARCHAR (64), password VARCHAR(64) );
さらに、フォローしている人とフォローされている人を返すための1対多を実現するテーブルが必要です。
CREATE TABLE followers ( user INTEGER REFERENCES user(id), follower INTEGER REFERENCES user(id) ); CREATE TABLE following ( user INTEGER REFERENCES user(id), followed INTEGER REFERENCES user(id) );
もちろん、ツイート自体を保存するテーブルも必要でしょう。
CREATE TABLE tweets ( id INTEGER, user INTEGER REFERENCES user(id), body VARCHAR(140), timestamp TIMESTAMP );
デモのため、物事を過度に単純化していますが、このようなちょっとしたモデルであっても、当然のこととされているものが多くあります。例えば、データ正規化のため、実践的には外部キー制約が必要になりますし、複数のテーブルからデータを取得するのに結合を行う必要があるので、それを効率に行うためには適切な属性に任意のインデックスを張る必要があります。
これらに対して、分散システムを正しく稼働させるということは大きな変化であり、トレードオフなしには実現できません。このことはCassandraにおいても該当し、そのため上記のデータモデルは機能しません。まず、参照の整合性は存在しません。また2次インデックスがサポートされないので結合を効率的に行うことができず、そのため正規化を崩す必要があります。別の言い方をすれば、実行するクエリと期待する結果の観点から考える必要があります。おそらくはそれがモデルがどう見えるかということを示すものだからです。
Twissandra
それでは、前述したモデルはCassandraにおいてどう表現されるでしょうか?幸いにも私たちはTwissandraを参照することができます。これは機能を必要最小限に抑えたTwitterのクローンであり、Eric Florenzano氏によりサンプルとして書かれたものです。TwitterとTwissandraを例に用いつつ、Cassandraにおけるデータモデリングについて検討していきましょう。
スキーマ
Cassandraはスキーマレスのデータストアであると考えられています。しかしアプリケーションに応じてある程度の定義を行う必要はあります。Twissandraには、動作に必要なCassandraの定義が備わっていますが、ここで一旦立ち止まり、データモデルに関連した特別な側面を検討する価値はあるでしょう。
キースペース
キースペースとは、Cassandraにおける最上位のネームスペースであり、基本的には各アプリケーションにおいて1つだけ存在します。将来的には、RDBMSにおいてデータベースを作るのと同じように動的に作られる予定ですが、0.6以前では、メインのコンフィグレーションファイルなどで定義されています。
<Keyspaces> <Keyspace Name="Twissandra"> ... </Keyspace> </Keyspaces>
カラムファミリー
各キースペースにおいて、1つ以上のカラムファミリーが存在します。カラムファミリーとは類似のレコードを関連させるために用いられる名前空間です。Cassandraは書き込みの際、レコードレベルのアトミック性をカラムファミリー内において保証し、カラムファミリーに対するクエリは効率的になります。データモデルを設計する際にこの性質を覚えておく事は重要です。その理由は以降の議論で明らかになります。
キースペースと同様、カラムファミリー自体もメインコンフィグにおいて定義されます。将来的には、RDBMSにおいてテーブルを作成するのと似たようなやり方で、オンザフライに生成する事ができるようになります。
<Keyspaces> <Keyspace Name="Twissandra"/> <ColumnFamily CompareWith="UTF8Type" Name="User"/> <ColumnFamily CompareWith="BytesType" Name="Username"/> <ColumnFamily CompareWith="BytesType" Name="Friends"/> <ColumnFamily CompareWith="BytesType" Name="Followers"/> <ColumnFamily CompareWith="UTF8Type" Name="Tweet"/> <ColumnFamily CompareWith="LongType" Name="Userline"/> <ColumnFamily CompareWith="LongType" Name="Timeline"/> </Keyspace> </Keyspaces>
上に抜粋したコンフィグでは、カラムファミリーの名前に加えて比較子("comparator")も定義しています。これは伝統的なデータベースとの重要な違いを示すものです。つまり、レコードがソートされる順序は設計レベルの意思決定であり、後で容易に返られるものではないということです。
カラムファミリーとは何か?
これら7つのTwissandraカラムが何のためにあるかは、直感的にすぐ理解できるものではありません。したがって、それぞれについてさらに詳細に見て行きましょう。
- User
これはユーザが保存される場所であり、上記のSQLスキーマにおけるユーザテーブルに該当します。このカラムファミリーにおいて保存されるレコードはUUIDに紐づけられ、ユーザ名とパスワードというカラムを保持します。
- Username
上記のユーザカラムファミリーを検索するには、ユーザのキーを知っている必要があります。しかし、ユーザ名しか分からなかった場合、どうすればこのようなUUIDベースのキーを見つける事ができるでしょうか。リレーショナルデータベースと上記のSQLスキーマを用いた場合であれば、ユーザテーブルに対し、ユーザ名をマッチさせるような(WHERE username = ‘jericevans’)SELECTを実行するでしょう。しかし、これは2つの理由からCassandraで機能しません。
まず、リレーショナルデータベースはこういったSELECTを実行する際に、テーブルをシーケンシャルにスキャンします。しかし、Cassandraではレコードがキーに基づいてクラスターの間に分散されているので、同じ事をやろうとすると1つ以上のノードとやり取りすることになります(おそらくそのノードの数は多いでしょう)。しかし、すべてのデータが単一のマシン上にあったとしても、このような操作がリレーショナルデータベースで非効率になることもあり、その場合にはユーザ名属性にインデックスを張る必要が生じます。前述した通り、Cassandraは今のところ、このような2次インデックスをサポートしません。
これに対する解答は、読み取り可能なユーザ名からUUIDに紐づくキーへの転置インデックスを生成することになります。そしてこれこそがカラムファミリーの目的なのです。
- Friends
- Followers
FriendsとFollowersカラムファミリーは、ユーザXがフォローしているのは誰か?、誰がユーザXをフォローしているか?という問いにそれぞれ答えます。それぞれは一意のユーザIDに紐づいており、対応する関連を追跡するためのカラム群と生成された時刻を保持します。
これは、ツイート自体が保存される場所です。このカラムファミリーが保持しているレコードは、一意のキー(UUID)、ユーザIDに対応するカラム、本文、そしてツイートが追加された時間です。
- Userline
これは、保存された各ユーザに対応するタイムラインです。このレコードは、ユーザIDキーと、Tweetカラムファミリーにおける一意のツイートIDとタイムスタンプを紐づけるカラムから構成されます。
- Timeline
このカラムファミリーはUserlineに似ていますが、各ユーザのためにフレンドのツイートのマテリアライズドビューを保持する点が異なります。
上記のカラムファミリーを踏まえて、いくつか一般的な操作を行い、これらがどのように適用されるのかを見て行きましょう。
すべてを結びつける
ユーザを新規追加する
まず、新しいユーザにはアカウントにサインアップする方法が必要です。その際、ユーザはCassandraデータベースに追加されます。Twissandraにおいて、この処理は以下のようになります。
username = 'jericevans' password = '**********' useruuid = str(uuid()) columns = {'id': useruuid, 'username': username, 'password': password} USER.insert(useruuid, columns) USERNAME.insert(username, {'id': useruuid})
TwissandraはPythonで書かれており、クライアントアクセスにはPycassaを使用しています。上に示した大文字のUSERとUSERNAMEがpycassaです。ColumnFamilyのインスタンスは、それぞれ"User"と"Username"の初期化の際に別の場所で生成されています。
ここで追記しておくと、上記のコードやこの後に出てくるサンプルは、Twissandraのものをそのまま切り取ったものではありません。より簡潔にになるように、あるいは自分の気に入るように変更を加えています。例えば、上記のコードでは、ユーザ名やパスワードに変数を割り当てる意味はありません。Webアプリケーションにおいて、これらはサインアップページのフォームエレメントから取得されます。
サンプルに戻ると、ここでは2つの異なるCassandraの書き込み(insert()
)処理が登場します。1つはUserカラムファミリーに新しいレコードを追加する処理であり、もう1つは人間に読む事ができるユーザ名をUUIDキーにマッピングさせる転置インデックスの更新処理です。どちらのケースもinsert()
の引数は、後でレコードを検索するのに利用するキーと、カラム名と値を保持するマップです。
フレンドをフォローする
frienduuid = 'a4a70900-24e1-11df-8924-001ff3591711'
FRIENDS.insert(useruuid, {frienduuid: time.time()})
FOLLOWERS.insert(frienduuid, {useruuid: time.time()})
ここでも別々のInsert()
処理を実行しています。今回の場合は誰かをフレンドのリストに追加し、その関係を逆から追跡するために新しいフォロワーを対象となるユーザに追加しています。
ツイートする
tweetuuid = str(uuid()) body = '@ericflo thanks for Twissandra, it helps!' timestamp = long(time.time() * 1e6) columns = {'id': tweetuuid, 'user_id': useruuid, 'body': body, '_ts': timestamp} TWEET.insert(tweetuuid, columns) columns = {struct.pack('&gt;d'), timestamp: tweetuuid} USERLINE.insert(useruuid, columns) TIMELINE.insert(useruuid, columns) for otheruuid in FOLLOWERS.get(useruuid, 5000): TIMELINE.insert(otheruuid, columns)
新しいツイートを保存するには、新しく生成されたUUIDをキーとして利用し、Tweetカラムファミリーに新しいレコードを生成します。カラムとしては、ツイートした人のユーザIDと生成された時刻、そしてもちろんツイート自体のテキストがあります。
加えて、このユーザのUserlineはツイートの時刻を一意のIDに紐づけるために更新されます。もし、これがユーザの初めてのツイートであれば、insert()
の結果新しいレコードが追加され、その後に続くインサートによってこのレコードに新しいカラムが追加されます。
最後に、このユーザとフォロワーのため、Timelineが時刻とツイートIDを紐づけるカラムと共に更新されます。
ここで使用されているタイムスタンプがlong(64 bit)である事は注目に値します。そしてこれがカラム名として与えられた場合には、ネットワークバイトオーダーにおいてバイナリ値としてパッキングされます。この理由から、UserlineとTimelineカラムファミリーはLongType比較子を使用しているのであり、このことによって数述語("numeric predicate")を用いてカラムの範囲検索を行う事ができ、その結果も数字の順序に従ってソートされるのです。
ユーザのツイートを取得する。
timeline = USERLINE.get(useruuid, column_reversed=True)
tweets = TWEET.multiget(timeline.values())
ここで、ユーザからツイートを検索しています。まずUserlineからIDのリストを取得し、その後でmultiget()
を用いてTweetカラムファミリーからフェッチしています。この結果は日時の数字順にソートされます。その際、UserlineがLongTypeの比較子を使用し、reversedがTrueに設定されていることから、順序は降順となります。
ユーザのタイムラインを検索する
start = request.GET.get('start') limit = NUM_PER_PAGE timeline = TIMELINE.get(useruuid, column_start=start, column_count=limit, column_reversed=True) tweets = TWEET.multiget(timeline.values())
前述の例と同じく、ここではツイートIDのリストを検索していますが、今回はTimelineが対象となります。しかし、今回は返されるカラムの範囲を制御するためにstartとlimitを使用しています。これは結果のページングを行うのに便利です。
次のステップ
一般的な考え方について、これで十分理解して頂けていたらと思います。繰り返しますが、簡潔にするためにコードのサンプルの一部を改変し、いくつかの処理を省略しています。したがって、ここでTwissandraのソースをチェックアウトし、より深く調べてみるのも良いかもしれません。リツイートやリストのように、意図的に実装されていないものも数多くありますが、これは入門時のエクササイズとして使えるようにするためです。PythonとDjangoで事が足りるのならば、どちらかを試してみても良いでしょう。
wikiに書かれている情報は増えており、そこに含まれる「記事とプレゼンテーション」の一覧も更新されています。
IRCを使っているのであれば、irc.freenode.netの#cassandraに参加できます。ここでは先達とチャットができますし、彼らはいつでも喜んで質問に答えるでしょう。もしEメールの方が好みであれば、cassandra-userリスト上にも助けてくれる人がたくさんいます。