シャーディング / パーティショニング戦略: レンジ・ハッシュ・リバランシング・ルーティング

データベースをスケールさせる手段は大きく2つあります。1つはレプリケーション(同じデータを複数ノードに複製する)、もう1つが本記事の主題であるパーティショニング(異なるデータを複数ノードに分散させる)です。レプリケーションだけでは1ノードに収まらない書き込み量・データ量を捌けないため、大規模システムは両者を組み合わせて使います。

本記事では『データ指向アプリケーションデザイン』(以下 DDIA)第6章「パーティショニング」をベースに、キーバリューデータの分割方式・二次インデックスの扱い・リバランシング・リクエストルーティングという4つの軸でパーティショニング設計を整理します。

レプリケーション3方式(シングル / マルチ / リーダーレス)の整理は『レプリケーション戦略: シングルリーダー・マルチリーダー・リーダーレスの使い分け』に、分散整合性のモデル(強整合性・結果整合性・W+R>N)は『結果整合性とBASE特性: 強整合性とのトレードオフと調整可能整合性(W+R>N)』に、CAP/PACELC による分類学は『CAP定理(CA/CP/AP)とPACELC定理(PA/EL・PA/EC・PC/EC)による分散データベース分類』に委ねます。

本記事は「データをどう分け、どう動かし、どう参照するか」に集中します。

パーティショニングが解決する課題

レプリケーションは読み取りスケールと耐障害性には効きますが、書き込みスループットは1ノード(リーダー)の上限に縛られます。シングルリーダー構成ではすべての書き込みがリーダー1台を経由するため、CPU・ディスクI/O・ネットワーク帯域のどれかが詰まれば全体が頭打ちになります。データ量も1ノードのストレージ上限を超えられません。

パーティショニングはこれを別アプローチで解決します。データそのものを分割して別ノードに分けて持たせれば、書き込み先のリーダーもN個に増え、スループットを横にスケールできます。実運用では「パーティショニングしたうえで、各パーティションをレプリケーションする」のが標準的な構成で、たとえば100シャード × 各3レプリカ = 300ノード、というイメージになります。CassandraもMongoDBもこの形でシャード単位にレプリカを持ちます。

なお呼称は DBMS ごとにバラバラです。MongoDB は「シャード」、Cassandra と Riak は「パーティション」、HBase は「リージョン」、Couchbase は「vBucket」と呼びます。本記事では DDIA に倣い「パーティション」で統一しますが、実体はどれも同じく「分割されたデータの単位」を指します。

キーバリューデータのパーティショニング

データをどう分けるかには大きく2系統あります。キーの値で範囲を切る方式(レンジ)と、キーをハッシュした値で振り分ける方式(ハッシュ)です。両者は範囲クエリの効きやすさと負荷分散しやすさのトレードオフ関係にあります。

レンジ(key-range)パーティショニング

ソート済みのキーを範囲で分割し、各パーティションにキー範囲を割り当てます。たとえば百科事典を A〜B C〜D E〜F … のように分割するイメージです。

パーティション1: keys [A-F]
パーティション2: keys [G-M]
パーティション3: keys [N-S]
パーティション4: keys [T-Z]

利点はパーティション内でキーがソートされていることです。これにより範囲クエリ(例: WHERE created_at BETWEEN ... AND ...)が効率的に処理できます。HBase、BigTable、MongoDB(レンジシャーディング)が採用しています。

弱点はホットスポットです。タイムスタンプをキーにすると、最新の書き込みが常に同じパーティションに集中します。センサデータやログのように単調増加する値を主キーにする設計では特に注意が必要です。回避策として、キーの先頭にデバイスID等の別属性をプレフィックスとして付与し、書き込みを分散させる方法が DDIA で紹介されています。

ハッシュパーティショニング

キーをハッシュ関数(一般的なハッシュ関数。暗号学的に強い必要はない)に通し、ハッシュ値の範囲で分割します。同じキーは常に同じパーティションに行くため、書き込みが偏りにくくなります。

採用例は Cassandra、Riak、Voldemort、MongoDB(ハッシュシャーディング)、ScyllaDB など。書き込みヘビーな分散KVSの主流です。

トレードオフは範囲クエリが効かなくなることです。隣接するキーがハッシュで散らばるため、BETWEEN のような範囲指定では全パーティションに問い合わせる必要があります。Cassandra は複合主キー(パーティションキー + クラスタリングキー)でこれを補います。たとえばユーザーごとの時系列データなら、user_id をパーティションキー(ハッシュで分散)、timestamp をクラスタリングキー(パーティション内でソート)にすることで、「特定ユーザーの過去24時間のデータ」のような範囲クエリを1パーティション内のソート済みデータの連続スキャンで返せます。

一貫性ハッシング

ノード追加・削除時の再配置量を抑える古典的な手法として一貫性ハッシング(consistent hashing)があります。ハッシュ値の取りうる範囲(例: 0〜2^32)を円環として扱い、ノードもキーもその円環上にハッシュで配置します。キーの担当は「キーの位置から時計回りに進んで最初に出会うノード」と決めるルールにすることで、ノードを1台追加・削除しても動くのはその周辺のキーだけで済みます(hash mod N のようにほぼ全キーが動くことがありません)。

ただし DDIA は、データベース文脈では一貫性ハッシングは実用上あまりうまくいかず、後述する「固定数パーティション」方式の方が主流だと整理しています。CDN 等での用途とは事情が違うため、用語として知っておく程度で十分です。

ホットスポットとアプリ側の回避策

ハッシュパーティショニングを採用しても、単一キーへの書き込み集中は解消されません。たとえば SNS で大量のフォロワーを持つ著名人のIDをキーにする場合、ハッシュ関数を通しても結局そのキーに対応する1パーティションに書き込みが集中します(DDIA で「セレブリティ問題」として言及されています)。

DDIA は対策として、アプリ側でキーにランダムなサフィックスを付与する方法を紹介しています。ホットなキーの末尾に2桁の乱数を付ければ100倍に分散します。ただし読み取り側で全サフィックスを集約する処理が必要になるため、ホットなキーが特定できているケースに限定的に使う手法です。

二次インデックスとパーティショニング

主キー以外の属性で検索したい場合、二次インデックスをどう持つかが問題になります。パーティショニングしたデータに対しては、二次インデックスの設計に2つの流派があります。

ローカル(ドキュメントベース)インデックス

各パーティションが、そのパーティションに含まれるデータについてのみ二次インデックスを持つ方式です。書き込みは1パーティション内で完結するため高速です。

パーティション1: データ + そのパーティション分のインデックス
パーティション2: データ + そのパーティション分のインデックス

弱点は読み取りで、二次インデックスを使った検索はすべてのパーティションに問い合わせて結果をマージする scatter/gather が必要になります。これは主キー(パーティション割り当てを決める)と二次インデックスの値が無関係に振られるためで、たとえば中古車を car_id でパーティショニングしているとき、「色 = red」の車はどのパーティションにも分布しうるので、全パーティションに問い合わせないと取りこぼしが起きます。クエリレイテンシは最も遅いパーティションに引きずられるテイルレイテンシ問題も顕著です。

採用例は MongoDB、Cassandra、Elasticsearch、Riak など。

グローバル(用語ベース)インデックス

インデックス自体をパーティショニングし、各用語(インデックス値)の担当ノードを決める方式です。中古車の例で言えば、「red 担当ノード」「blue 担当ノード」「製造年2020 担当ノード」のようにインデックス値ごとに別ノードへ配置されます。検索時は該当ノードに1回問い合わせれば済むため、読み取りは速くなります。

書き込みのトレードオフは大きく、1件のレコード追加で複数のインデックスパーティションへの分散書き込みになります。先ほどの中古車(color = red、製造年 = 2020、car_id = 5000)を1件追加するだけでも、car_id = 5000 のパーティション(データ本体)・red 担当ノード・2020担当ノードと3つの異なるノードへの書き込みが発生し、ネットワーク越しの遅延が積み上がります。さらに「全ノードの書き込みを揃って成功させる」には分散トランザクション(2PCなど)が必要なため、多くの実装はインデックス更新は非同期で妥協します。結果として「書いたばかりのデータが二次インデックス経由では見えない」という整合性ギャップが生まれます。

採用例は DynamoDB のグローバルセカンダリインデックス、Riak の検索機能など。

リバランシング戦略

ノードの追加・削除や負荷変動に応じてパーティションをノード間で移動させる作業をリバランシングと呼びます。リバランシング中もリクエストは捌き続ける必要があるため、データ移動量を最小化する工夫が重要になります。

やってはいけない方式: hash mod N

最も素朴な発想は hash(key) mod N(N = ノード数)でノードを決める方式ですが、ノード数が1台変わるとほとんどのキーが別ノードへ移動するため、リバランシングのたびに大量のデータ移動が発生します。たとえば N=10 → N=11 にすると、hash(key) % 10hash(key) % 11 は大半のキーで結果が変わるため、ほとんどのキーが別ノードへ引っ越すことになります。本番運用では使い物になりません。

固定数パーティション

ノード数よりはるかに多い固定数のパーティションを最初に作っておき、ノード間で「どのパーティションをどのノードに置くか」の割り当てだけを動かす方式です。肝は「キー → パーティション」の対応(hash(key) mod パーティション数 で決まる)と「パーティション → ノード」の対応を分離する点で、パーティション数は最初に決めたら変えないので、キーがどのパーティションに属するかは永遠に変わりません。

たとえば10ノードに対して1000パーティションを用意し、各ノードに100個ずつ割り当てておきます。11ノード目を追加するときは、各既存ノードから約9個ずつ「割り当て表」を書き換えるだけで済みます。動くのはパーティション → ノードのマッピング情報と、移動対象パーティションの実データのみです。

ここで「キーの居場所」と「キーのパーティション帰属」を分けて見るのがポイントです。たとえば P5 が ノード1 から ノード11 に移管されると、P5 に属するキーAは結果として ノード1 → ノード11 で見つかるようになります(居場所のノードは変わる)。一方で「キーAはどのパーティションに属するか」は hash(A) mod 1000 = 5 のまま永遠に不変です。クライアント側のキー → パーティションの計算ロジックは書き換え不要で、調整サービスから「P5 は今ノード11」という配置情報の更新通知さえ受ければ動き続けます。これが「データの再ハッシュは不要」の意味です。

採用例: Riak、Elasticsearch、Couchbase、Voldemort。

注意点はパーティション数が固定されることです。データ量が想定を大きく超えるとパーティションあたりが肥大化するため、初期設計でパーティション数を多めに見積もる必要があります。

動的パーティショニング

データ量に応じてパーティションを分割(split)したり統合(merge)したりする方式です。閾値を超えたら自動的に分割し、空に近づいたら統合します。レンジパーティショニングと相性が良く、HBase や MongoDB のレンジシャーディングが採用しています。

データ量に応じて自動調整される一方、分割・統合の動作中に瞬間的な負荷スパイクが起こるため、運用監視ではここを見る必要があります。

ノード比例パーティショニング

パーティション数をノード数に比例させる方式です。1ノードあたり固定数のパーティション(例: 256個)を持ち、ノード追加時は新ノードのパーティション分だけ既存ノードからランダムに移管します。Cassandra と Ketama が採用しています。

自動 vs 手動リバランシング

リバランシングを完全自動にするか、人間の承認を挟むかの判断は重要です。

DDIA は「完全自動リバランシングは危険な場合がある」と指摘しています。リバランシングはデータ移動でディスクI/O・ネットワーク帯域を大量消費します。これがフェイルオーバーと重なると、たまたま遅延が増えたノードを「障害」と誤判定して切り離し、その負荷が他ノードへ波及して連鎖障害を引き起こす危険があります。

本番運用では人間の承認を挟む半自動方式が現実的です。リバランシングのプランは自動生成、実行は人間がボタンを押す、というスタイルです。

リクエストルーティング

クライアントはどのノードに問い合わせるべきかを知る必要があります。これをサービスディスカバリ問題と呼び、DDIA は3つのアプローチを整理しています。

1. クライアント → 任意のノード → 正しいノードへフォワード
2. クライアント → ルーティング層 → 正しいノード
3. クライアント → 配置情報をクライアントが直接保持 → 正しいノード
アプローチ仕組み採用例
ノード側フォワードクライアントは任意ノードに送る。ノードがゴシッププロトコル等で配置を共有し、自分が担当でなければフォワードCassandra、Riak
ルーティング層クライアントとノードの間に専用ルータ(プロキシ)を挟むMongoDB の mongos、Couchbase
クライアント直接クライアントがメタデータストアから配置情報を取得して直接アクセスHelix、Kafka(一部)

配置情報そのものは多くの実装で ZooKeeper・etcd・Helix のような調整サービス(distributed coordination service)が管理します。配置が変わったら通知を受け取り、各コンポーネントが情報を更新します。

設計判断のヒント

DBMS 選定でパーティショニング方式は選べる場合と選べない場合があります(Cassandra はハッシュ専用、MongoDB はレンジ / ハッシュ両対応など)。選べる場合のチェックポイントを整理します。

各プロダクトのシャーディング設計やパーティションキーの個別整理は『主要NoSQLプロダクトリファレンス: Redis・Cassandra・DynamoDB・MongoDB・Neo4j を中心に』で扱っています。「IoT時系列ならCassandra」のようにユースケース起点で候補プロダクトを逆引きする整理は『ユースケース別NoSQL選定ガイド: 「こういうときはどのNoSQL?」を逆引きする』を参照してください。

  • 範囲クエリの頻度が高い場合
    • レンジ系を選ぶ
    • 用途例: 時系列データやログでの BETWEEN クエリが主用途のとき、レンジ + 動的パーティショニング(HBase / MongoDB レンジ)が向く
  • 書き込み分散が最優先の場合
    • ハッシュ系を選ぶ
    • 用途例: ホットスポットを許容できないワークロードでは、ハッシュ + 固定数パーティション(Cassandra / Riak)が向く
  • 二次インデックスの読み取りが頻繁か
    • ローカルは書き込みが速いが、読み取りで scatter/gather が必要
    • グローバルは読み取りが速いが、書き込みが分散して遅延が出る
    • 読み取り頻度と書き込み頻度の比で選ぶ
  • リバランシング方式の選択
    • パーティション数を将来見直したくないなら固定数を選ぶ
    • データ量変動に応じて自動調整したいなら動的を選ぶ
  • 自動リバランシングは慎重に判断する
    • 特にフェイルオーバーと重なると連鎖障害になりがち
    • SLO が厳しい本番では半自動運用にする判断がよくある
  • 配置情報の管理基盤
    • ZooKeeper / etcd を運用できるチーム力があるか
    • なければ ZooKeeper 不要の Cassandra のような選択肢もある

まとめ

  • パーティショニングはレプリケーションとは別軸で、書き込みスケールとデータ量スケールが主目的。実運用では両者を併用する
  • 分割方式はレンジ vs ハッシュ。範囲クエリ vs 負荷分散のトレードオフ
  • 二次インデックスはローカル(書き込み速い・scatter/gather読み取り)vs グローバル(読み取り速い・分散書き込み)の2系統
  • リバランシングは hash mod N を避け、固定数 / 動的 / ノード比例から選ぶ。完全自動は危険な場合があり、半自動運用が現実的
  • リクエストルーティングはノード側フォワード / ルーティング層 / クライアント直接の3形態。配置情報は ZooKeeper / etcd 等で集中管理することが多い

主要な選択肢を表で整理します。

観点選択肢A選択肢B
分割方式レンジ(範囲クエリ得意・ホットスポット注意)ハッシュ(負荷分散得意・範囲クエリ不可)
二次インデックスローカル(書き込み速い・scatter/gather)グローバル(読み取り速い・分散書き込み)
パーティション数の決め方固定数(事前に多めに用意)動的(データ量に応じて分割・統合)
リバランシング判断完全自動(人手不要・誤判定リスク)半自動(人間の承認を挟む・安全)
リクエストルーティングノード側フォワード / ルーティング層 / クライアント直接(3つから選択)

参考書

タグ: RDB , NoSQL , データベース基礎 , 分散データベース