LabHub

ブログ

HBaseデータモデリングプレイブック:ホットスポット回避とパフォーマンス安定化

한국어English日本語

HBaseデータモデリングプレイブック:ホットスポット回避とパフォーマンス安定化

1. はじめに

HBaseはGoogle Bigtable論文をベースに設計された分散型Column-Family NoSQLデータベースである。HDFS上で動作し、数十億行・数百万カラム規模のデータをミリ秒単位のレイテンシで処理できる。しかし、このパフォーマンスは正しいデータモデリングが前提となる。RowKeyの設計を一つ間違えるだけで、数十台のRegionServerのうちたった1台にトラフィック全体が集中するホットスポットが発生し、クラスタ全体のスループットが事実上単一サーバーレベルに低下する。

本記事はHBaseの一般的なアーキテクチャ紹介ではなく、データモデリングとホットスポット回避に集中した実践プレイブックである。RowKey設計パターン、ホットスポット検出方法、Region分散戦略、スキーマ設計パターン、パフォーマンス安定化のための運用手法を体系的に扱う。

2. HBaseデータモデルの基本原理

コア構成要素

HBaseのデータモデルはRDBMSとは根本的に異なる。以下の5つの要素が1つのセル(Cell)を構成する。

構成要素説明
Row (RowKey)行を一意に識別するバイト配列。辞書順ソートuser001_20260308
Column Familyカラムの物理的グループ。テーブル作成時に定義cf, info, stats
Column QualifierColumn Family内の個別カラム名。動的に追加可能name, email, count
Timestampセルのバージョンを表すミリ秒単位の時刻1709856000000
Cell (Value)上記4つの座標で特定される実際のデータ値"田中太郎"

物理的ストレージ構造

HBaseのデータは論理的にはテーブルだが、物理的にはColumn Family単位で分離して保存される。この点がスキーマ設計における最も重要な制約である。

論理的ビュー:
┌──────────────────────────────────────────────────────────────┐
RowKeyCF:info            │ CF:metrics             │
│                │ name    │ email    │ cpu_avg  │ mem_used    │
├──────────────────────────────────────────────────────────────┤
│ server_web01   │ Web-01...72.58192│ server_db01    │ DB-01...45.216384└──────────────────────────────────────────────────────────────┘

物理的ストレージ (Column Family別に別々のHFile):
[HFile: info]
(server_db01, info:email, t1)"admin@example.com"
(server_db01, info:name, t1)"DB-01"
(server_web01, info:email, t1)"web@example.com"
(server_web01, info:name, t1)"Web-01"

[HFile: metrics]
(server_db01, metrics:cpu_avg, t2)45.2
(server_db01, metrics:mem_used, t2)16384
(server_web01, metrics:cpu_avg, t2)72.5
(server_web01, metrics:mem_used, t2)8192

RowKeyのソートとRegion分配

HBaseテーブルはRowKeyの辞書順(lexicographic order) でソートされ、連続するRowKey範囲が1つのRegionを構成する。RegionはRegionServerに割り当てられ、実際の読み取り/書き込みを処理する。

テーブル全体のRowKey空間:
[aaa...] ─────────── [mmm...] ─────────── [zzz...]

Region分割:
Region 1: [aaa ~ fff]RegionServer A
Region 2: [fff ~ mmm]RegionServer B
Region 3: [mmm ~ sss]RegionServer C
Region 4: [sss ~ zzz]RegionServer D

ここでの核心は、RowKeyの分布がそのまま負荷分布になるという点である。特定範囲のRowKeyに書き込みが集中すると、そのRegionを担当する単一のRegionServerに負荷が集中する。

Column Family設計原則

Column Familyは必ずテーブル作成時に定義する必要があり、物理ストレージと設定(圧縮、TTL、バージョン数など)の単位となる。設計時は以下の原則に従う。

# Column Family設計例
create 'metrics', \
  {NAME => 'd', VERSIONS => 1, COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW', TTL => 7776000}, \
  {NAME => 'm', VERSIONS => 1, COMPRESSION => 'SNAPPY', BLOOMFILTER => 'ROW', TTL => 31536000}

# d: 生データ (90日TTL)
# m: 集計されたメタ情報 (1年TTL)

3. RowKey設計パターン

RowKey設計はHBaseパフォーマンスの80%を決定する。読み取り/書き込みパターン、データ分布、スキャン範囲をすべて考慮する必要がある。

3.1 Salting(プレフィックス分散)

Saltingは、RowKeyの前にハッシュベースのプレフィックス(salt)を付与して、データを複数のRegionに均等分散させる手法である。

public class SaltedRowKeyGenerator {

    private static final int NUM_BUCKETS = 16;  // Region数に合わせる

    /**
     * 元のRowKeyにsaltプレフィックスを追加して分散させる。
     * 例: "20260308_sensor001" → "0a_20260308_sensor001"
     */
    public static byte[] generateSaltedKey(String originalKey) {
        int bucket = Math.abs(originalKey.hashCode() % NUM_BUCKETS);
        String saltPrefix = String.format("%02x", bucket);
        String saltedKey = saltPrefix + "_" + originalKey;
        return Bytes.toBytes(saltedKey);
    }

    /**
     * 特定の元キーに対するsaltを逆算する(Getリクエスト時)。
     */
    public static byte[] getSaltedKey(String originalKey) {
        return generateSaltedKey(originalKey);  // 同じハッシュ結果
    }

    /**
     * 全範囲Scan: salt別に並列スキャンを実行する必要がある。
     */
    public static List<Scan> createParallelScans(String startKey, String endKey) {
        List<Scan> scans = new ArrayList<>();
        for (int i = 0; i < NUM_BUCKETS; i++) {
            String prefix = String.format("%02x", i);
            Scan scan = new Scan();
            scan.withStartRow(Bytes.toBytes(prefix + "_" + startKey));
            scan.withStopRow(Bytes.toBytes(prefix + "_" + endKey));
            scans.add(scan);
        }
        return scans;
    }
}

適したシナリオ: 書き込みスループットの最大化が必要で、範囲スキャンが少ない場合(ログ取り込み、イベント収集)

トレードオフ: 範囲スキャン時にすべてのsaltバケットに対して並列スキャンを実行する必要があるため、スキャンコストが増加する。

3.2 Hashing(ハッシュプレフィックス)

RowKey全体または一部をハッシュ関数で変換して均等分散を実現する。Saltingと類似しているが、ハッシュ結果自体をプレフィックスとして使用し、より広い分散範囲を確保する。

import org.apache.commons.codec.digest.DigestUtils;

public class HashedRowKeyGenerator {

    /**
     * MD5ハッシュの先頭4文字をプレフィックスとして使用。
     * 65,536種類の分散範囲を提供する。
     */
    public static byte[] createHashedKey(String userId, long timestamp) {
        String baseKey = userId + "_" + timestamp;
        String hashPrefix = DigestUtils.md5Hex(baseKey).substring(0, 4);
        String rowKey = hashPrefix + "_" + userId + "_" + timestamp;
        return Bytes.toBytes(rowKey);
    }

    /**
     * 特定ユーザーのデータ照会時にも同じハッシュ計算が必要。
     */
    public static byte[] getHashedKey(String userId, long timestamp) {
        return createHashedKey(userId, timestamp);
    }
}

// 使用例
// 元: "user001_1709856000000"
// ハッシュ: "a3f2_user001_1709856000000"

適したシナリオ: Point Get中心のアクセスパターン。特定のRowKeyがわかっている場合の高速照会。

注意: ハッシュは元のキーの順序を破壊するため、範囲スキャンが事実上不可能になる。

3.3 Key Reversing(キー反転)

ドメイン名やタイムスタンプのように、先頭部分が類似し末尾部分が多様なキーを反転させて分散を確保する。

public class ReversedKeyExamples {

    /**
     * ドメイン反転: 同じTLDに属するドメインを分散させる。
     * "www.google.com" → "moc.elgoog.www"
     */
    public static String reverseDomain(String domain) {
        return new StringBuilder(domain).reverse().toString();
    }

    /**
     * Reverse Timestamp: 最新データを最初にスキャンするためのパターン。
     * HBaseはRowKey昇順ソートのため、Long.MAX_VALUEから引くと
     * 最新タイムスタンプが最小値(上位)に配置される。
     */
    public static long reverseTimestamp(long timestamp) {
        return Long.MAX_VALUE - timestamp;
    }

    /**
     * ユーザー別の最新アクティビティを高速照会するRowKey設計。
     */
    public static byte[] createUserActivityKey(String userId, long timestamp) {
        long reversedTs = Long.MAX_VALUE - timestamp;
        String rowKey = userId + "_" + String.format("%019d", reversedTs);
        return Bytes.toBytes(rowKey);
        // 結果: "user001_9223370449055775807"
        // Scan(startRow=user001_, stopRow=user002) → 最新順に返却
    }
}

適したシナリオ: 「特定ユーザーの最新N件」のように、特定プレフィックス内で最新データを優先照会するパターン。

3.4 Composite Key(複合キー)

複数の次元のデータを1つのRowKeyに結合する。区切り文字として_やnullバイト(\x00)を使用する。

public class CompositeKeyDesign {

    /**
     * IoTセンサーデータ用複合キー設計。
     * 構造: {region_code}_{device_id}_{reverse_timestamp}
     *
     * - region_code: 地理的分散 (2バイト)
     * - device_id: デバイス識別 (可変)
     * - reverse_timestamp: 最新順ソート (8バイト)
     */
    public static byte[] createIoTRowKey(String regionCode, String deviceId, long timestamp) {
        long reversedTs = Long.MAX_VALUE - timestamp;
        String rowKey = String.format("%s_%s_%019d", regionCode, deviceId, reversedTs);
        return Bytes.toBytes(rowKey);
    }

    /**
     * メッセージングシステム用複合キー。
     * 構造: {chat_room_id}_{reverse_timestamp}_{message_id}
     * → 特定チャットルームの最新メッセージをScanで効率的に照会
     */
    public static byte[] createMessageRowKey(String roomId, long timestamp, String msgId) {
        long reversedTs = Long.MAX_VALUE - timestamp;
        return Bytes.toBytes(roomId + "_" + String.format("%019d", reversedTs) + "_" + msgId);
    }
}

3.5 時系列データ向けRowKey設計

時系列データはHBaseで最も一般的なワークロードであると同時に、ホットスポットが最も発生しやすいタイプである。連続的なタイムスタンプをRowKeyとして直接使用すると、常に最後のRegionに書き込みが集中する。

[誤った設計] タイムスタンプをRowKeyの先頭に配置
RowKey: 20260308120000_sensor001
RowKey: 20260308120001_sensor001
RowKey: 20260308120002_sensor001
→ すべての書き込みが最後のRegionに集中(ホットスポット!)

[正しい設計] salt + デバイスID + reverse timestamp
RowKey: 0a_sensor001_9223370449055775807
RowKey: 03_sensor002_9223370449055775807
RowKey: 0f_sensor003_9223370449055775807
16個のRegionに均等分散

時系列データに推奨するRowKeyパターン:

/**
 * 時系列メトリック収集用RowKey生成器。
 *
 * パターン: {salt}_{metric_name}_{device_id}_{reverse_timestamp}
 *
 * 利点:
 * 1. saltによるRegion分散
 * 2. metric_name + device_idで特定メトリックのScan範囲を限定
 * 3. reverse_timestampで最新データ優先照会
 */
public class TimeSeriesRowKeyGenerator {

    private static final int SALT_BUCKETS = 32;

    public static byte[] generate(String metricName, String deviceId, long timestamp) {
        String baseKey = metricName + "_" + deviceId;
        int salt = Math.abs(baseKey.hashCode() % SALT_BUCKETS);
        long reversedTs = Long.MAX_VALUE - timestamp;

        String rowKey = String.format("%02x_%s_%s_%019d",
            salt, metricName, deviceId, reversedTs);
        return Bytes.toBytes(rowKey);
    }

    /**
     * 特定デバイスの特定メトリックに対する時間範囲Scan。
     * saltがわかっているため、単一Regionに対する効率的なスキャンが可能。
     */
    public static Scan createRangeScan(
            String metricName, String deviceId,
            long startTime, long endTime) {

        String baseKey = metricName + "_" + deviceId;
        int salt = Math.abs(baseKey.hashCode() % SALT_BUCKETS);
        String prefix = String.format("%02x", salt);

        // reverse timestampのため、start/endが反転
        long reversedEnd = Long.MAX_VALUE - startTime;
        long reversedStart = Long.MAX_VALUE - endTime;

        Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes(
            prefix + "_" + metricName + "_" + deviceId + "_"
            + String.format("%019d", reversedStart)));
        scan.withStopRow(Bytes.toBytes(
            prefix + "_" + metricName + "_" + deviceId + "_"
            + String.format("%019d", reversedEnd)));
        scan.setCaching(500);
        return scan;
    }
}

RowKey設計の意思決定ガイド

アクセスパターン推奨RowKey戦略理由
ランダムPoint Get中心Hashing均等分散、順序不要
特定エンティティの最新N件entity_id + reverse timestampPrefix Scanで最新順照会
大量順次書き込み(ログ)Salting + composite key書き込み分散 + 最小限のScanサポート
時系列範囲クエリsalt + metric + device + reverse ts分散と範囲照会の両方をサポート
全文検索の代替reverse domain + pathサブドメインのグルーピング

4. ホットスポット問題の原因と検出

ホットスポットとは

ホットスポットとは、特定のRegionに読み取りまたは書き込みリクエストが異常に集中する現象である。HBaseは水平スケーリングを前提に設計されているが、ホットスポットが発生すると単一RegionServerの処理限界がクラスタ全体のボトルネックとなる。

正常な分布:
RS-1: ████████  (25%)
RS-2: ████████  (25%)
RS-3: ████████  (25%)
RS-4: ████████  (25%)

ホットスポット発生:
RS-1: ██  (5%)
RS-2:    (2%)
RS-3:    (3%)
RS-4: ████████████████████████████████████████  (90%)  ← 過負荷!

ホットスポットの主な原因

1. Sequential RowKey(連番キー)

タイムスタンプや自動増分IDなど単調増加する値をRowKeyとして使用すると、新しいデータが常にキー空間の末端(最後のRegion)に書き込まれる。

# 悪い例: タイムスタンプベースのRowKey
2026030812000001Region [2026030811~ 2026030812]  ← ここに書き込み集中
2026030812000002Region [2026030811~ 2026030812]
2026030812000003Region [2026030811~ 2026030812]

2. 偏ったキー分布

特定のプレフィックスのデータが他のプレフィックスより圧倒的に多い場合。例えば、ユーザーIDがuser_プレフィックスで始まるデータが90%で残りのプレフィックスが10%なら、user_範囲のRegionに負荷が集中する。

3. 人気キー(Popular Key)

少数の特定RowKeyに読み取り/書き込みが集中する場合。有名人のプロフィール、人気商品ページなどが該当する。

ホットスポット検出方法

HBase ShellでRegion別リクエスト数を確認:

# テーブルのRegion分布を確認
hbase shell <<'EOF'
status 'detailed'
EOF

# 特定テーブルのRegion別リクエスト数を確認
hbase shell <<'EOF'
list_regions 'metrics_table'
EOF

# 出力例:
# REGION                  | START_KEY  | END_KEY    | SIZE   | REQ    | LOCALITY
# metrics_table,,1709...  | (empty)    | 08_       | 2.1GB  | 1204   | 0.95
# metrics_table,08_,17... | 08_        | 10_       | 2.3GB  | 982341 | 0.92  ← ホットスポット!
# metrics_table,10_,17... | 10_        | 18_       | 1.8GB  | 1102   | 0.97

JMXメトリックでRegionServer別負荷を確認:

# RegionServer別リクエスト数の比較
curl -s "http://regionserver1:16030/jmx?qry=Hadoop:service=HBase,name=RegionServer,sub=Server" \
  | python3 -c "
import json, sys
data = json.load(sys.stdin)
for bean in data['beans']:
    print(f\"ReadRequests: {bean.get('readRequestCount', 'N/A')}\")
    print(f\"WriteRequests: {bean.get('writeRequestCount', 'N/A')}\")
    print(f\"TotalRequests: {bean.get('totalRequestCount', 'N/A')}\")
"

# 特定サーバーのリクエストが平均の3倍以上ならホットスポットを疑う

HBaseメトリックを活用した自動検出スクリプト:

#!/bin/bash
# hotspot_detector.sh - Region別リクエスト偏差検出

TABLE="metrics_table"
THRESHOLD=3.0  # 平均の3倍以上で警告

echo "=== Hotspot Detection for $TABLE ==="

# Region別リクエスト数を抽出
REGIONS=$(echo "list_regions '$TABLE'" | hbase shell 2>/dev/null | grep -E "^\s+\{" | \
  awk -F',' '{for(i=1;i<=NF;i++) if($i ~ /REQ/) print $i}' | \
  grep -oP '\d+')

if [ -z "$REGIONS" ]; then
    echo "No region data found."
    exit 1
fi

# 平均計算
TOTAL=0
COUNT=0
for req in $REGIONS; do
    TOTAL=$((TOTAL + req))
    COUNT=$((COUNT + 1))
done
AVG=$((TOTAL / COUNT))

echo "Average requests per region: $AVG"
echo "Threshold (${THRESHOLD}x average): $(echo "$AVG * $THRESHOLD" | bc | cut -d. -f1)"
echo ""

# ホットスポットRegionの特定
IDX=0
for req in $REGIONS; do
    RATIO=$(echo "scale=2; $req / $AVG" | bc)
    if (( $(echo "$RATIO > $THRESHOLD" | bc -l) )); then
        echo "[HOTSPOT] Region $IDX: $req requests (${RATIO}x average)"
    fi
    IDX=$((IDX + 1))
done

5. ホットスポット回避戦略

5.1 Pre-splitting(事前分割)

テーブル作成時にRegionを事前に分割しておくと、初期データロード時に単一Regionに負荷が集中するのを防止できる。

# 方法1: 均等分割 (hexベース)
# RowKeyがハッシュプレフィックスで始まる場合に適する
create 'events', 'data', SPLITS => [
  '10', '20', '30', '40', '50', '60', '70', '80', '90',
  'a0', 'b0', 'c0', 'd0', 'e0', 'f0'
]

# 方法2: HexStringSplitユーティリティ使用
# 指定されたRegion数でhexキー空間を均等分割
create 'events', 'data', {NUMREGIONS => 16, SPLITALGO => 'HexStringSplit'}

# 方法3: UniformSplit (バイナリキー均等分割)
create 'events', 'data', {NUMREGIONS => 32, SPLITALGO => 'UniformSplit'}

# 方法4: カスタムsplitポイントファイル使用
# splitポイントをファイルに1行ずつ記録
create 'events', 'data', SPLITS_FILE => '/path/to/splits.txt'

Pre-split Region数の決定基準:

推奨公式:
初期Region数 = RegionServer数 × サーバーあたり推奨Region数(1030)

:
- 10台のRegionServerクラスタ
- サーバーあたり20 Region目標
- 初期Region数 = 10 × 20 = 200

ただし、RowKeyの分布を考慮して実際に均等に分割されるか検証が必要。

5.2 Key分散(Bucketing)

RowKeyの先頭に固定数のバケット番号をプレフィックスとして追加し、書き込みを複数のRegionに分散させる。

/**
 * バケットベースのRowKey分散器。
 * N個のバケットで書き込みを分散させつつ、読み取り時には
 * バケット番号を計算して正確なRegionに直接アクセスする。
 */
public class BucketedKeyStrategy {

    private final int numBuckets;

    public BucketedKeyStrategy(int numBuckets) {
        this.numBuckets = numBuckets;
    }

    /**
     * バケット番号はRowKeyのハッシュで決定されるため、
     * 同じ元キーは常に同じバケットにマッピングされる。
     */
    public byte[] createKey(String entityId, long timestamp) {
        int bucket = Math.abs(entityId.hashCode() % numBuckets);
        String key = String.format("%04d_%s_%d", bucket, entityId, timestamp);
        return Bytes.toBytes(key);
    }

    /**
     * 特定エンティティの全データを照会する場合:
     * バケット番号を計算して単一Scanで照会可能。
     */
    public Scan createEntityScan(String entityId) {
        int bucket = Math.abs(entityId.hashCode() % numBuckets);
        String prefix = String.format("%04d_%s_", bucket, entityId);

        Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes(prefix));
        scan.withStopRow(Bytes.toBytes(prefix + "~"));  // ~はASCIIで高い値
        return scan;
    }

    /**
     * 全データをスキャンする場合:
     * すべてのバケットに対して並列Scanを実行。
     */
    public List<Scan> createFullScans() {
        List<Scan> scans = new ArrayList<>();
        for (int i = 0; i < numBuckets; i++) {
            String startPrefix = String.format("%04d_", i);
            String endPrefix = String.format("%04d_~", i);
            Scan scan = new Scan();
            scan.withStartRow(Bytes.toBytes(startPrefix));
            scan.withStopRow(Bytes.toBytes(endPrefix));
            scans.add(scan);
        }
        return scans;
    }
}

5.3 TTLベースのデータパーティショニング

時系列データで古いデータを自動的に期限切れにすることで、Regionサイズを一定に保ち、Compaction負荷を軽減できる。

# Column FamilyにTTL設定(秒単位)
# 90日後に自動削除
alter 'sensor_data', {NAME => 'raw', TTL => 7776000}

# 集計データは1年保管
alter 'sensor_data', {NAME => 'agg', TTL => 31536000}
<!-- hbase-site.xml: テーブルレベルTTLポリシーと連携した設定 -->
<configuration>
    <!-- MemStore flush後のMajor Compaction時に期限切れデータをクリーンアップ -->
    <property>
        <name>hbase.hstore.compaction.min</name>
        <value>3</value>
    </property>

    <!-- TTL設定テーブルでMajor Compaction周期を短縮して -->
    <!-- 期限切れデータを素早くクリーンアップ (デフォルト7日 → 1日) -->
    <property>
        <name>hbase.hregion.majorcompaction</name>
        <value>86400000</value>
    </property>
</configuration>

5.4 時間ベースのテーブルパーティショニング

データを時間単位で別々のテーブルに保存すると、古いテーブルを丸ごと削除してCompactionなしで素早くクリーンアップできる。

/**
 * 日別/月別テーブルパーティショニング戦略。
 * テーブル名に日付を含めてライフサイクルを管理する。
 */
public class TimePartitionedTableStrategy {

    private final Connection connection;
    private final Admin admin;

    public TimePartitionedTableStrategy(Connection connection) throws IOException {
        this.connection = connection;
        this.admin = connection.getAdmin();
    }

    /**
     * 月別テーブル自動作成。
     * 例: logs_202603, logs_202604
     */
    public Table getOrCreateMonthlyTable(String baseName, LocalDate date) throws IOException {
        String tableName = baseName + "_" + date.format(DateTimeFormatter.ofPattern("yyyyMM"));
        TableName tn = TableName.valueOf(tableName);

        if (!admin.tableExists(tn)) {
            TableDescriptorBuilder builder = TableDescriptorBuilder.newBuilder(tn);
            ColumnFamilyDescriptor cf = ColumnFamilyDescriptorBuilder
                .newBuilder(Bytes.toBytes("d"))
                .setCompressingAlgo(Algorithm.SNAPPY)
                .setBloomFilterType(BloomType.ROW)
                .setMaxVersions(1)
                .build();
            builder.setColumnFamily(cf);

            // 16個のRegionにPre-split
            byte[][] splits = RegionSplitter.HexStringSplit
                .split(16);
            admin.createTable(builder.build(), splits);
        }

        return connection.getTable(tn);
    }

    /**
     * 90日以上前のテーブルを削除。
     * DROP TABLEはCompactionなしで即座に完了するため、TTLより効率的。
     */
    public void purgeOldTables(String baseName, int retentionDays) throws IOException {
        LocalDate cutoff = LocalDate.now().minusDays(retentionDays);
        String cutoffStr = cutoff.format(DateTimeFormatter.ofPattern("yyyyMM"));

        for (TableDescriptor td : admin.listTableDescriptors()) {
            String name = td.getTableName().getNameAsString();
            if (name.startsWith(baseName + "_")) {
                String datePart = name.substring(baseName.length() + 1);
                if (datePart.compareTo(cutoffStr) < 0) {
                    admin.disableTable(td.getTableName());
                    admin.deleteTable(td.getTableName());
                    System.out.println("Purged table: " + name);
                }
            }
        }
    }
}

6. スキーマ設計実践パターン

6.1 Tall-Narrow vs Flat-Wide

HBaseテーブル設計において最も重要な構造的選択である。

Tall-Narrow(高くて狭い構造)

行数が多く、各行のカラム数が少ない。各イベントや測定値を別々の行として保存する。

RowKey                              | d:value | d:type
────────────────────────────────────┼─────────┼────────
sensor001_9223370449055775807       | 72.5    | cpu
sensor001_9223370449055775808       | 71.3    | cpu
sensor001_9223370449055775809       | 73.1    | cpu

Flat-Wide(平たくて広い構造)

行数が少なく、各行のカラム数が非常に多い。1つのエンティティのすべての時系列データをColumn Qualifierにエンコードする。

RowKey       | d:20260308120000 | d:20260308120100 | d:20260308120200 | ...
─────────────┼──────────────────┼──────────────────┼──────────────────┤
sensor001    | 72.5             | 71.3             | 73.1             | (数千カラム)

比較と選択基準:

基準Tall-NarrowFlat-Wide
アトミックな行操作行単位でのみアトミック全時系列がアトミック
スキャン効率時間範囲Scanが容易単一Getで全体照会
行サイズ制限制限なしカラム数が多いとRegion Splitに問題
削除効率行単位の削除カラム単位の削除が必要
推奨用途大部分の時系列小規模時系列、単一Getパターン

一般的にTall-Narrowを推奨する。 HBaseのScanパフォーマンスは行数よりデータサイズに比例し、Flat-Wideは単一行が大きくなりすぎるとRegion Splitポイントの決定が困難になる。

6.2 逆インデックス(Secondary Index)パターン

HBaseはRowKeyに対するインデックスのみをデフォルトで提供する。他のカラムで検索するには、逆インデックステーブルを別途管理する必要がある。

/**
 * 逆インデックステーブルを活用した多次元検索サポート。
 *
 * メインテーブル: users (RowKey = user_id)
 * インデックステーブル: users_by_email (RowKey = email, value = user_id)
 * インデックステーブル: users_by_region (RowKey = region_user_id, value = "")
 */
public class SecondaryIndexManager {

    private final Table mainTable;
    private final Table emailIndex;
    private final Table regionIndex;

    /**
     * データ挿入時にメインテーブルとインデックステーブルの両方を更新。
     */
    public void putWithIndex(String userId, String email, String region,
                             Map<String, String> attributes) throws IOException {
        // 1. メインテーブルにデータを挿入
        Put mainPut = new Put(Bytes.toBytes(userId));
        mainPut.addColumn(Bytes.toBytes("info"), Bytes.toBytes("email"),
                          Bytes.toBytes(email));
        mainPut.addColumn(Bytes.toBytes("info"), Bytes.toBytes("region"),
                          Bytes.toBytes(region));
        for (Map.Entry<String, String> attr : attributes.entrySet()) {
            mainPut.addColumn(Bytes.toBytes("info"),
                              Bytes.toBytes(attr.getKey()),
                              Bytes.toBytes(attr.getValue()));
        }
        mainTable.put(mainPut);

        // 2. メールインデックスを更新
        Put emailPut = new Put(Bytes.toBytes(email));
        emailPut.addColumn(Bytes.toBytes("idx"), Bytes.toBytes("uid"),
                           Bytes.toBytes(userId));
        emailIndex.put(emailPut);

        // 3. リージョンインデックスを更新(複合キー: region_userId)
        Put regionPut = new Put(Bytes.toBytes(region + "_" + userId));
        regionPut.addColumn(Bytes.toBytes("idx"), Bytes.toBytes(""),
                            Bytes.toBytes(""));
        regionIndex.put(regionPut);
    }

    /**
     * メールでユーザーを照会: インデックス → メインテーブルの2段階照会。
     */
    public Result getUserByEmail(String email) throws IOException {
        Get indexGet = new Get(Bytes.toBytes(email));
        Result indexResult = emailIndex.get(indexGet);
        byte[] userId = indexResult.getValue(Bytes.toBytes("idx"),
                                             Bytes.toBytes("uid"));
        if (userId == null) return null;

        Get mainGet = new Get(userId);
        return mainTable.get(mainGet);
    }

    /**
     * 特定リージョンのすべてのユーザーを照会: インデックステーブルのPrefix Scan。
     */
    public List<String> getUsersByRegion(String region) throws IOException {
        Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes(region + "_"));
        scan.withStopRow(Bytes.toBytes(region + "_~"));

        List<String> userIds = new ArrayList<>();
        try (ResultScanner scanner = regionIndex.getScanner(scan)) {
            for (Result r : scanner) {
                String rowKey = Bytes.toString(r.getRow());
                String userId = rowKey.substring(region.length() + 1);
                userIds.add(userId);
            }
        }
        return userIds;
    }
}

逆インデックス運用時の注意事項:

6.3 Phoenixを活用したSecondary Index

Apache PhoenixはHBase上にSQLレイヤーを提供し、Secondary Indexを自動的に管理する。

-- Phoenixでのテーブル作成とインデックス活用

-- テーブル作成
CREATE TABLE IF NOT EXISTS users (
    user_id VARCHAR NOT NULL PRIMARY KEY,
    email VARCHAR,
    region VARCHAR,
    created_at TIMESTAMP,
    login_count BIGINT
) SALT_BUCKETS=16, COMPRESSION='SNAPPY';

-- Covered Index: インデックスに追加カラムを含めてメインテーブル照会なしで結果返却
CREATE INDEX idx_users_email ON users (email)
    INCLUDE (region, login_count);

-- メールで照会時にインデックスが自動使用される
SELECT user_id, email, region, login_count
FROM users
WHERE email = 'user@example.com';

-- リージョン別ユーザー照会
CREATE INDEX idx_users_region ON users (region, created_at DESC)
    INCLUDE (email);

SELECT user_id, email, created_at
FROM users
WHERE region = 'ap-northeast-2'
ORDER BY created_at DESC
LIMIT 100;

7. パフォーマンス安定化運用

7.1 Compaction戦略

CompactionはHBaseパフォーマンスに最も大きな影響を与えるバックグラウンド処理である。管理を誤ると書き込み遅延、読み取りパフォーマンスの低下、I/O暴走を引き起こす。

Minor Compaction: 小さなHFileを結合。削除マーカー(tombstone)は維持。自動的に頻繁に実行。

Major Compaction: すべてのHFileを1つに結合。削除マーカーと期限切れデータを除去。大量I/Oが発生するため慎重に管理する必要がある。

<!-- hbase-site.xml: 本番Compaction設定 -->
<configuration>
    <!-- 自動Major Compactionを無効化 -->
    <property>
        <name>hbase.hregion.majorcompaction</name>
        <value>0</value>
        <description>0に設定して自動Major Compactionを無効化。
        cronでオフピーク時間に手動実行。</description>
    </property>

    <!-- Minor Compactionトリガー: HFile 3個以上で実行 -->
    <property>
        <name>hbase.hstore.compactionThreshold</name>
        <value>3</value>
    </property>

    <!-- Minor Compactionに含める最大HFile数 -->
    <property>
        <name>hbase.hstore.compaction.max</name>
        <value>10</value>
    </property>

    <!-- Compaction対象の最小HFileサイズ -->
    <property>
        <name>hbase.hstore.compaction.min.size</name>
        <value>134217728</value> <!-- 128MB -->
    </property>

    <!-- Compactionスロットル: I/O制限でサービス影響を最小化 -->
    <property>
        <name>hbase.regionserver.throughput.controller</name>
        <value>org.apache.hadoop.hbase.regionserver.compactions.PressureAwareCompactionThroughputController</value>
    </property>
    <property>
        <name>hbase.hstore.compaction.throughput.lower.bound</name>
        <value>52428800</value> <!-- 50MB/s 下限 -->
    </property>
    <property>
        <name>hbase.hstore.compaction.throughput.higher.bound</name>
        <value>104857600</value> <!-- 100MB/s 上限 -->
    </property>
</configuration>

オフピーク時間にMajor Compactionを実行(cron):

#!/bin/bash
# major_compaction_scheduler.sh
# crontab: 0 3 * * 0 /opt/hbase/scripts/major_compaction_scheduler.sh

TABLES=("metrics_table" "events_table" "logs_table")
LOG_FILE="/var/log/hbase/major_compaction_$(date +%Y%m%d).log"

echo "=== Major Compaction Start: $(date) ===" >> "$LOG_FILE"

for table in "${TABLES[@]}"; do
    echo "Compacting $table..." >> "$LOG_FILE"
    echo "major_compact '$table'" | hbase shell >> "$LOG_FILE" 2>&1

    # テーブル間1時間の間隔でI/O負荷を分散
    sleep 3600
done

echo "=== Major Compaction End: $(date) ===" >> "$LOG_FILE"

7.2 Region SplitとMerge管理

自動Splitポリシーのチューニング:

<configuration>
    <!-- Region最大サイズ: このサイズに達すると自動Split -->
    <property>
        <name>hbase.hregion.max.filesize</name>
        <value>10737418240</value> <!-- 10GB -->
    </property>

    <!-- Splitポリシー選択 -->
    <property>
        <name>hbase.regionserver.region.split.policy</name>
        <value>org.apache.hadoop.hbase.regionserver.SteppingSplitPolicy</value>
        <description>
        SteppingSplitPolicy: Region数が少ない時は素早く分割し、
        Region数が十分になるとmax.filesizeまで待つ。
        IncreasingToUpperBoundRegionSplitPolicyより安定的。
        </description>
    </property>
</configuration>

手動SplitとMerge:

# ホットスポットRegionの手動分割
# まずホットスポットRegionのencoded nameと適切なsplit keyを確認
hbase shell <<'EOF'
list_regions 'metrics_table'
EOF

# 特定キーでRegionを分割
hbase shell <<'EOF'
split 'metrics_table', '08_sensor500'
EOF

# Region Merge: 過度に分割された小規模Regionの結合
# HBase 2.xではhbase shellのmerge_regionコマンドを使用
hbase shell <<'EOF'
merge_region 'ENCODED_REGION_NAME_1', 'ENCODED_REGION_NAME_2', true
EOF

7.3 BlockCacheとBucketCacheの設定

読み取りパフォーマンスの核心はキャッシュヒット率である。BlockCacheはHFileのデータブロックをメモリにキャッシュする。

<configuration>
    <!-- On-heap BlockCache比率 (ヒープの40%) -->
    <property>
        <name>hfile.block.cache.size</name>
        <value>0.4</value>
    </property>

    <!-- BucketCache有効化: Off-heapメモリでキャッシュを拡張 -->
    <property>
        <name>hbase.bucketcache.ioengine</name>
        <value>offheap</value>
        <description>offheap, file:/path/to/cache, mmap:/path/to/cache から選択</description>
    </property>

    <!-- BucketCacheサイズ (MB) -->
    <property>
        <name>hbase.bucketcache.size</name>
        <value>8192</value> <!-- 8GB Off-heap -->
    </property>

    <!-- CombinedBlockCache使用: On-heap(インデックス/メタ) + Off-heap(データ) -->
    <property>
        <name>hbase.bucketcache.combinedcache.enabled</name>
        <value>true</value>
    </property>
</configuration>
# RegionServer JVM設定 (hbase-env.sh)
# On-heap 32GB + Off-heap(BucketCache) 8GB
export HBASE_REGIONSERVER_OPTS="
  -Xmx32g -Xms32g
  -XX:MaxDirectMemorySize=10g
  -XX:+UseG1GC
  -XX:MaxGCPauseMillis=100
  -XX:G1HeapRegionSize=16m
  -XX:InitiatingHeapOccupancyPercent=65
"

キャッシュヒット率のモニタリング:

# BlockCacheヒット率の確認 (JMX)
curl -s "http://regionserver:16030/jmx?qry=Hadoop:service=HBase,name=RegionServer,sub=Server" \
  | python3 -c "
import json, sys
data = json.load(sys.stdin)
for bean in data['beans']:
    hit = bean.get('blockCacheHitCount', 0)
    miss = bean.get('blockCacheMissCount', 0)
    total = hit + miss
    ratio = (hit / total * 100) if total > 0 else 0
    print(f'BlockCache Hit Ratio: {ratio:.1f}%')
    print(f'  Hits: {hit:,}')
    print(f'  Misses: {miss:,}')
    print(f'  Evictions: {bean.get(\"blockCacheEvictionCount\", 0):,}')
"
# 目標: 95%以上のヒット率を維持

7.4 MemStoreチューニング

MemStoreは書き込みバッファであり、満杯になるとHFileにFlushされる。MemStoreのサイズとFlush頻度が書き込みパフォーマンスに直接影響する。

<configuration>
    <!-- 個別MemStore flushサイズ -->
    <property>
        <name>hbase.hregion.memstore.flush.size</name>
        <value>134217728</value> <!-- 128MB -->
    </property>

    <!-- RegionServer全体のMemStore上限 (ヒープの40%) -->
    <property>
        <name>hbase.regionserver.global.memstore.size</name>
        <value>0.4</value>
    </property>

    <!-- 全体MemStoreがこの比率を超えると強制Flush開始 -->
    <property>
        <name>hbase.regionserver.global.memstore.size.lower.limit</name>
        <value>0.95</value>
    </property>

    <!-- MemStore + BlockCacheの合計がヒープの80%を超えないように -->
    <!-- global.memstore.size(0.4) + block.cache.size(0.4) = 0.8 -->
</configuration>

8. 実践チェックリストとアンチパターン

設計段階チェックリスト

運用段階チェックリスト

アンチパターン集

アンチパターン1: タイムスタンプをRowKeyの先頭に配置

# 悪い例
RowKey: 20260308120000_event_click
# → すべての最新書き込みが最後のRegionに集中

# 正しい代替案
RowKey: 0a_click_20260308120000  (salt + event_type + timestamp)

アンチパターン2: 過剰なColumn Family数

# 悪い例: CFが10個
create 'user_profile', 'basic', 'contact', 'preference', 'history',
       'security', 'billing', 'social', 'activity', 'settings', 'cache'
# → CF間の連鎖flushでI/O暴走、MemStoreメモリの浪費

# 正しい代替案: 2〜3個のCFに統合
create 'user_profile', \
  {NAME => 'i', VERSIONS => 1},   \  # info: 基本 + 連絡先 + 設定
  {NAME => 'a', VERSIONS => 1, TTL => 7776000}  # activity: アクティビティログ (90日)

アンチパターン3: 可変長RowKeyのソート問題

# 悪い例: 数値を文字列として保存(辞書順ソートに注意)
"1", "10", "100", "2", "20", "3"  ← 辞書順で 1 < 10 < 100 < 2

# 正しい代替案: 固定長パディング
"001", "002", "003", "010", "020", "100"  ← 正しい順序

アンチパターン4: RowKeyに機密情報を含む

# 悪い例: メールアドレスをRowKeyに直接使用
RowKey: user@example.com_20260308
# → RowKeyはWAL、HFile、メタテーブルに平文で公開される

# 正しい代替案: ハッシュ処理
RowKey: sha256(user@example.com)_20260308

アンチパターン5: 単一Rowが大きすぎる場合

# 悪い例: 1つのRowに数万個のカラム(極端なFlat-Wide)
# → Region Split時にそのRowを分割できない
# → MemStoreに単一Rowがflushサイズを超える可能性

# 正しい代替案: Tall-Narrowに転換するか、Rowを時間単位で分割
RowKey: entity001_20260308_00  (時間単位でRow分割)
RowKey: entity001_20260308_01

アンチパターン6: Scanで範囲を指定しない

// 悪い例: テーブル全体のScan
Scan scan = new Scan();
// → 数十億行を走査してRegionServerに過負荷

// 正しい代替案: 範囲を明確に制限
Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes("sensor001_"));
scan.withStopRow(Bytes.toBytes("sensor001_~"));
scan.setCaching(500);      // RPCあたりの返却行数
scan.setMaxResultSize(5 * 1024 * 1024);  // 5MB制限
scan.addColumn(Bytes.toBytes("d"), Bytes.toBytes("value"));  // 必要なカラムのみ

9. まとめ

HBaseデータモデリングは「RowKey設計がすべて」と言っても過言ではない。核心をまとめると以下の通りである。

  1. RowKeyが負荷分布を決定する: Sequential Keyは必ず避け、Salting/Hashing/Bucketingで分散を確保せよ。
  2. 読み取りパターンがRowKeyを決定する: 最も頻繁なクエリがPrefix ScanやPoint Getで効率的に処理できるようRowKeyを構成せよ。
  3. Tall-Narrowをデフォルトとして選択せよ: 大部分のワークロードでFlat-Wideより安定的である。
  4. ホットスポットは予防が最善: Pre-split、Key分散、モニタリングでホットスポットが発生する前に阻止せよ。
  5. Compactionを制御せよ: Major Compactionの自動実行をオフにし、オフピーク時間に手動実行し、I/Oスロットルを適用せよ。
  6. キャッシュヒット率を死守せよ: BlockCache + BucketCacheを適切に構成し、95%以上のヒット率を目標とせよ。
  7. Column Familyは少なく、RowKeyは短く: ストレージ効率とパフォーマンスの両方のために簡潔さを維持せよ。

正しいデータモデリングは、クラスタ規模を2倍にする以上のパフォーマンス改善をもたらす。1つのRowKeyの設計に十分な時間を投資せよ。

クイズ

Q1: 「HBaseデータモデリングプレイブック:ホットスポット回避とパフォーマンス安定化」の主なトピックは何ですか?

HBase RowKey設計パターン、ホットスポットの検出と回避戦略、Region分散の最適化など、大規模HBase運用でパフォーマンスを安定化させるための実践プレイブック。

Q2: HBaseデータモデルの基本原理とは何ですか? コア構成要素 HBaseのデータモデルはRDBMSとは根本的に異なる。以下の5つの要素が1つのセル(Cell)を構成する。 物理的ストレージ構造 HBaseのデータは論理的にはテーブルだが、物理的にはColumn Family単位で分離して保存される。この点がスキーマ設計における最も重要な制約である。 RowKeyのソートとRegion分配 HBaseテーブルはRowKeyの辞書順(lexicographic order) でソートされ、連続するRowKey範囲が1つのRegionを構成する。

Q3: RowKey設計パターンについて説明してください。 RowKey設計はHBaseパフォーマンスの80%を決定する。読み取り/書き込みパターン、データ分布、スキャン範囲をすべて考慮する必要がある。 3.1 Salting(プレフィックス分散) Saltingは、RowKeyの前にハッシュベースのプレフィックス(salt)を付与して、データを複数のRegionに均等分散させる手法である。

Q4: ホットスポット問題の原因と検出の主な特徴は何ですか? ホットスポットとは ホットスポットとは、特定のRegionに読み取りまたは書き込みリクエストが異常に集中する現象である。HBaseは水平スケーリングを前提に設計されているが、ホットスポットが発生すると単一RegionServerの処理限界がクラスタ全体のボトルネックとなる。 ホットスポットの主な原因 1. Sequential RowKey(連番キー) タイムスタンプや自動増分IDなど単調増加する値をRowKeyとして使用すると、新しいデータが常にキー空間の末端(最後のRegion)に書き込まれる。 2.

Q5: ホットスポット回避戦略はどのように機能しますか? 5.1 Pre-splitting(事前分割) テーブル作成時にRegionを事前に分割しておくと、初期データロード時に単一Regionに負荷が集中するのを防止できる。 Pre-split Region数の決定基準: 5.2 Key分散(Bucketing) RowKeyの先頭に固定数のバケット番号をプレフィックスとして追加し、書き込みを複数のRegionに分散させる。 5.3 TTLベースのデータパーティショニング 時系列データで古いデータを自動的に期限切れにすることで、Regionサイズを一定に保ち、Compaction負荷を軽減できる。

コメント

まだコメントはありません。

ログインするとコメントできます