Redis #
Redis is an in-memory data structure store that doubles as a cache, database, message broker, and queue — with sub-millisecond latency that disk-based databases can’t match. Redis’s speed isn’t just because data lives in memory; it’s also because of the single-threaded architecture that avoids context switching, and the optimized data structures: String, Hash, List, Set, Sorted Set, Stream, and more, each designed for specific use cases. In Python application development, Redis is most used for caching database query results, session storage, rate limiting, real-time leaderboards, and distributed locks between instances.
Installation #
pip install redis
To run Redis locally:
# Docker (easiest)
docker run -d --name redis -p 6379:6379 redis:latest
# With a password
docker run -d --name redis -p 6379:6379 redis:latest redis-server --requirepass "secret"
Creating a Connection #
import redis
import os
# ANTI-PATTERN: hardcoded connection without password, without decode_responses
r = redis.Redis(host="localhost", port=6379, db=0)
nilai = r.get("kunci") # ✗ -- the value is bytes: b"data", not a string
# CORRECT: use environment variables, password, and decode_responses=True
r = redis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD"),
db=int(os.getenv("REDIS_DB", "0")),
decode_responses=True, # return str instead of bytes
socket_timeout=5, # timeout during operations
socket_connect_timeout=5 # timeout when connecting
)
# Or use a URL (more concise)
r = redis.from_url(
os.getenv("REDIS_URL", "redis://localhost:6379/0"),
decode_responses=True,
socket_timeout=5
)
# Test the connection
try:
r.ping()
print("Redis connection successful.")
except redis.ConnectionError as e:
print(f"Connection failed: {e}")
Connection Pool #
# Connection Pool -- one pool for the whole application
pool = redis.ConnectionPool(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD"),
db=0,
decode_responses=True,
max_connections=20 # connection limit in the pool
)
# Create a client that uses the pool
r = redis.Redis(connection_pool=pool)
# All operations use connections from the same pool
# Connections are automatically returned to the pool after an operation finishes
decode_responses=Trueis an important parameter that’s often forgotten. Without it, all values returned by Redis arebytes(b"nilai"), notstr. Always enable it unless you’re actually working with binary data.
String — The Most Basic Data Type #
A Redis String isn’t just text — it can store integers, floats, or any serialized data up to 512MB per key.
import json
from datetime import timedelta
# Basic Set and Get
r.set("nama", "Budi Santoso")
print(r.get("nama")) # "Budi Santoso"
# Set with TTL (Time To Live) -- the key is automatically deleted after N seconds
r.set("token:abc123", "user-42", ex=3600) # expire in 3600 seconds (1 hour)
r.set("otp:081234", "7823", px=300000) # expire in 300000 milliseconds (5 minutes)
r.setex("session:xyz", timedelta(hours=24), "data") # use a timedelta
# Check the remaining TTL
print(r.ttl("token:abc123")) # remaining seconds, -1 if no TTL, -2 if the key doesn't exist
# Increment / decrement -- atomic
r.set("halaman_dilihat", 0)
r.incr("halaman_dilihat") # +1
r.incrby("halaman_dilihat", 10) # +10
r.decr("halaman_dilihat") # -1
print(r.get("halaman_dilihat")) # "9"
# Store JSON (manual serialization)
pengguna = {"id": 1, "nama": "Budi", "email": "[email protected]"}
r.set("pengguna:1", json.dumps(pengguna), ex=3600)
# Fetch JSON
data_raw = r.get("pengguna:1")
pengguna = json.loads(data_raw) if data_raw else None
# Atomic operation: SET only if the key doesn't exist (NX = Not eXists)
berhasil = r.set("kunci_unik", "nilai", nx=True, ex=60)
print("Set successful:", berhasil) # True if newly set, None if it already exists
Caching Patterns #
Cache-Aside (Lazy Loading) #
The most common pattern — read from cache first; on a miss, read from the database and store into the cache. The decision flow of this Cache-Aside pattern can be visualized in the following diagram:
flowchart TD
Start["Request Data (Client)"] --> CheckCache{"Check Cache (Redis)"}
CheckCache -->|"Cache HIT (Exists)"| ReturnData["Return Data to Client"]
CheckCache -->|"Cache MISS (Absent)"| QueryDB["Query Database"]
QueryDB --> SaveCache["Save Data to Cache (Redis) + TTL"]
SaveCache --> ReturnDataimport json
from typing import Callable, Any
def get_dengan_cache(
r: redis.Redis,
cache_key: str,
fetch_fn: Callable, # function to fetch data from the source (DB, API, etc.)
ttl_detik: int = 3600
) -> Any:
"""
Cache-Aside pattern:
1. Check the cache
2. If hit → return from the cache
3. If miss → fetch from the source, store in the cache, return
"""
# Check the cache
cached = r.get(cache_key)
if cached is not None:
print(f"Cache HIT: {cache_key}")
return json.loads(cached)
# Cache miss -- fetch from the source
print(f"Cache MISS: {cache_key}")
data = fetch_fn()
if data is not None:
r.set(cache_key, json.dumps(data, ensure_ascii=False), ex=ttl_detik)
return data
# Usage example
def ambil_produk_dari_db(produk_id: int) -> dict:
# Simulate a database query
return {"id": produk_id, "nama": "Laptop Gaming", "harga": 18500000}
produk = get_dengan_cache(
r,
cache_key=f"produk:{101}",
fetch_fn=lambda: ambil_produk_dari_db(101),
ttl_detik=1800
)
print(produk)
Cache Invalidation #
def update_produk(r: redis.Redis, produk_id: int, data_baru: dict) -> None:
"""Update the database and invalidate related caches."""
# 1. Update the database (not shown)
# update_produk_di_db(produk_id, data_baru)
# 2. Delete the stale cache
r.delete(f"produk:{produk_id}")
# Or update the cache directly (write-through)
r.set(
f"produk:{produk_id}",
json.dumps(data_baru, ensure_ascii=False),
ex=1800
)
def invalidasi_cache_pattern(r: redis.Redis, pattern: str) -> int:
"""
Delete all keys matching a pattern.
Use carefully in production -- SCAN can be slow on large data.
"""
keys = r.keys(pattern) # e.g., "produk:*", "session:user-42:*"
if keys:
return r.delete(*keys)
return 0
# Delete all product caches
jumlah_dihapus = invalidasi_cache_pattern(r, "produk:*")
print(f"{jumlah_dihapus} cache keys deleted.")
Hash — A Dictionary-Like Structure #
Hashes are good for storing objects with many fields — more efficient than storing a JSON string if you often access only specific fields.
# Set a hash
r.hset("pengguna:42", mapping={
"nama": "Budi Santoso",
"email": "[email protected]",
"usia": "28",
"aktif": "1"
})
# Get one field
nama = r.hget("pengguna:42", "nama") # "Budi Santoso"
# Get all fields
semua = r.hgetall("pengguna:42") # dict of all fields
print(semua) # {'nama': 'Budi Santoso', 'email': '...', 'usia': '28', 'aktif': '1'}
# Get specific fields
nama, email = r.hmget("pengguna:42", ["nama", "email"])
# Update one field only (without changing the others)
r.hset("pengguna:42", "usia", "29")
# Delete one field
r.hdel("pengguna:42", "aktif")
# Set a TTL for the whole hash key
r.expire("pengguna:42", 3600)
# Increment a numeric field
r.hincrby("pengguna:42", "login_count", 1)
List — Queues and Stacks #
A Redis List is a linked list — push/pop at both ends runs in O(1). Good for task queues, activity feeds, or bounded logs.
# Push to the right (FIFO queue)
r.rpush("antrian:email", json.dumps({"to": "[email protected]", "subject": "Welcome"}))
r.rpush("antrian:email", json.dumps({"to": "[email protected]", "subject": "Order"}))
# Pop from the left (get the first in)
item_raw = r.lpop("antrian:email")
item = json.loads(item_raw) if item_raw else None
# Blocking pop -- wait until an item arrives (useful for workers)
item_raw = r.blpop("antrian:email", timeout=30) # wait 30 seconds
# List length
print(r.llen("antrian:email"))
# Get all items without removing
semua = r.lrange("antrian:email", 0, -1) # 0 = first index, -1 = last
# Trim the list to the last N items (sliding window)
r.ltrim("log:aktivitas", 0, 999) # keep only the newest 1000 items
Set — Unique Collections #
Sets are good for storing unique collections: tags, followers, visited items, or group members.
# Add to a set
r.sadd("tag:produk:101", "laptop", "gaming", "asus")
r.sadd("tag:produk:102", "laptop", "bisnis", "lenovo")
# Check membership
print(r.sismember("tag:produk:101", "gaming")) # True
print(r.sismember("tag:produk:101", "bisnis")) # False
# All members
print(r.smembers("tag:produk:101")) # {'laptop', 'gaming', 'asus'}
# Set operations
irisan = r.sinter("tag:produk:101", "tag:produk:102") # {'laptop'}
gabungan = r.sunion("tag:produk:101", "tag:produk:102") # all tags
perbedaan = r.sdiff("tag:produk:101", "tag:produk:102") # {'gaming', 'asus'}
# Remove from a set
r.srem("tag:produk:101", "asus")
Sorted Set — Leaderboards and Rate Limiting #
A Sorted Set is a set with a float score — members are ordered by score. Good for leaderboards, ratings, priority queues, and rate limiting.
# Game score leaderboard
r.zadd("leaderboard:game", {
"player_alice": 9500,
"player_budi": 8750,
"player_sari": 9200,
"player_andi": 7800
})
# Add/update a score
r.zincrby("leaderboard:game", 500, "player_budi") # +500 for budi
# Top 3 players (highest scores)
top3 = r.zrange("leaderboard:game", 0, 2, desc=True, withscores=True)
print("Top 3:")
for rank, (pemain, skor) in enumerate(top3, start=1):
print(f" #{rank} {pemain}: {int(skor)}")
# A player's rank (0-based)
rank = r.zrevrank("leaderboard:game", "player_budi")
print(f"Budi's rank: #{rank + 1}")
# A player's score
skor = r.zscore("leaderboard:game", "player_budi")
print(f"Budi's score: {int(skor)}")
Rate Limiting with a Sorted Set #
def cek_rate_limit(r: redis.Redis, user_id: str, maks_request: int = 10, window_detik: int = 60) -> bool:
"""
Sliding window rate limiter using a Sorted Set.
Return True if the request is allowed, False if it exceeds the limit.
"""
import time
key = f"rate_limit:{user_id}"
sekarang = time.time()
window_start = sekarang - window_detik
pipe = r.pipeline()
pipe.zremrangebyscore(key, 0, window_start) # remove requests outside the window
pipe.zadd(key, {str(sekarang): sekarang}) # add the current request
pipe.zcard(key) # count requests in the window
pipe.expire(key, window_detik) # set a TTL so the key doesn't pile up
results = pipe.execute()
jumlah_request = results[2]
return jumlah_request <= maks_request
# Check the rate limit
for i in range(12):
diizinkan = cek_rate_limit(r, "user-42", maks_request=10, window_detik=60)
print(f"Request {i+1}: {'✓ allowed' if diizinkan else '✗ rejected'}")
Pipelines — Batch Commands #
Pipelines send many commands at once in a single network round-trip, significantly reducing latency.
# ANTI-PATTERN: operations one by one (N network round-trips)
for i in range(100):
r.set(f"kunci:{i}", f"nilai:{i}") # ✗ -- 100 round-trips to Redis
# CORRECT: use a pipeline (1 round-trip for everything)
pipe = r.pipeline(transaction=False) # transaction=False = faster, non-atomic
for i in range(100):
pipe.set(f"kunci:{i}", f"nilai:{i}", ex=3600)
pipe.execute() # ✓ -- one round-trip
# Pipeline with an atomic transaction (MULTI/EXEC)
with r.pipeline(transaction=True) as pipe:
pipe.set("saldo:alice", 1000)
pipe.set("saldo:budi", 500)
pipe.execute() # all or nothing
# Pipeline with WATCH (optimistic locking)
def transfer_saldo(r: redis.Redis, dari: str, ke: str, jumlah: int) -> bool:
kunci_dari = f"saldo:{dari}"
kunci_ke = f"saldo:{ke}"
with r.pipeline() as pipe:
while True:
try:
pipe.watch(kunci_dari, kunci_ke) # watch for changes
saldo_dari = int(pipe.get(kunci_dari) or 0)
if saldo_dari < jumlah:
pipe.unwatch()
return False
pipe.multi() # start the transaction
pipe.decrby(kunci_dari, jumlah)
pipe.incrby(kunci_ke, jumlah)
pipe.execute() # commit -- fails if changed externally
return True
except redis.WatchError:
# Data changed while we were processing -- try again
continue
r.set("saldo:alice", 1000)
r.set("saldo:budi", 500)
berhasil = transfer_saldo(r, "alice", "budi", 300)
print(f"Transfer: {'successful' if berhasil else 'failed'}")
print(f"Alice: {r.get('saldo:alice')}, Budi: {r.get('saldo:budi')}")
Distributed Locks #
A distributed lock ensures only one instance runs a critical operation at a time — important in multi-server environments.
import uuid
import time
def acquire_lock(r: redis.Redis, lock_name: str, ttl_detik: int = 30) -> str | None:
"""
Acquire a distributed lock. Return lock_value on success, None on failure.
Use SET NX EX -- atomic, can't be race-conditioned.
"""
lock_key = f"lock:{lock_name}"
lock_value = str(uuid.uuid4()) # unique value per lock
# SET only if it doesn't exist (NX), with a TTL (EX)
berhasil = r.set(lock_key, lock_value, nx=True, ex=ttl_detik)
return lock_value if berhasil else None
def release_lock(r: redis.Redis, lock_name: str, lock_value: str) -> bool:
"""
Release a lock. Only the acquirer can release it (checks lock_value).
Use a Lua script for atomicity.
"""
lock_key = f"lock:{lock_name}"
# Lua script: check the value then delete atomically
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
result = r.eval(lua_script, 1, lock_key, lock_value)
return bool(result)
# Using the distributed lock
def proses_dengan_lock(r: redis.Redis, operasi_id: str) -> None:
lock_value = acquire_lock(r, f"operasi:{operasi_id}", ttl_detik=30)
if not lock_value:
print(f"Operation {operasi_id} is being processed by another instance, skipping.")
return
try:
print(f"Lock acquired for operation {operasi_id}")
# Process the critical operation here
time.sleep(1)
print(f"Operation {operasi_id} finished.")
finally:
release_lock(r, f"operasi:{operasi_id}", lock_value)
print(f"Lock released for operation {operasi_id}")
proses_dengan_lock(r, "generate-report")
Redis Pub/Sub #
Redis also supports simple Pub/Sub for real-time inter-process messaging.
import threading
def publisher_loop(r: redis.Redis) -> None:
"""Publish messages to a Redis channel."""
import time
for i in range(5):
payload = json.dumps({"event": "update", "seq": i})
r.publish("notifikasi:order", payload)
print(f"Published: {payload}")
time.sleep(1)
def subscriber_loop(r: redis.Redis) -> None:
"""Subscribe to a channel and process messages."""
pubsub = r.pubsub()
pubsub.subscribe("notifikasi:order")
for message in pubsub.listen():
if message["type"] == "message":
payload = json.loads(message["data"])
print(f"Received: {payload}")
# Run the subscriber in a separate thread
sub_thread = threading.Thread(target=subscriber_loop, args=(r,), daemon=True)
sub_thread.start()
# Publisher on the main thread
publisher_loop(r)
Error Handling #
from redis.exceptions import ConnectionError, TimeoutError, ResponseError
def get_cache_aman(r: redis.Redis, key: str) -> str | None:
try:
return r.get(key)
except ConnectionError:
print("Redis unreachable — falling back to the database.")
return None
except TimeoutError:
print("Redis timeout — trying again.")
return None
except ResponseError as e:
print(f"Redis response error: {e}")
return None
# Pattern with a database fallback if Redis is down
def ambil_data(r: redis.Redis, key: str, fetch_fn) -> Any:
try:
cached = r.get(key)
if cached:
return json.loads(cached)
except (ConnectionError, TimeoutError):
pass # Redis unavailable, go straight to the database
data = fetch_fn()
try:
if data:
r.set(key, json.dumps(data), ex=3600)
except (ConnectionError, TimeoutError):
pass # Can't store the cache, but the data is still returned
return data
Summary #
decode_responses=True— always enable it so values are returned asstrinstead ofbytes; without it every value isb"...".- Connection Pool — create one
ConnectionPoolfor the whole application; redis-py manages connections automatically from the pool.- TTL for every cache key — always set
ex(seconds) orpx(milliseconds) when storing caches so memory doesn’t slowly fill up.- Pipelines for bulk operations — use
r.pipeline()to send many commands in one round-trip; significantly reduces latency.- Hash vs JSON string — use Hash when you often access only specific fields; use a JSON string when you always need the whole object.
- Sorted Sets for leaderboards and rate limiting — float scores enable efficient ordering, ranking, and sliding windows.
SET NX EXfor distributed locks — atomic and safe; use a Lua script for release so the check-and-delete is also atomic.BLPOPfor task queues — blocking pop is more efficient than active polling; workers sleep until new work arrives.- Cache-Aside pattern — check cache → hit? return; miss? fetch from the source, store in the cache, return. Always include a TTL.
- Database fallback — catch
ConnectionErrorandTimeoutErrorso the app keeps running even when Redis is down; Redis should be an optimization, not a single point of failure.