Apache HBase:10億件規模のスパースデータを扱うワイドカラム型NoSQLデータベース

Database tutorial - IT technology blog
Database tutorial - IT technology blog

問題提起:データベースがスパースデータに溺れ始めるとき

こんなシナリオを想像してほしい。モバイルアプリのユーザー行動を追跡する分析プラットフォームを構築しているとする――クリック、ページビュー、セッション時間、機能のインタラクションなど。各ユーザーは数十種類のイベントを発生させるが、1日のうちに特定のユーザーが実際に触れるイベントは全体のほんの一部に過ぎない。

6か月後、MySQLには5億件のレコードが溜まっている。以前はミリ秒で返ってきたクエリが今では30秒かかる。インデックスを追加しても一時的な改善にしかならず、書き込みスループットがボトルネックになる――INSERTがREADと競合し、DBAは実装に数週間かかるパーティショニング戦略についての厄介な質問を投げかけてくる。

データそのものの構造的な問題もある。イベントテーブルには80カラムあるが、個々の行に実際に値が入っているのは5〜10個程度だ。残りはすべてNULL。これがスパースデータであり、リレーショナルデータベースは10億件規模でこれを効率的に扱うように設計されていなかった。

根本原因:従来のデータベースがスパースな大量データで苦労する理由

根本原因はデータの物理的な格納方法にある。リレーショナルデータベースはデータを行単位で格納する。ほとんどのカラムがNULLであっても、エンジンはディスク上でその行のフルスキーマを維持し続ける。5億行にそれぞれ75個の空カラムがあれば、何も入っていない領域のためにストレージを無駄遣いしていることになり、クエリのたびにその空領域をスキャンしなければならない。

もう1つの問題は水平方向の書き込みスケーラビリティだ。RDBMSは単一の書き込みマスターを前提としたアーキテクチャになっている。読み込みレプリカは追加できるが、書き込みは1台のマシンがボトルネックになる。分散システムから毎秒5万件のイベントを取り込む必要がある場合、その単一マスターはすぐに詰まってしまう。

MongoDBのような標準的なドキュメントデータベースはスパースデータをより上手く扱える――各ドキュメントには実際に存在するフィールドだけが格納される――しかし、その柔軟性と引き換えに一貫性の保証が弱くなり、ソート済みキーに対するレンジスキャンの効率も低下する。2つのタイムスタンプ間のuser_id 12345のイベントをすべて取得するには、コレクションスキャンを避けるためのインデックス設計が依然として必要だ。

解決策の比較:HBase vs 代替手段

Apache Cassandra

CassandraはHBaseと並んでワイドカラム型ストアとして頻繁に言及される。どちらも大規模な書き込みスループットと水平スケーリングを処理できる。

主な違いは、Cassandraはマスターレス(P2P)であるため運用が容易だが、デフォルトは結果整合性であるという点だ。自分の書き込みをすぐに読み取るような強整合性が必要なユースケースでは、Cassandraは丁寧なチューニングが必要になる。また、Hadoopエコシステムとのネイティブ統合がないため、CassandraのデータでSparkジョブを直接実行するには追加のコネクタと運用上の複雑さが伴う。

Google Cloud Bigtable

HBaseのデータモデルはGoogleのBigtable論文に直接インスパイアされている。GCP上にあるなら、Cloud Bigtableは完全マネージド版であり、運用オーバーヘッドを完全に排除できる。オンプレミス環境やデータローカリティを完全に制御する必要がある場合、自分のHadoopクラスター上のHBaseが実践的な代替手段となる――同じデータモデル、同じAPIコンセプトを持ちながら、自己管理で運用できる。

Apache HBase on Hadoop

HBaseはHDFS(Hadoop分散ファイルシステム)とZooKeeperの上に構築されている。以下の機能を提供する:

  • 強整合性――Cassandraの結果整合性モデルとは異なり、すべての読み取りで最新の書き込みが反映される
  • クラスターノード間でのリージョンの自動分割と負荷分散
  • 同じデータに対するバッチ分析のためのMapReduceとApache Sparkとのネイティブ統合
  • カラムファミリーベースのストレージ――実際にデータを持つカラムのみを保存
  • 自動セルバージョニング――HBaseはデフォルトで各値の複数のタイムスタンプ付きバージョンを保持

トレードオフとして、HBaseはHadoop + ZooKeeperスタックの運用が必要であり、Cassandraよりもオーバーヘッドが大きい。すでにHadoopエコシステムを使っているか、運用データの上に重ねて分析を行うためにSpark/Hiveとの深い統合が必要な場合に最適な選択肢となる。

最良のアプローチ:HBaseのセットアップとスパースデータの操作

データモデルの理解

HBaseは4つの座標でデータを整理する:row key(行キー)、column family(カラムファミリー)、column qualifier(カラム修飾子)、timestamp(タイムスタンプ)。行は行キーで辞書順にソートされるため、ソート済みキーに対するレンジスキャンが非常に効率的になる――同じ規模の従来のテーブルでのインデックス参照よりもはるかに高速だ。

ユーザーイベント追跡では、userId_reversedTimestampのような行キー設計が適している。タイムスタンプを逆順にすることで、前方スキャン時に最新のイベントが先頭に来るようになり、最も一般的なクエリパターンに合致する。

スタンドアロンモードでのHBaseのインストール

ローカル開発とテストでは、スタンドアロンモードですべてを単一のJVM上で実行できる――フルHadoopクラスターは不要だ:

# HBaseをダウンロード(hbase.apache.orgで最新の安定バージョンを確認すること)
wget https://downloads.apache.org/hbase/stable/hbase-2.5.7-bin.tar.gz
tar -xzf hbase-2.5.7-bin.tar.gz
cd hbase-2.5.7

# conf/hbase-env.shにJAVA_HOMEを設定する
echo 'export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64' >> conf/hbase-env.sh

# HBaseを起動する
bin/start-hbase.sh

# インタラクティブシェルを開く
bin/hbase shell

テーブルの作成とスパースデータの書き込み

すべてのカラムをあらかじめ定義する必要があるリレーショナルテーブルとは異なり、HBaseではカラムファミリーを定義するだけでよい。ファミリー内の個々のカラム修飾子は書き込み時に作成される――異なる行が異なる属性を持つスパースデータに最適だ。

# HBaseシェル内で
# 2つのカラムファミリーを持つuser_eventsテーブルを作成する
create 'user_events', {NAME => 'meta', VERSIONS => 1}, {NAME => 'data', VERSIONS => 3}

# クリックイベントを挿入する――関連するカラムのみ保存
put 'user_events', '1001_9999999999000', 'meta:event_type', 'click'
put 'user_events', '1001_9999999999000', 'data:element_id', 'btn_purchase'
put 'user_events', '1001_9999999999000', 'data:page', '/checkout'

# ビューイベントを挿入する――カラムが異なり、element_idはなし
put 'user_events', '1001_9999999998000', 'meta:event_type', 'view'
put 'user_events', '1001_9999999998000', 'data:page', '/product/42'
put 'user_events', '1001_9999999998000', 'data:duration_ms', '4500'

# ユーザー1001の最新イベントをすべてスキャンする(バッククォートはASCIIでアンダースコアの1文字上)
scan 'user_events', {STARTROW => '1001_', STOPROW => '1001`'}

ビューイベントにはelement_idカラムがない――その行には単純に存在せず、ストレージを1バイトも消費しない。NULLもなく、無駄なバイトもない。これがスパースストレージが意図通りに機能している状態だ。

本番ワークロード向けのJava API

import org.apache.hadoop.hbase.client.*;
import org.apache.hadoop.hbase.util.Bytes;

Configuration config = HBaseConfiguration.create();
config.set("hbase.zookeeper.quorum", "zk-host1,zk-host2,zk-host3");

try (Connection connection = ConnectionFactory.createConnection(config);
     Table table = connection.getTable(TableName.valueOf("user_events"))) {

    // スパース行を書き込む――このイベントタイプに必要なカラムのみ
    Put put = new Put(Bytes.toBytes("1001_9999999997000"));
    put.addColumn(
        Bytes.toBytes("meta"),
        Bytes.toBytes("event_type"),
        Bytes.toBytes("purchase")
    );
    put.addColumn(
        Bytes.toBytes("data"),
        Bytes.toBytes("amount"),
        Bytes.toBytes("149.99")
    );
    table.put(put);

    // ユーザー1001の最新イベントをレンジスキャンする
    Scan scan = new Scan();
    scan.withStartRow(Bytes.toBytes("1001_"));
    scan.withStopRow(Bytes.toBytes("1001`"));
    scan.addFamily(Bytes.toBytes("meta"));

    try (ResultScanner scanner = table.getScanner(scan)) {
        for (Result result : scanner) {
            System.out.println(Bytes.toString(result.getRow()));
        }
    }
}

HappyBaseを使ったPythonアクセス

Pythonサービスでは、HappyBaseがThrift上にクリーンなインターフェースを提供する――PythonコードベースからJava APIを呼び出すよりもはるかに実用的だ:

pip install happybase
import happybase

connection = happybase.Connection('hbase-thrift-host', port=9090)
table = connection.table('user_events')

# スパースデータを書き込む――このイベントタイプに実際に必要なカラムのみ
table.put(
    b'1002_9999999996000',
    {
        b'meta:event_type': b'search',
        b'data:query': b'linux docker tutorial',
        b'data:results_count': b'42',
    }
)

# レンジスキャン――ユーザー1002のすべてのイベントを取得する
for key, data in table.scan(row_start=b'1002_', row_stop=b'1002`'):
    print(key, data)

HBaseプロジェクトを立ち上げる際に頻繁に直面する問題として、テスト用データセットはほぼ常にCSVファイルとして提供され、バルクロードの前に再フォーマットが必要になることがある。データインポート用にCSVをJSONに素早く変換する必要があるときは、toolcraft.app/ja/tools/data/csv-to-jsonを使っている――ブラウザ上で完全に動作するため、データが外部に送信されることがなく、ユーザーの個人情報(PII)を含むCSVをオンラインコンバーターに送れない場合にも安心して使える。

行キーの設計:すべてを決定する重要な判断

行キーの設計が悪ければ、クエリや設定の問題よりもHBaseのパフォーマンスに深刻な影響を及ぼす。絶対に避けるべき2つのアンチパターンがある:

  • シーケンシャルキー(自動インクリメントID、単調増加タイムスタンプ):すべての書き込みが同一のリージョンサーバーに集中する――典型的なホットスポット問題だ。1つのノードが限界に達している間、クラスターの残りのノードはアイドル状態になる。
  • 過度に長い行キー:行キーはHBaseのすべてのセルと一緒に保存される。100億行のテーブルでセルが10個ずつある場合、200バイトのキーは20TBもの純粋なキーオーバーヘッドになる。

効果的なパターン:書き込みをリージョン全体に分散させるためにハッシュプレフィックスでキーをソルト化する、最新のデータを先頭に表示するためにタイムスタンプを逆順にする、そしてレンジスキャンが単一エンティティのキースペース内に収まるように常にエンティティ識別子を先頭に配置する。

HBaseが実際に正しい選択となる場面

HBaseが運用の複雑さに見合う価値を発揮するのは、以下のような状況だ:

  • エンティティごとに異なるスパースな可変カラムセットを持つ数十億行のデータ
  • 毎秒数万件の持続的な高書き込みスループット
  • 任意のカラムフィルターではなく行キーのレンジによって駆動されるクエリパターン
  • HDFS、Spark、Hiveがすでに稼働している既存のHadoopエコシステム
  • 結果整合性ではなく強整合性の要件

データセットが1台のサーバーに余裕を持って収まる場合(適切なインデックスを持つPostgreSQLの方が優れている)、異なるエンティティタイプ間でのJOINクエリが必要な場合(リレーショナルDBの方が有利)、またはチームにZooKeeper + HDFS + HBase RegionServersを運用する余力がない場合(Cloud BigtableやマネージドCassandraサービスならその負担を排除できる)は、HBaseを使わない方がよい。

運用オーバーヘッドは確かに存在する――ZooKeeperクォーラム、HDFS DataNode、HBase RegionServer、HMasterのすべてが監視、チューニング、キャパシティプランニングを必要とする。しかし、スパースデータで本当に数十億件規模に達しており、Hadoopスタックがすでにインフラの一部となっている場合、HBaseはオープンソースエコシステムの中で他のどんなものよりもそのワークロードを上手く処理できる。

Share: