Split View: Consistent Hashing 완전 가이드 2025: 가상 노드, Jump Hash, Rendezvous, 분산 시스템의 핵심 빌딩 블록
Consistent Hashing 완전 가이드 2025: 가상 노드, Jump Hash, Rendezvous, 분산 시스템의 핵심 빌딩 블록
들어가며: 왜 Consistent Hashing이 필요한가?
문제 상황: 해시 기반 샤딩의 함정
캐시 서버 4대를 운영 중이라고 가정하자. 단순한 해싱으로 키를 분산시킨다면:
def get_server(key, num_servers):
return hash(key) % num_servers
# 4대 서버에 분산
get_server("user:1234", 4) # 서버 2
get_server("user:5678", 4) # 서버 0
get_server("user:9012", 4) # 서버 3
평상시엔 잘 동작한다. 그런데 트래픽이 늘어 서버를 5대로 증설하면?
get_server("user:1234", 5) # 서버 4 (기존 2번 → 4번)
get_server("user:5678", 5) # 서버 3 (기존 0번 → 3번)
get_server("user:9012", 5) # 서버 2 (기존 3번 → 2번)
거의 모든 키의 위치가 바뀐다. 캐시 미스가 폭발하고, 데이터베이스에 트래픽이 쏟아지며, 서비스 장애로 이어진다. 이를 리해싱 폭풍(Rehashing Storm) 이라 부른다.
Consistent Hashing의 약속
1997년 MIT의 David Karger 등이 발표한 Consistent Hashing은 이 문제를 우아하게 해결한다:
노드가 N개에서 N+1개로 바뀌어도, 이동해야 할 키는 평균 K/N개뿐이다. (K는 전체 키 개수)
즉, 서버 4대 → 5대로 늘어나면 전체 키의 약 1/5만 이동한다. 나머지 4/5는 그대로다. 이 원리가 Amazon DynamoDB, Apache Cassandra, Riak, Memcached, Akamai CDN 등 수많은 분산 시스템의 기초가 되었다.
graph LR
A[Key] --> B{해싱 방식}
B -->|modulo| C[80% 키 이동<br/>❌ 재앙]
B -->|Consistent Hash| D[20% 키 이동<br/>✅ 정상]
1. 기본 아이디어: 해시 링(Hash Ring)
원형 공간에 노드와 키 배치
Consistent Hashing의 핵심 아이디어는 의외로 간단하다:
- 해시 함수의 출력 공간을 원형(0 ~ 2^32-1)으로 생각한다.
- 각 노드(서버)를 해싱해서 링 위의 한 점에 놓는다.
- 각 키(데이터)도 해싱해서 링 위의 한 점에 놓는다.
- 키는 시계 방향으로 가장 가까운 노드에 저장된다.
0 / 2^32
|
NodeA |
---+----+----+---
| | |
| | |
NodeD NodeB
| | |
| | |
---+----+----+---
NodeC |
|
파이썬 기본 구현
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를 추가하면:
- E의 해시값이 기존 노드들 사이 어딘가에 들어간다.
- E와 직전 노드 사이의 키들만 E로 이동한다.
- 나머지 키들은 그대로다.
평균적으로 K/N개(1/5)의 키만 이동한다. 이게 마법이다.
2. 기본 방식의 문제점: 불균형
기본 Consistent Hashing은 완벽하지 않다. 가장 큰 문제는 키 분포의 불균형이다.
문제 1: 노드 소수일 때 편중
4개 노드만으로 링을 구성하면, 해시값이 균등하게 분포한다고 가정해도 각 노드가 맡는 "구간"의 크기가 천차만별이다:
NodeA: 0 ~ 5 (5%)
NodeB: 5 ~ 40 (35%) ← 많이 할당
NodeC: 40 ~ 50 (10%)
NodeD: 50 ~ 100 (50%) ← 절반을 차지
결과: NodeD가 전체 트래픽의 절반을 받고, NodeA는 거의 놀고 있다.
문제 2: 노드 삭제 시 편중 악화
NodeB가 다운되면, B의 키는 전부 NodeC로 몰린다. 핫스팟(Hotspot)이 발생한다.
문제 3: 이질적인 서버 용량 반영 어려움
어떤 서버는 CPU 16코어 메모리 64GB이고, 어떤 서버는 4코어 16GB일 수 있다. 기본 방식으로는 서버 용량 비율을 반영할 수 없다.
3. 해결책: 가상 노드 (Virtual Nodes, vnodes)
아이디어
각 물리 노드를 여러 개의 가상 노드로 표현해서 링에 뿌린다. 예를 들어 노드당 150개 가상 노드를 만들면:
- 물리 노드 10개 × 150 = 1,500개의 가상 노드가 링에 분산된다.
- 대수의 법칙에 의해 각 물리 노드가 담당하는 구간이 균등해진다.
물리 노드 4개 (각 150 vnode)
링 위 분포:
A-1, B-7, C-3, A-99, D-42, B-15, C-88, D-1, A-15, ...
(거의 균등하게 섞임)
가상 노드 구현
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는 전체 키의 ~40%, 나머지는 각 ~20%를 받는다
가상 노드 개수 선택 가이드
| 가상 노드 수 | 표준편차 | 메모리 오버헤드 |
|---|---|---|
| 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 키를 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개 가상 노드로도 ±6% 수준의 균형을 달성할 수 있다.
5. Jump Consistent Hash: 가상 노드 없이도 균등하게
2014년 Google의 John Lamping과 Eric Veach가 발표한 Jump Consistent Hash는 가상 노드 없이 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: 버킷(노드) 개수
"""
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)개의 키만 새 노드로 이동한다.
- 분포는 완벽하게 균등하다 (가상 노드가 전혀 필요 없다).
한계: 노드 제거 문제
Jump Hash는 오직 마지막 노드(가장 높은 인덱스)만 제거 가능하다. 중간 노드를 제거하면 뒷 노드들의 인덱스가 바뀌어 모든 키 매핑이 깨진다.
이를 해결하려면 Lookup Table 을 추가로 관리하거나, 다음에 설명할 Rendezvous Hashing을 사용한다.
6. Rendezvous Hashing (HRW: Highest Random Weight)
1997년 David Thaler와 Chinya Ravishankar가 제안한 Rendezvous Hashing은 또 다른 우아한 해결책이다.
아이디어
각 키에 대해 모든 노드와의 해시값을 계산하고, 그중 가장 높은(또는 낮은) 값을 가진 노드를 선택한다.
def rendezvous_hash(key: str, nodes: list) -> str:
"""
각 키를 가장 높은 가중치를 가진 노드에 할당
"""
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개 키만 이동
- 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개 복제본을 위한 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비트 해시 공간을 원형으로 구성.
- 각 물리 노드를 **여러 가상 노드(토큰)**로 분산.
- 각 키는 시계 방향 가장 가까운 N개 노드에 복제(Preference List).
- 노드 추가/제거 시 인접 노드에서만 데이터 이동.
Cassandra: 토큰 링
Cassandra도 유사한 구조지만 용어가 조금 다르다:
- 토큰(Token) = 가상 노드 (Murmur3 64비트 해시)
- 기본
num_tokens: 256→ 각 물리 노드가 256개 토큰 소유. - 데이터 복제는 Replication Strategy (SimpleStrategy, NetworkTopologyStrategy)로 설정.
# cassandra.yaml
num_tokens: 256
partitioner: org.apache.cassandra.dht.Murmur3Partitioner
Memcached (libketama): 가상 노드 표준
libketama는 Last.fm이 만든 Memcached 클라이언트 라이브러리로, Consistent Hashing의 사실상 표준이 되었다:
- 노드당 160개 가상 노드.
- MD5 해시 (128비트 → 32비트로 절단).
- 서버 정보 문자열 → MD5 → 4개의 32비트 정수 → 4개의 가상 노드 생성.
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 Policy와 Maglev 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) # 파이썬 내장 hash()는 재시작 시 바뀜!
파이썬 내장 hash()는 보안상 이유로 프로세스 시작마다 다른 시드를 쓴다. 분산 시스템에서 재시작 시 매핑이 완전히 바뀌면 재앙이다.
좋은 선택:
- MD5, SHA-1: 느리지만 분포 좋음.
- MurmurHash3: 빠르고 분포 좋음 (Cassandra 사용).
- xxHash: 매우 빠름 (최근 선호됨).
함정 2: 가상 노드 수 부족
노드 4개 × 가상 노드 1개 = 4개 → 분포가 매우 불균형. 항상 노드당 최소 100개 이상의 가상 노드를 사용하자.
함정 3: 노드 ID의 문자열 포맷
가상 노드를 만들 때 포맷이 일관되지 않으면 재현 불가:
# 나쁨
f"{node}-{i}" # node1-0, node1-1, ...
f"{node}_{i}" # node1_0, node1_1, ... (다른 시스템에서 사용)
# 좋음: 팀/조직 표준 문서화
f"{node}#{i}" # 문서에 명시
함정 4: 핫키(Hot Key)
Consistent Hashing은 키 분포가 균등할 때만 잘 작동한다. 특정 키(예: user:celebrity_account)가 엄청난 트래픽을 받으면 한 노드에 부하가 집중된다.
해결책:
- Key Salting:
user:celebrity_account:{random_suffix}로 여러 샤드에 분산. - Local Cache: 앱 레벨 캐싱으로 분산 캐시 호출 자체를 줄임.
- Read Replica: 읽기 전용 복제본 추가.
함정 5: 노드 클러스터의 급격한 변동
쿠버네티스 같은 환경에서 노드가 빈번하게 생성/삭제되면, 매번 데이터 이동이 발생한다. Rebalancing Throttling이 필요하다:
- Warm-up 기간: 새 노드는 점진적으로 트래픽을 받는다.
- Cooldown: 노드 제거 후 데이터가 완전히 이동할 때까지 대기.
10. 한 걸음 더: Weighted Consistent Hashing
Maglev (Google)
Google의 Maglev는 다음과 같은 요구사항으로 설계됐다:
- 완벽한 균등성 (비슷한 가중치 서버들 간)
- 최소 disruption (노드 변경 시)
- 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코어) 가중치를 반영하려면:
- 가상 노드 방식:
vnodes = base * weight - Maglev 방식: permutation을
weight배만큼 사용. - Rendezvous 가중치: 해시값에 log(weight)를 곱해 bias.
11. 성능 비교: 어떤 알고리즘을 선택할까?
| 알고리즘 | 시간 복잡도 | 공간 복잡도 | 분포 균등도 | 노드 제거 | 주 사용처 |
|---|---|---|---|---|---|
| Modulo Hash | O(1) | O(1) | 완벽 | ❌ 전체 재분배 | 단순 샤딩 |
| Consistent + vnode | O(log V) | O(V) | 양호 (V↑일수록) | ✅ 임의 | Cassandra, DynamoDB |
| Jump Hash | O(log N) | O(1) | 완벽 | ❌ 마지막만 | Google 내부 |
| Rendezvous Hash | O(N) | O(N) | 완벽 | ✅ 임의 | 소규모 클러스터 |
| Maglev | O(1) 조회 | O(M) | 거의 완벽 | ✅ 임의 | Google Cloud LB |
| Anchor Hashing | O(log N) | O(N) | 완벽 | ✅ 임의 | 최신 시스템 |
선택 가이드
- 노드 수 < 100, 잦은 변동: Rendezvous Hashing (간단, 유연)
- 대규모 캐시/DB 클러스터: Consistent Hashing + vnode (검증됨)
- 정적 노드 집합, 극한 성능: Jump Hash (O(log N))
- L4/L7 로드밸런서: Maglev (O(1) 조회)
- 이질 용량 서버: vnode 가중치 또는 Maglev weighted
12. 실전 팁: 운영하면서 배운 것들
Tip 1: 가상 노드 수는 절대 바꾸지 마라
운영 중에 num_tokens를 150 → 200으로 바꾸면 거의 모든 데이터가 재분배된다. 클러스터 초기 설계 단계에서 신중히 선택하자.
Tip 2: 로그로 분포 모니터링
실제 키 분포가 얼마나 균등한지 주기적으로 측정하자:
# Prometheus 메트릭
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
표준편차가 갑자기 커지면 핫키 의심.
Tip 3: Graceful Shutdown
노드를 제거할 땐 갑자기 죽이지 말고:
- 해당 노드를 "draining" 상태로 표시 (링에서 제거).
- 기존 연결 처리 완료 대기.
- 데이터 이동 확인.
- 프로세스 종료.
Tip 4: 해시 함수 속도 vs 품질
초당 수십만 건의 조회가 있다면, MD5는 느릴 수 있다. 벤치마크:
| 해시 함수 | 속도 (MB/s) | 분포 품질 |
|---|---|---|
| xxHash3 | ~30,000 | 양호 |
| MurmurHash3 | ~6,000 | 양호 |
| CityHash | ~10,000 | 양호 |
| MD5 | ~500 | 우수 |
| SHA-256 | ~400 | 우수 |
캐시 샤딩 같은 용도엔 xxHash나 Murmur가 적합하다.
퀴즈로 복습하기
Q1. 서버 4대에서 5대로 증설할 때, 일반적인 모듈로 해싱(hash % N)은 몇 퍼센트의 키가 이동하는가?
A. 약 **80%**의 키가 이동한다. 기존 hash % 4와 hash % 5의 결과는 거의 무관하기 때문이다. 반면 Consistent Hashing은 약 20%(K/N = K/5)만 이동한다.
Q2. 기본 Consistent Hashing에서 가상 노드가 왜 필요한가?
A. 노드가 적을 때 링 위 노드 분포가 불균등해져서 일부 노드에 키가 편중된다. 각 물리 노드를 100~200개의 가상 노드로 흩뿌리면 대수의 법칙에 의해 거의 균등한 분포를 얻을 수 있다. 또한 서버 용량에 따라 가상 노드 수를 조절해 가중치를 부여할 수도 있다.
Q3. Jump Consistent Hash의 가장 큰 장점과 단점은?
A. 장점: O(1) 공간과 완벽한 균등 분포. 가상 노드 없이도 잘 작동하며 매우 빠르다. 단점: 오직 가장 끝(높은 인덱스) 노드만 제거 가능. 중간 노드를 제거하려면 전체 매핑이 깨진다. 임의 노드 제거가 필요하면 Rendezvous Hashing이나 Anchor Hashing을 써야 한다.
Q4. Rendezvous Hashing이 N개 복제본 선택에 유용한 이유는?
A. Rendezvous Hashing은 각 키에 대해 모든 노드의 가중치(해시값)를 계산한다. 이를 정렬해서 상위 K개 노드를 선택하면 자연스럽게 K개 복제본의 배치가 결정된다. 이는 Consistent Hashing 링에서 "시계 방향으로 다음 K개 노드"를 찾는 것과 유사한 효과지만, 더 균등하게 분포된다.
Q5. Memcached의 libketama는 노드당 몇 개의 가상 노드를 기본으로 사용하며, 그 선택 이유는?
A. Ketama는 노드당 160개의 가상 노드를 사용한다. 이는 균형성(±5% 표준편차) 과 메모리 오버헤드 사이의 타협점이다. 실제로는 MD5 해시의 결과를 4개의 4바이트 정수로 쪼개 각 "replica"당 4개 가상 노드를 만들므로, 40 replicas × 4 = 160이다.
마치며: Consistent Hashing의 교훈
Consistent Hashing은 1997년 MIT에서 CDN의 웹 캐시 문제를 풀기 위해 고안되었다. Akamai를 탄생시켰고, 이후 20년 넘게 분산 시스템의 핵심 빌딩 블록이 되었다.
핵심 원리 5가지
- 원형 해시 공간: 노드와 키를 같은 공간에 배치해 이웃 관계를 정의.
- 시계 방향 할당: 키는 시계 방향 가장 가까운 노드로.
- 가상 노드: 대수의 법칙으로 균등 분포 달성.
- 최소 disruption: 노드 추가/제거 시 K/N개 키만 이동.
- 가중치 유연성: vnode 수로 용량 차이 반영.
현대의 진화
- Jump Hash: O(1) 공간으로 극도의 효율.
- Rendezvous Hashing: 간단함의 극치.
- Maglev: O(1) 조회 + weighted LB.
- Anchor Hashing: 최신 개선판.
기억할 것
"분산 시스템에서 데이터를 어디에 둘지 결정하는 문제" 의 답은 거의 항상 Consistent Hashing의 어떤 변종이다.
당신이 지금 사용하는 Redis Cluster, DynamoDB, Cassandra, Memcached, CDN — 모두 이 30년 된 아이디어 위에서 돌아간다. 기본을 알면 디버깅과 설계의 깊이가 달라진다.
다음번에 "왜 이 키가 저 서버로 가지?"라는 질문이 생기면, 해시 링을 머릿속에 그려보자. 답이 거기에 있다.
참고 자료
- Karger et al., "Consistent Hashing and Random Trees" (1997) - 원 논문
- Dynamo: Amazon's Highly Available Key-value Store (2007)
- Jump Consistent Hash (Google, 2014)
- Maglev: A Fast and Reliable Software Network Load Balancer (Google, 2016)
- Anchor Hashing (2018)
- libketama Source
- Cassandra Data Distribution
- Envoy Ring Hash Load Balancer
Consistent Hashing Complete Guide 2025: Virtual Nodes, Jump Hash, Rendezvous, and the Core Building Block of Distributed Systems
Introduction: Why Do We Need Consistent Hashing?
The Problem: The Trap of Hash-Based Sharding
Suppose you operate four cache servers and distribute keys with a simple hash:
def get_server(key, num_servers):
return hash(key) % num_servers
# Distribute across 4 servers
get_server("user:1234", 4) # server 2
get_server("user:5678", 4) # server 0
get_server("user:9012", 4) # server 3
Works fine in steady state. But what happens when traffic grows and you scale up to 5 servers?
get_server("user:1234", 5) # server 4 (was 2 → now 4)
get_server("user:5678", 5) # server 3 (was 0 → now 3)
get_server("user:9012", 5) # server 2 (was 3 → now 2)
Almost every key moves. Cache misses explode, traffic floods the database, and the service collapses. This is called the Rehashing Storm.
The Promise of Consistent Hashing
Consistent Hashing, published in 1997 by David Karger and colleagues at MIT, solves this elegantly:
When nodes change from N to N+1, only K/N keys on average need to move. (K is the total number of keys.)
So scaling from 4 to 5 servers moves only about 1/5 of keys. The other 4/5 stay put. This principle became the foundation of countless distributed systems: Amazon DynamoDB, Apache Cassandra, Riak, Memcached, Akamai CDN, and more.
graph LR
A[Key] --> B{Hashing method}
B -->|modulo| C[80% keys move<br/>Disaster]
B -->|Consistent Hash| D[20% keys move<br/>Normal]
1. The Core Idea: The Hash Ring
Place Nodes and Keys on a Circular Space
The core idea of Consistent Hashing is surprisingly simple:
- Treat the output space of the hash function as a circle (0 to 2^32-1).
- Hash each node (server) and place it on a point on the ring.
- Hash each key (data) and place it on a point on the ring.
- A key is stored on the nearest node in the clockwise direction.
0 / 2^32
|
NodeA |
---+----+----+---
| | |
| | |
NodeD NodeB
| | |
| | |
---+----+----+---
NodeC |
|
Basic Python Implementation
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)
# Find the nearest node clockwise
idx = bisect_right(self.sorted_keys, h)
if idx == len(self.sorted_keys):
idx = 0 # Wrap around the ring
return self.ring[self.sorted_keys[idx]]
# Example usage
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
Impact of Adding/Removing Nodes
When you add node E:
- E's hash value lands somewhere between existing nodes.
- Only keys between E and the preceding node move to E.
- The rest of the keys stay put.
On average only K/N (1/5) keys move. That's the magic.
2. Problems With the Basic Approach: Imbalance
Basic Consistent Hashing isn't perfect. The biggest issue is uneven key distribution.
Problem 1: Skew With Few Nodes
With only 4 nodes on the ring, even if hash values are uniformly distributed, the size of the "arc" each node owns varies wildly:
NodeA: 0 ~ 5 (5%)
NodeB: 5 ~ 40 (35%) ← heavy load
NodeC: 40 ~ 50 (10%)
NodeD: 50 ~ 100 (50%) ← half the ring
Result: NodeD gets half of all traffic, and NodeA sits nearly idle.
Problem 2: Worsening Skew on Node Removal
If NodeB goes down, all of B's keys pile onto NodeC. A hotspot forms.
Problem 3: Hard to Reflect Heterogeneous Server Capacity
Some servers might have 16 cores and 64GB RAM; others 4 cores and 16GB. The basic scheme can't express capacity ratios.
3. The Fix: Virtual Nodes (vnodes)
The Idea
Represent each physical node as multiple virtual nodes scattered on the ring. For example, with 150 vnodes per node:
- 10 physical nodes × 150 = 1,500 virtual nodes distributed on the ring.
- By the law of large numbers, each physical node's arc becomes uniform.
4 physical nodes (150 vnodes each)
Distribution on ring:
A-1, B-7, C-3, A-99, D-42, B-15, C-88, D-1, A-15, ...
(nearly uniformly interleaved)
Virtual Node Implementation
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):
# Higher weight → more vnodes
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]]
# Example: servers with different capacities
ch = ConsistentHashWithVNodes(vnodes_per_node=150)
ch.add_node("server-A", weight=2) # 2x performance → 300 vnodes
ch.add_node("server-B", weight=1) # standard → 150 vnodes
ch.add_node("server-C", weight=1)
ch.add_node("server-D", weight=1)
# Now A gets ~40% of keys, the rest get ~20% each
Vnode Count Selection Guide
| Vnode count | Std. deviation | Memory overhead |
|---|---|---|
| 10 | ±30% | low |
| 100 | ±10% | medium |
| 160 | ±6% | balanced (Ketama default) |
| 1000 | ±2% | high |
In practice, 100-200 is the sweet spot. Memcached's libketama uses 160; Cassandra defaults to 256 (num_tokens).
4. Benchmark: How Even Is the Actual Distribution?
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)")
# Distribute 1,000,000 keys across 10 nodes
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)
Expected output:
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)
With just 100 virtual nodes you can achieve ±6% balance.
5. Jump Consistent Hash: Uniform Without Virtual Nodes
In 2014, John Lamping and Eric Veach at Google published Jump Consistent Hash, which achieves O(1) space and nearly perfect uniform distribution without virtual nodes.
Striking Properties
- Memory: O(1) (no node list needed!)
- Time: O(log N) (N is node count)
- Distribution: nearly perfect
- Downside: inconvenient node removal (only the last node can be removed)
The Jump Hash Algorithm
def jump_consistent_hash(key: int, num_buckets: int) -> int:
"""
Google Jump Consistent Hash
key: hashed integer
num_buckets: number of buckets (nodes)
"""
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
# Example
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}")
Why Does This Work?
The core of Jump Hash is a pseudo-random probabilistic skip. Each iteration probabilistically decides the "next jump position." Mathematically proven properties:
- When node count changes from N to N+1, exactly K/(N+1) keys move to the new node.
- Distribution is perfectly uniform (no virtual nodes required).
Limitation: Node Removal
Jump Hash can only remove the last node (highest index). Removing a middle node shifts all following indices, breaking every key mapping.
To work around this, add a Lookup Table or use Rendezvous Hashing, described next.
6. Rendezvous Hashing (HRW: Highest Random Weight)
In 1997, David Thaler and Chinya Ravishankar proposed Rendezvous Hashing, another elegant solution.
The Idea
For each key, compute the hash with every node and pick the node with the highest (or lowest) value.
def rendezvous_hash(key: str, nodes: list) -> str:
"""
Assign each key to the node with the highest 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
# Example
nodes = ["server-A", "server-B", "server-C", "server-D"]
print(rendezvous_hash("user:1234", nodes)) # e.g., "server-C"
Pros and Cons
Pros:
- Extremely simple (just needs a hash function)
- Perfectly uniform distribution
- Only K/N keys move on node add/remove
- Unlike Jump Hash, any node can be removed
- Great for replica selection: picking the Top-K nodes immediately gives K replica placements.
Cons:
- O(N) time complexity: slow with many nodes.
- Not suitable beyond 1000 nodes.
Replica Selection
Where Rendezvous Hashing shines is Top-K node selection:
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]]
# Pick 3 nodes for 3 replicas
replicas = rendezvous_hash_topk("user:1234", nodes, k=3)
print(replicas) # ["server-C", "server-A", "server-D"]
This is similar to the idea used in Cassandra's replica placement strategy.
7. Anchor Hashing: A Modern Algorithm (2018)
Anchor Hashing, published in 2018 by Gal Mendelson et al., provides both Jump Hash's speed and Rendezvous's flexible node removal.
Key properties:
- O(log N) time complexity
- O(N) space (constant memory per node)
- Arbitrary node removal
- Perfectly uniform distribution
The real implementation is complex, so we only mention it here. Maglev Hash (Google) falls in the same category.
8. How Is This Used in Real Systems?
DynamoDB: Basic Consistent Hashing + vnodes
Amazon DynamoDB follows the design of the Amazon Dynamo paper (2007):
- Circular 128-bit hash space.
- Each physical node holds many virtual nodes (tokens).
- Each key is replicated to the next N clockwise nodes (Preference List).
- On node add/remove, data only moves between adjacent nodes.
Cassandra: Token Ring
Cassandra has a similar structure but uses slightly different terminology:
- Token = virtual node (Murmur3 64-bit hash)
- Default
num_tokens: 256→ each physical node owns 256 tokens. - Replication is configured via Replication Strategy (SimpleStrategy, NetworkTopologyStrategy).
# cassandra.yaml
num_tokens: 256
partitioner: org.apache.cassandra.dht.Murmur3Partitioner
Memcached (libketama): The Virtual Node Standard
libketama, built by Last.fm, is a Memcached client library that became the de facto standard for Consistent Hashing:
- 160 virtual nodes per physical node.
- MD5 hash (128 bits truncated to 32 bits).
- Server info string → MD5 → four 32-bit integers → four virtual nodes.
Nginx: upstream consistent hash
Nginx supports Consistent Hashing for upstream load balancing:
upstream backend {
hash $request_uri consistent;
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}
The consistent keyword enables Ketama-based Consistent Hashing.
Envoy / HAProxy / Istio
Envoy's Ring Hash LB Policy and Maglev LB Policy implement Ketama-style and Google Maglev-style Consistent Hashing respectively.
# 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. Common Mistakes and Pitfalls
Pitfall 1: Choice of Hash Function
Bad choice:
def bad_hash(key):
return hash(key) # Python's built-in hash() changes on restart!
Python's built-in hash() uses a different seed per process for security reasons. In a distributed system, having the mapping change on every restart is catastrophic.
Good choices:
- MD5, SHA-1: slow but good distribution.
- MurmurHash3: fast with good distribution (used in Cassandra).
- xxHash: very fast (increasingly preferred).
Pitfall 2: Too Few Virtual Nodes
4 nodes × 1 vnode = 4 → severely unbalanced distribution. Always use at least 100 virtual nodes per node.
Pitfall 3: String Format of Node IDs
If vnode name format isn't consistent, the mapping isn't reproducible:
# Bad
f"{node}-{i}" # node1-0, node1-1, ...
f"{node}_{i}" # node1_0, node1_1, ... (different system)
# Good: document team/org standard
f"{node}#{i}" # explicitly documented
Pitfall 4: Hot Keys
Consistent Hashing only works well when key distribution is uniform. If one key (e.g., user:celebrity_account) gets huge traffic, one node is overloaded.
Solutions:
- Key Salting:
user:celebrity_account:{random_suffix}to spread across shards. - Local Cache: reduce distributed cache calls with in-app caching.
- Read Replica: add read-only replicas.
Pitfall 5: Rapid Cluster Churn
In environments like Kubernetes where nodes are frequently created/destroyed, data movement happens every time. Rebalancing Throttling is needed:
- Warm-up period: new nodes gradually take traffic.
- Cooldown: wait for data to fully migrate after node removal.
10. Going Further: Weighted Consistent Hashing
Maglev (Google)
Google's Maglev was designed with these requirements:
- Perfect uniformity (across similarly weighted servers)
- Minimum disruption (on node changes)
- O(1) lookup (fast runtime queries)
Maglev computes a preference permutation for each server and uses it to build a lookup table of size M. Lookup is lookup_table[hash(key) % M] in O(1).
# Conceptual pseudocode
def build_maglev_table(servers, table_size=65537):
# table_size should be a 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 is available as Envoy's maglev load balancer policy and is used internally by Google Cloud Load Balancer.
Assigning Weights
With heterogeneous servers (e.g., 16-core vs. 4-core), reflect weights by:
- Vnode approach:
vnodes = base * weight - Maglev approach: use the permutation
weighttimes. - Rendezvous weighting: bias the hash value by multiplying by log(weight).
11. Performance Comparison: Which Algorithm to Choose?
| Algorithm | Time | Space | Uniformity | Node removal | Main uses |
|---|---|---|---|---|---|
| Modulo Hash | O(1) | O(1) | Perfect | Full rebalance | Simple sharding |
| Consistent + vnode | O(log V) | O(V) | Good (with higher V) | Arbitrary | Cassandra, DynamoDB |
| Jump Hash | O(log N) | O(1) | Perfect | Last only | Google internal |
| Rendezvous Hash | O(N) | O(N) | Perfect | Arbitrary | Small clusters |
| Maglev | O(1) lookup | O(M) | Near perfect | Arbitrary | Google Cloud LB |
| Anchor Hashing | O(log N) | O(N) | Perfect | Arbitrary | Modern systems |
Selection Guide
- Nodes
< 100, frequent churn: Rendezvous Hashing (simple, flexible) - Large-scale cache/DB cluster: Consistent Hashing + vnode (battle-tested)
- Static node set, extreme performance: Jump Hash (O(log N))
- L4/L7 load balancer: Maglev (O(1) lookup)
- Heterogeneous capacity servers: vnode weights or weighted Maglev
12. Operational Tips: Lessons From Production
Tip 1: Never Change the Vnode Count
Changing num_tokens from 150 to 200 in production causes nearly full data rebalance. Pick carefully at initial cluster design time.
Tip 2: Monitor Distribution With Logs
Regularly measure how uniform the actual key distribution is:
# 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
If standard deviation spikes suddenly, suspect a hot key.
Tip 3: Graceful Shutdown
Don't kill a node abruptly when removing it:
- Mark it "draining" (remove from ring).
- Wait for existing connections to complete.
- Verify data migration.
- Terminate the process.
Tip 4: Hash Function Speed vs. Quality
At hundreds of thousands of lookups per second, MD5 can be slow. Benchmark:
| Hash function | Speed (MB/s) | Distribution quality |
|---|---|---|
| xxHash3 | ~30,000 | Good |
| MurmurHash3 | ~6,000 | Good |
| CityHash | ~10,000 | Good |
| MD5 | ~500 | Excellent |
| SHA-256 | ~400 | Excellent |
For cache sharding, xxHash or Murmur is appropriate.
Quiz Review
Q1. When scaling from 4 to 5 servers, what percentage of keys move under plain modulo hashing (hash % N)?
A. About 80% of keys move, because the results of hash % 4 and hash % 5 are largely unrelated. Consistent Hashing, in contrast, moves only about 20% (K/N = K/5).
Q2. Why are virtual nodes needed in basic Consistent Hashing?
A. With few nodes, the distribution of nodes on the ring is uneven, causing some nodes to get disproportionate keys. Scattering each physical node as 100-200 virtual nodes gives nearly uniform distribution by the law of large numbers. It also lets you assign weights by tuning vnode counts per server capacity.
Q3. What are the biggest pros and cons of Jump Consistent Hash?
A. Pros: O(1) space and perfectly uniform distribution. Works well without virtual nodes and is very fast. Cons: Only the last (highest-index) node can be removed. Removing a middle node breaks the entire mapping. If arbitrary node removal is required, use Rendezvous Hashing or Anchor Hashing.
Q4. Why is Rendezvous Hashing useful for selecting N replicas?
A. Rendezvous Hashing computes the weight (hash) of every node for each key. Sorting them and picking the top K naturally determines K replica placements. This has the same effect as "next K clockwise nodes" on a Consistent Hashing ring, but with a more uniform distribution.
Q5. How many virtual nodes per node does Memcached's libketama use by default, and why?
A. Ketama uses 160 vnodes per node. It's a balance between balance (std. dev ~5%) and memory overhead. In practice, it splits the MD5 result into four 4-byte integers, creating 4 virtual nodes per "replica." So 40 replicas × 4 = 160.
Closing: Lessons From Consistent Hashing
Consistent Hashing was conceived in 1997 at MIT to solve the CDN web-cache problem. It birthed Akamai, and has been a core building block of distributed systems for over 20 years.
Five Core Principles
- Circular hash space: placing nodes and keys in the same space to define adjacency.
- Clockwise assignment: keys go to the nearest clockwise node.
- Virtual nodes: uniform distribution via the law of large numbers.
- Minimum disruption: only K/N keys move on node add/remove.
- Weight flexibility: vnode counts reflect capacity differences.
Modern Evolution
- Jump Hash: O(1) space for extreme efficiency.
- Rendezvous Hashing: the pinnacle of simplicity.
- Maglev: O(1) lookup + weighted LB.
- Anchor Hashing: the latest improvement.
Remember
"The answer to 'where should this data go' in distributed systems" is almost always a variant of Consistent Hashing.
The Redis Cluster, DynamoDB, Cassandra, Memcached, and CDN you use today all run on this 30-year-old idea. Knowing the fundamentals changes the depth of debugging and design.
Next time you ask "why does this key go to that server?", picture the hash ring in your head. The answer is there.
References
- Karger et al., "Consistent Hashing and Random Trees" (1997) - The original paper
- Dynamo: Amazon's Highly Available Key-value Store (2007)
- Jump Consistent Hash (Google, 2014)
- Maglev: A Fast and Reliable Software Network Load Balancer (Google, 2016)
- Anchor Hashing (2018)
- libketama Source
- Cassandra Data Distribution
- Envoy Ring Hash Load Balancer