StackPractices
advanced Por Mathias Paulenko

Patrón Write-Behind Cache

Escribe en el caché de forma síncrona y persiste en la base de datos de forma asíncrona para cargas de escritura intensivas con consistencia eventual.

Descripción general

En un caché write-behind (también llamado write-back), las escrituras llegan al caché de forma síncrona y a la base de datos de forma asíncrona. La aplicación escribe en el caché, recibe una confirmación inmediata y un proceso en segundo plano vuelca los cambios pendientes a la base de datos en lotes.

Esto desacopla la latencia de escritura del rendimiento de la base de datos. El caché absorbe escrituras a velocidad de memoria y la base de datos recibe lotes consolidados a un ritmo controlado, lo que suaviza los picos de carga y aumenta el throughput total. Es el mismo truco que usan las propias bases de datos con sus write-ahead logs y buffer pools, subido una capa hacia tu aplicación.

El coste es la consistencia eventual más una ventana de pérdida de datos: todo lo que esté en el conjunto de claves pendientes (dirty set) cuando el caché cae se pierde. El tamaño de esa ventana depende del intervalo de volcado y, igual de importante, de si el dirty set sobrevive a un reinicio.

Cuándo usarlo

  • Cargas de escritura intensivas donde la latencia debe mantenerse baja: contadores, telemetría, feeds de actividad
  • La base de datos no puede seguir el ritmo pico de escritura, pero sí el promedio
  • Puedes tolerar unos segundos de divergencia entre caché y base de datos
  • Las escrituras en lote son más baratas que las de fila a fila, casi siempre
  • Tienes un plan de recuperación ante fallos del caché: AOF de Redis, replicación o eventos reejecutables

Evita write-behind cuando otros servicios leen los mismos datos directamente de la base de datos (verán filas obsoletas), cuando perder unos segundos de escrituras es inaceptable (pagos, inventario, estado de autenticación) o cuando el volumen de escritura es modesto; la maquinaria extra no te compra nada. Si necesitas la garantía síncrona más simple, usa el Patrón Write-Through Cache.

Solución

Python con Redis y un dirty set durable

import redis
import json
import threading
import time

class WriteBehindCache:
    """Las claves pendientes viven en un set de Redis: un crash no pierde la cola de volcado."""

    DIRTY_SET = "writebehind:dirty"
    FLUSH_SET = "writebehind:flushing"

    def __init__(self, redis_client: redis.Redis, db_writer: callable, flush_interval: float = 5.0):
        self.redis = redis_client
        self.db_writer = db_writer
        self.flush_interval = flush_interval
        self._running = True
        self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
        self._flush_thread.start()

    def write(self, key: str, value: dict, ttl: int = 3600) -> dict:
        # Pipeline: escritura del valor y marca de clave pendiente en un solo round trip
        pipe = self.redis.pipeline()
        pipe.set(key, json.dumps(value), ex=ttl)
        pipe.sadd(self.DIRTY_SET, key)
        pipe.execute()
        return value

    def _flush_loop(self):
        while self._running:
            time.sleep(self.flush_interval)
            self._flush()

    def _flush(self):
        try:
            # Toma atómica del dirty set actual; las escrituras durante el volcado
            # caen en un DIRTY_SET nuevo y se recogen en el siguiente ciclo
            self.redis.rename(self.DIRTY_SET, self.FLUSH_SET)
        except redis.ResponseError:
            return  # no hay claves pendientes

        keys = self.redis.smembers(self.FLUSH_SET)
        batch = {}
        for key in keys:
            raw = self.redis.get(key)
            if raw is not None:
                batch[key] = json.loads(raw)

        try:
            if batch:
                self.db_writer(list(batch.values()))
        except Exception:
            # Reintegra las claves fallidas para reintentarlas en el siguiente ciclo
            self.redis.sunionstore(self.DIRTY_SET, self.DIRTY_SET, self.FLUSH_SET)
            self.redis.delete(self.FLUSH_SET)
            raise

        self.redis.delete(self.FLUSH_SET)

    def flush_now(self):
        self._flush()

    def shutdown(self):
        self._running = False
        self._flush_thread.join(timeout=10)
        self._flush()


def batch_upsert_users(users: list[dict]):
    """Un upsert multi-fila en lugar de N updates individuales."""
    if not users:
        return
    db.executemany(
        """
        INSERT INTO users (id, name, email)
        VALUES (%s, %s, %s)
        ON CONFLICT (id) DO UPDATE
        SET name = EXCLUDED.name, email = EXCLUDED.email
        """,
        [(u["id"], u["name"], u["email"]) for u in users],
    )


cache = WriteBehindCache(
    redis.Redis(host="localhost", port=6379),
    db_writer=batch_upsert_users,
    flush_interval=2.0,
)

def update_user(user_id: str, name: str, email: str) -> dict:
    user = {"id": user_id, "name": name, "email": email}
    return cache.write(f"user:{user_id}", user, ttl=3600)

TypeScript con Redis y un dirty set durable

import { createClient } from 'redis';

const DIRTY_SET = 'writebehind:dirty';
const FLUSH_SET = 'writebehind:flushing';

class WriteBehindCache {
  private flushTimer: NodeJS.Timeout | null = null;

  constructor(
    private client: ReturnType<typeof createClient>,
    private dbWriter: (batch: Array<{ key: string; value: any }>) => Promise<void>,
    private flushIntervalMs = 5000
  ) {
    this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
  }

  async write<T>(key: string, value: T, ttl = 3600): Promise<T> {
    await this.client
      .multi()
      .set(key, JSON.stringify(value), { EX: ttl })
      .sAdd(DIRTY_SET, key)
      .exec();
    return value;
  }

  async flush(): Promise<void> {
    // RENAME es atómico: las escrituras concurrentes crean un DIRTY_SET nuevo
    try {
      await this.client.rename(DIRTY_SET, FLUSH_SET);
    } catch {
      return; // no hay claves pendientes
    }

    const keys = await this.client.sMembers(FLUSH_SET);
    const batch: Array<{ key: string; value: any }> = [];
    for (const key of keys) {
      const raw = await this.client.get(key);
      if (raw !== null) batch.push({ key, value: JSON.parse(raw) });
    }

    try {
      if (batch.length > 0) await this.dbWriter(batch);
      await this.client.del(FLUSH_SET);
    } catch (error) {
      // Reintegra las claves fallidas en DIRTY_SET para el siguiente ciclo
      await this.client.sUnionStore(DIRTY_SET, [DIRTY_SET, FLUSH_SET]);
      await this.client.del(FLUSH_SET);
      throw error;
    }
  }

  async shutdown(): Promise<void> {
    if (this.flushTimer) clearInterval(this.flushTimer);
    await this.flush();
  }
}

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

const cache = new WriteBehindCache(
  redisClient,
  async (batch) => {
    const values = batch.flatMap((b) => [b.value.id, b.value.name, b.value.email]);
    const placeholders = batch
      .map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`)
      .join(', ');
    await db.query(
      `INSERT INTO users (id, name, email) VALUES ${placeholders}
       ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email`,
      values
    );
  },
  3000
);

await cache.write(`user:${userId}`, { id: userId, name, email });

Java con Spring Cache y volcado programado

import org.springframework.cache.annotation.CachePut;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class WriteBehindCacheService {

    private final UserRepository userRepository;
    // Mapa en memoria: la opción más simple, pero un crash pierde las escrituras
    // pendientes. Para durabilidad, persiste los IDs en Redis junto a los valores.
    private final Map<String, User> dirtyEntries = new ConcurrentHashMap<>();

    public WriteBehindCacheService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @CachePut(value = "users", key = "#user.id")
    public User writeUser(User user) {
        dirtyEntries.put(user.getId().toString(), user);
        return user;
    }

    @Scheduled(fixedDelay = 5000)
    public void flushToDatabase() {
        if (dirtyEntries.isEmpty()) return;

        List<User> batch = new ArrayList<>(dirtyEntries.values());
        dirtyEntries.clear();

        try {
            userRepository.saveAll(batch);
        } catch (Exception e) {
            // Reencola las entradas fallidas para el siguiente volcado programado
            for (User u : batch) {
                dirtyEntries.put(u.getId().toString(), u);
            }
        }
    }
}

Explicación

El patrón write-behind separa la confirmación de escritura de la persistencia:

  1. Escritura en caché: la aplicación escribe en el caché y marca la clave como pendiente. El llamador recibe la confirmación en torno a un milisegundo y nunca toca la base de datos.
  2. Seguimiento de pendientes: el dirty set registra qué claves aún deben llegar a la base de datos. En los ejemplos vive dentro del propio Redis, así que sobrevive a un crash de la aplicación y puede compartirse entre instancias.
  3. Volcado en segundo plano: un temporizador salta cada N segundos. El volcador toma el dirty set, lee los valores más recientes del caché y los escribe en la base de datos en un único lote.
  4. Recuperación de fallos: si el lote falla, las claves vuelven al dirty set para el siguiente ciclo. Nada se descarta en silencio.
Flujo write-behind — la aplicación escribe en el caché y recibe un ACK inmediato mientras la clave se marca como pendiente; un volcador en segundo plano envía después las claves pendientes a la base de datos en lote, y cualquier escritura no volcada se pierde si el caché cae antes.

La parte sutil que la mayoría de textos omite: el seguimiento de pendientes tiene que ser al menos tan durable como los datos que registra. Un Set o HashMap en memoria funciona hasta que la aplicación reinicia a mitad de volcado y olvida qué claves estaban pendientes: los valores siguen en Redis, pero nada los envía jamás a la base de datos. Mantener el dirty set en Redis (o en un write-ahead log de verdad) cierra ese agujero. Las opciones de persistencia del propio Redis (AOF everysec, snapshots RDB o ambos) acotan después cuánto puedes perder si cae Redis.

Dos mandos controlan la ventana de pérdida y la carga en la base de datos:

  • El intervalo de volcado es tu presupuesto de pérdida. Un intervalo de 5 segundos significa que un crash cuesta como máximo 5 segundos de escrituras confirmadas. Elígelo a partir de lo que el negocio puede perder, no del valor por defecto del framework.
  • El tamaño de lote es tu palanca de eficiencia. Un INSERT ... ON CONFLICT multi-fila de 500 registros es dramáticamente más barato que 500 updates individuales; dimensiona el lote para que un volcado termine holgadamente dentro del intervalo.

Versiones ejecutables de estas implementaciones están en el repositorio complementario.

Variantes

EnfoqueDisparador de volcadoIdeal para
Volcado por tiempoIntervalo fijo (ej. 5s)Cargas de escritura estables
Volcado por tamaño de loteCuando se acumulan N entradas pendientesEscrituras de alto volumen
Volcado híbridoTiempo O tamaño de lote, lo primero que ocurraTasas de escritura variables
Volcado por eventoEn eventos específicos (ej. apagado)Degradación elegante
Volcado con WALWrite-ahead log para durabilidadRequisitos duros de recuperación

La versión de servicio gestionado de esta idea aparece en la documentación de estrategias de caché de AWS ElastiCache, que contrasta lazy loading, write-through y write-behind con TTLs. Si tus lecturas fallan a menudo y las escrituras son raras, Cache-Aside o la guía del patrón Cache-Aside de Microsoft son mejor punto de partida: write-behind solo compensa cuando dominan las escrituras.

Buenas prácticas

  • Persiste el dirty set: un dirty set en memoria olvida la cola de volcado al reiniciar. Registra las claves pendientes en Redis (como arriba) o en un WAL para que la cola sobreviva al proceso.
  • Agrupa las escrituras a base de datos: la ganancia de throughput viene de la consolidación. Prefiere un upsert multi-fila o una API bulk sobre sentencias fila a fila.
  • Reintenta con backoff: reencola las claves fallidas, pero aplica backoff cuando la base de datos sufre para que el volcador no se sume a una recuperación en curso.
  • Vuelca al apagar: registra un shutdown hook que drene el dirty set antes de que el proceso termine.
  • Vigila el tamaño del dirty set y el lag de volcado: un dirty set que crece sin límite significa que el volcador está perdiendo la carrera. Alerta sobre SCARD dirty_set y sobre el tiempo desde el último volcado exitoso.
  • Dimensiona el intervalo desde el presupuesto de pérdida: ver las notas de ajuste en Explicación. Para una vista más amplia, la guía de estrategias de caché con Redis cubre cómo encaja write-behind con TTLs y evicción.

Errores comunes

  • Dirty set solo en memoria del proceso: el bug de producción número uno de este patrón. La aplicación cae, Redis sigue teniendo los valores y nadie los vuelca jamás.
  • Volcar más rápido de lo que la base de datos drena: el dirty set crece hasta que la memoria de Redis o el volcador colapsan. Mide la duración del volcado, no solo la latencia de escritura.
  • TTL más corto que el ciclo de volcado: una clave pendiente que expira o se evicte antes de volcarse es una escritura perdida en silencio. Mantén los TTL muy por encima del peor lag de volcado.
  • Servir lecturas de base de datos que esperan datos frescos: cualquier cosa que lea la base de datos directamente (informes, otros servicios, consumidores CDC) verá filas obsoletas. Write-behind asume que todas las lecturas pasan por el caché.
  • Usarlo para dinero o inventario: la consistencia eventual no es aceptable donde «la base de datos es la verdad». Usa Write-Through ahí.

Resolución de problemas

  • El dirty set crece sin límite: la base de datos no drena la cola tan rápido como llegan las escrituras. Aumenta el tamaño de lote, acorta el intervalo o escala la vía de escritura, y comprueba si una clave envenenada tumba cada lote.
  • Un registro defectuoso tumba todos los volcados: el lote completo reintenta eternamente y la cola se dispara. Captura fallos por registro y envía las claves problemáticas a un dead-letter set para inspección.
  • Escrituras confirmadas que nunca llegan a la base de datos: el seguimiento de pendientes se perdió (set en memoria + reinicio) o el hilo volcador murió. Expón last_flush_success como métrica y alerta cuando envejezca.
  • Consumidores downstream ven datos obsoletos: réplicas de lectura, pipelines CDC y analytics leen la base de datos, que va con retraso por diseño. Enruta las lecturas sensibles a frescura por el caché o elige otro patrón.
  • Pérdida de datos tras reiniciar Redis: los snapshots RDB pierden todo lo escrito desde el último guardado; AOF con everysec aún puede perder ~1 segundo. Combina AOF con el dirty set durable de arriba y conoce tu límite.

Errores comunes en producción

  • Tratar el dirty set como un detalle de implementación: es la frontera de durabilidad, y perderlo significa descartar en silencio escrituras ya confirmadas.
  • Fallos de volcado que solo aparecen en logs que nadie lee; la cola crece discretamente durante días hasta que la memoria de Redis explota.
  • Tests de carga que miden latencia de escritura pero nunca comprueban si el dirty set drena; el sistema parece rápido justo hasta que la base de datos se queda atrás.
  • Múltiples instancias con su propio dirty set en memoria, de modo que los volcados se duplican o entran en conflicto; comparte un set de Redis o particiona claves por instancia.
  • Asumir que AOF everysec significa pérdida cero: es una cota de ~1 segundo, excelente pero no cero.
  • Olvidar que las réplicas de lectura y los consumidores CDC observan la base de datos retrasada, no el caché fresco.

Preguntas frecuentes

¿Cuál es la diferencia entre write-behind y write-through?

Write-through escribe en caché y base de datos de forma síncrona, así que el llamador espera a ambos. Write-behind escribe en el caché de forma síncrona y en la base de datos de forma asíncrona, así que el llamador solo espera al caché. Write-through es consistente; write-behind es eventualmente consistente y más rápido bajo carga.

¿Cómo evito perder datos si el caché cae?

Dos capas. Primero, mantén el dirty set durable (en Redis o en un WAL) para que un proceso reiniciado siga sabiendo qué volcar. Segundo, activa la persistencia de Redis: AOF con everysec acota la pérdida a un segundo aproximadamente, mientras que los snapshots RDB solos pueden perder minutos. Para garantías duras, escribe en un WAL de aplicación antes de tocar el caché.

¿Cómo elijo el intervalo de volcado y el tamaño de lote?

Empieza por el presupuesto de pérdida: el intervalo es la pérdida máxima de datos que aceptas. Después dimensiona el lote para que un volcado termine holgadamente dentro de ese intervalo a carga pico; si un volcado tarda más que el intervalo, el dirty set crece para siempre. Un intervalo de 2–5 segundos con lotes de unos cientos de filas es un punto de partida habitual.

¿Cuándo es write-behind mejor que write-through?

Cuando el throughput de escritura importa más que la consistencia inmediata: contadores de vistas, eventos de analytics, datos de sesión, preferencias de usuario. Write-through gana cuando la base de datos debe reflejar el estado más reciente en el momento en que la llamada retorna: transacciones financieras, inventario, cualquier cosa que otro servicio lea directamente.

¿Puedo combinar write-behind con read-through?

Sí, y es una combinación habitual. Las lecturas pasan por el caché con un callback loader (Read-Through), las escrituras van al caché con persistencia asíncrona. El caché siempre tiene los datos más recientes para las lecturas mientras la base de datos se pone al día en segundo plano.