Skip to content

Split View: Redis 완전 가이드 2025: 캐싱 전략, 데이터 구조, Pub/Sub, 그리고 Redis Stack까지

✨ Learn with Quiz
|

Redis 완전 가이드 2025: 캐싱 전략, 데이터 구조, Pub/Sub, 그리고 Redis Stack까지

들어가며

Redis는 2025년 현재 가장 널리 사용되는 인메모리 데이터 스토어입니다. 단순한 캐시를 넘어 메시지 브로커, 세션 스토어, 실시간 리더보드, Rate Limiter, 분산 잠금까지 다양한 역할을 수행합니다. Redis 7.4의 새 기능과 Redis Stack의 등장, 그리고 Valkey 포크 논쟁까지 — 이 글에서 Redis의 모든 것을 다룹니다.


1. Redis 개요

Redis란?

Redis(Remote Dictionary Server)는 인메모리 키-값 데이터 스토어입니다. 모든 데이터를 메모리에 저장하므로 마이크로초 단위의 응답 시간을 제공합니다.

Redis 7.4 주요 기능

  • Redis Functions — Lua 스크립트의 진화. 라이브러리 형태로 관리
  • ACL v2 — 세분화된 접근 제어
  • Client-side caching — 클라이언트 측 캐시 무효화 지원
  • Multi-part AOF — 지속성 향상

Valkey 포크 이야기

2024년 Redis가 라이선스를 변경하면서(SSPL + RSALv2), Linux Foundation은 Redis 7.2를 기반으로 Valkey를 포크했습니다. AWS, Google, Oracle 등이 Valkey를 지원합니다. 현재 두 프로젝트는 호환성을 유지하지만, 장기적으로 분기될 가능성이 있습니다.


2. 기본 데이터 구조 5가지

2.1 String

가장 기본적인 데이터 타입. 최대 512MB까지 저장 가능합니다.

# 기본 SET/GET
SET user:1:name "Alice"
GET user:1:name

# 숫자 증감 (원자적 연산)
SET page:views 0
INCR page:views          # 1
INCRBY page:views 10     # 11

# 만료 시간 설정
SET session:abc123 "user_data" EX 3600   # 1시간 후 만료
TTL session:abc123                        # 남은 시간 확인

# SET 옵션
SET lock:resource "owner1" NX EX 30      # NX: 키가 없을 때만 설정
SET user:1:name "Bob" XX                 # XX: 키가 있을 때만 업데이트

활용 사례: 세션 토큰, 카운터, 임시 데이터, 분산 잠금

2.2 List

이중 연결 리스트. 양쪽 끝에서 O(1) push/pop 가능합니다.

# 기본 연산
LPUSH queue:emails "email1" "email2" "email3"
RPOP queue:emails                        # "email1" (FIFO 큐)

# 범위 조회
LRANGE queue:emails 0 -1                 # 모든 요소

# 블로킹 팝 (메시지 큐로 활용)
BRPOP queue:emails 30                    # 30초 대기 후 팝

# 트리밍 (최근 N개 유지)
LPUSH notifications:user1 "new_msg"
LTRIM notifications:user1 0 99          # 최근 100개만 유지

활용 사례: 메시지 큐, 최근 활동 피드, 작업 큐

2.3 Set

순서 없는 고유 요소의 집합. 집합 연산(합집합, 교집합, 차집합) 지원합니다.

# 기본 연산
SADD tags:post:1 "python" "redis" "backend"
SADD tags:post:2 "python" "django" "orm"

# 멤버십 확인
SISMEMBER tags:post:1 "python"           # 1 (true)

# 집합 연산
SINTER tags:post:1 tags:post:2           # "python" (교집합)
SUNION tags:post:1 tags:post:2           # 합집합
SDIFF tags:post:1 tags:post:2            # tags:post:1에만 있는 요소

# 랜덤 추출
SRANDMEMBER tags:post:1 2               # 랜덤 2개

활용 사례: 태그 시스템, 고유 방문자 추적, 친구 관계, 추천 시스템

2.4 Sorted Set (ZSet)

점수(score)로 정렬된 고유 요소의 집합. 리더보드에 최적입니다.

# 리더보드 구현
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
ZADD leaderboard 1800 "player:charlie"

# 랭킹 조회 (높은 점수 순)
ZREVRANGE leaderboard 0 2 WITHSCORES
# 1) "player:bob"     2) "2300"
# 3) "player:charlie" 4) "1800"
# 5) "player:alice"   6) "1500"

# 특정 멤버 랭킹 (0부터 시작)
ZREVRANK leaderboard "player:alice"      # 2

# 점수 증가
ZINCRBY leaderboard 500 "player:alice"   # 2000

# 범위 검색
ZRANGEBYSCORE leaderboard 1500 2000 WITHSCORES

활용 사례: 리더보드, 우선순위 큐, 시간순 이벤트, Rate Limiting

2.5 Hash

필드-값 쌍의 맵. 객체를 표현하기에 적합합니다.

# 사용자 프로필 저장
HSET user:1 name "Alice" email "alice@example.com" age "30" role "admin"

# 개별 필드 조회
HGET user:1 name                         # "Alice"

# 전체 조회
HGETALL user:1

# 필드 증감
HINCRBY user:1 age 1                     # 31

# 존재 확인
HEXISTS user:1 email                     # 1 (true)

# 여러 필드 한 번에
HMGET user:1 name email role

활용 사례: 사용자 프로필, 설정 값, 세션 데이터, 장바구니


3. 고급 데이터 구조

3.1 HyperLogLog

대량의 고유 요소 수를 추정하는 확률적 자료 구조. 메모리 12KB로 최대 2의 64승 개 카운트 가능(오차율 0.81%).

# 고유 방문자 수 추정
PFADD visitors:2025-03-23 "user1" "user2" "user3"
PFADD visitors:2025-03-23 "user1" "user4"          # user1은 중복

PFCOUNT visitors:2025-03-23                         # 4

# 여러 날 합산
PFMERGE visitors:week visitors:2025-03-23 visitors:2025-03-24
PFCOUNT visitors:week

3.2 Bitmap

비트 단위 연산. 불리언 상태 추적에 메모리 효율적입니다.

# 일일 출석 체크
SETBIT attendance:2025-03-23 1001 1      # 사용자 1001 출석
SETBIT attendance:2025-03-23 1002 1
SETBIT attendance:2025-03-23 1003 0      # 결석

# 출석 여부 확인
GETBIT attendance:2025-03-23 1001        # 1

# 출석 인원 수
BITCOUNT attendance:2025-03-23           # 2

# 연속 출석 (AND 연산)
BITOP AND consecutive attendance:2025-03-22 attendance:2025-03-23
BITCOUNT consecutive

3.3 Geospatial

위치 기반 데이터. 반경 검색, 거리 계산을 지원합니다.

# 위치 추가 (경도, 위도)
GEOADD stores 126.9784 37.5665 "gangnam-store"
GEOADD stores 127.0276 37.4979 "samsung-store"
GEOADD stores 126.9316 37.5563 "hongdae-store"

# 거리 계산
GEODIST stores "gangnam-store" "hongdae-store" km    # ~5.2 km

# 반경 검색 (Redis 6.2+)
GEOSEARCH stores FROMLONLAT 126.9784 37.5665 BYRADIUS 10 km ASC COUNT 5

3.4 Redis Streams

로그 기반 메시지 구조. Kafka와 유사하게 Consumer Group을 지원합니다.

# 메시지 추가
XADD events * type "order" user_id "123" amount "50000"
XADD events * type "payment" user_id "123" status "completed"

# 읽기
XRANGE events - + COUNT 10

# Consumer Group 생성
XGROUP CREATE events analytics-group $ MKSTREAM

# Consumer로 읽기
XREADGROUP GROUP analytics-group consumer-1 COUNT 5 BLOCK 2000 STREAMS events >

# ACK (처리 완료)
XACK events analytics-group "1679000000000-0"

# 미처리 메시지 확인
XPENDING events analytics-group

4. 캐싱 패턴

4.1 Cache-Aside (Lazy Loading)

가장 보편적인 패턴. 애플리케이션이 캐시를 직접 관리합니다.

import redis
import json

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"

    # 1. 캐시 확인
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. 캐시 미스 -> DB 조회
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        return None

    # 3. 캐시에 저장
    r.setex(cache_key, 3600, json.dumps(user.to_dict()))
    return user.to_dict()

def update_user(user_id: int, data: dict):
    # DB 업데이트
    db.query(User).filter(User.id == user_id).update(data)
    db.commit()

    # 캐시 무효화
    r.delete(f"user:{user_id}")

4.2 Write-Through

쓰기 시 캐시와 DB를 동시에 업데이트합니다.

def save_user(user_id: int, data: dict):
    cache_key = f"user:{user_id}"

    # DB와 캐시 동시 업데이트
    db.query(User).filter(User.id == user_id).update(data)
    db.commit()

    r.setex(cache_key, 3600, json.dumps(data))

4.3 Write-Behind (Write-Back)

쓰기를 캐시에 먼저 하고, 비동기로 DB에 반영합니다.

def save_user_async(user_id: int, data: dict):
    cache_key = f"user:{user_id}"

    # 캐시에 먼저 쓰기
    r.setex(cache_key, 3600, json.dumps(data))

    # 비동기로 DB에 반영 (Celery 등)
    sync_to_db_task.delay(user_id, data)

4.4 Read-Through

캐시 라이브러리가 DB 조회를 대신 처리합니다.

캐싱 패턴 비교

패턴읽기 성능쓰기 성능일관성복잡도
Cache-Aside높음보통최종 일관성낮음
Write-Through높음낮음강한 일관성중간
Write-Behind높음높음최종 일관성높음
Read-Through높음보통최종 일관성중간

5. 캐시 무효화

5.1 TTL 기반

가장 단순한 방법. 일정 시간 후 자동 삭제됩니다.

SET product:123 "data" EX 300           # 5분 후 만료

5.2 이벤트 기반 무효화

데이터 변경 시 명시적으로 캐시를 삭제합니다.

def update_product(product_id: int, data: dict):
    db.update(product_id, data)

    # 관련 캐시 모두 삭제
    r.delete(f"product:{product_id}")
    r.delete(f"product_list:category:{data['category_id']}")
    r.delete("product_list:featured")

5.3 버전 키

키에 버전을 포함하여 일괄 무효화합니다.

def get_product_list(category_id: int) -> list:
    version = r.get(f"product_version:{category_id}") or "1"
    cache_key = f"products:cat:{category_id}:v:{version}"

    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    products = db.query(Product).filter(Product.category_id == category_id).all()
    r.setex(cache_key, 3600, json.dumps([p.to_dict() for p in products]))
    return [p.to_dict() for p in products]

def invalidate_category(category_id: int):
    # 버전을 증가시켜 기존 캐시를 무효화
    r.incr(f"product_version:{category_id}")

5.4 Thundering Herd 방지

캐시 만료 시 동시에 많은 요청이 DB로 몰리는 문제를 방지합니다.

import random

def get_with_jitter(key: str, ttl: int = 3600) -> dict:
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    # 분산 잠금으로 하나의 요청만 DB 조회
    lock_key = f"lock:{key}"
    if r.set(lock_key, "1", nx=True, ex=10):
        try:
            data = fetch_from_db(key)
            # TTL에 랜덤 지터 추가
            jitter = random.randint(0, 300)
            r.setex(key, ttl + jitter, json.dumps(data))
            return data
        finally:
            r.delete(lock_key)
    else:
        # 다른 프로세스가 갱신 중 — 짧은 대기 후 재시도
        import time
        time.sleep(0.1)
        return get_with_jitter(key, ttl)

6. Pub/Sub와 Streams

6.1 Pub/Sub 기본

# 구독자
SUBSCRIBE notifications:user:123

# 발행자
PUBLISH notifications:user:123 "You have a new message!"

# 패턴 구독
PSUBSCRIBE notifications:*
# Python 구독자
import redis

r = redis.Redis()
pubsub = r.pubsub()
pubsub.subscribe("notifications:user:123")

for message in pubsub.listen():
    if message["type"] == "message":
        print(f"Received: {message['data']}")

6.2 Redis Streams vs Kafka

기능Redis StreamsKafka
메시지 지속성메모리 + AOF디스크
처리량수만/초수십만/초
Consumer Group지원지원
메시지 재생지원지원
파티셔닝미지원지원
운영 복잡도낮음높음
적합한 규모중소대규모

6.3 Streams를 활용한 이벤트 드리븐 패턴

import redis
import time

r = redis.Redis(decode_responses=True)

# 이벤트 발행
def publish_event(stream: str, event_type: str, data: dict):
    r.xadd(stream, {"type": event_type, **data}, maxlen=10000)

# Consumer Group 기반 처리
def consume_events(stream: str, group: str, consumer: str):
    # 그룹 생성 (이미 존재하면 무시)
    try:
        r.xgroup_create(stream, group, id="0", mkstream=True)
    except redis.ResponseError:
        pass

    while True:
        messages = r.xreadgroup(
            group, consumer,
            {stream: ">"},
            count=10,
            block=5000,
        )

        for stream_name, entries in messages:
            for msg_id, fields in entries:
                try:
                    process_event(fields)
                    r.xack(stream_name, group, msg_id)
                except Exception as e:
                    print(f"Error processing {msg_id}: {e}")

def process_event(fields: dict):
    event_type = fields.get("type")
    if event_type == "order_created":
        handle_order(fields)
    elif event_type == "payment_completed":
        handle_payment(fields)

7. Redis Stack

Redis Stack은 Redis에 JSON, Search, TimeSeries, Bloom Filter 모듈을 추가한 확장 버전입니다.

7.1 RedisJSON

# JSON 문서 저장
JSON.SET user:1 $ '{"name":"Alice","age":30,"address":{"city":"Seoul","zip":"06000"},"tags":["python","redis"]}'

# 경로 기반 조회
JSON.GET user:1 $.name                   # "Alice"
JSON.GET user:1 $.address.city           # "Seoul"

# 부분 업데이트
JSON.SET user:1 $.age 31
JSON.ARRAPPEND user:1 $.tags '"fastapi"'

# 숫자 증감
JSON.NUMINCRBY user:1 $.age 1

7.2 RediSearch (전문 검색)

# 인덱스 생성
FT.CREATE idx:products
  ON JSON
  PREFIX 1 product:
  SCHEMA
    $.name AS name TEXT WEIGHT 5.0
    $.description AS description TEXT
    $.price AS price NUMERIC SORTABLE
    $.category AS category TAG

# 문서 추가
JSON.SET product:1 $ '{"name":"Redis in Action","description":"Complete guide to Redis","price":45000,"category":"book"}'
JSON.SET product:2 $ '{"name":"Python Cookbook","description":"Python recipes and patterns","price":38000,"category":"book"}'

# 검색
FT.SEARCH idx:products "Redis guide"
FT.SEARCH idx:products "@category:{book} @price:[30000 50000]"
FT.SEARCH idx:products "@name:(Python)" SORTBY price ASC

7.3 RedisTimeSeries

# 시계열 데이터 생성
TS.CREATE sensor:temperature:1 RETENTION 86400000 LABELS sensor_id 1 type temperature

# 데이터 추가
TS.ADD sensor:temperature:1 * 23.5
TS.ADD sensor:temperature:1 * 24.1
TS.ADD sensor:temperature:1 * 22.8

# 범위 조회
TS.RANGE sensor:temperature:1 - + COUNT 10

# 집계 (5분 평균)
TS.RANGE sensor:temperature:1 - + AGGREGATION avg 300000

# 다운샘플링 규칙
TS.CREATERULE sensor:temperature:1 sensor:temperature:1:avg AGGREGATION avg 300000

8. Lua 스크립팅

8.1 기본 Lua 스크립트

# 원자적 읽기-수정-쓰기
EVAL "
  local current = redis.call('GET', KEYS[1])
  if current then
    local new_val = tonumber(current) + tonumber(ARGV[1])
    redis.call('SET', KEYS[1], new_val)
    return new_val
  end
  return nil
" 1 counter 5

8.2 Rate Limiter (Sliding Window)

# Python + Lua Rate Limiter
RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 윈도우 밖의 오래된 요청 제거
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 현재 요청 수 확인
local count = redis.call('ZCARD', key)

if count < limit then
    -- 허용: 새 요청 추가
    redis.call('ZADD', key, now, now .. ':' .. math.random())
    redis.call('EXPIRE', key, window)
    return 1
else
    -- 거부
    return 0
end
"""

import redis
import time

r = redis.Redis()
rate_limit_sha = r.script_load(RATE_LIMIT_SCRIPT)

def is_allowed(user_id: str, limit: int = 100, window: int = 60) -> bool:
    key = f"ratelimit:{user_id}"
    now = int(time.time() * 1000)
    result = r.evalsha(rate_limit_sha, 1, key, limit, window * 1000, now)
    return bool(result)

8.3 분산 잠금 (Redlock 알고리즘)

import redis
import time
import uuid

class DistributedLock:
    def __init__(self, redis_client: redis.Redis, resource: str, ttl: int = 10):
        self.redis = redis_client
        self.resource = f"lock:{resource}"
        self.ttl = ttl
        self.token = str(uuid.uuid4())

    def acquire(self) -> bool:
        return bool(self.redis.set(
            self.resource, self.token,
            nx=True, ex=self.ttl,
        ))

    def release(self) -> bool:
        # Lua 스크립트로 원자적 확인 + 삭제
        script = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
        """
        return bool(self.redis.eval(script, 1, self.resource, self.token))

    def __enter__(self):
        if not self.acquire():
            raise Exception("Could not acquire lock")
        return self

    def __exit__(self, *args):
        self.release()

# 사용
r = redis.Redis()
with DistributedLock(r, "order:process:123"):
    # 임계 영역 — 하나의 프로세스만 실행
    process_order(123)

9. Redis Cluster

9.1 해시 슬롯

Redis Cluster는 16384개의 해시 슬롯으로 데이터를 분산합니다. 키의 CRC16 해시값을 16384로 나눈 나머지가 슬롯 번호입니다.

노드 구성 예시:
  Node A: 슬롯 0-5460
  Node B: 슬롯 5461-10922
  Node C: 슬롯 10923-16383

각 노드에 1개의 복제본(Replica)을 추가하면:
  Node A -> Replica A'
  Node B -> Replica B'
  Node C -> Replica C'

9.2 클러스터 설정

# 클러스터 생성 (6노드: 3 마스터 + 3 레플리카)
redis-cli --cluster create \
  127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \
  127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \
  --cluster-replicas 1

# 클러스터 상태 확인
redis-cli -c -p 7001 cluster info
redis-cli -c -p 7001 cluster nodes

9.3 Failover와 Resharding

# 수동 페일오버
redis-cli -c -p 7004 cluster failover

# 리샤딩 (슬롯 이동)
redis-cli --cluster reshard 127.0.0.1:7001

# 노드 추가
redis-cli --cluster add-node 127.0.0.1:7007 127.0.0.1:7001

# 노드 제거
redis-cli --cluster del-node 127.0.0.1:7001 <node-id>

9.4 해시 태그

같은 슬롯에 키를 배치하여 다중 키 연산을 가능하게 합니다.

# 해시 태그 사용 — 중괄호 안의 부분으로 슬롯 결정
SET {user:1}:profile "data"
SET {user:1}:settings "data"
SET {user:1}:sessions "data"

# 이 세 키는 같은 슬롯에 배치됨 -> MULTI 키 연산 가능

10. Redis 실전 활용

10.1 세션 스토어

import redis
import json
import uuid

r = redis.Redis(decode_responses=True)

def create_session(user_id: int, data: dict) -> str:
    session_id = str(uuid.uuid4())
    session_data = {
        "user_id": str(user_id),
        "created_at": str(time.time()),
        **data,
    }
    r.hset(f"session:{session_id}", mapping=session_data)
    r.expire(f"session:{session_id}", 86400)  # 24시간
    return session_id

def get_session(session_id: str) -> dict | None:
    data = r.hgetall(f"session:{session_id}")
    if not data:
        return None
    # 접근 시 TTL 갱신
    r.expire(f"session:{session_id}", 86400)
    return data

def destroy_session(session_id: str):
    r.delete(f"session:{session_id}")

10.2 Rate Limiter (Fixed Window)

def check_rate_limit(user_id: str, limit: int = 100, window: int = 60) -> bool:
    key = f"rate:{user_id}:{int(time.time()) // window}"

    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, window)
    count, _ = pipe.execute()

    return count <= limit

10.3 리더보드

class Leaderboard:
    def __init__(self, name: str):
        self.key = f"leaderboard:{name}"

    def add_score(self, player_id: str, score: float):
        r.zadd(self.key, {player_id: score})

    def increment_score(self, player_id: str, delta: float):
        r.zincrby(self.key, delta, player_id)

    def get_rank(self, player_id: str) -> int | None:
        rank = r.zrevrank(self.key, player_id)
        return rank + 1 if rank is not None else None

    def get_top(self, count: int = 10) -> list[tuple[str, float]]:
        return r.zrevrange(self.key, 0, count - 1, withscores=True)

    def get_around(self, player_id: str, count: int = 5) -> list:
        rank = r.zrevrank(self.key, player_id)
        if rank is None:
            return []
        start = max(0, rank - count)
        end = rank + count
        return r.zrevrange(self.key, start, end, withscores=True)

# 사용
lb = Leaderboard("weekly")
lb.add_score("player:alice", 1500)
lb.increment_score("player:alice", 200)
print(lb.get_top(10))
print(lb.get_rank("player:alice"))

10.4 작업 큐 (Simple Job Queue)

import json
import time

def enqueue_job(queue: str, job_data: dict):
    job = {
        "id": str(uuid.uuid4()),
        "data": job_data,
        "created_at": time.time(),
    }
    r.lpush(f"queue:{queue}", json.dumps(job))

def dequeue_job(queue: str, timeout: int = 30) -> dict | None:
    result = r.brpop(f"queue:{queue}", timeout=timeout)
    if result:
        _, job_json = result
        return json.loads(job_json)
    return None

def worker(queue: str):
    while True:
        job = dequeue_job(queue)
        if job:
            try:
                process_job(job["data"])
            except Exception as e:
                # 실패 시 재시도 큐에 추가
                r.lpush(f"queue:{queue}:failed", json.dumps(job))

11. Redis vs Memcached vs DragonflyDB

기능RedisMemcachedDragonflyDB
데이터 구조다양 (String, List, Set 등)String만Redis 호환
지속성RDB + AOF없음스냅샷
클러스터링Redis Cluster클라이언트 측단일 노드 (멀티스레드)
멀티스레드단일 스레드 (I/O 스레드)멀티스레드멀티스레드
메모리 효율보통높음높음
Pub/Sub지원미지원지원
Lua 스크립트지원미지원지원
처리량~100K ops/s~100K ops/s~400K ops/s
최대 값 크기512MB1MB512MB
적합한 사례범용단순 캐시고성능 단일 노드

12. 클라이언트 라이브러리 코드 예시

12.1 Spring Data Redis (Java)

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

@Service
public class UserCacheService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration TTL = Duration.ofHours(1);

    public UserCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheUser(String userId, UserDto user) {
        String key = "user:" + userId;
        redisTemplate.opsForValue().set(key, user, TTL);
    }

    public UserDto getCachedUser(String userId) {
        String key = "user:" + userId;
        return (UserDto) redisTemplate.opsForValue().get(key);
    }

    // Sorted Set 리더보드
    public void addScore(String leaderboard, String playerId, double score) {
        redisTemplate.opsForZSet().add("lb:" + leaderboard, playerId, score);
    }

    public Set<ZSetOperations.TypedTuple<Object>> getTopPlayers(String leaderboard, int count) {
        return redisTemplate.opsForZSet().reverseRangeWithScores("lb:" + leaderboard, 0, count - 1);
    }
}

12.2 ioredis (Node.js)

import Redis from 'ioredis';

const redis = new Redis({
  host: 'localhost',
  port: 6379,
  retryStrategy: (times) => Math.min(times * 50, 2000),
  maxRetriesPerRequest: 3,
});

// 기본 캐싱
async function getUser(userId) {
  const cacheKey = `user:${userId}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await db.findUser(userId);
  if (user) {
    await redis.setex(cacheKey, 3600, JSON.stringify(user));
  }
  return user;
}

// Pipeline (배치 처리)
async function getMultipleUsers(userIds) {
  const pipeline = redis.pipeline();
  userIds.forEach(id => pipeline.get(`user:${id}`));
  const results = await pipeline.exec();
  return results.map(([err, val]) => val ? JSON.parse(val) : null);
}

// Pub/Sub
const subscriber = new Redis();
subscriber.subscribe('notifications', (err, count) => {
  console.log(`Subscribed to ${count} channels`);
});

subscriber.on('message', (channel, message) => {
  console.log(`Received on ${channel}: ${message}`);
});

// 발행
await redis.publish('notifications', JSON.stringify({ type: 'alert', message: 'Server update' }));

12.3 redis-py (Python)

import redis.asyncio as aioredis
import json

# 비동기 Redis 클라이언트
pool = aioredis.ConnectionPool.from_url(
    "redis://localhost:6379",
    max_connections=20,
    decode_responses=True,
)
r = aioredis.Redis(connection_pool=pool)

# 파이프라인
async def batch_operations():
    async with r.pipeline(transaction=True) as pipe:
        pipe.set("key1", "value1")
        pipe.set("key2", "value2")
        pipe.get("key1")
        results = await pipe.execute()
        return results

# Pub/Sub (비동기)
async def subscribe_handler():
    pubsub = r.pubsub()
    await pubsub.subscribe("events")

    async for message in pubsub.listen():
        if message["type"] == "message":
            data = json.loads(message["data"])
            await process_event(data)

# 캐시 데코레이터
import functools

def redis_cache(ttl: int = 3600):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            cache_key = f"cache:{func.__name__}:{args}:{kwargs}"
            cached = await r.get(cache_key)
            if cached:
                return json.loads(cached)
            result = await func(*args, **kwargs)
            await r.setex(cache_key, ttl, json.dumps(result))
            return result
        return wrapper
    return decorator

@redis_cache(ttl=600)
async def get_product_list(category_id: int):
    return await db.get_products(category_id)

13. 면접 질문 15선

Q1. Redis가 싱글 스레드인데 어떻게 빠른가요?

Redis는 이벤트 루프 기반의 싱글 스레드로 동작하며, 모든 데이터가 메모리에 있어 디스크 I/O가 없습니다. 또한 epoll/kqueue 기반의 I/O 멀티플렉싱으로 수천 개의 연결을 효율적으로 처리합니다. Redis 6.0부터 I/O 스레드를 지원하여 네트워크 처리를 병렬화합니다.

Q2. Cache-Aside와 Write-Through의 차이는?

Cache-Aside는 애플리케이션이 캐시를 직접 관리합니다(읽기 시 캐시 미스면 DB 조회 후 캐시 저장). Write-Through는 쓰기 시 캐시와 DB를 동시에 업데이트합니다. Cache-Aside는 구현이 간단하고, Write-Through는 데이터 일관성이 더 강합니다.

Q3. Redis의 지속성 방식(RDB, AOF)을 설명하세요.

RDB(Redis Database)는 특정 시점의 스냅샷을 디스크에 저장합니다. AOF(Append Only File)는 모든 쓰기 연산을 로그로 기록합니다. RDB는 빠른 복구에 적합하고, AOF는 데이터 손실을 최소화합니다. 둘 다 사용하는 것이 권장됩니다.

Q4. Redis Cluster에서 MGET이 실패하는 이유는?

Redis Cluster에서 키는 해시 슬롯에 분산됩니다. MGET의 키들이 서로 다른 슬롯에 있으면 단일 명령으로 처리할 수 없습니다. 해시 태그(중괄호)를 사용하여 같은 슬롯에 키를 배치하거나, 클라이언트에서 여러 요청으로 분할해야 합니다.

Q5. Thundering Herd 문제란?

캐시 키가 만료되는 순간 수많은 요청이 동시에 DB로 몰리는 현상입니다. 해결 방법: 분산 잠금으로 하나의 요청만 DB 접근 허용, TTL에 랜덤 지터 추가, 백그라운드에서 캐시 사전 갱신(proactive refresh).

Q6. Redis Pub/Sub과 Streams의 차이는?

Pub/Sub은 Fire-and-Forget 방식으로, 구독자가 없으면 메시지가 소실됩니다. Streams는 메시지를 영구 저장하고, Consumer Group으로 안정적인 메시지 처리를 보장합니다. Streams는 ACK, 재처리, 이력 조회가 가능합니다.

Q7. Redis에서 큰 키(Big Key)가 문제가 되는 이유는?

큰 키는 삭제 시 Redis를 블로킹하고, 네트워크 대역폭을 소모하며, 클러스터에서 데이터 편중을 일으킵니다. UNLINK 명령으로 비동기 삭제하고, 큰 해시를 여러 작은 해시로 분할하는 것이 좋습니다.

Q8. Redis의 메모리 정책(eviction policy)을 설명하세요.

maxmemory에 도달하면 eviction 정책에 따라 키를 제거합니다. noeviction(새 쓰기 거부), allkeys-lru(LRU), allkeys-lfu(LFU), volatile-lru(TTL 있는 키 중 LRU), volatile-ttl(만료 시간 가까운 키 우선) 등이 있습니다.

Q9. Lua 스크립트를 사용하는 이유는?

Redis에서 여러 명령을 원자적으로 실행하기 위해 사용합니다. 네트워크 왕복을 줄이고, 서버 측에서 복잡한 로직을 원자적으로 실행합니다. 대표적 사례: Rate Limiter, 분산 잠금 해제, 조건부 업데이트.

Q10. Redis Sentinel과 Redis Cluster의 차이는?

Sentinel은 마스터-슬레이브 구조의 고가용성 솔루션입니다(자동 페일오버). Cluster는 데이터를 여러 노드에 분산하는 수평 확장 솔루션입니다. 소규모에는 Sentinel, 대규모 데이터에는 Cluster가 적합합니다.

Q11. Redis의 파이프라인이란?

여러 명령을 한 번에 서버로 보내고, 응답을 일괄적으로 받는 기법입니다. 네트워크 왕복 횟수를 줄여 성능을 크게 향상시킵니다. 100개의 명령을 개별로 보내면 100번 왕복하지만, 파이프라인은 1번입니다.

Q12. Redis에서 키 만료 처리는 어떻게 동작하나요?

두 가지 방식을 조합합니다. Lazy expiration은 키에 접근할 때 만료 여부를 확인합니다. Active expiration은 100ms마다 임의의 만료 키를 샘플링하여 삭제합니다. 이 조합으로 만료된 키가 메모리를 과도하게 차지하는 것을 방지합니다.

Q13. Redlock 알고리즘이란?

Martin Kleppmann이 비판하고 Salvatore Sanfilippo가 제안한 분산 잠금 알고리즘입니다. N개의 독립적 Redis 인스턴스에서 과반수(N/2+1)에 성공적으로 잠금을 획득하면 잠금이 유효합니다. 단일 인스턴스 잠금보다 안전하지만, 완벽하지는 않습니다.

Q14. Redis의 slow log란?

실행 시간이 특정 임계값을 초과하는 명령을 기록합니다. slowlog-log-slower-than으로 임계값(마이크로초)을 설정합니다. SLOWLOG GET으로 느린 명령을 확인하고 최적화할 수 있습니다.

Q15. Redis를 세션 스토어로 사용할 때의 장단점은?

장점: 빠른 읽기/쓰기, TTL 자동 만료, 수평 확장 가능, 서버 간 세션 공유. 단점: 메모리 비용, Redis 장애 시 세션 손실 위험, 네트워크 의존성. 지속성(AOF)과 복제를 활성화하여 리스크를 줄일 수 있습니다.


14. 퀴즈

Q1. Redis SET 명령의 NX와 XX 옵션의 차이는?

NX(Not eXists)는 키가 존재하지 않을 때만 설정합니다. 분산 잠금 획득에 사용됩니다. XX(eXists)는 키가 이미 존재할 때만 업데이트합니다. 기존 값을 안전하게 갱신할 때 사용합니다.

Q2. ZADD의 시간 복잡도는 무엇이며 왜 그런가요?

ZADD의 시간 복잡도는 O(log N)입니다. Sorted Set은 내부적으로 Skip List를 사용하여 정렬 상태를 유지하며, Skip List의 삽입 연산이 O(log N)이기 때문입니다.

Q3. Redis에서 KEYS 명령을 프로덕션에서 사용하면 안 되는 이유는?

KEYS는 모든 키를 순회하는 O(N) 연산으로, 키가 많으면 Redis를 장시간 블로킹합니다. 대신 SCAN 명령을 사용해야 합니다. SCAN은 커서 기반으로 점진적으로 순회하여 블로킹을 방지합니다.

Q4. Redis의 WATCH/MULTI/EXEC는 어떤 문제를 해결하나요?

낙관적 잠금(Optimistic Locking)을 구현합니다. WATCH로 키를 감시하고, MULTI로 트랜잭션을 시작하고, EXEC로 실행합니다. WATCH한 키가 다른 클라이언트에 의해 변경되면 트랜잭션이 실패합니다. 충돌이 적은 상황에서 효율적입니다.

Q5. HyperLogLog의 오차율은 약 0.81%입니다. 왜 정확하지 않은 데이터 구조를 사용하나요?

정확한 고유 카운트는 O(N) 메모리가 필요합니다(모든 요소 저장). HyperLogLog는 어떤 크기의 집합이든 12KB 고정 메모리만 사용합니다. 1억 명의 고유 방문자를 추적할 때, Set은 수 GB가 필요하지만 HyperLogLog는 12KB로 충분합니다. 대부분의 분석 사례에서 0.81% 오차는 허용 가능합니다.


참고 자료

  1. Redis 공식 문서
  2. Redis University
  3. Redis Stack 문서
  4. Valkey 프로젝트
  5. Redis in Action (Manning)
  6. ioredis GitHub
  7. redis-py 문서
  8. Spring Data Redis
  9. Redis Best Practices
  10. Redlock 알고리즘
  11. Martin Kleppmann의 Redlock 분석
  12. Redis Cluster 튜토리얼
  13. DragonflyDB
  14. Redis Streams 가이드
  15. RediSearch 문서

Redis Complete Guide 2025: Caching Strategies, Data Structures, Pub/Sub, and Redis Stack

Introduction

Redis is the most widely used in-memory data store in 2025. Beyond simple caching, it serves as a message broker, session store, real-time leaderboard, rate limiter, and distributed lock. From Redis 7.4 features and the emergence of Redis Stack to the Valkey fork controversy, this guide covers everything about Redis.


1. Redis Overview

What is Redis?

Redis (Remote Dictionary Server) is an in-memory key-value data store. Since all data is stored in memory, it provides microsecond response times.

Redis 7.4 Key Features

  • Redis Functions — Evolution of Lua scripts. Managed as libraries
  • ACL v2 — Fine-grained access control
  • Client-side caching — Client-side cache invalidation support
  • Multi-part AOF — Improved persistence

The Valkey Fork Story

In 2024, Redis changed its license (SSPL + RSALv2), and the Linux Foundation forked Redis 7.2 as Valkey. AWS, Google, Oracle, and others support Valkey. Currently both projects maintain compatibility, but may diverge long-term.


2. Five Core Data Structures

2.1 String

The most basic data type. Can store up to 512MB.

# Basic SET/GET
SET user:1:name "Alice"
GET user:1:name

# Atomic increment/decrement
SET page:views 0
INCR page:views          # 1
INCRBY page:views 10     # 11

# TTL setting
SET session:abc123 "user_data" EX 3600   # Expires in 1 hour
TTL session:abc123                        # Check remaining time

# SET options
SET lock:resource "owner1" NX EX 30      # NX: Set only if key doesn't exist
SET user:1:name "Bob" XX                 # XX: Update only if key exists

Use cases: Session tokens, counters, temporary data, distributed locks

2.2 List

Doubly linked list. O(1) push/pop from both ends.

# Basic operations
LPUSH queue:emails "email1" "email2" "email3"
RPOP queue:emails                        # "email1" (FIFO queue)

# Range query
LRANGE queue:emails 0 -1                 # All elements

# Blocking pop (message queue pattern)
BRPOP queue:emails 30                    # Wait 30 seconds then pop

# Trimming (keep recent N items)
LPUSH notifications:user1 "new_msg"
LTRIM notifications:user1 0 99          # Keep only recent 100

Use cases: Message queues, recent activity feeds, job queues

2.3 Set

Unordered collection of unique elements. Supports set operations (union, intersection, difference).

# Basic operations
SADD tags:post:1 "python" "redis" "backend"
SADD tags:post:2 "python" "django" "orm"

# Membership check
SISMEMBER tags:post:1 "python"           # 1 (true)

# Set operations
SINTER tags:post:1 tags:post:2           # "python" (intersection)
SUNION tags:post:1 tags:post:2           # union
SDIFF tags:post:1 tags:post:2            # elements only in post:1

# Random sampling
SRANDMEMBER tags:post:1 2               # Random 2 elements

Use cases: Tag systems, unique visitor tracking, friend relationships, recommendation systems

2.4 Sorted Set (ZSet)

Collection of unique elements sorted by score. Ideal for leaderboards.

# Leaderboard implementation
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
ZADD leaderboard 1800 "player:charlie"

# Ranking query (descending by score)
ZREVRANGE leaderboard 0 2 WITHSCORES
# 1) "player:bob"     2) "2300"
# 3) "player:charlie" 4) "1800"
# 5) "player:alice"   6) "1500"

# Specific member rank (0-based)
ZREVRANK leaderboard "player:alice"      # 2

# Score increment
ZINCRBY leaderboard 500 "player:alice"   # 2000

# Range search
ZRANGEBYSCORE leaderboard 1500 2000 WITHSCORES

Use cases: Leaderboards, priority queues, time-ordered events, rate limiting

2.5 Hash

Map of field-value pairs. Ideal for representing objects.

# Store user profile
HSET user:1 name "Alice" email "alice@example.com" age "30" role "admin"

# Get individual field
HGET user:1 name                         # "Alice"

# Get all
HGETALL user:1

# Field increment
HINCRBY user:1 age 1                     # 31

# Existence check
HEXISTS user:1 email                     # 1 (true)

# Multiple fields at once
HMGET user:1 name email role

Use cases: User profiles, configuration values, session data, shopping carts


3. Advanced Data Structures

3.1 HyperLogLog

Probabilistic data structure for estimating unique element counts. Uses only 12KB of memory to count up to 2^64 elements (0.81% error rate).

# Estimate unique visitors
PFADD visitors:2025-03-23 "user1" "user2" "user3"
PFADD visitors:2025-03-23 "user1" "user4"          # user1 is duplicate

PFCOUNT visitors:2025-03-23                         # 4

# Merge multiple days
PFMERGE visitors:week visitors:2025-03-23 visitors:2025-03-24
PFCOUNT visitors:week

3.2 Bitmap

Bit-level operations. Memory-efficient for boolean state tracking.

# Daily attendance check
SETBIT attendance:2025-03-23 1001 1      # User 1001 present
SETBIT attendance:2025-03-23 1002 1
SETBIT attendance:2025-03-23 1003 0      # Absent

# Check attendance
GETBIT attendance:2025-03-23 1001        # 1

# Count attendees
BITCOUNT attendance:2025-03-23           # 2

# Consecutive attendance (AND operation)
BITOP AND consecutive attendance:2025-03-22 attendance:2025-03-23
BITCOUNT consecutive

3.3 Geospatial

Location-based data. Supports radius search and distance calculation.

# Add locations (longitude, latitude)
GEOADD stores 126.9784 37.5665 "gangnam-store"
GEOADD stores 127.0276 37.4979 "samsung-store"
GEOADD stores 126.9316 37.5563 "hongdae-store"

# Distance calculation
GEODIST stores "gangnam-store" "hongdae-store" km    # ~5.2 km

# Radius search (Redis 6.2+)
GEOSEARCH stores FROMLONLAT 126.9784 37.5665 BYRADIUS 10 km ASC COUNT 5

3.4 Redis Streams

Log-based message structure. Supports Consumer Groups similar to Kafka.

# Add messages
XADD events * type "order" user_id "123" amount "50000"
XADD events * type "payment" user_id "123" status "completed"

# Read
XRANGE events - + COUNT 10

# Create Consumer Group
XGROUP CREATE events analytics-group $ MKSTREAM

# Read as consumer
XREADGROUP GROUP analytics-group consumer-1 COUNT 5 BLOCK 2000 STREAMS events >

# ACK (processing complete)
XACK events analytics-group "1679000000000-0"

# Check pending messages
XPENDING events analytics-group

4. Caching Patterns

4.1 Cache-Aside (Lazy Loading)

The most common pattern. The application manages the cache directly.

import redis
import json

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"

    # 1. Check cache
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. Cache miss -> Query DB
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        return None

    # 3. Store in cache
    r.setex(cache_key, 3600, json.dumps(user.to_dict()))
    return user.to_dict()

def update_user(user_id: int, data: dict):
    db.query(User).filter(User.id == user_id).update(data)
    db.commit()

    # Invalidate cache
    r.delete(f"user:{user_id}")

4.2 Write-Through

Updates cache and DB simultaneously on writes.

def save_user(user_id: int, data: dict):
    cache_key = f"user:{user_id}"

    # Update DB and cache simultaneously
    db.query(User).filter(User.id == user_id).update(data)
    db.commit()

    r.setex(cache_key, 3600, json.dumps(data))

4.3 Write-Behind (Write-Back)

Writes to cache first, then asynchronously persists to DB.

def save_user_async(user_id: int, data: dict):
    cache_key = f"user:{user_id}"

    # Write to cache first
    r.setex(cache_key, 3600, json.dumps(data))

    # Async DB persistence (Celery, etc.)
    sync_to_db_task.delay(user_id, data)

4.4 Caching Pattern Comparison

PatternRead PerfWrite PerfConsistencyComplexity
Cache-AsideHighMediumEventualLow
Write-ThroughHighLowStrongMedium
Write-BehindHighHighEventualHigh
Read-ThroughHighMediumEventualMedium

5. Cache Invalidation

5.1 TTL-Based

The simplest approach. Auto-expires after a set time.

SET product:123 "data" EX 300           # Expires in 5 minutes

5.2 Event-Based Invalidation

Explicitly delete cache when data changes.

def update_product(product_id: int, data: dict):
    db.update(product_id, data)

    # Delete all related caches
    r.delete(f"product:{product_id}")
    r.delete(f"product_list:category:{data['category_id']}")
    r.delete("product_list:featured")

5.3 Versioned Keys

Include version in keys for bulk invalidation.

def get_product_list(category_id: int) -> list:
    version = r.get(f"product_version:{category_id}") or "1"
    cache_key = f"products:cat:{category_id}:v:{version}"

    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    products = db.query(Product).filter(Product.category_id == category_id).all()
    r.setex(cache_key, 3600, json.dumps([p.to_dict() for p in products]))
    return [p.to_dict() for p in products]

def invalidate_category(category_id: int):
    # Increment version to invalidate existing caches
    r.incr(f"product_version:{category_id}")

5.4 Thundering Herd Prevention

Prevents many requests hitting DB simultaneously when cache expires.

import random

def get_with_jitter(key: str, ttl: int = 3600) -> dict:
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    # Distributed lock — only one request queries DB
    lock_key = f"lock:{key}"
    if r.set(lock_key, "1", nx=True, ex=10):
        try:
            data = fetch_from_db(key)
            # Add random jitter to TTL
            jitter = random.randint(0, 300)
            r.setex(key, ttl + jitter, json.dumps(data))
            return data
        finally:
            r.delete(lock_key)
    else:
        # Another process is refreshing — wait briefly and retry
        import time
        time.sleep(0.1)
        return get_with_jitter(key, ttl)

6. Pub/Sub and Streams

6.1 Pub/Sub Basics

# Subscriber
SUBSCRIBE notifications:user:123

# Publisher
PUBLISH notifications:user:123 "You have a new message!"

# Pattern subscription
PSUBSCRIBE notifications:*
# Python subscriber
import redis

r = redis.Redis()
pubsub = r.pubsub()
pubsub.subscribe("notifications:user:123")

for message in pubsub.listen():
    if message["type"] == "message":
        print(f"Received: {message['data']}")

6.2 Redis Streams vs Kafka

FeatureRedis StreamsKafka
Message persistenceMemory + AOFDisk
Throughput~10K/sec~100K+/sec
Consumer GroupsSupportedSupported
Message replaySupportedSupported
PartitioningNot supportedSupported
Operational complexityLowHigh
Suitable scaleSmall-MediumLarge

6.3 Event-Driven Pattern with Streams

import redis
import time

r = redis.Redis(decode_responses=True)

# Publish event
def publish_event(stream: str, event_type: str, data: dict):
    r.xadd(stream, {"type": event_type, **data}, maxlen=10000)

# Consumer Group processing
def consume_events(stream: str, group: str, consumer: str):
    try:
        r.xgroup_create(stream, group, id="0", mkstream=True)
    except redis.ResponseError:
        pass

    while True:
        messages = r.xreadgroup(
            group, consumer,
            {stream: ">"},
            count=10,
            block=5000,
        )

        for stream_name, entries in messages:
            for msg_id, fields in entries:
                try:
                    process_event(fields)
                    r.xack(stream_name, group, msg_id)
                except Exception as e:
                    print(f"Error processing {msg_id}: {e}")

def process_event(fields: dict):
    event_type = fields.get("type")
    if event_type == "order_created":
        handle_order(fields)
    elif event_type == "payment_completed":
        handle_payment(fields)

7. Redis Stack

Redis Stack adds JSON, Search, TimeSeries, and Bloom Filter modules to Redis.

7.1 RedisJSON

# Store JSON document
JSON.SET user:1 $ '{"name":"Alice","age":30,"address":{"city":"Seoul","zip":"06000"},"tags":["python","redis"]}'

# Path-based query
JSON.GET user:1 $.name                   # "Alice"
JSON.GET user:1 $.address.city           # "Seoul"

# Partial update
JSON.SET user:1 $.age 31
JSON.ARRAPPEND user:1 $.tags '"fastapi"'

# Numeric increment
JSON.NUMINCRBY user:1 $.age 1
# Create index
FT.CREATE idx:products
  ON JSON
  PREFIX 1 product:
  SCHEMA
    $.name AS name TEXT WEIGHT 5.0
    $.description AS description TEXT
    $.price AS price NUMERIC SORTABLE
    $.category AS category TAG

# Add documents
JSON.SET product:1 $ '{"name":"Redis in Action","description":"Complete guide to Redis","price":45000,"category":"book"}'
JSON.SET product:2 $ '{"name":"Python Cookbook","description":"Python recipes and patterns","price":38000,"category":"book"}'

# Search
FT.SEARCH idx:products "Redis guide"
FT.SEARCH idx:products "@category:{book} @price:[30000 50000]"
FT.SEARCH idx:products "@name:(Python)" SORTBY price ASC

7.3 RedisTimeSeries

# Create time series
TS.CREATE sensor:temperature:1 RETENTION 86400000 LABELS sensor_id 1 type temperature

# Add data
TS.ADD sensor:temperature:1 * 23.5
TS.ADD sensor:temperature:1 * 24.1
TS.ADD sensor:temperature:1 * 22.8

# Range query
TS.RANGE sensor:temperature:1 - + COUNT 10

# Aggregation (5-minute average)
TS.RANGE sensor:temperature:1 - + AGGREGATION avg 300000

# Downsampling rule
TS.CREATERULE sensor:temperature:1 sensor:temperature:1:avg AGGREGATION avg 300000

8. Lua Scripting

8.1 Basic Lua Script

# Atomic read-modify-write
EVAL "
  local current = redis.call('GET', KEYS[1])
  if current then
    local new_val = tonumber(current) + tonumber(ARGV[1])
    redis.call('SET', KEYS[1], new_val)
    return new_val
  end
  return nil
" 1 counter 5

8.2 Rate Limiter (Sliding Window)

RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- Remove old requests outside window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- Check current request count
local count = redis.call('ZCARD', key)

if count < limit then
    -- Allow: add new request
    redis.call('ZADD', key, now, now .. ':' .. math.random())
    redis.call('EXPIRE', key, window)
    return 1
else
    -- Deny
    return 0
end
"""

import redis
import time

r = redis.Redis()
rate_limit_sha = r.script_load(RATE_LIMIT_SCRIPT)

def is_allowed(user_id: str, limit: int = 100, window: int = 60) -> bool:
    key = f"ratelimit:{user_id}"
    now = int(time.time() * 1000)
    result = r.evalsha(rate_limit_sha, 1, key, limit, window * 1000, now)
    return bool(result)

8.3 Distributed Lock (Redlock Algorithm)

import redis
import time
import uuid

class DistributedLock:
    def __init__(self, redis_client: redis.Redis, resource: str, ttl: int = 10):
        self.redis = redis_client
        self.resource = f"lock:{resource}"
        self.ttl = ttl
        self.token = str(uuid.uuid4())

    def acquire(self) -> bool:
        return bool(self.redis.set(
            self.resource, self.token,
            nx=True, ex=self.ttl,
        ))

    def release(self) -> bool:
        # Atomic check + delete with Lua script
        script = """
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
        """
        return bool(self.redis.eval(script, 1, self.resource, self.token))

    def __enter__(self):
        if not self.acquire():
            raise Exception("Could not acquire lock")
        return self

    def __exit__(self, *args):
        self.release()

# Usage
r = redis.Redis()
with DistributedLock(r, "order:process:123"):
    # Critical section — only one process executes
    process_order(123)

9. Redis Cluster

9.1 Hash Slots

Redis Cluster distributes data across 16384 hash slots. The CRC16 hash of a key modulo 16384 determines the slot number.

Node configuration example:
  Node A: slots 0-5460
  Node B: slots 5461-10922
  Node C: slots 10923-16383

With one replica per node:
  Node A -> Replica A'
  Node B -> Replica B'
  Node C -> Replica C'

9.2 Cluster Setup

# Create cluster (6 nodes: 3 masters + 3 replicas)
redis-cli --cluster create \
  127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \
  127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \
  --cluster-replicas 1

# Check cluster status
redis-cli -c -p 7001 cluster info
redis-cli -c -p 7001 cluster nodes

9.3 Failover and Resharding

# Manual failover
redis-cli -c -p 7004 cluster failover

# Resharding (move slots)
redis-cli --cluster reshard 127.0.0.1:7001

# Add node
redis-cli --cluster add-node 127.0.0.1:7007 127.0.0.1:7001

# Remove node
redis-cli --cluster del-node 127.0.0.1:7001 <node-id>

9.4 Hash Tags

Place keys in the same slot to enable multi-key operations.

# Hash tags — slot determined by content in curly braces
SET {user:1}:profile "data"
SET {user:1}:settings "data"
SET {user:1}:sessions "data"

# These three keys are in the same slot -> multi-key operations possible

10. Redis in Practice

10.1 Session Store

import redis
import json
import uuid

r = redis.Redis(decode_responses=True)

def create_session(user_id: int, data: dict) -> str:
    session_id = str(uuid.uuid4())
    session_data = {
        "user_id": str(user_id),
        "created_at": str(time.time()),
        **data,
    }
    r.hset(f"session:{session_id}", mapping=session_data)
    r.expire(f"session:{session_id}", 86400)  # 24 hours
    return session_id

def get_session(session_id: str) -> dict | None:
    data = r.hgetall(f"session:{session_id}")
    if not data:
        return None
    # Refresh TTL on access
    r.expire(f"session:{session_id}", 86400)
    return data

def destroy_session(session_id: str):
    r.delete(f"session:{session_id}")

10.2 Rate Limiter (Fixed Window)

def check_rate_limit(user_id: str, limit: int = 100, window: int = 60) -> bool:
    key = f"rate:{user_id}:{int(time.time()) // window}"

    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, window)
    count, _ = pipe.execute()

    return count <= limit

10.3 Leaderboard

class Leaderboard:
    def __init__(self, name: str):
        self.key = f"leaderboard:{name}"

    def add_score(self, player_id: str, score: float):
        r.zadd(self.key, {player_id: score})

    def increment_score(self, player_id: str, delta: float):
        r.zincrby(self.key, delta, player_id)

    def get_rank(self, player_id: str) -> int | None:
        rank = r.zrevrank(self.key, player_id)
        return rank + 1 if rank is not None else None

    def get_top(self, count: int = 10) -> list[tuple[str, float]]:
        return r.zrevrange(self.key, 0, count - 1, withscores=True)

    def get_around(self, player_id: str, count: int = 5) -> list:
        rank = r.zrevrank(self.key, player_id)
        if rank is None:
            return []
        start = max(0, rank - count)
        end = rank + count
        return r.zrevrange(self.key, start, end, withscores=True)

# Usage
lb = Leaderboard("weekly")
lb.add_score("player:alice", 1500)
lb.increment_score("player:alice", 200)
print(lb.get_top(10))
print(lb.get_rank("player:alice"))

10.4 Simple Job Queue

import json
import time

def enqueue_job(queue: str, job_data: dict):
    job = {
        "id": str(uuid.uuid4()),
        "data": job_data,
        "created_at": time.time(),
    }
    r.lpush(f"queue:{queue}", json.dumps(job))

def dequeue_job(queue: str, timeout: int = 30) -> dict | None:
    result = r.brpop(f"queue:{queue}", timeout=timeout)
    if result:
        _, job_json = result
        return json.loads(job_json)
    return None

def worker(queue: str):
    while True:
        job = dequeue_job(queue)
        if job:
            try:
                process_job(job["data"])
            except Exception as e:
                # On failure, add to retry queue
                r.lpush(f"queue:{queue}:failed", json.dumps(job))

11. Redis vs Memcached vs DragonflyDB

FeatureRedisMemcachedDragonflyDB
Data structuresRich (String, List, Set, etc.)String onlyRedis-compatible
PersistenceRDB + AOFNoneSnapshots
ClusteringRedis ClusterClient-sideSingle node (multithreaded)
MultithreadingSingle thread (I/O threads)MultithreadedMultithreaded
Memory efficiencyMediumHighHigh
Pub/SubSupportedNot supportedSupported
Lua scriptingSupportedNot supportedSupported
Throughput~100K ops/s~100K ops/s~400K ops/s
Max value size512MB1MB512MB
Best forGeneral purposeSimple cachingHigh-perf single node

12. Client Library Code Examples

12.1 Spring Data Redis (Java)

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

@Service
public class UserCacheService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration TTL = Duration.ofHours(1);

    public UserCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheUser(String userId, UserDto user) {
        String key = "user:" + userId;
        redisTemplate.opsForValue().set(key, user, TTL);
    }

    public UserDto getCachedUser(String userId) {
        String key = "user:" + userId;
        return (UserDto) redisTemplate.opsForValue().get(key);
    }

    public void addScore(String leaderboard, String playerId, double score) {
        redisTemplate.opsForZSet().add("lb:" + leaderboard, playerId, score);
    }
}

12.2 ioredis (Node.js)

import Redis from 'ioredis';

const redis = new Redis({
  host: 'localhost',
  port: 6379,
  retryStrategy: (times) => Math.min(times * 50, 2000),
  maxRetriesPerRequest: 3,
});

// Basic caching
async function getUser(userId) {
  const cacheKey = `user:${userId}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await db.findUser(userId);
  if (user) {
    await redis.setex(cacheKey, 3600, JSON.stringify(user));
  }
  return user;
}

// Pipeline (batch processing)
async function getMultipleUsers(userIds) {
  const pipeline = redis.pipeline();
  userIds.forEach(id => pipeline.get(`user:${id}`));
  const results = await pipeline.exec();
  return results.map(([err, val]) => val ? JSON.parse(val) : null);
}

// Pub/Sub
const subscriber = new Redis();
subscriber.subscribe('notifications', (err, count) => {
  console.log(`Subscribed to ${count} channels`);
});

subscriber.on('message', (channel, message) => {
  console.log(`Received on ${channel}: ${message}`);
});

await redis.publish('notifications', JSON.stringify({ type: 'alert', message: 'Server update' }));

12.3 redis-py (Python)

import redis.asyncio as aioredis
import json

# Async Redis client
pool = aioredis.ConnectionPool.from_url(
    "redis://localhost:6379",
    max_connections=20,
    decode_responses=True,
)
r = aioredis.Redis(connection_pool=pool)

# Pipeline
async def batch_operations():
    async with r.pipeline(transaction=True) as pipe:
        pipe.set("key1", "value1")
        pipe.set("key2", "value2")
        pipe.get("key1")
        results = await pipe.execute()
        return results

# Cache decorator
import functools

def redis_cache(ttl: int = 3600):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            cache_key = f"cache:{func.__name__}:{args}:{kwargs}"
            cached = await r.get(cache_key)
            if cached:
                return json.loads(cached)
            result = await func(*args, **kwargs)
            await r.setex(cache_key, ttl, json.dumps(result))
            return result
        return wrapper
    return decorator

@redis_cache(ttl=600)
async def get_product_list(category_id: int):
    return await db.get_products(category_id)

13. Interview Questions (15)

Q1. How is Redis fast despite being single-threaded?

Redis uses an event loop-based single thread with all data in memory, eliminating disk I/O. It uses epoll/kqueue-based I/O multiplexing to efficiently handle thousands of connections. Since Redis 6.0, I/O threads parallelize network processing.

Q2. What is the difference between Cache-Aside and Write-Through?

Cache-Aside has the application manage the cache directly (on read miss, query DB then cache). Write-Through updates both cache and DB simultaneously on writes. Cache-Aside is simpler to implement; Write-Through provides stronger data consistency.

Q3. Explain Redis persistence mechanisms (RDB, AOF).

RDB (Redis Database) saves point-in-time snapshots to disk. AOF (Append Only File) logs every write operation. RDB is suitable for fast recovery; AOF minimizes data loss. Using both is recommended.

Q4. Why might MGET fail in Redis Cluster?

In Redis Cluster, keys are distributed across hash slots. If MGET keys are in different slots, they cannot be processed in a single command. Use hash tags (curly braces) to place keys in the same slot, or split into multiple requests at the client.

Q5. What is the Thundering Herd problem?

When a cache key expires, many requests simultaneously hit the DB. Solutions: distributed lock allowing only one request to access DB, random jitter added to TTL, proactive background cache refresh.

Q6. What is the difference between Redis Pub/Sub and Streams?

Pub/Sub is fire-and-forget; messages are lost if no subscribers exist. Streams persist messages permanently, with Consumer Groups ensuring reliable processing. Streams support ACK, reprocessing, and history queries.

Q7. Why are big keys problematic in Redis?

Big keys block Redis during deletion, consume network bandwidth, and cause data skew in clusters. Use UNLINK for async deletion and split large hashes into smaller ones.

Q8. Explain Redis eviction policies.

When maxmemory is reached, keys are removed by eviction policy: noeviction (reject new writes), allkeys-lru (LRU), allkeys-lfu (LFU), volatile-lru (LRU among TTL keys), volatile-ttl (nearest expiration first).

Q9. Why use Lua scripting in Redis?

For atomic execution of multiple commands. Reduces network round-trips and executes complex logic atomically server-side. Common use cases: rate limiters, distributed lock release, conditional updates.

Q10. What is the difference between Redis Sentinel and Redis Cluster?

Sentinel provides high availability for master-slave setups (automatic failover). Cluster provides horizontal scaling by distributing data across nodes. Sentinel for small scale; Cluster for large datasets.

Q11. What is Redis pipelining?

Sending multiple commands at once to the server and receiving all responses together. Dramatically reduces network round-trips. 100 individual commands require 100 round-trips; pipelining needs just 1.

Q12. How does Redis handle key expiration?

Two mechanisms combined: lazy expiration checks expiry on key access; active expiration samples random expired keys every 100ms for deletion. This combination prevents expired keys from consuming excessive memory.

Q13. What is the Redlock algorithm?

A distributed lock algorithm proposed by Salvatore Sanfilippo (and critiqued by Martin Kleppmann). Lock is valid when successfully acquired on a majority (N/2+1) of N independent Redis instances. Safer than single-instance locks but not perfect.

Q14. What is Redis slow log?

Records commands exceeding a time threshold. Set threshold with slowlog-log-slower-than (microseconds). Use SLOWLOG GET to identify and optimize slow commands.

Q15. What are the pros and cons of Redis as a session store?

Pros: fast reads/writes, automatic TTL expiry, horizontal scaling, cross-server session sharing. Cons: memory cost, session loss risk on Redis failure, network dependency. Enable persistence (AOF) and replication to mitigate risks.


14. Quiz

Q1. What is the difference between Redis SET NX and XX options?

NX (Not eXists) sets the key only if it does not exist. Used for distributed lock acquisition. XX (eXists) updates only if the key already exists. Used for safely updating existing values.

Q2. What is the time complexity of ZADD and why?

ZADD has O(log N) time complexity. Sorted Set internally uses a Skip List to maintain sorted order, and Skip List insertion is O(log N).

Q3. Why should KEYS command not be used in production?

KEYS iterates all keys with O(N) complexity, blocking Redis for extended periods with many keys. Use SCAN instead, which iterates incrementally with cursor-based approach, preventing blocking.

Q4. What problem does WATCH/MULTI/EXEC solve in Redis?

It implements optimistic locking. WATCH monitors keys, MULTI starts a transaction, EXEC executes it. If a WATCHed key is modified by another client, the transaction fails. Efficient in low-contention scenarios.

Q5. HyperLogLog has ~0.81% error rate. Why use an inaccurate data structure?

Exact unique counts require O(N) memory (storing all elements). HyperLogLog uses fixed 12KB regardless of set size. Tracking 100 million unique visitors would need several GB with Set, but only 12KB with HyperLogLog. The 0.81% error is acceptable for most analytics use cases.


References

  1. Redis Official Documentation
  2. Redis University
  3. Redis Stack Documentation
  4. Valkey Project
  5. Redis in Action (Manning)
  6. ioredis GitHub
  7. redis-py Documentation
  8. Spring Data Redis
  9. Redis Best Practices
  10. Redlock Algorithm
  11. Martin Kleppmann's Redlock Analysis
  12. Redis Cluster Tutorial
  13. DragonflyDB
  14. Redis Streams Guide
  15. RediSearch Documentation