簡易ブログサービスの開発
前置きが長くなってしまいましたが、ここから本題のHBaseを使った簡易ブログサービスを作っていきましょう。
要件定義
まず、簡易ブログサービスの要件定義をしましょう。以下の機能を持つブログサービスを作っていきます。
- ブログ記事を投稿、更新、削除できる
- ブログ記事を最新順で閲覧できる
- カテゴリ別にブログ記事を閲覧できる
- 記事を閲覧する際にページングができる
論理設計
論理設計についてはRDBと同じです。通常は、エンティティを抽出、定義し、その正規化を行いER図を作成していきますが、その工程については本連載の内容から外れるため割愛します。
今回の簡易ブログサービスのER図は以下のようになります。
物理設計
ここからがRDBとは違うところになります。RDBでは、論理設計によって得られたER図からテーブル定義を行います。基本的にはエンティティがそのままテーブルとして定義されますが、多対多の関連となっているものは関連テーブルとして定義されます。そして、実際のクエリやパフォーマンスを考慮してインデックスをどう張るかを考えるという流れになるかと思います。
HBaseの場合は、順序が違います。まず実際のクエリから考え、その後に、物理的なスキーマ設計を考えていくという流れです。
大規模なデータをもつシステムのスキーマ設計の原則としてDDIがあります。DDIとは、Denormalization(非正規化)、Duplication(複製)、Intelligent Keysの頭文字をとったものです(※1)。
HBaseでは標準でJoinがないので、同じデータでもDuplication(複製)を行い、スキーマをDenormalization(非正規化)することは常套手段となっています。また、標準でセカンダリインデックスもないので、RowKeyをうまく使って(Intelligent Keys)多様な検索に対応します。
Joinもセカンダリインデックスもないなんて扱いづらいと感じる方もいると思いますが、大規模なデータを扱うための仕方のないトレードオフです。
クエリ設計
それでは実際に、簡易ブログサービスのクエリを考えていきましょう。要件定義からクエリを考えていくと、以下のようなメソッドを実装すれば良いと思います。
// ブログ記事投稿 void postArticle(long userId, String title, String content, int categoryId) throws IOException; // ブログ記事更新 void updateArticle(Article article, String newTitle, String newContent) throws IOException; // ブログ記事削除 void deleteArticle(Article article) throws IOException; // ブログ記事の取得(最新順) List<Article> getArticles(long userId, int length, Article lastArticle) throws IOException; // ブログ記事の取得(カテゴリ別) List<Article> getArticles(long userId, int categoryId, int length, Article lastArticle) throws IOException;
getArticlesは最新順、カテゴリ別の2種類あります。それぞれの引数にlastArticleがありますが、これはページングに必要な引数です。使い方は、取得したArticleリストの一番最後のArticle指定することで、それ以降のArticleリストを取得できるというものです。nullを指定すると、一番最初から取得できます。
Articleクラスは、以下のようになります。
public class Article { // 記事ID private long articleId; // ユーザID private long userId; // ユーザ名 private String userName; // タイトル private String title; // 本文 private String content; // カテゴリID private int categoryId; // カテゴリ名 private String categoryName; // 投稿日時 private long postAt; // 更新日時 private long updateAt; // ... setterやgetterは省略 }
スキーマ設計
それでは、簡易ブログサービスのスキーマ設計に入ります。考え方としては、データをどのようにRowKey、ColumnFamily、Column、Timestamp、Valueにマッピングするかということになります。
HBaseではJoinがないため、非正規化をいくつか行います。userNameはuserIdから一意に決まる情報ですが、今回は同じスキーマの中に入れます。categoryNameとcategoryIdも同様です。当然、非正規化を行うと整合性を保つことが難しくなります。代わりに、読み込むときに一度にすべての情報を取得できるので高速になります。
一番シンプルなスキーマを考えてみましょう。RDBと同じようにRowKeyには主キーとなるarticleIdを割り当て、その他をColumnとして割り当てます。ColumnFamilyは今回は1つしか使わず、dataの頭文字の"d"という名前にしましょう。これはColumnFamilyやColumnは各エントリに保存されるため、短いほうが効率的になるためです。
また、Timestampはデータを追加・更新するときの時間を使うことにします。このとき、物理的なストレージフォーマットとしては以下のように格納されます。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
articleId | "d" | "userId" | timestamp | userId |
articleId | "d" | "userName" | timestamp | userName |
articleId | "d" | "title" | timestamp | title |
articleId | "d" | "content" | timestamp | content |
articleId | "d" | "categoryId" | timestamp | categoryId |
articleId | "d" | "categoryName" | timestamp | categoryName |
articleId | "d" | "postAt" | timestamp | postAt |
articleId | "d" | "updateAt | timestamp | updateAt |
このスキーマだと、記事ID指定の記事の作成、更新、削除はできますが、ユーザ単位の記事の検索をするためにすべてのRowをScanしなければなりません。
クエリ設計から、すべてのクエリでuserIdを指定するので、RowKeyの先頭に持ってきましょう。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
userId-articleId | "d" | "userId" | timestamp | userId |
userId-articleId | "d" | "userName" | timestamp | userName |
userId-articleId | "d" | "title" | timestamp | title |
userId-articleId | "d" | "content" | timestamp | content |
userId-articleId | "d" | "categoryId" | timestamp | categoryId |
userId-articleId | "d" | "categoryName" | timestamp | categoryName |
userId-articleId | "d" | "postAt" | timestamp | postAt |
userId-articleId | "d" | "updateAt | timestamp | updateAt |
これで、userIdをプレフィックスとして指定すれば、ユーザ単位の記事を検索できるようになりました。しかし、これでも最新順で取得するためには、userIdをプレフィックスとして指定してScanした後にアプリケーションでソートする必要があります。
ここで、RowKeyにlong型の最大値から投稿日時を引いたもの(Long.MAX_VALUE - postAt)を入れます。これはHBaseでよく使われるテクニックで、これにより最新順でソートされた状態で取得できます。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "userId" | timestamp | userId |
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "userName" | timestamp | userName |
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "title" | timestamp | title |
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "content" | timestamp | content |
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "categoryId" | timestamp | categoryId |
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "categoryName" | timestamp | categoryName |
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "postAt" | timestamp | postAt |
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "updateAt | timestamp | updateAt |
ここで、容量の節約のために、userNameやtitle、contentなどを、各データをシリアライズして1つにまとめてしまいましょう。この際にColumnは必要なくなるので空にしてしまいます。これもHBaseではよく使われるテクニックで、Columnを空にしてしまいRowKeyとValueのみを使うことで、キーがソートされているキーバリューストアのように扱うことが可能です。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
userId-(Long.MAX_VALUE - postAt)-articleId | "d" | "" | timestamp |
{articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt}
|
このようにすると容量の節約は可能になりますが、当然、更新時にすべてのデータを含めてPutしなければなりませんし、どれかのデータに対してCAS操作やIncrementができなくなるというデメリットはあります。
次にカテゴリごとの取得を考えましょう。HBaseにはセカンダリインデックスが標準ではないので、マニュアルにセカンダリインデックスを作成します。セカンダリインデックスのスキーマは以下のようになります。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
userId-categoryId-(Long.MAX_VALUE - postAt)-articleId | "d" | "" | timestamp | {articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt} |
Valueには、各データをシリアライズしたものを入れています。ここでも、非正規化を行っています。もちろん、Valueに最初のスキーマのRowKeyなどを入れておきルックアップするという選択肢もあります。
その場合は、非正規化によるデータの不整合が起きない代わりに、ランダムGetを行わなければならず読み込み時に多少遅くなってしまいます。
今回は2つのスキーマを同じTableの中に入れるので、それぞれを区別する必要があります。userIdの次に、最初のスキーマには0を、セカンダリインデックスのスキーマには1を入れることにしましょう。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
userId-0-(Long.MAX_VALUE - postAt)-articleId | "d" | "" | timestamp | {articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt} |
userId-1-categoryId-(Long.MAX_VALUE - postAt)-articleId | "d" | "" | timestamp | {articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt} |
最後に、userIdはシーケンシャルなIDなので負荷が偏る可能性があります。そこで、先頭にuserIdのhash値をつけます。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
hash(userId)-userId-0-(Long.MAX_VALUE - postAt)-articleId | "d" | "" | timestamp | {articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt} |
hash(userId)-userId-1-categoryId-(Long.MAX_VALUE - postAt)-articleId | "d" | "" | timestamp | {articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt} |
以上で、簡易ブログサービスのスキーマが決まりました。
今回はColumnを使わずに、Rowが多くなる方針でスキーマを設計しました。これをtall-narrow Tableと呼びます。これとは逆に、flat-wide Tableがあります。これは、Column多くなって、Rowが少なくなる方針のスキーマ設計です。
今回の簡易ブログサービスをflat-wide Tableとして設計すると、以下のようになります。
RowKey | ColumnFamily | Column | Timestamp | Value |
---|---|---|---|---|
hash(userId)-userId | "d" | (Long.MAX_VALUE - postAt)-articleId | timestamp | {articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt} |
hash(userId)-userId | "s" | categoryId-(Long.MAX_VALUE - postAt)-articleId | timestamp | {articleId, userId, userName, title, content, categoryId, categoryName, postAt, updateAt} |
上記スキーマではユーザごとに1つのRowが割り当てられます。さらに、セカンダリインデックスはColumnFamilyを分けて保存しています。同じRowに入れることのメリットは、アトミックな操作が可能なことです。上記スキーマでは、更新がアトミックに行われるので、セカンダリインデックスとの不整合が起こりません。tall-narrow Tableとflat-wide Tableでは、データの保存量は変わりません。ただし、基本的にはtall-narrow Tableの方がパフォーマンスが良いです。
また、すでに説明した通りHBaseではRowKeyの範囲でRegionが分割されるので、1つのRowが大きくなるとそれ以上分割ができなくなってしまいます。