サーバーを1台足しただけなのに、なぜキャッシュのヒット率が丸ごと落ちるのか?
あなたも Memcached や Redis のキャッシュサーバーをスケールアウトさせるために1台足したことがあるはずだ。負荷分散が改善されるはずが、直後にキャッシュのヒット率が急落し、DB への問い合わせが跳ね上がった経験はないだろうか。台数を増やしただけなのに、なぜキャッシュの中身がまるごと入れ替わったかのような挙動になるのか。
hash(key) % サーバー台数という素朴な振り分けは、台数という除数が1つ変わるだけで大半のキーの担当サーバーが変わってしまう。- コンシステントハッシングはサーバーとキーを同じ円環(0〜2^32 のリング)上にハッシュし、キーを時計回りに最初に見つかったサーバーへ割り当てることで、ノード増減時に動くキーの量を大幅に抑える。
- 実サーバーの台数が少ないとリング上の間隔が偏り負荷も偏るため、1台のサーバーをリング上の複数点(仮想ノード)に割り当てて偏りを均す。Cassandra の vnode や Memcached の Ketama が採用する手法だ。
mod N 方式が抱える脆さ
コンシステントハッシングは、1997 年に Karger らが発表した論文が、Web キャッシュへのアクセスが特定のサーバーに集中してしまう状態であるホットスポットを分散させる目的で提案した手法だ。キャッシュサーバーを複数台構成にするとき、まず思いつくのは hash(key) % サーバー台数 でどのサーバーが担当するかを決める方式だろう。台数が変わらない限り、この式はキーをほぼ均等にばらまける。
ところがサーバーを1台追加すると、割り算の除数そのものが変わる。ほぼすべてのキーで剰余の値が変わってしまうため、大半のキーが以前とは別のサーバーの担当になる。担当が変わったキーはそのサーバーのキャッシュ上には存在しないので、次に来たリクエストは軒並みキャッシュミスとして DB まで届いてしまう。これが、台数を増やしただけなのにヒット率が急落する理由だ。
リングの上でキーとサーバーを同じ空間に置く
この壊れやすさを解消するのが、サーバー名とキーの両方を同じハッシュ関数にかけ、大きな数直線を輪っか状につないだ円環(ハッシュリング)上の点として配置するコンシステントハッシングだ。キーの担当は「そのキーの位置から時計回りに歩いて最初に見つかったサーバー」と定義する。
ハッシュリング(0〜2^32 の円環、時計回り) ┌───────────────────────────┐ │ S1 ──▶ key ──▶ S2 ──▶ S3 │ └───────────────────────────┘ → key は次に現れる S2 が担当する → 一周すると S1 の手前に戻る
この定義の下では、サーバーを1台取り除いても、影響を受けるのは取り除かれたサーバーが担当していた区間のキーだけで済む。リングの反対側にあるキーは、担当サーバーの位置がそもそも変わらないので、そのまま同じサーバーがヒットし続ける。除数が変わるたびに全体が揺れ動く mod N 方式との違いはここにある。
円状に並んだごみ集積所を思い浮かべてほしい。各世帯は「自分の家から時計回りに一番近い集積所」に出すというルールを決めておく。ある集積所が閉鎖されると、困るのはその集積所と手前の集積所の間にある世帯だけで、彼らは次の集積所に出し先を変えればよい。円の反対側の世帯は、閉鎖の影響を受けず今まで通りの集積所に出し続けられる。コンシステントハッシングでサーバーを外したときに動くキーが一部で済むのも、この「影響範囲が隣接区間に閉じる」という同じ構造による。
実サーバーが少ないと生じる偏り、仮想ノードによる解消
サーバーを増やすほど負荷は自動的に均等になっていくように思えるかもしれない。しかし実際のリングでは、各サーバーが置かれる位置はハッシュ関数の出力次第であり、台数が少ないうちは担当区間の長さが大きく偏ることがある。ある1台だけが広い区間を受け持ってしまえば、そのサーバーだけがアクセス集中で重くなる。
この偏りを均すために使われるのが仮想ノード(virtual node)だ。1台の物理サーバーを「サーバー名+連番」のような複数の文字列でハッシュし、リング上の複数の点に割り当てる。担当区間の数が増えるほど、統計的に見た区間の合計長はサーバー間で近づいていく。Memcached 向けの実装である Ketama は、サーバー1台につき 100〜200 個のハッシュ値をリング(continuum)上に置く設計になっている。Cassandra も同様の考え方を num_tokens 設定として持ち、公式ドキュメントは1ノードあたり 8 個の vnode を割り当てる構成で、分散のばらつきをおよそ 10% に抑えられるとしている。
実際に使われている場所と Redis Cluster との違い
Amazon が 2007 年に公開した Dynamo の論文は、キーバリューストアのデータ分散にコンシステントハッシングと仮想ノードを採用し、後続の Cassandra など多くの分散データストアの設計に影響を与えた。ノードが増減しても、影響を受けるのはリング上で隣接する範囲のデータだけだという性質は、この種のシステムが「一部のノード故障時にも全データの再配置をせずに済む」ことの土台になっている。
一方で Redis Cluster は、リングそのものではなく、あらかじめ 16384 個に固定した担当範囲の単位であるハッシュスロットにキーを割り当てる方式を採る。キーの CRC16 値を 16384 で割った余りでスロット番号を決め、各ノードがスロット番号の範囲を1つずつ受け持つ。ノードを追加するときはスロットの範囲そのものをノード間で移動させるため、動くデータの量はスロット単位で見積もれる。同じ「ノード変化時の移動を最小化する」という目的でも、リングと固定スロットでは管理の単位が異なる。
Memcached や Redis のクラスタ構成、Kafka のパーティション割り当て、DB のシャーディング設計など、ノードの追加・削除時に「どれだけデータが動くか」を見積もる場面でこの考え方が使われる。ElastiCache や MemoryDB のようなマネージドサービスがノードをスケールアウトする裏側でも、同種の再配置の仕組みが動いている。
mod N 方式とリング方式で、サーバーを1台追加したときに実際に何割のキーの担当が変わるかを Python 標準ライブラリだけで比較する。
python3 - <<'PY'
import hashlib, bisect
def h(s):
return int(hashlib.md5(s.encode()).hexdigest(), 16)
keys = [f"user:{i}" for i in range(1000)]
def mod_assign(keys, n):
return {k: h(k) % n for k in keys}
before = mod_assign(keys, 3)
after = mod_assign(keys, 4)
moved = sum(1 for k in keys if before[k] != after[k])
print(f"mod方式: 3台→4台で {moved}/1000 件のキーが移動")
def ring_assign(keys, servers):
points = sorted((h(s), s) for s in servers)
ring_keys = [p[0] for p in points]
result = {}
for k in keys:
hk = h(k)
i = bisect.bisect_left(ring_keys, hk) % len(ring_keys)
result[k] = points[i][1]
return result
servers3 = [f"server{i}" for i in range(3)]
servers4 = servers3 + ["server3"]
before_r = ring_assign(keys, servers3)
after_r = ring_assign(keys, servers4)
moved_r = sum(1 for k in keys if before_r[k] != after_r[k])
print(f"リング方式: 3台→4台で {moved_r}/1000 件のキーが移動")
PY
手元で実行すると mod方式: 3台→4台で 742/1000 件のキーが移動、リング方式: 3台→4台で 251/1000 件のキーが移動という結果になる。mod 方式は7割以上のキーが動くのに対し、リング方式では新しく足した1台に相当する分(約4分の1)しか動かない。サーバー数をさらに増やして試すと、リング方式では移動するキーの割合がその分だけ小さくなっていくのが確認できる。
- コンシステントハッシングを使えば、ノードを追加してもキーは1つも動かない — 実際には新しいノードが担当することになった区間のキーは移動する。動く量が台数変化に比例して爆発しないだけで、ゼロになるわけではない。
- 仮想ノードは多ければ多いほど良い — 仮想ノードは負荷の偏りを減らす一方で、各ノードが保持するトークン数やルーティング用メタデータを増やす。台数や運用コストに応じて適切な数を選ぶ判断が要る。
- Redis Cluster も Cassandra と同じリング方式でキーを配っている — Redis Cluster は固定 16384 個のハッシュスロットをノードに割り当てる方式で、Cassandra のようなリング上の仮想ノードとは管理の単位が異なる。
- コンシステントハッシング
- サーバーとキーを同じハッシュ空間上の点として扱い、ノードの増減時に移動するキーを一部に抑える手法。
- ホットスポット
- 特定のキーやサーバーにアクセスが集中し、そこだけ負荷が高くなってしまう状態。
- ハッシュリング
- ハッシュ値の取りうる範囲を輪っか状につないだ円環。サーバーとキーの両方をこの上の点として配置する。
- 仮想ノード(virtual node)
- 1台の物理サーバーをリング上の複数の点に割り当てることで、担当区間の偏りを均す手法。
- ハッシュスロット
- Redis Cluster が採用する、あらかじめ固定数に区切った担当範囲の単位。
- Dynamo: Amazon's Highly Available Key-value Store — コンシステントハッシングと仮想ノードを分散キーバリューストアの設計に取り入れた原論文。
- Virtual nodes — Apache Cassandra Documentation (DataStax) — vnode がトークン範囲の自動管理と再分散をどう実現しているかを定義する公式ドキュメント。
- libketama: Consistent Hashing library for memcached clients — Last.fm が公開した Memcached 向けコンシステントハッシング実装 Ketama の設計を、原作者自身が解説するエンジニアリングブログ。