Skip to content

✍️ 필사 모드: Consistent Hashing 完全ガイド 2025: Virtual Nodes、Jump Hash、Rendezvous、分散システムの中核ビルディングブロック

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに: なぜ Consistent Hashing が必要か?

問題: hash ベース sharding の落とし穴

キャッシュサーバー 4 台を運用していると仮定する。単純なハッシュで key を分散すると:

def get_server(key, num_servers):
    return hash(key) % num_servers

# 4 台に分散
get_server("user:1234", 4)  # server 2
get_server("user:5678", 4)  # server 0
get_server("user:9012", 4)  # server 3

平常時は問題なく動く。しかしトラフィック増加でサーバーを 5 台に増設すると?

get_server("user:1234", 5)  # server 4 (旧 2 → 新 4)
get_server("user:5678", 5)  # server 3 (旧 0 → 新 3)
get_server("user:9012", 5)  # server 2 (旧 3 → 新 2)

ほぼ すべての key の配置が変わる。キャッシュミスが爆発し、データベースにトラフィックが殺到してサービス障害へとつながる。これを リハッシュストーム(Rehashing Storm) と呼ぶ。

Consistent Hashing の約束

1997 年に MIT の David Karger らが発表した Consistent Hashing はこれをエレガントに解決する:

ノードが N 個から N+1 個に変わっても、移動すべき key は平均 K/N 個だけで済む。(K は key 総数)

つまりサーバー 4 台 → 5 台なら全 key の約 1/5 だけ移動。残りの 4/5 はそのまま。この原理が Amazon DynamoDB、Apache Cassandra、Riak、Memcached、Akamai CDN など多くの分散システムの基礎になった。

graph LR
    A[Key] --> B{ハッシュ方式}
    B -->|modulo| C[80% の key が移動<br/>災害]
    B -->|Consistent Hash| D[20% の key が移動<br/>正常]

1. 基本アイデア: Hash Ring

円形空間にノードと key を配置

Consistent Hashing の核心は意外にシンプルだ:

  1. ハッシュ関数の出力空間を円形(0 〜 2^32-1)と考える。
  2. ノード(server)をハッシュ化し、リング上の点に置く。
  3. key(data)もハッシュ化し、リング上の点に置く。
  4. key は 時計回りに最も近いノードに格納される。
        0 / 2^32
            |
     NodeA  |
    ---+----+----+---
    |       |       |
    |       |       |
    NodeD      NodeB
    |       |       |
    |       |       |
    ---+----+----+---
     NodeC  |
            |

Python 基本実装

import hashlib
from bisect import bisect_right, insort

class ConsistentHashBasic:
    def __init__(self):
        self.ring = {}       # hash → node
        self.sorted_keys = []

    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def add_node(self, node: str):
        h = self._hash(node)
        self.ring[h] = node
        insort(self.sorted_keys, h)

    def remove_node(self, node: str):
        h = self._hash(node)
        del self.ring[h]
        self.sorted_keys.remove(h)

    def get_node(self, key: str) -> str:
        if not self.ring:
            return None
        h = self._hash(key)
        # 時計回りで最も近いノードを検索
        idx = bisect_right(self.sorted_keys, h)
        if idx == len(self.sorted_keys):
            idx = 0  # リングを一周して先頭へ
        return self.ring[self.sorted_keys[idx]]

# 使用例
ch = ConsistentHashBasic()
for node in ["server-A", "server-B", "server-C", "server-D"]:
    ch.add_node(node)

print(ch.get_node("user:1234"))  # server-C
print(ch.get_node("user:5678"))  # server-A

ノード追加/削除の影響

ノード E を追加すると:

  1. E のハッシュ値が既存ノードの間のどこかに入る。
  2. E と直前ノードの間の key だけ E へ移動する。
  3. 残りの key は そのまま

平均して K/N 個(1/5)の key だけが移動する。これが魔法だ。


2. 基本方式の問題点: 不均衡

基本 Consistent Hashing は完璧ではない。最大の問題は key 分布の不均衡 である。

問題 1: ノード数が少ないときの偏り

4 ノードだけでリングを構成すると、ハッシュ値が均等に分布していても各ノードが担う「区間」のサイズが大きく違う:

NodeA: 05 (5%)
NodeB: 540 (35%)  ← 多く割り当て
NodeC: 4050 (10%)
NodeD: 50100 (50%) ← 半分を占有

結果: NodeD が全トラフィックの半分を受け取り、NodeA はほとんど遊んでいる。

問題 2: ノード削除時に偏りが悪化

NodeB がダウンすると、B の key はすべて NodeC に集中する。ホットスポット(Hotspot)が発生する。

問題 3: サーバー容量の差を反映しにくい

あるサーバーは CPU 16 コア メモリ 64GB、別のサーバーは 4 コア 16GB かもしれない。基本方式ではサーバー容量比を反映できない。


3. 解決策: Virtual Nodes(vnodes)

アイデア

各物理ノードを 複数の virtual node として表現しリングに撒く。例えばノードあたり 150 個の virtual node を作ると:

  • 物理ノード 10 個 × 150 = 1,500 個の virtual node がリングに分散する。
  • 大数の法則により各物理ノードが担う区間が 均等になる
物理ノード 4 (150 vnode)

リング上の分布:
A-1, B-7, C-3, A-99, D-42, B-15, C-88, D-1, A-15, ...
(ほぼ均等に混ざる)

virtual node 実装

class ConsistentHashWithVNodes:
    def __init__(self, vnodes_per_node: int = 150):
        self.vnodes_per_node = vnodes_per_node
        self.ring = {}
        self.sorted_keys = []

    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def add_node(self, node: str, weight: int = 1):
        # weight が大きいほど vnode が多く作られる
        count = self.vnodes_per_node * weight
        for i in range(count):
            vnode_key = f"{node}#{i}"
            h = self._hash(vnode_key)
            self.ring[h] = node
            insort(self.sorted_keys, h)

    def remove_node(self, node: str, weight: int = 1):
        count = self.vnodes_per_node * weight
        for i in range(count):
            vnode_key = f"{node}#{i}"
            h = self._hash(vnode_key)
            del self.ring[h]
            self.sorted_keys.remove(h)

    def get_node(self, key: str) -> str:
        if not self.ring:
            return None
        h = self._hash(key)
        idx = bisect_right(self.sorted_keys, h)
        if idx == len(self.sorted_keys):
            idx = 0
        return self.ring[self.sorted_keys[idx]]

# 使用例: 容量が異なるサーバー群
ch = ConsistentHashWithVNodes(vnodes_per_node=150)
ch.add_node("server-A", weight=2)  # 性能 2 倍 → 300 vnode
ch.add_node("server-B", weight=1)  # 標準 → 150 vnode
ch.add_node("server-C", weight=1)
ch.add_node("server-D", weight=1)

# これで A は全 key の約 40%、残りは各約 20% を受け取る

virtual node 数の選定ガイド

virtual node 数標準偏差メモリオーバーヘッド
10±30%
100±10%
160±6%適正(Ketama デフォルト)
1000±2%

実運用では 100〜200 がスイートスポット。Memcached の libketama は 160 を使用し、Cassandra はデフォルトで 256(num_tokens)を使う。


4. ベンチマーク: 実際の分散はどれほど均等か?

import random
import statistics

def benchmark(vnodes_per_node: int, num_nodes: int, num_keys: int):
    ch = ConsistentHashWithVNodes(vnodes_per_node)
    for i in range(num_nodes):
        ch.add_node(f"node-{i}")

    counts = {f"node-{i}": 0 for i in range(num_nodes)}
    for k in range(num_keys):
        key = f"key-{random.random()}"
        node = ch.get_node(key)
        counts[node] += 1

    values = list(counts.values())
    avg = statistics.mean(values)
    stddev = statistics.stdev(values)
    max_val = max(values)
    min_val = min(values)

    print(f"vnodes={vnodes_per_node}: "
          f"min={min_val}, max={max_val}, "
          f"stddev={stddev:.0f} ({stddev/avg*100:.1f}% of avg)")

# 1,000,000 key を 10 ノードへ分散
benchmark(vnodes_per_node=1,   num_nodes=10, num_keys=1_000_000)
benchmark(vnodes_per_node=10,  num_nodes=10, num_keys=1_000_000)
benchmark(vnodes_per_node=100, num_nodes=10, num_keys=1_000_000)
benchmark(vnodes_per_node=500, num_nodes=10, num_keys=1_000_000)

期待される出力:

vnodes=1:   min=30000, max=250000, stddev=70000 (70.0% of avg)
vnodes=10:  min=60000, max=180000, stddev=35000 (35.0% of avg)
vnodes=100: min=92000, max=110000, stddev=5800  (5.8% of avg)
vnodes=500: min=97000, max=103000, stddev=2000  (2.0% of avg)

100 個の virtual node でも ±6% 水準の均衡を達成できる。


5. Jump Consistent Hash: virtual node なしで均等に

2014 年に Google の John Lamping と Eric Veach が発表した Jump Consistent Hash は virtual node なしで O(1) 空間ほぼ完璧な均等分布 を達成する。

驚くべき特性

  • メモリ使用量: O(1)(ノードリスト不要!)
  • 時間計算量: O(log N)(N はノード数)
  • 分布均等度: ほぼ完璧
  • 欠点: ノード削除が不便(末尾のノードしか削除できない)

Jump Hash アルゴリズム

def jump_consistent_hash(key: int, num_buckets: int) -> int:
    """
    Google Jump Consistent Hash
    key: ハッシュ化された整数
    num_buckets: bucket(node)の個数
    """
    b = -1
    j = 0
    while j < num_buckets:
        b = j
        key = (key * 2862933555777941757 + 1) & 0xFFFFFFFFFFFFFFFF
        j = int((b + 1) * (1 << 31) / ((key >> 33) + 1))
    return b

# 使用例
import hashlib

def hash_key(key: str) -> int:
    return int(hashlib.md5(key.encode()).hexdigest()[:16], 16)

num_nodes = 10
key = hash_key("user:1234")
bucket = jump_consistent_hash(key, num_nodes)
print(f"user:1234 → bucket {bucket}")

なぜこう動くのか?

Jump Hash の核は 疑似乱数ベースの確率的スキップ だ。反復ごとに「次にジャンプする位置」を確率的に決める。数学的に証明されている性質:

  • ノード数が N → N+1 に変わると、ちょうど K/(N+1) 個の key だけが新ノードへ移動する。
  • 分布は完璧に均等(virtual node は一切不要)。

限界: ノード削除問題

Jump Hash は 末尾ノード(最大インデックス)しか削除できない。中間ノードを削除すると後続ノードのインデックスがずれ、すべての key マッピングが崩壊する。

これを回避するには Lookup Table を追加管理するか、次に説明する Rendezvous Hashing を使う。


6. Rendezvous Hashing(HRW: Highest Random Weight)

1997 年に David Thaler と Chinya Ravishankar が提案した Rendezvous Hashing はもう一つのエレガントな解決策だ。

アイデア

各 key について すべてのノードとのハッシュ値を計算 し、その中で 最も高い(もしくは低い)値を持つノード を選ぶ。

def rendezvous_hash(key: str, nodes: list) -> str:
    """
    各 key を最大の weight を持つノードに割り当てる
    """
    if not nodes:
        return None

    max_weight = -1
    selected_node = None

    for node in nodes:
        combined = f"{key}:{node}"
        h = int(hashlib.md5(combined.encode()).hexdigest(), 16)
        if h > max_weight:
            max_weight = h
            selected_node = node

    return selected_node

# 使用例
nodes = ["server-A", "server-B", "server-C", "server-D"]
print(rendezvous_hash("user:1234", nodes))  # 例: "server-C"

長所と短所

長所:

  • 実装が非常にシンプル(ハッシュ関数さえあればよい)
  • 完璧に均等な分布
  • ノード追加/削除時に K/N 個の key だけ移動
  • Jump Hash と違い任意のノード削除が可能
  • replica 選択に有用: Top-K ノードを得れば K 個の複製配置が即座に決まる。

短所:

  • O(N) 時間計算量: ノードが多いと遅い。
  • 1000 ノード以上では不適切。

複製(Replica)選択

Rendezvous Hashing が輝くのは Top-K ノード選択 だ:

def rendezvous_hash_topk(key: str, nodes: list, k: int) -> list:
    weights = []
    for node in nodes:
        h = int(hashlib.md5(f"{key}:{node}".encode()).hexdigest(), 16)
        weights.append((h, node))
    weights.sort(reverse=True)
    return [node for _, node in weights[:k]]

# 3 個の replica 用に 3 ノードを選択
replicas = rendezvous_hash_topk("user:1234", nodes, k=3)
print(replicas)  # ["server-C", "server-A", "server-D"]

これは Cassandra の replica 配置戦略で使われるアイデアに近い。


7. Anchor Hashing: 最新アルゴリズム(2018)

Anchor Hashing は 2018 年に Gal Mendelson らが発表した最新アルゴリズムで、Jump Hash の速度Rendezvous の柔軟なノード削除 を両立する。

主な特性:

  • O(log N) 時間計算量
  • O(N) 空間(各ノードあたり定数メモリ)
  • 任意ノード削除可能
  • 完璧な均等分布

実装は複雑なため、本記事では存在を言及するにとどめる。Maglev Hash(Google)も同じカテゴリに属する。


8. 実システムでの使われ方

DynamoDB: 基本 Consistent Hashing + vnode

Amazon DynamoDB は Amazon Dynamo 論文(2007)の設計を踏襲する:

  • 128 bit ハッシュ空間を円形に構成。
  • 各物理ノードを 複数の virtual node(token) に分散。
  • 各 key は時計回りに最も近い N ノードへ複製(Preference List)。
  • ノード追加/削除時は 隣接ノード間のみでデータ移動

Cassandra: Token Ring

Cassandra も類似構造だが用語が少し違う:

  • Token = virtual node(Murmur3 64 bit ハッシュ)
  • デフォルト num_tokens: 256 → 各物理ノードが 256 token を所有。
  • データ複製は Replication Strategy(SimpleStrategy、NetworkTopologyStrategy)で設定。
# cassandra.yaml
num_tokens: 256
partitioner: org.apache.cassandra.dht.Murmur3Partitioner

Memcached(libketama): virtual node 標準

libketama は Last.fm 製の Memcached クライアントライブラリで、Consistent Hashing の事実上の標準となった:

  • ノードあたり 160 個の virtual node
  • MD5 ハッシュ(128 bit → 32 bit に切り詰め)。
  • サーバー情報文字列 → MD5 → 4 個の 32 bit 整数 → 4 個の virtual node を生成。

Nginx: upstream consistent hash

Nginx も upstream ロードバランシングで Consistent Hashing をサポートする:

upstream backend {
    hash $request_uri consistent;
    server backend1.example.com;
    server backend2.example.com;
    server backend3.example.com;
}

consistent キーワードが Ketama ベースの Consistent Hashing を有効化する。

Envoy / HAProxy / Istio

Envoy の Ring Hash LB PolicyMaglev LB Policy はそれぞれ Ketama 方式と Google Maglev 方式の Consistent Hashing を実装する。

# Envoy config
load_balancing_policy:
  policies:
  - typed_extension_config:
      name: envoy.load_balancing_policies.ring_hash
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.load_balancing_policies.ring_hash.v3.RingHash
        minimum_ring_size: 1024

9. よくあるミスと落とし穴

落とし穴 1: ハッシュ関数の選択

悪い選択:

def bad_hash(key):
    return hash(key)  # Python 内蔵 hash() は再起動で変わる!

Python 内蔵の hash()セキュリティ上の理由でプロセス起動ごとに異なるシード を使う。分散システムで再起動時にマッピングが全変化するのは大惨事だ。

良い選択:

  • MD5, SHA-1: 遅いが分布が良い。
  • MurmurHash3: 速く分布も良い(Cassandra で使用)。
  • xxHash: 非常に高速(最近好まれる)。

落とし穴 2: virtual node 数の不足

ノード 4 × virtual node 1 = 4 → 分布が非常に不均衡。 常に ノードあたり最低 100 以上 の virtual node を使おう。

落とし穴 3: ノード ID の文字列フォーマット

virtual node を作るときフォーマットが一貫していないと再現不能:

# 悪い
f"{node}-{i}"   # node1-0, node1-1, ...
f"{node}_{i}"   # node1_0, node1_1, ...(別システム利用)

# 良い: チーム/組織標準を文書化
f"{node}#{i}"   # ドキュメントに明記

落とし穴 4: Hot Key

Consistent Hashing は key 分布が均等なとき だけうまく働く。特定の key(例: user:celebrity_account)が膨大なトラフィックを受けると一ノードに負荷が集中する。

解決策:

  • Key Salting: user:celebrity_account:{random_suffix} で複数 shard に分散。
  • Local Cache: アプリレベルキャッシュで分散キャッシュ呼び出し自体を減らす。
  • Read Replica: 読み取り専用レプリカを追加。

落とし穴 5: クラスタノードの激しい変動

Kubernetes のような環境でノードが頻繁に作成/削除されると、その都度データ移動が発生する。Rebalancing Throttling が必要:

  • Warm-up 期間: 新ノードは徐々にトラフィックを受ける。
  • Cooldown: ノード削除後データが完全移動するまで待機。

10. さらに一歩: Weighted Consistent Hashing

Maglev(Google)

Google の Maglev は以下の要件で設計された:

  1. 完璧な均等性(同程度の weight のサーバー間)
  2. 最小 disruption(ノード変更時)
  3. O(1) lookup(ランタイムの検索が高速であること)

Maglev は各サーバーに対して preference permutation を計算し、これを使ってサイズ M の lookup table を構築する。検索は lookup_table[hash(key) % M] で O(1)。

# 概念レベルの擬似コード
def build_maglev_table(servers, table_size=65537):
    # table_size は素数(prime)を選ぶ
    permutations = {
        s: generate_permutation(s, table_size) for s in servers
    }
    table = [None] * table_size
    next_idx = {s: 0 for s in servers}

    filled = 0
    while filled < table_size:
        for s in servers:
            while table[permutations[s][next_idx[s]]] is not None:
                next_idx[s] += 1
            table[permutations[s][next_idx[s]]] = s
            next_idx[s] += 1
            filled += 1
            if filled == table_size:
                break
    return table

Maglev は Envoy の maglev ロードバランサーポリシーとして使え、Google Cloud Load Balancer 内部でも使用されている。

weight の付与

異種サーバーがある場合(例: 16 コアと 4 コア)、weight を反映するには:

  1. virtual node 方式: vnodes = base * weight
  2. Maglev 方式: permutation を weight 倍だけ使用。
  3. Rendezvous weight: ハッシュ値に log(weight)を掛けて bias をかける。

11. 性能比較: どのアルゴリズムを選ぶか?

アルゴリズム時間計算量空間計算量分布均等度ノード削除主な用途
Modulo HashO(1)O(1)完璧全再分配単純 sharding
Consistent + vnodeO(log V)O(V)良好(V↑で向上)任意Cassandra, DynamoDB
Jump HashO(log N)O(1)完璧末尾のみGoogle 内部
Rendezvous HashO(N)O(N)完璧任意小規模クラスタ
MaglevO(1) 検索O(M)ほぼ完璧任意Google Cloud LB
Anchor HashingO(log N)O(N)完璧任意最新システム

選択ガイド

  • ノード数 < 100、変動が激しい: Rendezvous Hashing(シンプル、柔軟)
  • 大規模 cache/DB クラスタ: Consistent Hashing + vnode(実績豊富)
  • 静的ノード集合、極限性能: Jump Hash(O(log N))
  • L4/L7 ロードバランサー: Maglev(O(1) 検索)
  • 異種容量サーバー: vnode weight もしくは weighted Maglev

12. 実運用 Tips: 運用から学んだこと

Tip 1: virtual node 数は絶対に変えるな

稼働中に num_tokens を 150 → 200 に変えると ほぼ全データが再分配 される。クラスタ初期設計段階で慎重に選ぼう。

Tip 2: ログで分布を監視

実 key 分布がどれほど均等か定期的に計測しよう:

# Prometheus metrics
consistent_hash_node_keys{node="server-A"} 12050
consistent_hash_node_keys{node="server-B"} 10800
consistent_hash_node_keys{node="server-C"} 11200
consistent_hash_node_keys{node="server-D"} 12500

標準偏差が急に大きくなったら hot key を疑おう。

Tip 3: Graceful Shutdown

ノード削除時は突然落とさず:

  1. 当該ノードを "draining" 状態にマーク(リングから除去)。
  2. 既存コネクションの処理完了を待機。
  3. データ移動を確認。
  4. プロセス終了。

Tip 4: ハッシュ関数の速度 vs 品質

毎秒数十万件の検索がある場合、MD5 は遅い可能性がある。ベンチマーク:

ハッシュ関数速度(MB/s)分布品質
xxHash3〜30,000良好
MurmurHash3〜6,000良好
CityHash〜10,000良好
MD5〜500優秀
SHA-256〜400優秀

cache sharding のような用途には xxHashMurmur が適切だ。


クイズで復習

Q1. サーバー 4 台から 5 台に増設するとき、通常の modulo hashing(hash % N)では何 % の key が移動するか?

A.80% の key が移動する。既存の hash % 4hash % 5 の結果がほぼ無関係だからだ。一方 Consistent Hashing では 約 20%(K/N = K/5)だけ移動する。

Q2. 基本 Consistent Hashing で virtual node がなぜ必要か?

A. ノードが少ないとき リング上のノード分布が不均等 になり、一部ノードに key が偏る。各物理ノードを 100〜200 個の virtual node として散らせば、大数の法則で ほぼ均等な分布 が得られる。またサーバー容量に応じて virtual node 数を調整し weight を付与 することもできる。

Q3. Jump Consistent Hash の最大の長所と短所は?

A. 長所: O(1) 空間と完璧な均等分布。virtual node なしでもうまく働き、非常に高速。短所: 末尾(最大インデックス)ノードしか削除できない。中間ノードを削除するとマッピング全体が崩壊する。任意ノード削除が必要なら Rendezvous Hashing や Anchor Hashing を使うべきだ。

Q4. Rendezvous Hashing が N 個の replica 選択に有用な理由は?

A. Rendezvous Hashing は各 key に対し すべてのノードの weight(ハッシュ値)を計算 する。それをソートして 上位 K ノード を選べば自然に K 個の replica 配置が決まる。これは Consistent Hashing リングで「時計回りに次の K ノード」を探すのと似た効果だが、より均等に分布する。

Q5. Memcached の libketama はノードあたりいくつの virtual node をデフォルト使用し、その理由は?

A. Ketama はノードあたり 160 個 の virtual node を使う。これは 均衡性(標準偏差約 5%)メモリオーバーヘッド のバランス点だ。実装上は MD5 ハッシュの結果を 4 個の 4 byte 整数に分割 し各 "replica" あたり 4 個の virtual node を作るため、40 replicas × 4 = 160 となる。


おわりに: Consistent Hashing の教訓

Consistent Hashing は 1997 年 MIT で CDN の Web キャッシュ問題を解くために考案された。Akamai を生み、その後 20 年以上にわたり分散システムの中核ビルディングブロックであり続けている。

核となる 5 原理

  1. 円形ハッシュ空間: ノードと key を同じ空間に配置して隣接関係を定義。
  2. 時計回り割り当て: key は時計回りに最も近いノードへ。
  3. virtual node: 大数の法則で均等分布を達成。
  4. 最小 disruption: ノード追加/削除時に K/N 個の key だけ移動。
  5. weight の柔軟性: vnode 数で容量差を反映。

現代の進化

  • Jump Hash: O(1) 空間による極限効率。
  • Rendezvous Hashing: シンプルさの極致。
  • Maglev: O(1) 検索 + weighted LB。
  • Anchor Hashing: 最新改良版。

覚えておくこと

「分散システムでデータをどこに置くか決める問題」 の答えはほぼ常に Consistent Hashing の何らかの変種だ。

いま使っている Redis Cluster、DynamoDB、Cassandra、Memcached、CDN はすべてこの 30 年前のアイデアの上で動いている。基本を知れば、デバッグと設計の深さが変わる。

次に「なぜこの key があのサーバーに行くのか?」と思ったときは、hash ring を頭の中に描いてみよう。答えはそこにある。


参考資料

현재 단락 (1/380)

キャッシュサーバー 4 台を運用していると仮定する。単純なハッシュで key を分散すると:

작성 글자: 0원문 글자: 15,111작성 단락: 0/380