Ameba Smart Phone PlatformのAPI開発を担当している狭間と申します。今回はAmeba Smart Phone Platformで使用しているCassandraのデータ設計時に気をつけていることを実際に起きた事例を交えてお話したいと思います。
Cassandraのverstionは1.1.5を使用していて、100台構成のクラスタを組んでいます。ピーク帯ではおよそ50000write/sec、40000read/secのリクエストを処理していて、およそ45TBのデータを保持しています。そのような条件下で発生した事例と対処方法を紹介させていただきます。
Cassandraのverstionは1.1.5を使用していて、100台構成のクラスタを組んでいます。ピーク帯ではおよそ50000write/sec、40000read/secのリクエストを処理していて、およそ45TBのデータを保持しています。そのような条件下で発生した事例と対処方法を紹介させていただきます。
Ameba Smart Phone Platformでの使用例
Cassandraでは以下の様なデータ構造をもっていてデータの最小単位はColumnで以下の様な構造をしています。
Column | |||
---|---|---|---|
name | value | timestamp | ttl |
CassandraではColumnFamilyと呼ばれる単位でデータがまとめられいて(RDBでいうテーブルに近いイメージ)、ColumnFamilyは複数のrowを持ち、rowは複数のColumnを持ちます。Columnは動的に追加することが可能で、Row keyとColumnのnameを指定してデータを取得します。
RowkeyとColumnのnameを指定してデータを保存、取得するというのが最も単純な使い方ですが、nameがソートされて格納されることを利用して範囲指定で一括取得などを行うことができます。nameはデータ型を指定でき(long、UTF-8など)、そのデータ型に応じたソート順で保存されます。
我々は使っているデータ構造として代表的なものを幾つか紹介します。
開発当初よく使用していたのは単純なKVSの用に利用するパターンです(開発初期は他のデータストアの採用も検討していたので)。下記の図の用にnameを固定にしてrow keyを指定してvalueを取得するような形で利用します。valueにはJson形式のデータを格納していることが多いです。
Row Key | Column | |
---|---|---|
name | value | |
1 | _ | "value" |
2 | _ | {"aaaaa":"bbbb"} |
3 | _ | {"ccccc":"ddddd","eeeee","fffff"} |
上記のKVS方式でも実現できるのですが、格納するJsonのフィールドが多くなってくると、読み込み時に必要のないフィールドまで取得してしまったり、更新時に一度全データを取得する必要があったりと、無駄が多くなってくるのでの下図の用にcolumn nameをフィールド名にしてRDBのテーブルの様な形でデータを保存しています。
Row Key | Column | |
---|---|---|
name | value | |
1 | name | 太郎 |
bloodType | A | |
birthday | 1980-01-01 | |
2 | name | 花子 |
bloodType | O | |
birthday | 1981-01-01 |
Cassandraではcolumnがnameのデータ型によってソートされるので、それを利用して下記のようにunix timeをnameに指定することでタイムラインの様なデータ構造を比較的容易に実現することができます(実際には衝突回避のためにunix timeとatomicなカウンターを組み合わせています)。が、この形は気をつけないととんでもないことになります。理由は後述します。
Row Key | Column | |
---|---|---|
name | value | |
1 | 1404044261 | data1 |
1404044265 | data2 | |
1404044275 | data3 | |
2 | 1404044261 | data1 |
1404044269 | data2 | |
1404044278 | data3 |
またnameの値を指定でColumnを取得することができるのでColumn nameを検索キーの様な形で使用することもできます。下記の様な形式ででRow keyがユーザのID、Column nameにユーザのフレンドのユーザIDが入っていて、valueにフレンドになった時刻が入っているとすると、Row keyとColumn name指定でデータを取得できるのでIDが1のユーザとIDが2のユーザがフレンドかどうかの判定を行ったりすることができます。
Row Key | Column | |
---|---|---|
name | value | |
1 | 2 | {"time" : "2014-06-30T02:12:33Z"} |
3 | {"time" : "2014-05-30T02:12:33Z"} | |
2 | 1 | {"time" : "2014-06-30T02:12:33Z"} |
3 | 1 | {"time" : "2014-05-30T02:12:33Z"} |
実際に運用してきて起きた問題と対策
運用当初ははデータ量も少なくそれほど問題にはならなかったのですが、サービスが拡大するにつれ様々な問題が発生しました。ここではアプリ起因となって発生した問題とその時に行った対策を紹介したいと思います。
特定のnodeのみ負荷があがる
運用開始してしばらくしてから、特定のnodeのみ負荷があがるという現象が発生しました。データのレプリケーション数分のnodeの負荷だけが上がっていたので、そのnodeが保持しているデータに問題があるだろうとあたりを付け調査した所、特定のデータのreadが大量に発生していたことが原因でした。
マスタ系のデータを先ほどの例の4番目のような形(column nameを検索キーとして使用するよう形)で保存していて、全ユーザーのアクセスが一つのキーに集中していました。そもそもなんでそんなものをCassandraに入れるんだよと言われると何も言えないのですが、、、データ反映に遅延があっても問題ないデータだったのでアプリ側でキャッシュすることでとりあえずしのいでいます。
Cassandraはwriteに比べreadは弱く、またkey単位での分散なのでこういったデータは保存しないほうが良いです(お前が言うなって感じでしょうが)。まだ移行はできていないのですが、別のデータストアへの移行を検討中です。
データ設計の話ではないのですが、監視はやっておいた方がよいと思います。当時は監視もきちんと行われていなくて、実際に問題が発生するまで気づかず、発生してからもJMXの値を手動で更新して調査を行っていたので、原因の特定にも時間がかかりました。現在はGrothForecastなどで異常なデータアクセスなどがないか監視を行っているので、こういった問題が起きそうかどうか事前に把握できるようになっています。監視周りに関しては弊社の@oranieがブログに書いていますので、興味があるようでしたらそちらもご覧になって下さい。
マスタ系のデータを先ほどの例の4番目のような形(column nameを検索キーとして使用するよう形)で保存していて、全ユーザーのアクセスが一つのキーに集中していました。そもそもなんでそんなものをCassandraに入れるんだよと言われると何も言えないのですが、、、データ反映に遅延があっても問題ないデータだったのでアプリ側でキャッシュすることでとりあえずしのいでいます。
Cassandraはwriteに比べreadは弱く、またkey単位での分散なのでこういったデータは保存しないほうが良いです(お前が言うなって感じでしょうが)。まだ移行はできていないのですが、別のデータストアへの移行を検討中です。
データ設計の話ではないのですが、監視はやっておいた方がよいと思います。当時は監視もきちんと行われていなくて、実際に問題が発生するまで気づかず、発生してからもJMXの値を手動で更新して調査を行っていたので、原因の特定にも時間がかかりました。現在はGrothForecastなどで異常なデータアクセスなどがないか監視を行っているので、こういった問題が起きそうかどうか事前に把握できるようになっています。監視周りに関しては弊社の@oranieがブログに書いていますので、興味があるようでしたらそちらもご覧になって下さい。
特定のnodeのデータが肥大化する
運用を開始して半年くらい経ってからnodeごとのデータの偏りが顕著になってきて、一部nodeだけディスク容量が足りなくなるという自体が発生しました。肥大化していたのはユーザのフレンドのアクティビティを時系列に保存しているところでした。
アクティビティの情報はcolumn nameをunix timeにしてユーザのフレンドが何らかのアクションを起こすたびにcolumnを追加するという方式で実装していて、ttlを設定して一定時間経過後に消えるようにはしていたのですが、それ以外に特に制限はなくcolumnが無限に増えていくような設計になっていました。そのためフレンドが多いユーザのrowは大量のカラムを持つこととなり、そういったユーザのデータを保持しているnodeのみデータが肥大化するという結果になりました。
これに関してひとまずnodeの追加を行ってディスクがいっぱいになったnodeのデータを分散して持つことでしのいだのですが、row keyでの分散を行っているcassandraではcolumnが際限なく増えていくような設計は避けたほうがいいと思います。これに関してはRow keyを日別に作ってRowを分割するような方法で対応しました。
Columnの削除はtombstoneと呼ばれるプロパティが設定され、データが読み込まれなくなるだけでcompactionが行われるまで削除されません。columnの追加、削除を大量に行われるような機能を作ると気づいたらディスクが足りないなんてことになるかもしれないので、そのような機能を作る場合には注意しておいたほうがよいと思います。
またColumnの削除にはもう一つ気をつけたほうがいいことがあって、それはtombstoneがついたcolumnは読み込み時にスキップされているだけであるということです。そのため削除される可能性のあるcolumnを持つRowをColumn nameの範囲指定で一括取得するような処理を行う場合には注意が必要です。先ほど紹介したフレンドのアクティビティの例ではColumnのnameが時刻でttlによってColumnの削除を行っているので、範囲指定で一括取得する際に現在時刻とColumn追加時に指定しているttlから残っているColumnの中で最も古いものの時刻を計算して、必ずその範囲内で取得するようにしています。
まとめ
Cassandraは構築が比較的容易でデータ構造も柔軟なため非常に扱いやすいように見えますが、実際に運用を始めると一筋縄でいかない部分が多いです。
Cassandraの採用を検討している方、実際に運用されている方の少しでも参考になれば嬉しいです。またもっとこうしたほうがよい等のご意見があれば参考にさせていただきたいのでぜひ教えて下さい。
アクティビティの情報はcolumn nameをunix timeにしてユーザのフレンドが何らかのアクションを起こすたびにcolumnを追加するという方式で実装していて、ttlを設定して一定時間経過後に消えるようにはしていたのですが、それ以外に特に制限はなくcolumnが無限に増えていくような設計になっていました。そのためフレンドが多いユーザのrowは大量のカラムを持つこととなり、そういったユーザのデータを保持しているnodeのみデータが肥大化するという結果になりました。
これに関してひとまずnodeの追加を行ってディスクがいっぱいになったnodeのデータを分散して持つことでしのいだのですが、row keyでの分散を行っているcassandraではcolumnが際限なく増えていくような設計は避けたほうがいいと思います。これに関してはRow keyを日別に作ってRowを分割するような方法で対応しました。
Columnの削除はtombstoneと呼ばれるプロパティが設定され、データが読み込まれなくなるだけでcompactionが行われるまで削除されません。columnの追加、削除を大量に行われるような機能を作ると気づいたらディスクが足りないなんてことになるかもしれないので、そのような機能を作る場合には注意しておいたほうがよいと思います。
またColumnの削除にはもう一つ気をつけたほうがいいことがあって、それはtombstoneがついたcolumnは読み込み時にスキップされているだけであるということです。そのため削除される可能性のあるcolumnを持つRowをColumn nameの範囲指定で一括取得するような処理を行う場合には注意が必要です。先ほど紹介したフレンドのアクティビティの例ではColumnのnameが時刻でttlによってColumnの削除を行っているので、範囲指定で一括取得する際に現在時刻とColumn追加時に指定しているttlから残っているColumnの中で最も古いものの時刻を計算して、必ずその範囲内で取得するようにしています。
まとめ
Cassandraは構築が比較的容易でデータ構造も柔軟なため非常に扱いやすいように見えますが、実際に運用を始めると一筋縄でいかない部分が多いです。
Cassandraの採用を検討している方、実際に運用されている方の少しでも参考になれば嬉しいです。またもっとこうしたほうがよい等のご意見があれば参考にさせていただきたいのでぜひ教えて下さい。