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

ソートされたセット: リーダーボードと範囲クエリ

ソート セットは、Redis で最も汎用性の高いデータ構造です。あらゆる要素 1つあります スコア 関連するフロート;セットは自動的に並べ替えられます スコアによって。ポジション(ランク)またはスコア範囲ごとに要素を読み取ることができ、 すべて 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 の威力は、適切なデータ構造を問題に適合させることです。 リーダーボードのソート セットにより、アプリケーション ロジックがゼロになります: Redis ソートを自動的に維持します。独自のビューのための HyperLogLog 50MB の代わりに 12KB を使用します。近接検索のための地理空間インデックス コード内で三角関数の計算を行う必要がなくなります。 次の記事では、Pub/Sub と Streams の 2 つのモードについて説明します。 非常に異なる配信保証を持つ Redis メッセージングの。

Redis シリーズの今後の記事

  • 第2条: Pub/Sub と Redis ストリーム — コンシューマ グループと分散処理
  • 第3条: Lua スクリプト — アトミック オペレーション、EVAL、Redis 関数
  • 第4条: レート制限 — トークンバケット、スライディングウィンドウ、固定カウンター