Skip to content
StackPractices
intermediate Por Mathias Paulenko

Patron Read-Through Cache

Una capa de cache transparente que intercepta peticiones de lectura, obtiene del origen de datos en caso de miss y puebla el cache automaticamente.

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Patron Read-Through Cache

Descripcion general

En un read-through cache, la aplicacion habla solo con la capa de cache. En un cache hit, el cache devuelve los datos directamente. En un cache miss, el cache obtiene los datos del backing store, los almacena y los devuelve a la aplicacion. La aplicacion nunca interactua con la base de datos directamente para lecturas.

Esto difiere de cache-aside, donde la aplicacion gestiona la poblacion del cache. Read-through mueve esa responsabilidad a la capa de cache o a una libreria de cache, simplificando el codigo de la aplicacion y asegurando comportamiento de cache consistente en todos los caminos de lectura.

Cuando usarlo

  • For alternatives, see Refresh-Ahead Cache Pattern.

  • Multiples caminos de lectura acceden a los mismos datos y quieres comportamiento de cache consistente

  • Quieres desacoplar el codigo de aplicacion de la logica de gestion de cache

  • Tu capa de cache soporta un callback read-through (Redis con Lua, memcached con callbacks)

  • Necesitas comportamiento de cache predecible sin depender de que cada desarrollador llame al cache correctamente

  • La latencia de lectura es mas critica que la de escritura

Solucion

Python con Redis

import redis
import json
import pickle
from typing import Callable, TypeVar, Optional

T = TypeVar('T')

class ReadThroughCache:
    def __init__(self, redis_client: redis.Redis, ttl: int = 3600):
        self.redis = redis_client
        self.ttl = ttl

    def get_or_load(self, key: str, loader: Callable[[], T], ttl: Optional[int] = None) -> T:
        cached = self.redis.get(key)
        if cached is not None:
            return pickle.loads(cached)

        value = loader()
        self.redis.setex(key, ttl or self.ttl, pickle.dumps(value))
        return value

    def get_or_load_json(self, key: str, loader: Callable[[], dict], ttl: Optional[int] = None) -> dict:
        cached = self.redis.get(key)
        if cached is not None:
            return json.loads(cached)

        value = loader()
        self.redis.setex(key, ttl or self.ttl, json.dumps(value))
        return value


cache = ReadThroughCache(redis.Redis(host='localhost', port=6379))

def get_user(user_id: str) -> dict:
    return cache.get_or_load_json(
        f"user:{user_id}",
        lambda: db.query_one("SELECT id, name, email FROM users WHERE id = %s", [user_id]),
        ttl=1800
    )

TypeScript con Redis

import { createClient } from 'redis';

class ReadThroughCache {
  private client: ReturnType<typeof createClient>;
  private defaultTtl: number;

  constructor(client: ReturnType<typeof createClient>, ttl = 3600) {
    this.client = client;
    this.defaultTtl = ttl;
  }

  async getOrLoad<T>(key: string, loader: () => Promise<T>, ttl?: number): Promise<T> {
    const cached = await this.client.get(key);
    if (cached !== null) {
      return JSON.parse(cached) as T;
    }

    const value = await loader();
    await this.client.set(key, JSON.stringify(value), { EX: ttl ?? this.defaultTtl });
    return value;
  }

  async invalidate(key: string): Promise<void> {
    await this.client.del(key);
  }
}

// Uso
const redisClient = createClient({ url: 'redis://localhost:6379' });
await redisClient.connect();
const cache = new ReadThroughCache(redisClient);

async function getUser(userId: string): Promise<User> {
  return cache.getOrLoad(
    `user:${userId}`,
    () => db.query('SELECT id, name, email FROM users WHERE id = $1', [userId]),
    1800
  );
}

Java con Caffeine

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;

public class ReadThroughCacheManager {

    private final Cache<String, Object> cache;

    public ReadThroughCacheManager() {
        this.cache = Caffeine.newBuilder()
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .maximumSize(10_000)
            .build();
    }

    @SuppressWarnings("unchecked")
    public <T> T getOrLoad(String key, java.util.function.Function<String, T> loader) {
        return (T) cache.get(key, k -> loader.apply(k));
    }

    public void invalidate(String key) {
        cache.invalidate(key);
    }
}

// Uso
ReadThroughCacheManager cache = new ReadThroughCacheManager();

User user = cache.getOrLoad("user:" + userId, k -> {
    return userRepository.findById(Long.parseLong(k.split(":")[1]))
        .orElseThrow(() -> new NotFoundException("User not found"));
});

Redis con Lua Script (read-through del lado del servidor)

-- read_through.lua
-- KEYS[1]: cache key
-- ARGV[1]: TTL in seconds
-- ARGV[2]: loader command (e.g., "GET user:123:db")

local cached = redis.call('GET', KEYS[1])
if cached then
  return cached
end

-- En produccion, usa un mecanismo de loader mas sofisticado
local value = redis.call('GET', ARGV[2])
if value then
  redis.call('SETEX', KEYS[1], ARGV[1], value)
  return value
end
return nil
# Usando el script Lua
script = r.register_script(lua_script)
result = script(keys=["user:123"], args=[3600, "user:123:db"])

Explicacion

El patron read-through desplaza la gestion del cache de la aplicacion a la capa de cache:

  1. Cache hit — el cache tiene los datos y los devuelve inmediatamente. No se hace llamada a base de datos.
  2. Cache miss — el cache llama a una funcion loader (o callback) que sabe como obtener los datos del backing store. Los datos obtenidos se almacenan en el cache con un TTL y se devuelven al llamador.
  3. Lecturas subsiguientes — la misma key hace hit en el cache hasta que el TTL expira o la entrada se invalida.

La diferencia clave con cache-aside es la transparencia. Con cache-aside, la aplicacion comprueba el cache, falla, obtiene de la base de datos y puebla el cache. Con read-through, la aplicacion llama cache.get(key) y el cache maneja el resto. Esto significa que cada camino de lectura obtiene cache automaticamente sin que cada desarrollador escriba logica de cache.

Variantes

EnfoqueCapaIdeal para
Libreria a nivel aplicacionWrapper de cache Python/TS/JavaLa mayoria de casos, control total
Script Lua en RedisServidor RedisRead-through atomico sin round trips
Read-through CDNEdge CDNContenido estatico y semi-estatico
Proxy de cache de base de datosCapa proxyCache transparente sin cambios de codigo
Cache de segundo nivel ORMCapa ORMJava/Hibernate, cache de entidades automatico

Buenas practicas

  • Establece un TTL en cada entrada — sin TTL, los datos stale persisten indefinidamente. Elige TTLs segun la frecuencia de cambio de datos.
  • Usa nombrado de keys consistente — formato entity:id (ej. user:123, product:456) hace el debugging e invalidacion predecibles.
  • Maneja fallos del loader gracefulmente — si la base de datos esta caida, devuelve datos stale del cache si estan disponibles en lugar de fallar toda la peticion.
  • Serializa eficientemente — usa JSON para objetos simples, MessagePack o Protobuf para complejos. Evita payloads grandes que consumen memoria del cache.
  • Monitoriza el cache hit ratio — un hit ratio por debajo del 80% significa que el cache no es efectivo. Revisa TTLs, patrones de keys y tamano del cache.

Errores comunes

  • Sin TTL en entradas de cache — los datos se quedan en cache para siempre, volviendose stale. Siempre establece un TTL, aunque sea largo (24h).
  • Cachear demasiado agresivamente — cachear todo lleva a presion de memoria y datos stale. Cachea hot keys y queries costosas, no cada lectura.
  • No invalidar despues de escrituras — despues de actualizar un usuario, la version cacheada esta stale. Llama cache.invalidate(key) o usa write-through.
  • Objetos grandes en cache — cachear un objeto de 10MB consume memoria del cache y aumenta el overhead de serializacion. Cachea proyecciones, no entidades completas.
  • Ignorar cache stampede — cuando una key popular expira, muchas peticiones fallan simultaneamente y inundan la base de datos. Usa un lock o single-flight.

Preguntas frecuentes

Cual es la diferencia entre read-through y cache-aside?

En cache-aside, la aplicacion gestiona la poblacion del cache: comprueba cache, falla, obtiene de DB, puebla cache. En read-through, la capa de cache maneja la poblacion via un callback loader. Read-through centraliza la logica de cache; cache-aside da a la aplicacion mas control.

Cuando debo usar read-through sobre cache-aside?

Usa read-through cuando quieres cache consistente en todos los caminos de lectura sin depender de que cada desarrollador implemente logica de cache correctamente. Usa cache-aside cuando necesitas control fino sobre cuando y como poblar el cache.

Como manejo cache stampede en read-through?

Usa un mecanismo de lock o single-flight: cuando ocurre un cache miss, solo una peticion obtiene de la base de datos mientras otras esperan. Los locks distribuidos de Redis o librerias single-flight a nivel lenguaje funcionan bien.

Puedo usar read-through con un cache distribuido?

Si. Redis y Memcached soportan patrones read-through. Para Redis, usa un script Lua para read-through del lado del servidor. A nivel aplicacion, usa una libreria wrapper de cache que maneje el callback loader.

Temas Avanzados

Escenario: Read-Through Cache con Redis

// Read-Through: el cache gestiona la lectura de DB transparentemente
class ReadThroughCache {
  constructor(private redis: RedisClient, private db: Pool, private ttlSec: number = 300) {}

  async get<T>(key: string, queryFn: () => Promise<T>): Promise<T> {
    // 1. Intentar cache
    const cached = await this.redis.get(key);
    if (cached) return JSON.parse(cached) as T;

    // 2. Cache miss: el cache se encarga de leer y llenar
    const data = await queryFn();
    await this.redis.setex(key, this.ttlSec, JSON.stringify(data));
    return data;
  }

  // Batch read-through: multiples keys en una operacion
  async getBatch<T>(keys: string[], queryFn: (keys: string[]) => Promise<Record<string, T>>): Promise<Record<string, T>> {
    const results: Record<string, T> = {};
    const missingKeys: string[] = [];

    // 1. Leer todas las keys de cache
    const cached = await this.redis.mget(...keys);
    keys.forEach((key, i) => {
      if (cached[i]) { results[key] = JSON.parse(cached[i]) as T; }
      else { missingKeys.push(key); }
    });

    // 2. Leer missing de DB
    if (missingKeys.length > 0) {
      const dbData = await queryFn(missingKeys);
      // 3. Llenar cache para las que faltaban
      const cacheEntries: string[] = [];
      for (const [key, value] of Object.entries(dbData)) {
        results[key] = value;
        cacheEntries.push(key, JSON.stringify(value));
      }
      if (cacheEntries.length > 0) {
        await this.redis.mset(...cacheEntries);
        // Set TTL para cada key
        for (const key of Object.keys(dbData)) {
          await this.redis.expire(key, this.ttlSec);
        }
      }
    }

    return results;
  }
}

// Uso
const cache = new ReadThroughCache(redis, dbPool, 300);

// Single read
const user = await cache.get("user:123", async () => {
  const res = await dbPool.query("SELECT * FROM users WHERE id = $1", ["123"]);
  return res.rows[0];
});

// Batch read
const users = await cache.getBatch(["user:1", "user:2", "user:3"], async (ids) => {
  const res = await dbPool.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
  return Object.fromEntries(res.rows.map((r: any) => [`user:${r.id}`, r]));
});

Lecciones:

  • Read-Through: el cache gestiona la lectura transparente
  • El cliente solo llama a get(): no sabe si viene de cache o DB
  • Cache-Aside: el cliente gestiona cache y DB por separado
  • Batch read-through: mget + mset para reducir round-trips
  • TTL como safety net: si la invalidacion falla, el TTL limpia
  • Usar pipeline en Redis para reducir latencia en batch

### Read-Through vs Cache-Aside: cual uso?

Read-Through: el cache gestiona la lectura. El cliente llama a get() y el cache decide si va a DB. Mas simple para el cliente. Cache-Aside: el cliente gestiona explicitamente. Lee cache, si miss lee DB y llena cache. Mas control. Usa Read-Through cuando la logica de lectura es uniforme y quieres centralizar. Usa Cache-Aside cuando necesitas logica condicional (ej: no cachear ciertos resultados, o TTL variable por key).