Implementation of a first example of a Resilient Cache-Aside Layer with Redis and PostgreSQL.

Redis

Redis (Remote Dictionary Server) is a high-performance, in-memory data structure store that functions as a database, cache, and message broker. Unlike traditional relational databases (RDBMS) like PostgreSQL that prioritize disk storage, Redis maintains its entire dataset in RAM. This ensures near-instantaneous read/write speeds, with latencies measured in sub-milliseconds.

Key Characteristics

  • Extreme Speed: Capable of processing hundreds of thousands of operations per second.
  • Flexible Data Structures: Supports Lists, Sets, Hashes, Bitmaps, and Streams.
  • Optional Persistence: Can be configured to save snapshots to disk periodically.
  • Simplicity: Follows an intuitive Key-Value model.

Common Use Cases

  • Caching System: Reduces load on the primary DB by storing frequent query results.
  • Session Management: Ideal for web applications needing fast access to login states or shopping carts.
  • Leaderboards: Uses Sorted Sets to rank scores in real-time.
  • Message Queues: Facilitates microservice communication via Pub/Sub or Streams.

Infrastructure Strategy: VM vs. Kubernetes

Choosing between Virtual Machines (VM) and Kubernetes (K8s) depends on performance vs. convenience.

CriterionVirtual Machine (VM) Kubernetes (K8s)
PerformanceMaximum (Low Latency). Avoids SDN overhead.Good, but dependent on the network layer.
Memory ControlFull kernel control (Overcommit, THP).+1Shared resources via Cgroups.
PersistenceNative and simple.+1Complex (StatefulSets).
Ideal ForLatency-critical production.Microservices and rapid CI/CD.

Exportar para Sheets

Implementation Choice: We utilized a dedicated VM (srv-infrastructure-redis-master-01) via Vagrant to ensure maximum hardware performance and direct kernel access.


Provisioning and Kernel Optimization

Infrastructure starts at the OS level. Using Vagrant, we provisioned a node named srv-infrastructure-redis-master-01.

And a DNS: redis.devops-db.internal to IP 172.21.5.165

nslookup redis.devops-db.internal
Server: 100.64.0.1
Address: 100.64.0.1#53
Name: redis.devops-db.internal
Address: 172.21.5.165

The “Why” Behind Kernel Tuning

One of the most critical steps in a Redis deployment is the Overcommit Memory setting.

# vi /etc/sysctl.conf 

vm.overcommit_memory = 1


sudo sysctl -p

Technical Justification: Redis performs background saves (BGSAVE/AOF rewrite) by forking the process. Without overcommit_memory = 1, the Linux kernel might deny the fork request if it detects that the memory usage could potentially exceed the physical RAM, even if the actual copy-on-write memory usage is small. This ensures stability during data persistence tasks.

Installation

Simple as that:

sudo apt update
sudo apt install redis-server -y

Start the service

sudo systemctl restart redis-server
sudo systemctl enable redis-server
sudo systemctl status redis-server

Check:

redis-cli -h redis.devops-db.internal -a zaMKYDo8AJ4XnAS2SwqJLTE8GsqrBEA939dQUlI5UK1ikJyA325U9zRtB ping
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
PONG

With this, you can then use redis-cli normally.

export REDISCLI_AUTH="zaMKYDo8AJ4XnAS2SwqJLTE8GsqrBEA939dQUlI5UK1ikJyA325U9zRtB"

redis-cli -h redis.devops-db.internal

Advanced Redis Configuration

Our instance is mapped to redis.devops-db.internal. We configured the server with a focus on memory boundaries and persistence durability.

Memory Management and Policy

# /etc/redis/redis.conf
bind 127.0.0.1 172.21.5.165
maxmemory 3gb
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
# Strong temporary password (we can replace it with ACLs later)
requirepass zaMKYDo8AJ4XnAS2SwqJLTE8GsqrBEA939dQUlI5UK1ikJyA325U9zRtB

The “Magic” of Persistence: RDB, AOF, and Hybrid

Redis offers two primary durability methods:

  1. RDB (Redis Database): Takes “snapshots” at specific intervals (e.g., every 5 minutes). Excellent for backups.+1
  2. AOF (Append Only File): Logs every write operation. Safer but results in larger files.

How Hybrid Persistence Works

The “magic” occurs during the AOF Rewrite process. With aof-use-rdb-preamble yes:+1

  • Binary Snapshot: Redis takes an instantaneous binary snapshot (RDB format) of the entire memory.
  • Injection: This binary snapshot is written at the start of the new AOF file.
  • Log Append: Subsequent commands are appended in standard text log format at the end of the file.
  • The Result: Upon restart, Redis loads the binary preamble at staggering speeds and then replays the few remaining text commands. You get millisecond boot times and zero data loss.+1

Caching Patterns and Strategies

Side-Cache (Cache-Aside)

The application manages the communication between Redis and the DB.

  • Workflow: The app checks Redis. On a Cache Miss, it fetches from PostgreSQL, responds to the user, and then saves it in Redis for future use.
  • Resilience: High; if Redis fails, the system continues via the DB (at lower speeds).

Inline-Cache (Read-Through / Write-Through)

The application treats Redis as the primary data store, unaware of the DB backend.

  • Workflow: The app asks Redis. If missing, a middleware/plugin fetches from the DB and returns it to the app.
  • Pros/Cons: Cleaner code but higher infra complexity and total dependency on the cache layer.+2

Comparison Table

FeatureSide-Cache (Cache-Aside)Inline-Cache (Read/Write Through)
ComplexityHigh (App manages logic).Low (App only asks Redis).
InfrastructureLow.High.
ResilienceSystem works if Redis fails.System fails if the cache fails.

Exportar para Sheets


Data Modeling: SQL vs. Redis

We simulate tables using Namespacing (prefixes separated by :).

Translation Table

SQL OperationRedis Command (Hash)Description
INSERT INTO usersHSET user:1 field valCreates the object.
SELECT * WHERE id=1HGETALL user:1Returns the full object.
SELECT emailHGET user:1 emailExtracts one field.
UPDATE SET dept='X'HSET user:1 dept 'X'Partial update.
DELETE WHERE id=1DEL user:1Removes object.
SELECT count(*)DBSIZECounts all keys in the DB.

Exportar para Sheets


Professional Security: The ACL Revolution

Security was transitioned from a global requirepass to a granular ACL system.

Admin and User Roles

We defined an administrator, faustobranco, with total control, and a restricted devops_api_user for the application.

https://redis.io/docs/latest/commands/acl-cat

User Implementation

The Admin (faustobranco): Full privileges over all keys and commands.

ACL SETUSER faustobranco on >1234qwer ~* +@all

API User (api_user): Restricted to user:profile:* keys; cannot flush the DB.

ACL SETUSER api_user on >password_forte_123 ~user:profile:* +@hash +@read +@write -flushall -flushdb+1

LDAP Sync (ldap_sync): Manage ldap:* keys without administrative access.

ACL SETUSER ldap_sync on >password_ldap_456 ~ldap:* +@all -@admin

Check:

ACL USERS
1) "default"
2) "devops_api_user"
3) "faustobranco"

ACL GETUSER faustobranco
1) "flags"
2) 1) "on"
   2) "allkeys"
   3) "allcommands"
3) "passwords"
4) 1) "ef51306214d9a6361ee1d5b452e6d2bb70dc7ebb85bf9e02c3d4747fb57d6bec"
5) "commands"
6) "+@all"
7) "keys"
8) 1) "*"

With a username/password, calling redis-cli is slightly different.

redis-cli -h redis.devops-db.internal --user faustobranco -a 1234qwer

ACL LISTDisplays all users and their rules in text format.
ACL USERSLists only the names of created users.
ACL WHOAMIIt tells you which user you are currently logged in as.
ACL GETUSER <nome>Shows specific details about a user (very useful!).
ACL DELUSER <nome>Removes the user from the system.

Expert-Level redis.conf Tuning

To achieve production stability on a 4GB VM, we implemented specific resource boundaries.

Multi-Threaded I/O: Offloads network socket operations.

io-threads 3
io-threads-do-reads yes

Active Defragmentation: Real-time memory compaction to eliminate fragmentation.

activedefrag yes

Slow Client Protection: Prevents RAM exhaustion from slow consumers.

client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60

Aggressiveness settings (recommended default values)

active-defrag-ignore-bytes 100mb
active-defrag-threshold-initial 10
active-defrag-threshold-upper 100

Records events that cause latency greater than 100 milliseconds

latency-monitor-threshold 100

Avoid performing fsync while a BGSAVE or BGREWRITEAOF is running. This reduces latency (ms spikes) in exchange for a minimal risk of data loss.
no-appendfsync-on-rewrite yes


Memory Auditing and Monitoring

Distinguishing between used_memory and used_memory_rss is vital for capacity planning.

Memory Footprint (Lua Script)

To calculate the total memory used by a specific key pattern:

EVAL "local keys = redis.call('keys', ARGV[1]) local total = 0 for i, k in ipairs(keys) do total = total + redis.call('memory', 'usage', k) end return total" 0 "user:profile:*"

Integration Code

The user_manager.py logic implements the core strategy: Check the cache; if not found, query the DB and update the cache.

Why Hashes instead of Strings?

While SET/GET (Strings) are easy, Hashes (HSET/HGETALL) are significantly more memory-efficient in Redis. They allow us to store complex objects as a single key while maintaining the ability to retrieve specific fields without deserializing a whole JSON blob.

Data Seeding (faker2.py)

This script populates PostgreSQL with 300-500 realistic profiles.

import random
import unicodedata
import psycopg2
from psycopg2.extras import execute_batch
from faker import Faker

# Existing connection constants
PG_HOST = 'postgresql.devops-db.internal'
PG_USER = 'devops_api'
PG_PASSWORD = '1234qwer'
PG_DATABASE = 'devops_api'

def remove_accents(input_text: str) -> str:
    """
    Removes diacritical marks (accents) from a given string.
    """
    normalized_text = unicodedata.normalize('NFD', input_text)
    
    filtered_text = ''.join(
        character for character in normalized_text
        if unicodedata.category(character) != 'Mn'
    )
    
    return filtered_text

def recreate_table_and_populate():
    """
    Recreates the users table with the new schema and inserts 
    between 300 and 500 realistic records.
    """
    # Initialize Faker with Portuguese locale for realistic local names
    data_generator = Faker('pt_PT')
    valid_departments = ['Engineering', 'Human Resources', 'DevOps', 'Security', 'Sales', 'Marketing']
    
    # Define a random volume of rows between 300 and 500
    total_records = random.randint(300, 500)
    
    try:
        db_connection = psycopg2.connect(
            host=PG_HOST,
            user=PG_USER,
            password=PG_PASSWORD,
            dbname=PG_DATABASE
        )
        db_cursor = db_connection.cursor()
        
        # Step 1: Update the schema to include gender and birth_date
        print("INFO: Recreating 'users' table with the new schema...")
        db_cursor.execute("""
            DROP TABLE IF EXISTS devops_api.users;
            CREATE TABLE devops_api.users (
                id SERIAL PRIMARY KEY,
                full_name VARCHAR(100) NOT NULL,
                gender CHAR(1) NOT NULL,
                birth_date DATE NOT NULL,
                department VARCHAR(100) NOT NULL,
                email VARCHAR(100) NOT NULL,
                username VARCHAR(100) NOT NULL
            );
        """)
        
        # Step 2: Generate realistic, correlated data
        print(f"INFO: Generating {total_records} realistic records...")
        records_to_insert = []
        
        for _ in range(total_records):
            # Determine gender first to match the generated name appropriately
            random_gender = random.choice(['M', 'F'])
            
            if random_gender == 'M':
                fake_name = data_generator.name_male()
            else:
                fake_name = data_generator.name_female()

            user_name = remove_accents(fake_name).split()[0].lower() + "." + remove_accents(fake_name).split()[-1].lower()
            fake_email = f"{user_name}@fake-devops-db.internal"    
                
            # Generate a birth date for working-age adults
            fake_birth_date = data_generator.date_of_birth(minimum_age=18, maximum_age=65)
            fake_department = random.choice(valid_departments)
            
            records_to_insert.append((fake_name, random_gender, fake_birth_date, fake_department, fake_email, user_name))
            
        # Step 3: Perform the bulk insert
        sql_insert_query = """
            INSERT INTO devops_api.users (full_name, gender, birth_date, department, email, username) 
            VALUES (%s, %s, %s, %s, %s, %s);
        """
        
        print("INFO: Executing bulk insert into PostgreSQL...")
        execute_batch(db_cursor, sql_insert_query, records_to_insert)
        
        db_connection.commit()
        print(f"INFO: Database seeded successfully with {total_records} rows.")
        
        db_cursor.close()
        db_connection.close()
        
    except psycopg2.Error as err:
        print(f"ERROR: Database operation failed. Details: {err}")

if __name__ == "__main__":
    recreate_table_and_populate()

Cache Orchestrator (user_manager.py)

Implements the Cache-Aside strategy with ACL-based authentication.

import os
import psycopg2
import redis
from datetime import date

# Infrastructure Configuration
PG_CONFIG = {
    'host': 'postgresql.devops-db.internal',
    'user': 'devops_api',
    'password': '1234qwer',
    'dbname': 'devops_api'
}

REDIS_CONFIG = {
    'host': '172.21.5.165',
    'port': 6379,
    'password': 'zaMKYDo8AJ4XnAS2SwqJLTE8GsqrBEA939dQUlI5UK1ikJyA325U9zRtB',
    'decode_responses': True
}

def get_redis_client():
    """Returns a functional Redis client."""
    return redis.StrictRedis(**REDIS_CONFIG)

def fetch_user_from_db(user_id):
    """
    Retrieves the complete user profile from PostgreSQL.
    Maps the new schema: full_name, gender, birth_date, department, email, username.
    """
    try:
        conn = psycopg2.connect(**PG_CONFIG)
        cursor = conn.cursor()
        
        # Explicitly selecting columns based on the new schema
        query = """
            SELECT full_name, gender, birth_date, department, email, username 
            FROM devops_api.users 
            WHERE id = %s;
        """
        cursor.execute(query, (user_id,))
        row = cursor.fetchone()
        
        cursor.close()
        conn.close()
        
        if row:
            # Map birth_date to string for Redis compatibility
            return {
                "full_name": row[0],
                "gender": row[1],
                "birth_date": str(row[2]), # Convert date object to string
                "department": row[3],
                "email": row[4],
                "username": row[5]
            }
        return None
    except Exception as error:
        print(f"DATABASE_ERROR: {error}")
        return None

def sync_user_to_cache(redis_client, user_id, user_data):
    """
    Saves the user profile into a Redis Hash and sets a TTL.
    """
    cache_key = f"user:profile:{user_id}"
    
    # Store all fields at once using the mapping argument
    redis_client.hset(cache_key, mapping=user_data)
    
    # Set expiration for 2 hours (7200 seconds)
    redis_client.expire(cache_key, 7200)
    print(f"CACHE_SYNC: User {user_id} cached successfully.")

def get_user_data(user_id):
    """
    Main entry point implementing the Cache-Aside pattern.
    """
    r_client = get_redis_client()
    cache_key = f"user:profile:{user_id}"
    
    # Check Redis first
    cached_profile = r_client.hgetall(cache_key)
    
    if cached_profile:
        print(f"CACHE_HIT: Serving user {user_id} from Redis.")
        return cached_profile
    
    # Fallback to Postgres
    print(f"CACHE_MISS: Fetching user {user_id} from PostgreSQL.")
    db_profile = fetch_user_from_db(user_id)
    
    if db_profile:
        sync_user_to_cache(r_client, user_id, db_profile)
        return db_profile
    
    return {"error": "User not found"}

if __name__ == "__main__":
    # Test with one of the IDs generated by your faker script
    test_user_id = 1 
    profile = get_user_data(test_user_id)
    print(f"FINAL_DATA: {profile}")

SCAN 0 should not be performed; it’s like a Select * in RDBMS, but to illustrate:

SCAN 0
1) "0"
2) 1) "user:profile:1"
   2) "totp:secret:user_ldap_100"
   3) "totp:secret:user_ldap_99"

After running the Python script to generate the data and insert it into Redis, we can verify the inserted data and the TTL (Time To Live) behavior using iredis, a command-line client for Redis.

redis.devops-db.internal:6379> HGETALL user:profile:1
 1) "full_name"
 2) "\xc3\x89rica de Teixeira"
 3) "gender"
 4) "F"
 5) "birth_date"
 6) "1987-09-21"
 7) "department"
 8) "DevOps"
 9) "email"
10) "erica.teixeira@fake-devops-db.internal"
11) "username"
12) "erica.teixeira"

redis.devops-db.internal:6379> TTL "user:profile:1"
(integer) 7182

After 2 hours:

redis.devops-db.internal:6379> TTL "user:profile:1"
(integer) -2
redis.devops-db.internal:6379> HGETALL user:profile:1
(empty array)