sábado, 17 de enero de 2026

Principios Fundamentales de Diseño en Python - Parte 8 - Performance Patterns

Performance Patterns en Python
Python // Chapter 08

Performance Patterns

Patrones para optimizar velocidad, memoria y escalabilidad

Los Performance Patterns abordan cuellos de botella comunes y desafíos de optimización, proporcionando metodologías probadas para mejorar el tiempo de ejecución, reducir el uso de memoria y escalar efectivamente.

En este capítulo exploraremos tres patrones fundamentales: Cache-Aside para gestionar datos frecuentemente accedidos, Memoization para evitar cálculos redundantes, y Lazy Loading para diferir la inicialización de recursos costosos.

  • Python 3.12+
  • Faker: pip install faker
  • Redis: pip install redis
  • Redis Server: docker run --name myredis -p 6379:6379 redis
PATTERN_01

Cache-Aside Pattern

En situaciones donde los datos se leen con más frecuencia de lo que se actualizan, las aplicaciones usan una caché para optimizar el acceso repetido a información almacenada en una base de datos. El patrón Cache-Aside almacena datos frecuentemente accedidos en una caché, reduciendo la necesidad de consultar repetidamente la base de datos.

Ejemplos del Mundo Real

MEMCACHED
Almacén key-value en memoria para resultados de DB y llamadas API
REDIS
Servidor de caché para almacenamiento en memoria de alto rendimiento
ELASTICACHE
Servicio AWS para entornos de caché distribuida en la nube

Casos de Uso

01
Reducir Carga de BD
Menos consultas enviadas a la base de datos al cachear datos frecuentes
02
Mejorar Respuesta
Datos cacheados se recuperan más rápido que consultando la BD

Flujo del Patrón

Cache-Aside Flow
Application
—— check ——→
Cache
—— miss ——→
Database
Fetch: Retornar de caché si existe, si no, leer de BD, guardar en caché y retornar. Update: Escribir en BD y eliminar entrada de caché.

Implementación: Script de Población

populate_db.py
import sqlite3
from pathlib import Path
from random import randint
import redis
from faker import Faker

fake = Faker()
DB_PATH = Path(__file__).parent / Path("quotes.sqlite3")
cache = redis.StrictRedis(
    host="localhost", 
    port=6379, 
    decode_responses=True
)

def setup_db():
    try:
        with sqlite3.connect(DB_PATH) as db:
            cursor = db.cursor()
            cursor.execute("""
                CREATE TABLE quotes(id INTEGER PRIMARY KEY, text TEXT)
            """)
            db.commit()
            print("Table 'quotes' created")
    except Exception as e:
        print(e)

def add_quotes(quotes_list):
    added = []
    try:
        with sqlite3.connect(DB_PATH) as db:
            cursor = db.cursor()
            for quote_text in quotes_list:
                quote_id = randint(1, 100)
                quote = (quote_id, quote_text)
                cursor.execute(
                    """INSERT OR IGNORE INTO quotes(id, text) 
                    VALUES(?, ?)""", quote
                )
                added.append(quote)
            db.commit()
    except Exception as e:
        print(e)
    return added

Implementación: Operaciones Cache-Aside

cache_aside.py
import sqlite3
from pathlib import Path
import redis

CACHE_KEY_PREFIX = "quote"
DB_PATH = Path(__file__).parent / Path("quotes.sqlite3")
cache = redis.StrictRedis(
    host="localhost", 
    port=6379, 
    decode_responses=True
)

def get_quote(quote_id: str) -> str:
    out = []
    # Primero: Buscar en caché
    quote = cache.get(f"{CACHE_KEY_PREFIX}.{quote_id}")
    
    if quote is None:
        # Cache miss: Obtener de la base de datos
        query_fmt = "SELECT text FROM quotes WHERE id = {}"
        try:
            with sqlite3.connect(DB_PATH) as db:
                cursor = db.cursor()
                res = cursor.execute(
                    query_fmt.format(quote_id)
                ).fetchone()
                if not res:
                    return "No quote found with that id!"
                quote = res[0]
                out.append(f"Got '{quote}' FROM DB")
        except Exception as e:
            print(e)
            quote = ""
        
        # Agregar a la caché con TTL de 60 segundos
        if quote:
            key = f"{CACHE_KEY_PREFIX}.{quote_id}"
            cache.set(key, quote, ex=60)
            out.append(f"Added TO CACHE, key '{key}'")
    else:
        # Cache hit
        out.append(f"Got '{quote}' FROM CACHE")
    
    return " - ".join(out) if out else ""
python cache_aside.py
Enter the ID of the quote: 23
Got 'Dark team exactly really wind.' FROM DB - Added TO CACHE, with key 'quote.23'

Enter the ID of the quote: 23
Got 'Dark team exactly really wind.' FROM CACHE
La primera consulta obtiene datos de la BD y los cachea. Las consultas subsecuentes con el mismo ID retornan directamente desde Redis, eliminando la necesidad de consultar la base de datos.
PATTERN_02

Memoization Pattern

El patrón Memoization es una técnica de optimización crucial que mejora la eficiencia de programas al cachear los resultados de llamadas a funciones costosas. Si una función se llama con los mismos argumentos más de una vez, el resultado cacheado se retorna, eliminando la necesidad de cálculos repetitivos y costosos.

Ejemplos del Mundo Real

FIBONACCI
Almacenar valores previamente computados acelera drásticamente el cálculo
TEXT SEARCH
Cachear resultados de búsquedas previas para respuestas instantáneas
API CALLS
Evitar llamadas redundantes a servicios externos costosos

Casos de Uso

01
Acelerar Algoritmos Recursivos
Transforma algoritmos de alta complejidad temporal, especialmente útil para Fibonacci
02
Reducir Overhead Computacional
Conserva recursos de CPU evitando recálculos innecesarios
03
Mejorar Rendimiento
Aplicaciones más responsivas y eficientes desde la perspectiva del usuario

Comparación de Rendimiento

Fibonacci(30) Execution Time
7:38:53
SIN CACHE
VS
0:00:02
CON LRU_CACHE

Implementación con lru_cache

memoization.py
from datetime import timedelta
from functools import lru_cache
import time

# Versión SIN memoization - MUY LENTA
def fibonacci_func1(n):
    if n < 2:
        return n
    return fibonacci_func1(n - 1) + fibonacci_func1(n - 2)

# Versión CON memoization - ULTRARRÁPIDA
@lru_cache(maxsize=None)
def fibonacci_func2(n):
    if n < 2:
        return n
    return fibonacci_func2(n - 1) + fibonacci_func2(n - 2)

def main():
    n = 30
    
    # Test sin caching
    start_time = time.time()
    result = fibonacci_func1(n)
    duration = timedelta(time.time() - start_time)
    print(f"Fibonacci_func1({n}) = {result}, time: {duration}")
    
    # Test con caching
    start_time = time.time()
    result = fibonacci_func2(n)
    duration = timedelta(time.time() - start_time)
    print(f"Fibonacci_func2({n}) = {result}, time: {duration}")

if __name__ == "__main__":
    main()
python memoization.py
Fibonacci_func1(30) = 832040, time: 7:38:53.090973
Fibonacci_func2(30) = 832040, time: 0:00:02.760315
Cómo funciona lru_cache
func(args)
——→
¿En caché?
——→
Sí: Retornar
No: Calcular + Cachear
El decorador @lru_cache(maxsize=None) almacena los resultados de las llamadas en memoria. Llamadas repetidas con los mismos argumentos obtienen el resultado directamente del caché sin ejecutar el código de la función.
PATTERN_03

Lazy Loading Pattern

El patrón Lazy Loading es un enfoque de diseño crítico que difiere la inicialización o carga de recursos hasta el momento en que realmente se necesitan. De esta manera, las aplicaciones logran una utilización de recursos más eficiente, reducen tiempos de carga inicial y mejoran la experiencia del usuario.

Ejemplos del Mundo Real

GALERÍA DE IMÁGENES
Solo carga imágenes visibles en viewport, más al hacer scroll
VIDEO STREAMING
Netflix/YouTube cargan videos en chunks para minimizar buffering
SPREADSHEETS
Excel/Sheets cargan solo datos relevantes a la vista actual

Casos de Uso

01
Reducir Tiempo de Carga
Mejora engagement y retención de usuarios en desarrollo web
02
Conservar Recursos
Experiencia uniforme desde desktops hasta smartphones
03
Mejorar UX
Minimiza tiempos de espera, aplicaciones más responsivas

Implementación 1: Lazy Attribute Loading

lazy_attribute_loading.py
class LazyLoadedData:
    def __init__(self):
        self._data = None  # Dato costoso NO cargado aún
    
    @property
    def data(self):
        # Solo cargar cuando se accede por primera vez
        if self._data is None:
            self._data = self.load_data()
        return self._data
    
    def load_data(self):
        # Simula operación costosa
        print("Loading expensive data...")
        return sum(i * i for i in range(100000))

def main():
    obj = LazyLoadedData()
    print("Object created, expensive attribute not loaded yet.")
    
    print("Accessing expensive attribute:")
    print(obj.data)  # Aquí se carga
    
    print("Accessing again, no reloading occurs:")
    print(obj.data)  # Retorna valor cacheado

if __name__ == "__main__":
    main()
python lazy_attribute_loading.py
Object created, expensive attribute not loaded yet.
Accessing expensive attribute:
Loading expensive data...
333328333350000
Accessing again, no reloading occurs:
333328333350000
Lazy Loading Flow
Object Created
_data = None
First Access → Load Data
Subsequent Access → Return Cached

Implementación 2: Lazy Loading con Caching

lazy_loading_with_caching.py
import time
from datetime import timedelta
from functools import lru_cache

def recursive_factorial(n):
    """Calcular factorial (costoso para n grande)"""
    if n == 1:
        return 1
    else:
        return n * recursive_factorial(n - 1)

@lru_cache(maxsize=128)
def cached_factorial(n):
    """Factorial con lazy loading via caching"""
    return recursive_factorial(n)

def main():
    n = 20
    
    # Sin caching
    start_time = time.time()
    print(f"Recursive factorial of {n}: {recursive_factorial(n)}")
    duration = timedelta(time.time() - start_time)
    print(f"Time without caching: {duration}")
    
    # Con caching - Primera llamada
    start_time = time.time()
    print(f"Cached factorial of {n}: {cached_factorial(n)}")
    duration = timedelta(time.time() - start_time)
    print(f"Time with caching: {duration}")
    
    # Con caching - Llamada repetida (instantánea)
    start_time = time.time()
    print(f"Cached factorial repeated: {cached_factorial(n)}")
    duration = timedelta(time.time() - start_time)
    print(f"Second call time: {duration}")

if __name__ == "__main__":
    main()
python lazy_loading_with_caching.py
Recursive factorial of 20: 2432902008176640000
Time without caching: 0:00:04.840851
Cached factorial of 20: 2432902008176640000
Time with caching: 0:00:00.865173
Cached factorial repeated: 2432902008176640000
Second call time: 0:00:00.350189
El lru_cache es inherentemente una herramienta de memoization, pero puede adaptarse para casos de lazy loading donde hay procesos de inicialización costosos que deben ejecutarse solo cuando se requieren.

Resumen de Patrones

01 // Cache-Aside

Gestiona caché efectivamente, asegurando que los datos se obtengan y almacenen de manera que optimice rendimiento y consistencia con fuentes de datos dinámicas.

02 // Memoization

Demuestra el poder de cachear resultados de funciones para acelerar aplicaciones evitando computaciones redundantes en operaciones costosas y repetibles.

03 // Lazy Loading

Retrasa la inicialización de recursos hasta que se necesitan, mejorando tiempos de arranque y reduciendo overhead de memoria.

10x
FASTER DB QUERIES
100x
FASTER RECURSION
50%
LESS MEMORY

Basado en "Mastering Python Design Patterns" // Kamon Ayeva & Sakis Kasampalis

Chapter 8: Performance Patterns