Redis Deep Dive: Caching, Pub/Sub, Rate Limiting, and Session Storage on a VPS

Redis Deep Dive: Caching, Pub/Sub, Rate Limiting, and Session Storage on a VPS

Most developers know Redis as a cache. But Redis is more accurately described as an in-memory data structure server — it supports strings, hashes, lists, sets, sorted sets, streams, and more, each with different performance characteristics and use cases. This guide goes beyond basic caching to cover four high-value Redis patterns on a VPS: application caching with proper eviction, Pub/Sub for real-time messaging, API rate limiting, and session storage. Each pattern is production-ready and immediately applicable.

Redis Installation and Production Configuration

Install Redis

sudo apt update
sudo apt install redis-server -y
sudo systemctl enable redis-server

Production Configuration

sudo nano /etc/redis/redis.conf

Key production settings:

# Bind to localhost only (never expose Redis publicly without authentication)
bind 127.0.0.1 ::1

# Require authentication
requirepass YourVeryStrongRedisPassword!

# Memory management
maxmemory 512mb
maxmemory-policy allkeys-lru

# Disable persistence for pure cache use cases
save ""
appendonly no

# Connection settings
tcp-backlog 511
timeout 300
tcp-keepalive 60

# Performance
hz 20
dynamic-hz yes
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes

The lazyfree-lazy-eviction yes and lazyfree-lazy-expire yes settings perform memory reclamation in background threads, preventing the main Redis thread from blocking during large key deletions — critical for consistent low latency.

sudo systemctl restart redis-server

# Test connectivity with authentication
redis-cli -a YourVeryStrongRedisPassword! ping

Pattern 1: Application Caching with Proper Eviction Policies

Understanding Eviction Policies

When Redis reaches maxmemory, it must decide which keys to evict. The policy choice dramatically affects cache behavior:

Policy Behavior Best For
allkeys-lru Evicts least recently used keys across all keys General application cache (most common choice)
volatile-lru Evicts LRU keys that have a TTL set When mixing cached and persistent data in Redis
allkeys-lfu Evicts least frequently used keys Workloads with hot/cold access patterns
volatile-ttl Evicts keys with shortest remaining TTL When TTL accuracy matters more than recency
noeviction Returns errors when memory is full Queue/session storage where data must not be lost

Caching Pattern in Python

import redis
import json
import hashlib
from functools import wraps

r = redis.Redis(host='localhost', port=6379, password='YourPassword', decode_responses=True)

def cache(ttl=300, key_prefix='cache'):
    """Decorator that caches function results in Redis."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Generate cache key from function name and arguments
            cache_key = f"{key_prefix}:{func.__name__}:{hashlib.md5(str(args).encode()).hexdigest()}"

            # Try to get from cache
            cached = r.get(cache_key)
            if cached is not None:
                return json.loads(cached)

            # Cache miss: call the function
            result = func(*args, **kwargs)

            # Store in cache with TTL
            r.setex(cache_key, ttl, json.dumps(result))
            return result
        return wrapper
    return decorator

@cache(ttl=600, key_prefix='products')
def get_product_catalog(category_id):
    # Expensive database query
    return db.query("SELECT * FROM products WHERE category_id = ?", category_id)

# Cache invalidation: delete specific keys or use patterns
def invalidate_product_cache(category_id):
    pattern = f"products:get_product_catalog:*"
    for key in r.scan_iter(match=pattern):
        r.delete(key)

Caching Pattern in Node.js

const Redis = require('ioredis');
const redis = new Redis({ password: 'YourPassword' });

async function withCache(key, ttl, fetchFn) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const data = await fetchFn();
  await redis.setex(key, ttl, JSON.stringify(data));
  return data;
}

// Usage
const user = await withCache(
  `user:${userId}`,
  3600,
  () => db.query('SELECT * FROM users WHERE id = ?', [userId])
);

Pattern 2: Pub/Sub for Real-Time Messaging

Redis Pub/Sub enables real-time messaging between different parts of your application — perfect for chat systems, live notifications, and event broadcasting.

Publisher (sends messages)

import redis
import json

publisher = redis.Redis(host='localhost', port=6379, password='YourPassword')

def notify_order_update(order_id, status, user_id):
    message = json.dumps({
        'order_id': order_id,
        'status': status,
        'user_id': user_id,
        'timestamp': time.time()
    })
    # Publish to a channel - all subscribers receive this instantly
    subscriber_count = publisher.publish(f'order_updates:{user_id}', message)
    return subscriber_count

Subscriber (receives messages)

import redis
import json

subscriber_client = redis.Redis(host='localhost', port=6379, password='YourPassword')
pubsub = subscriber_client.pubsub()

# Subscribe to a channel
pubsub.subscribe('order_updates:*')  # Pattern subscription

# Listen for messages
for message in pubsub.listen():
    if message['type'] == 'message':
        data = json.loads(message['data'])
        print(f"Order {data['order_id']} updated to {data['status']}")
        # Push notification, update WebSocket clients, etc.

Pub/Sub with Redis Streams (More Durable)

For event streaming that requires message persistence and consumer groups (multiple workers processing the same stream), Redis Streams are more appropriate than basic Pub/Sub:

import redis

r = redis.Redis(host='localhost', password='YourPassword')

# Producer: append events to stream
r.xadd('orders', {
    'order_id': '12345',
    'action': 'created',
    'user_id': '789',
    'amount': '99.99'
})

# Consumer group (allows multiple workers to share stream processing)
r.xgroup_create('orders', 'order-processors', id='0', mkstream=True)

# Worker: read from consumer group
messages = r.xreadgroup(
    'order-processors', 'worker-1',
    {'orders': '>'},  # '>' means undelivered messages
    count=10,
    block=2000  # Block for 2 seconds if no messages
)

for stream, msgs in (messages or []):
    for msg_id, fields in msgs:
        process_order(fields)
        r.xack('orders', 'order-processors', msg_id)  # Acknowledge processing

Pattern 3: API Rate Limiting

Rate limiting protects your API from abuse and ensures fair usage. Redis’s atomic operations make it ideal for distributed rate limiting across multiple application servers.

Sliding Window Rate Limiter

import redis
import time

r = redis.Redis(host='localhost', password='YourPassword')

def is_rate_limited(identifier, limit=100, window=60):
    """
    Sliding window rate limiter.
    identifier: unique key (user ID, IP address, API key)
    limit: max requests allowed in window
    window: time window in seconds
    Returns True if rate limited, False if allowed
    """
    key = f"rate_limit:{identifier}"
    now = time.time()
    window_start = now - window

    pipe = r.pipeline()
    # Remove old entries outside the current window
    pipe.zremrangebyscore(key, 0, window_start)
    # Add current request timestamp
    pipe.zadd(key, {str(now): now})
    # Count requests in current window
    pipe.zcard(key)
    # Set key expiry (cleanup)
    pipe.expire(key, window)
    results = pipe.execute()

    request_count = results[2]
    return request_count > limit

# Flask middleware example
from flask import request, jsonify
from functools import wraps

def rate_limit(limit=100, window=60, key_func=None):
    def decorator(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            identifier = key_func() if key_func else request.remote_addr
            if is_rate_limited(identifier, limit, window):
                return jsonify({'error': 'Rate limit exceeded. Try again later.'}), 429
            return f(*args, **kwargs)
        return wrapped
    return decorator

@app.route('/api/data')
@rate_limit(limit=60, window=60)  # 60 requests per minute per IP
def get_data():
    return jsonify({'data': 'response'})

Token Bucket Rate Limiter (Allows Bursts)

import redis
import time

r = redis.Redis(host='localhost', password='YourPassword')

def token_bucket_allowed(key, rate=10, capacity=50):
    """
    Token bucket: allows bursts up to capacity,
    refills at rate tokens per second.
    """
    now = time.time()
    bucket_key = f"token_bucket:{key}"

    pipe = r.pipeline(True)  # Optimistic locking
    try:
        pipe.watch(bucket_key)
        bucket = pipe.hgetall(bucket_key)

        tokens = float(bucket.get(b'tokens', capacity))
        last_update = float(bucket.get(b'last_update', now))

        # Refill tokens based on elapsed time
        elapsed = now - last_update
        tokens = min(capacity, tokens + elapsed * rate)

        if tokens >= 1:
            tokens -= 1
            pipe.multi()
            pipe.hset(bucket_key, mapping={
                'tokens': tokens,
                'last_update': now
            })
            pipe.expire(bucket_key, 3600)
            pipe.execute()
            return True
        return False
    except redis.WatchError:
        return token_bucket_allowed(key, rate, capacity)  # Retry

Pattern 4: Session Storage

Storing sessions in Redis rather than files or the database provides several advantages: sessions persist across server restarts, work seamlessly with multiple application servers, and can be expired automatically.

PHP Session Storage in Redis

sudo nano /etc/php/8.2/fpm/pool.d/www.conf

Add PHP session configuration:

php_value[session.save_handler] = redis
php_value[session.save_path] = "tcp://127.0.0.1:6379?auth=YourPassword&database=1"
php_value[session.gc_maxlifetime] = 3600

Express.js Session Storage

const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const { createClient } = require('redis');

const redisClient = createClient({
  socket: { host: 'localhost', port: 6379 },
  password: 'YourPassword',
  database: 1  // Use a different database number than your cache
});

redisClient.connect().catch(console.error);

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,   // HTTPS only
    httpOnly: true,
    maxAge: 3600000 // 1 hour
  }
}));

Monitoring Redis in Production

Key Metrics to Watch

redis-cli -a YourPassword INFO all | grep -E \
  "used_memory_human|maxmemory_human|hit_rate|connected_clients|blocked_clients|evicted_keys|keyspace_hits|keyspace_misses"

Calculate Cache Hit Rate

redis-cli -a YourPassword INFO stats | grep -E "keyspace_hits|keyspace_misses"
# hit_rate = hits / (hits + misses) * 100
# Aim for >90% hit rate; below 80% suggests cache configuration issues

Monitor Memory Usage

redis-cli -a YourPassword MEMORY DOCTOR
redis-cli -a YourPassword MEMORY USAGE keyname  # Memory cost of specific key

Real-Time Monitoring

redis-cli -a YourPassword MONITOR  # Stream all commands (development only — impacts performance)
redis-cli -a YourPassword --stat   # Live stats every second

Redis Data Separation Strategy

Use Redis database numbers to separate different types of data:

  • DB 0: Application cache (evictable, use allkeys-lru)
  • DB 1: Session storage (use noeviction, configure separately if possible)
  • DB 2: Queue jobs (never evict — use dedicated Redis instance for production queues)
  • DB 3: Rate limiting counters (short-lived, volatile-ttl)

For production systems where queue reliability is critical, run a second Redis instance with persistence enabled (appendonly yes) specifically for queues, separate from the evictable cache instance.

Getting Started

Redis runs efficiently on modest hardware — a 1 GB RAM VPS is sufficient for most caching and session workloads. For large caches or high-throughput Pub/Sub, 2–4 GB RAM provides comfortable headroom. KVM VPS plans at VPS.DO provide the low-latency local network access that makes Redis’s microsecond-level response times practical, with NVMe storage for fast Redis persistence operations when appendonly yes is required.

Conclusion

Redis is a Swiss Army knife for application infrastructure. Beyond simple key-value caching, its atomic operations, Pub/Sub messaging, sorted sets, and streams solve a wide range of real-time and performance problems elegantly. The four patterns in this guide — application caching with proper eviction, Pub/Sub for event broadcasting, sliding window rate limiting, and session storage — address the most common Redis use cases and provide a foundation for more advanced patterns like distributed locks, leaderboards, and real-time analytics.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!