고급 Redis 데이터 구조: Hash, Set, Sorted Set, HyperLogLog 및 Geospatial
문자열 이외의 마스터 Redis 데이터 구조: 순위표에 정렬 세트를 사용하는 방법 실시간으로 대략적인 카디널리티 계산을 위한 HyperLogLog 및 지리공간 색인 O(log N) 복잡도를 갖는 지리적 근접성 검색의 경우.
Redis는 단순한 캐시가 아닙니다
Redis는 종종 "캐시로 사용되는 메모리 내 키-값 저장소"로 설명됩니다. 이 정의는 가장 일반적인 사용 사례를 포착하지만 이를 완전히 모호하게 합니다. 시스템의 진정한 힘. Redis 7.x는 10가지 유형의 기본 데이터 구조를 제공합니다. 각각은 특정 액세스 패턴에 최적화되어 있으므로 데이터를 모델링할 필요가 없습니다. 보고서나 문서에서는 이미 쿼리를 반영하는 형식으로 저장합니다.
무엇을 배울 것인가
- 해시: 개별 필드에 대한 O(1) 액세스를 사용하여 구조화된 객체 모델링
- 집합 및 정렬된 집합: 집합, 교차점 및 점수별 정렬
- ZADD, ZRANK 및 ZRANGEBYSCORE를 사용한 실시간 리더보드
- HyperLogLog: 고정 12KB의 대략적인 카디널리티 수
- 지리공간: 근접 검색을 위한 GEOADD, GEORADIUS 및 GEODIST
- 각 구조를 사용해야 하는 경우와 피해야 할 사항
해시: Redis의 구조화된 객체
Redis 해시는 단일 키와 연결된 필드-값 쌍의 맵입니다. 객체(사용자, 제품, 세션)를 표현하는 자연스러운 방법입니다. 모든 것을 JSON으로 직렬화하지 않고. 주요 이점: 읽거나 업데이트할 수 있습니다. 전체 객체를 로드하지 않고 O(1)의 단일 필드.
# Redis Hash: operazioni base
# HSET key field value [field value ...]
HSET user:1001 name "Mario Rossi" email "mario@example.com" age 35 city "Milano"
# HGET: singolo campo
HGET user:1001 name # "Mario Rossi"
HGET user:1001 email # "mario@example.com"
# HMGET: piu campi in una sola round trip
HMGET user:1001 name email city
# 1) "Mario Rossi"
# 2) "mario@example.com"
# 3) "Milano"
# HGETALL: tutto il hash (attenzione su hash grandi!)
HGETALL user:1001
# 1) "name"
# 2) "Mario Rossi"
# 3) "email"
# 4) "mario@example.com"
# 5) "age"
# 6) "35"
# 7) "city"
# 8) "Milano"
# HINCRBY: incremento atomico su campi numerici
HINCRBY user:1001 login_count 1
# HEXISTS: verifica esistenza campo
HEXISTS user:1001 phone # 0 (non esiste)
HEXISTS user:1001 name # 1
# HDEL: elimina campi specifici
HDEL user:1001 city
# HLEN: numero di campi
HLEN user:1001 # 4 (age, name, email, login_count)
# Python con redis-py
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Salva un oggetto utente
user_data = {
'name': 'Mario Rossi',
'email': 'mario@example.com',
'age': '35',
'city': 'Milano',
'login_count': '0',
}
r.hset('user:1001', mapping=user_data)
# Lettura parziale: solo i campi che servono
name, email = r.hmget('user:1001', ['name', 'email'])
print(f"{name} <{email}>") # Mario Rossi <mario@example.com>
# Incremento atomico del contatore login
new_count = r.hincrby('user:1001', 'login_count', 1)
print(f"Login count: {new_count}") # 1
# Pattern: cache object con TTL
r.hset('session:abc123', mapping={
'user_id': '1001',
'role': 'admin',
'created_at': '1710000000',
})
r.expire('session:abc123', 3600) # 1 ora di TTL
# Lettura selettiva su campo singolo: O(1)
user_id = r.hget('session:abc123', 'user_id') # '1001'
세트: 고유한 세트 및 세트에 대한 작업
Redis 세트는 순서가 지정되지 않은 고유 문자열 모음입니다. 세트의 힘 집합 사이의 연산에 있습니다: SUNION, SINTER, SDIFF 계산 합집합, 교집합 데이터를 클라이언트로 가져올 필요 없이 서버 측의 차이점이 있습니다.
# Redis Set: tag system e operazioni su insiemi
# Aggiungi tag a articoli (SADD è idempotente per duplicati)
SADD article:100:tags "python" "fastapi" "backend"
SADD article:200:tags "python" "django" "web"
SADD article:300:tags "rust" "backend" "systems"
# SMEMBERS: tutti i membri (non ordinato)
SMEMBERS article:100:tags
# 1) "python"
# 2) "backend"
# 3) "fastapi"
# SISMEMBER: check membership O(1)
SISMEMBER article:100:tags "python" # 1
SISMEMBER article:100:tags "java" # 0
# SCARD: cardinalita del set
SCARD article:100:tags # 3
# SINTER: articoli con tag in comune (python + backend)
SINTER article:100:tags article:300:tags
# 1) "backend"
# SUNION: tutti i tag di entrambi gli articoli
SUNION article:100:tags article:200:tags
# 1) "python"
# 2) "fastapi"
# 3) "backend"
# 4) "django"
# 5) "web"
# SDIFF: tag in article:100 ma NON in article:200
SDIFF article:100:tags article:200:tags
# 1) "fastapi"
# 2) "backend"
# SMOVE: sposta membro da un set all'altro (atomico)
SMOVE article:100:tags article:300:tags "backend"
# SRANDMEMBER: N elementi casuali (utile per sampling)
SRANDMEMBER article:100:tags 2
정렬된 집합: 순위표 및 범위 쿼리
Sorted Set은 Redis에서 가장 다양한 데이터 구조입니다. 모든 요소 하나 있어요 점수 관련 플로트; 세트가 자동으로 정렬됩니다. 점수로. 위치(순위) 또는 점수 범위별로 요소를 읽을 수 있으며, 모두 O(log N)입니다. 리더보드, 타임라인, 대기열을 위한 자연스러운 선택입니다. 우선순위 및 범위 기반 필터링.
# Sorted Set: leaderboard gaming in tempo reale
# ZADD key score member
ZADD leaderboard:weekly 1500 "player:alice"
ZADD leaderboard:weekly 2300 "player:bob"
ZADD leaderboard:weekly 1800 "player:carol"
ZADD leaderboard:weekly 3100 "player:dave"
ZADD leaderboard:weekly 900 "player:eve"
# ZINCRBY: incremento atomico dello score (ogni kill += 100 punti)
ZINCRBY leaderboard:weekly 100 "player:alice" # nuovo score: 1600
# ZRANK: posizione 0-based (dal basso, score crescente)
ZRANK leaderboard:weekly "player:bob" # 2 (0-based)
# ZREVRANK: posizione dal top (score decrescente)
ZREVRANK leaderboard:weekly "player:dave" # 0 (e' primo!)
ZREVRANK leaderboard:weekly "player:bob" # 2
# ZSCORE: score di un membro specifico
ZSCORE leaderboard:weekly "player:carol" # "1800"
# ZREVRANGE: top N giocatori (posizione, score decrescente)
ZREVRANGE leaderboard:weekly 0 4 WITHSCORES
# 1) "player:dave"
# 2) "3100"
# 3) "player:bob"
# 4) "2300"
# 5) "player:carol"
# 6) "1800"
# 7) "player:alice"
# 8) "1600"
# 9) "player:eve"
# 10) "900"
# ZRANGEBYSCORE: giocatori tra 1000 e 2000 punti
ZRANGEBYSCORE leaderboard:weekly 1000 2000 WITHSCORES
# 1) "player:alice"
# 2) "1600"
# 3) "player:carol"
# 4) "1800"
# ZCOUNT: quanti giocatori con score >= 1500
ZCOUNT leaderboard:weekly 1500 +inf # 3
# ZCARD: totale membri nel sorted set
ZCARD leaderboard:weekly # 5
# Python: leaderboard con Sorted Sets
import redis
from datetime import datetime
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
class Leaderboard:
def __init__(self, name: str):
self.key = f"leaderboard:{name}"
def add_score(self, player: str, score: float) -> float:
"""Aggiunge punti al giocatore, restituisce nuovo totale."""
return r.zincrby(self.key, score, player)
def get_rank(self, player: str) -> int | None:
"""Posizione del giocatore (1-based, top = 1)."""
rank = r.zrevrank(self.key, player)
return rank + 1 if rank is not None else None
def get_top(self, n: int = 10) -> list[dict]:
"""Top N giocatori con score."""
entries = r.zrevrange(self.key, 0, n - 1, withscores=True)
return [
{'player': player, 'score': score, 'rank': i + 1}
for i, (player, score) in enumerate(entries)
]
def get_around(self, player: str, delta: int = 2) -> list[dict]:
"""I delta giocatori sopra e sotto un dato giocatore."""
rank = r.zrevrank(self.key, player)
if rank is None:
return []
start = max(0, rank - delta)
end = rank + delta
entries = r.zrevrange(self.key, start, end, withscores=True)
return [
{'player': p, 'score': s, 'rank': start + i + 1}
for i, (p, s) in enumerate(entries)
]
# Uso
lb = Leaderboard("weekly")
lb.add_score("player:alice", 1500)
lb.add_score("player:bob", 2300)
lb.add_score("player:carol", 1800)
lb.add_score("player:dave", 3100)
print(lb.get_top(3))
# [{'player': 'player:dave', 'score': 3100.0, 'rank': 1}, ...]
print(lb.get_rank("player:carol")) # 2
print(lb.get_around("player:bob", delta=1)) # bob + dave + carol
HyperLogLog: 상수 메모리를 사용한 카디널리티 계산
HyperLogLog는 카디널리티를 추정하기 위한 확률적 프레임워크입니다. 많은 양의 메모리를 사용하는 세트(개별 요소 수) 끊임없는: 데이터 세트 크기에 관계없이 12KB입니다. 실수 표준은 약 0.81%이다. 그 사람은 당신에게 말할 수 없어요 어느 그가 본 요소들, 혼자 얼마나 별개의.
# HyperLogLog: conteggio unique views
# PFADD: aggiunge elementi all'HLL
PFADD page:article-100:views "user:alice" "user:bob" "user:carol"
PFADD page:article-100:views "user:alice" # Duplicato: ignorato nella stima
# PFCOUNT: stima del numero di elementi distinti
PFCOUNT page:article-100:views # 3 (stima, non esatto)
# Aggiunta in batch
PFADD page:article-100:views "user:dave" "user:eve" "user:frank"
PFCOUNT page:article-100:views # 6
# PFMERGE: unisce piu HLL in uno (unique across multiple sets)
PFADD page:article-200:views "user:alice" "user:george" "user:henry"
PFMERGE all-articles page:article-100:views page:article-200:views
PFCOUNT all-articles # ~8 (alice contata una sola volta nell'unione)
# Pattern: daily unique visitors
# Chiave per giorno: views:2026-03-20
PFADD views:2026-03-20 "user:alice"
PFADD views:2026-03-20 "user:bob"
# ... milioni di utenti, sempre 12KB
# Weekly count: merge dei 7 giorni
PFMERGE views:week-12 \
views:2026-03-14 views:2026-03-15 views:2026-03-16 views:2026-03-17 \
views:2026-03-18 views:2026-03-19 views:2026-03-20
PFCOUNT views:week-12 # Unique visitors della settimana
# Python: tracking unique page views con HyperLogLog
import redis
from datetime import date
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def track_page_view(article_id: int, user_id: str) -> None:
"""Registra una view di un articolo da un utente."""
today = date.today().isoformat()
# Chiave giornaliera per articolo
daily_key = f"hll:article:{article_id}:{today}"
r.pfadd(daily_key, user_id)
r.expire(daily_key, 90 * 86400) # TTL 90 giorni
def get_unique_views(article_id: int, since_date: date, until_date: date) -> int:
"""Unique views in un range di date."""
keys = []
current = since_date
while current <= until_date:
keys.append(f"hll:article:{article_id}:{current.isoformat()}")
current = date.fromordinal(current.toordinal() + 1)
if not keys:
return 0
# Merge temporaneo per ottenere il conteggio dell'intero range
temp_key = f"hll:temp:{article_id}:{since_date}:{until_date}"
r.pfmerge(temp_key, *keys)
r.expire(temp_key, 60) # Cache il risultato per 60s
return r.pfcount(temp_key)
# Track views
track_page_view(100, "user:alice")
track_page_view(100, "user:bob")
track_page_view(100, "user:alice") # Secondo accesso: non conta
from datetime import date
views = get_unique_views(100, date(2026, 3, 1), date(2026, 3, 20))
print(f"Unique views marzo: ~{views}")
# Confronto memoria: Set vs HLL per 1 milione di utenti
# Redis Set: ~50MB (64 byte per elemento)
# HyperLogLog: 12KB fissi (4000x piu efficiente)
지리공간: 지리공간 색인을 사용한 근접 검색
Redis 지리공간 색인은 내부적으로 점수가 다음과 같은 정렬된 집합을 사용합니다. 지오해시를 조정합니다. GEOADD, GEORADIUS 및 GEODIST를 사용하면 다음을 수행할 수 있습니다. 복잡도가 O(N + log M)인 근접 검색 여기서 N은 결과는 면적이고 M은 요소의 총계입니다.
# Redis Geospatial: trova ristoranti vicini
# GEOADD key longitude latitude member
GEOADD restaurants 9.1859 45.4654 "ristorante-dal-mario"
GEOADD restaurants 9.1900 45.4680 "trattoria-lombarda"
GEOADD restaurants 9.1750 45.4600 "pizzeria-napoli"
GEOADD restaurants 9.2100 45.4800 "sushi-bento"
GEOADD restaurants 9.1850 45.4660 "bar-centrale"
# GEODIST: distanza tra due punti
GEODIST restaurants "ristorante-dal-mario" "trattoria-lombarda" km
# "0.3821" (circa 382 metri)
# GEOPOS: coordinate di un membro
GEOPOS restaurants "ristorante-dal-mario"
# 1) 1) "9.18589949607849121"
# 2) "45.46539883597492027"
# GEOSEARCH (Redis 6.2+): sostituisce GEORADIUS deprecato
# Trova ristoranti entro 500m dalla posizione corrente
GEOSEARCH restaurants
FROMMEMBER "bar-centrale"
BYRADIUS 500 m
ASC
COUNT 5
WITHCOORD WITHDIST
# Oppure da coordinate GPS
GEOSEARCH restaurants
FROMLONLAT 9.1860 45.4655
BYRADIUS 1 km
ASC
COUNT 10
# GEOHASH: hash della posizione (per indicizzazione esterna)
GEOHASH restaurants "ristorante-dal-mario"
# 1) "u0nd0swfxh0"
# Python: proximity search per delivery app
import redis
from dataclasses import dataclass
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
@dataclass
class Restaurant:
name: str
longitude: float
latitude: float
category: str
def index_restaurant(restaurant: Restaurant) -> None:
"""Aggiunge ristorante all'indice geospaziale."""
r.geoadd('restaurants:geo', {
restaurant.name: (restaurant.longitude, restaurant.latitude)
})
# Salva metadata in un Hash separato
r.hset(f"restaurant:{restaurant.name}", mapping={
'name': restaurant.name,
'category': restaurant.category,
'lon': str(restaurant.longitude),
'lat': str(restaurant.latitude),
})
def find_nearby(lon: float, lat: float, radius_km: float, limit: int = 10) -> list[dict]:
"""Trova ristoranti entro radius_km dalla posizione."""
results = r.geosearch(
'restaurants:geo',
longitude=lon,
latitude=lat,
radius=radius_km,
unit='km',
sort='ASC',
count=limit,
withdist=True,
withcoord=True,
)
restaurants = []
for entry in results:
name, dist, (res_lon, res_lat) = entry
metadata = r.hgetall(f"restaurant:{name}")
restaurants.append({
'name': name,
'distance_km': round(dist, 3),
'coordinates': {'lon': res_lon, 'lat': res_lat},
'category': metadata.get('category', ''),
})
return restaurants
# Popola indice
for restaurant in [
Restaurant("dal-mario", 9.1859, 45.4654, "italiana"),
Restaurant("trattoria-lombarda", 9.1900, 45.4680, "italiana"),
Restaurant("sushi-bento", 9.2100, 45.4800, "giapponese"),
]:
index_restaurant(restaurant)
# Cerca ristoranti entro 1km da piazza Duomo Milano
nearby = find_nearby(lon=9.1895, lat=45.4654, radius_km=1.0)
for r_info in nearby:
print(f"{r_info['name']}: {r_info['distance_km']}km ({r_info['category']})")
언제 어떤 데이터 구조를 사용해야 하는가
빠른 선택 가이드
- 끈: 단순 값, 카운터, 플래그, JWT 토큰, 캐시된 응답
- 해시시: 개별 필드에 액세스할 수 있는 구조화된 개체(사용자, 세션, 제품)
- 목록: FIFO/LIFO 대기열, 활동 피드, 삽입별로 정렬된 로그
- 세트: 태그, 다대다 관계, 멤버십 확인, 집합 연산
- 정렬된 세트: 리더보드, 우선순위 대기열, 정렬된 타임라인, 범위 쿼리
- 하이퍼로그로그: 지속적인 메모리를 사용한 대략적인 고유 수(조회수, 방문자)
- 지리공간: 근접 검색, 배송 반경, '내 주변' 기능
- 비트맵: 사용자별 기능 플래그, 일일 접속 상태 추적(DAU)
- 스트림: 지속적인 이벤트 로그, 소비자 그룹이 있는 메시지 대기열
피해야 할 안티패턴
# ANTI-PATTERN 1: HGETALL su hash enormi
# Se un hash ha 10.000 campi, HGETALL porta tutto in memoria del client
# Usa HSCAN per iterare in modo sicuro
cursor = 0
while True:
cursor, fields = r.hscan('big-hash', cursor, count=100)
# processa fields
if cursor == 0:
break
# ANTI-PATTERN 2: SMEMBERS su set molto grandi
# SMEMBERS blocca Redis per la durata della risposta
# Usa SSCAN invece
cursor = 0
while True:
cursor, members = r.sscan('huge-set', cursor, count=100)
# processa members
if cursor == 0:
break
# ANTI-PATTERN 3: KEYS * in produzione
# KEYS * blocca Redis finche non completa la scansione
# Usa SCAN con pattern
cursor = 0
while True:
cursor, keys = r.scan(cursor, match='user:*', count=100)
# processa keys
if cursor == 0:
break
# ANTI-PATTERN 4: Usare HLL quando hai bisogno dell'insieme esatto
# HLL non puo dirti QUALI elementi ha visto, solo QUANTI (approssimativamente)
# Se hai bisogno di sapere "questo utente ha visto questo articolo?",
# usa un Set o un Bloom Filter (RedisBloom)
결론
Redis의 힘은 문제에 적합한 데이터 구조를 일치시키는 것입니다. 순위표에 대한 정렬된 집합은 애플리케이션 논리를 0으로 줄입니다. Redis 자동으로 정렬을 유지합니다. 고유한 보기를 위한 HyperLogLog 50MB 대신 12KB를 사용하세요. 근접 검색을 위한 지리공간 색인 코드에서 삼각법 계산이 필요하지 않습니다. 다음 기사에서는 두 가지 모드인 Pub/Sub와 Streams를 살펴보겠습니다. 전달 보장이 매우 다른 Redis 메시징의 경우.
Redis 시리즈의 향후 기사
- 제2조: Pub/Sub와 Redis Streams — 소비자 그룹 및 분산 처리
- 제3조: Lua 스크립팅 — 원자적 연산, EVAL 및 Redis 기능
- 제4조: 속도 제한 — 토큰 버킷, 슬라이딩 윈도우 및 고정 카운터







