不適切な行キー設計は、ApsaraDB for HBase におけるホットスポットの一般的な原因です。ホットスポットは、単一のリージョンに読み取りと書き込みを集中させ、その RegionServer に過負荷をかけ、ノード全体のパフォーマンスを低下させます。深刻な場合、リージョンが利用できなくなることもあります。このページでは、リージョン間で負荷を均等に分散させるための手法と、それぞれのトレードオフについて説明します。
ホットスポットの発生メカニズム
HBase は、行キーによって行を辞書式順序でソートします。この順序付けにより、範囲スキャンが効率的になり、関連する行が物理的に隣接して保持されます。欠点として、シーケンシャルな行キーや時間ベースの行キーは、リージョンが分割されるまで、すべての書き込みが同じリージョンに集中してしまいます。
ApsaraDB for HBase クラスターにデータが書き込まれる際、各操作中に書き込みプロセスがロックされます。すべてのクライアントは、リージョンが利用可能になるまで待機する必要があり、その後、サイクルが再び開始されます。このロック動作は、単調増加する行キーの場合に特に問題となり、すべての書き込みが単一のリージョンに集中します。
単一のリージョンが不均衡な量のトラフィックを吸収すると、その RegionServer がボトルネックになります。同じ RegionServer 上の他のリージョンも、サーバーが全体の負荷に対応できないため影響を受けます。
分散戦略の選択
行キーを設計する前に、ワークロードに関する次の3つの質問に答えてください。
データサイズ: 1秒あたりに書き込まれるデータ量と、各行のサイズはどのくらいですか。
データ形状: アプリケーションはどのようなクエリパターン (ポイントルックアップ、範囲スキャン、またはその両方) をサポートする必要がありますか。
データ速度: 書き込みはシーケンシャル (時系列、自動インクリメント ID) ですか、それとも自然に分散されていますか。
これらの回答によって、ユースケースに最適な分散戦略が決まります。
| 戦略 | 書き込み分散 | 読み取り予測可能性 | 使用する状況 |
|---|---|---|---|
| ソルティング | 非常に優れている — ランダムなプレフィックスが負荷を均等に分散 | 低い — すべてのソルトバケットをクエリし、結果をマージする必要がある | 書き込みスループットが優先され、範囲スキャンがまれな場合 |
| ハッシュ化 | 良好 — 決定論的なプレフィックスが負荷を分散 | 高い — クライアント側でプレフィックスを再計算してポイント Get を実行 | 書き込み分散とターゲットを絞った読み取りの両方が必要な場合 |
| キー反転 | 良好 — 単調な行キーをランダム化 | 中程度 — 行の順序が失われ、範囲スキャンの有用性が低下 | 行キーが固定長または数値で、実装の複雑さを最小限に抑える必要がある場合 |
ソルティング
ソルティングは、各行キーにランダムなプレフィックスを付加します。可能なプレフィックス値の数は、データを分散させたいリージョンの数と一致します。
ソルティングなしでは、同じプレフィックスを共有する行キーはすべて1つのリージョンに集中します。
foo0001
foo0002
foo0003
foo00044つのソルト値 (a、b、c、d) を使用すると、同じ行が4つのリージョンに同時に分散され、書き込みスループットが4倍になります。
a-foo0003
b-foo0001
c-foo0004
d-foo0002より多くの行が到着し、ソルト値を共有するにつれて、データは引き続き分散されます。
a-foo0003
b-foo0001
c-foo0003
c-foo0004
d-foo0002トレードオフ: ソルティングは書き込みスループットを向上させますが、行の順序を破壊します。特定の元のキーを持つすべての行を読み取るには、各ソルトバケットを個別にクエリし、クライアント側で結果をマージする必要があります。この操作は、ソルトバケットの数に比例してスケーリングします。
ハッシュ化
ハッシュ化は、ランダムなプレフィックスを行キーの一方向ハッシュに置き換えます。同じ入力は常に同じハッシュを生成するため、プレフィックスは決定論的です。
たとえば、foo0003 に一方向ハッシュを適用すると、常にプレフィックス a が生成されます。クライアント側では、ハッシュを再計算して完全な行キーを再構築し、単一の Get を発行します。スキャッターギャザーは不要です。
ハッシュ化により、特定の行キーペアを同じプレフィックスにマップするハッシュ関数を選択することで、同じリージョンに共存させることも可能です。
トレードオフ: ハッシュ化は、書き込みを分散させながら、予測可能な読み取りを提供します。クライアントは、書き込み時に使用されたものと同じハッシュ関数を実装する必要があります。
キー反転
固定長または数値の行キーを反転すると、最も頻繁に変化する部分 (最下位桁) が先頭に移動します。これにより、プレフィックス計算を必要とせずに、行キーが自然にランダム化されます。
トレードオフ: 実装が簡単で、クライアント側のハッシュ関数は不要です。行の順序が失われるため、範囲スキャンの効率が制限されます。
単調増加する行キーの回避
タイムスタンプ、自動インクリメントシーケンス (1、2、3…)、およびその他の単調増加する値は、すべての書き込みを単一のリージョンに集中させます。これらを直接行キーとして使用することは避けてください。
複合行キーによる時系列データの処理
アプリケーションがログやメトリックなどの時系列データをインジェストする場合、タイムスタンプの前にカーディナリティの高いフィールドを配置する複合行キーを使用してください。OpenTSDB はこのパターンを示しています。その行キー形式は [metric_type][event_timestamp] です。数百の異なる metric_type 値がある場合、データストリームが連続していても、Put 操作は多くのリージョンに分散されます。
行とカラムのサイズを最小化
HBase は値をセルとして保存します。セルを特定するには、行キー、カラム修飾子、タイムスタンプの3つの座標が必要です。これらの座標は、すべての StoreFile インデックスエントリに表示されます。座標が大きい場合、インデックスはより多くのメモリを消費し、メモリを使い果たす可能性があります。
インデックスサイズを管理可能に保つには、次の点に注意してください。
アクセスパターンをサポートする最短の行キーを使用します。
カラムファミリー名を単一の文字 (例:
f) にします。短いカラム修飾子 (例:
viaをmyVeryImportantAttributeの代わりに) を使用します。座標サイズをさらに削減できない場合は、圧縮を有効にします。
小規模なデータセットでは、過大なキーの影響はほとんどありません。しかし、数十億行の規模になると、その差は顕著になります。カラムファミリーとカラム修飾子は、すべてのセルで繰り返されるためです。
行キーサイズ削減のためのバイナリエンコーディングの使用
数値を文字列ではなくバイナリとして保存すると、サイズが大幅に削減されます。long 値は8バイトを必要としますが、同じ値を文字列として保存すると、約3倍のバイト数が必要になります。次のコードは、その違いを示しています。
// long stored as binary: 8 bytes
long l = 1234567890L;
byte[] lb = Bytes.toBytes(l);
System.out.println("long bytes length: " + lb.length); // 8
// long stored as string: 10 bytes
String s = String.valueOf(l);
byte[] sb = Bytes.toBytes(s);
System.out.println("long as string length: " + sb.length); // 10
// MD5 digest as binary: 16 bytes
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(Bytes.toBytes(s));
System.out.println("md5 digest bytes length: " + digest.length); // 16
// MD5 digest as string: 26 bytes
String sDigest = new String(digest);
byte[] sbDigest = Bytes.toBytes(sDigest);
System.out.println("md5 digest as string length: " + sbDigest.length); // 26バイナリ行キーはコンパクトですが、HBase シェルのようなツールでは読みにくく、バイナリ値は16進エスケープシーケンスとして表示されます。
hbase(main):001:0> incr 't', 'r', 'f:q', 1
COUNTER VALUE = 1
hbase(main):002:0> get 't', 'r'
COLUMN CELL
f:q timestamp=1369163040570, value=\x00\x00\x00\x00\x00\x00\x00\x01
1 row(s) in 0.0310 secondsストレージ効率が重要であり、運用ツールで人間が読める行キーが不要な場合は、バイナリエンコーディングを使用してください。
最新バージョン取得のための逆タイムスタンプの使用
行の最新バージョンを効率的に取得するには、行キーに Long.MAX_VALUE - timestamp を追加します。
[key][Long.MAX_VALUE - timestamp]HBase は行キーを辞書式順序でソートするため、最小の逆タイムスタンプ (最大の実際のタイムスタンプに対応) が最初にソートされます。[key] からスキャンすると、最新バージョンが最初の結果として返されます。
この手法により、同じスキャンパターンを使用して古いバージョンを取得することもでき、長期的なバージョン保持のための HBase バージョン番号の代替としても機能します。
行キーとカラムファミリー
行キーはカラムファミリーにスコープされます。同じ行キーは、同じテーブルの各カラムファミリーに競合なく存在できます。
行キーの変更不可
一度書き込まれた行キーは、その場で更新できません。行キーを変更するには、既存の行を削除し、新しい行を挿入します。大規模なデータロードを行う前に、行キーのスキーマを設計してください。数十億行にわたる行キー設計の修正はコストがかかります。
既知のキースペースに対するテーブルの事前分割
テーブルを事前分割すると、データが書き込まれる前にリージョンとそのキースペース境界が割り当てられます。事前分割を行わないと、HBase が自動的に分割するまで、すべてのデータは最初に単一のリージョンに集中します。このプロセスは一時的にホットスポットを生成します。
分割ポイントを設計する際は、各リージョンが実際にデータを受信するキースペースの一部をカバーするようにしてください。一般的な間違いは、使用可能な値のサブセットのみを使用するキースペースに対して、デフォルトの分割ロジックを使用することです。
例: 行キーが 0000000000000000 から ffffffffffffffff の範囲の16文字の16進文字列であるとします。Bytes.split を使用してこの範囲を10個のリージョンに分割すると、境界は完全なバイト範囲 (0~255) に基づいて生成され、16進文字範囲 (0~9、a~f) には基づきません。16進文字セットは、ASCII テーブルの48~57番目と97~102番目の位置のみを占めるため、中間リージョン (58~96番目の位置をカバー) にはデータが受信されることはありません。
16進キースペースに対して正しく事前分割するには、実際の文字範囲内で分割ポイントを計算してください。
public static boolean createTable(Admin admin, HTableDescriptor table, byte[][] splits)
throws IOException {
try {
admin.createTable(table, splits);
return true;
} catch (TableExistsException e) {
logger.info("table " + table.getNameAsString() + " already exists");
return false;
}
}
public static byte[][] getHexSplits(String startKey, String endKey, int numRegions) {
byte[][] splits = new byte[numRegions - 1][];
BigInteger lowestKey = new BigInteger(startKey, 16);
BigInteger highestKey = new BigInteger(endKey, 16);
BigInteger range = highestKey.subtract(lowestKey);
BigInteger regionIncrement = range.divide(BigInteger.valueOf(numRegions));
lowestKey = lowestKey.add(regionIncrement);
for (int i = 0; i < numRegions - 1; i++) {
BigInteger key = lowestKey.add(regionIncrement.multiply(BigInteger.valueOf(i)));
byte[] b = String.format("%016x", key).getBytes();
splits[i] = b;
}
return splits;
}この原則は、16進文字列だけでなく、あらゆるキースペースに適用されます。データがキースペース内で実際に存在する場所と一致する分割ポイントを定義してください。