Persistencia de Redis: RDB, AOF y cuándo perder datos

1. Descripción general

Redis mantiene todos los datos en RAM. Si el proceso muere, los datos solo se preservan si Redis los escribió previamente en disco. Este artículo explica dos mecanismos de persistencia: RDB (snapshot) y AOF (append-only file), cómo elegir y configurar cada uno. También lista situaciones en las que aún pierdes datos aunque hayas habilitado persistencia. Al final, encontrarás código Spring Boot para inspeccionar el estado de persistencia y un test ejecutado con Testcontainers.

2. RDB: captura de snapshots periódicos

RDB escribe todo el dataset en un archivo binario, por defecto dump.rdb, en un momento específico. Configuración en redis.conf:

# Snapshot si después de 3600s hay al menos 1 cambio,
# después de 300s hay al menos 100 cambios, o después de 60s hay al menos 10000 cambios
save 3600 1 300 100 60 10000

dbfilename dump.rdb
dir /data

# Rechaza comandos de escritura si el último BGSAVE falló
stop-writes-on-bgsave-error yes
rdbcompression yes

Cuando se cumple una condición save, Redis invoca fork(). El proceso hijo escribe el snapshot en un archivo temporal y luego lo renombra a dump.rdb. El proceso padre continúa sirviendo a los clientes normalmente. Gracias al mecanismo copy-on-write del sistema operativo, ambos procesos comparten páginas de memoria. Una página solo se copia cuando el proceso padre la modifica.

También puedes capturar un snapshot manualmente:

redis-cli BGSAVE
redis-cli LASTSAVE
Background saving started
(integer) 1760169600

BGSAVE se ejecuta en segundo plano. LASTSAVE devuelve el timestamp Unix del último snapshot exitoso. Evita usar SAVE en production: este comando bloquea todo el servidor hasta que termina de escribir.

Ventajas de RDB:

  • El archivo es compacto, ideal para hacer backup o copiar a otro entorno.
  • El reinicio es rápido porque Redis solo necesita cargar un archivo binario.
  • Tiene casi ningún impacto en el throughput entre snapshots.

Desventajas: cualquier cambio después del último snapshot se pierde si Redis falla. Con la configuración anterior, en el peor caso puedes perder varios minutos de datos.

Nota: fork() en datasets grandes (decenas de GB) puede hacer que Redis se quede congelado varios cientos de milisegundos. Si escribes mucho durante el snapshot, copy-on-write puede aumentar la RAM casi al doble. Deja memoria disponible y habilita vm.overcommit_memory = 1 en Linux.

3. AOF: registra cada comando de escritura

AOF registra cada comando que cambia datos (SET, INCR, HSET, …) al final del archivo de log. Al iniciarse, Redis reproduce este log para reconstruir el dataset.

appendonly yes
appenddirname "appendonlydir"
appendfilename "appendonly.aof"

# always | everysec | no
appendfsync everysec

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes

3.1. Política appendfsync

Escribir en un archivo no significa que los datos estén en disco. Los datos pueden estar en el page cache del kernel hasta que se ejecute fsync(). Este es el factor clave que determina cuánto perderás:

Valor Cuándo fsync Pérdida máxima en crash Rendimiento
always Después de cada comando de escritura Casi sin pérdida Más lento
everysec Cada segundo, en un thread de fondo Alrededor de 1 segundo (puede llegar a 2) Bueno, es el predeterminado
no Decidido por el SO, típicamente ~30 segundos Decenas de segundos Más rápido

Con everysec, si el fsync anterior no ha terminado después de 2 segundos porque el disco es lento, Redis bloqueará los comandos de escritura para no atrasarse más. Por lo tanto, la ventana de pérdida de datos real puede ser de 2 segundos, no de 1.

También necesitas distinguir entre dos tipos de fallos. Si solo falla el proceso Redis, los datos ya enviados con write() seguirán en el page cache y el kernel los escribirá en disco después. Si toda la máquina pierde poder o entra en kernel panic, la parte que no fue fsync se pierde.

3.2. AOF rewrite y AOF multi-part

El archivo AOF crece con el tiempo. Por ejemplo, 1 millón de comandos INCR counter podrían ser reemplazados por un solo SET counter 1000000. Redis reescribe el AOF automáticamente cuando el archivo es el doble del tamaño de la última reescritura (auto-aof-rewrite-percentage 100) y alcanza un mínimo de 64 MB.

A partir de Redis 7, el AOF está en el directorio appendonlydir y consta de múltiples archivos:

appendonlydir/
├── appendonly.aof.1.base.rdb    # base, formato RDB gracias a aof-use-rdb-preamble
├── appendonly.aof.1.incr.aof    # comandos de escritura generados después del base
└── appendonly.aof.manifest      # describe qué archivos pertenecen al AOF actual

Cuando habilitas aof-use-rdb-preamble yes, el archivo base se escribe en formato RDB, por lo que se carga rápidamente. La parte incr sigue siendo un log de comandos, así que pierde menos datos. Este es el modo “híbrido” y es el predeterminado desde Redis 4.0.

4. ¿Qué modo deberías elegir?

Necesidad Configuración recomendada
Cache puro, datos reconstruibles desde DB Desactiva todo: save "", appendonly no
Acepto perder unos minutos, necesito backup compacto Solo RDB
Sessions, carrito de compras, rate limits que necesitan durabilidad AOF everysec + RDB para backup
No puedo perder ningún comando (raro) AOF always, pero considera usar otra DB

La opción razonable para la mayoría de aplicaciones es habilitar ambos: AOF everysec para reducir pérdidas, RDB para tener un archivo de backup periódico. Cuando ambos están habilitados, Redis prioriza cargar AOF al iniciar porque AOF es más completo.

Nota: si solo usas Redis como caché, desactiva la persistencia. Habilitar RDB para caché solo consume más fork(), I/O de disco y RAM sin beneficio alguno.

5. Inspecciona la persistencia desde tu aplicación Spring Boot

Agrega la dependencia:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

La siguiente clase lee el estado de persistencia, solicita un snapshot y espera confirmación del replica:

@Component
public class PersistenceInspector {

    private final StringRedisTemplate template;

    public PersistenceInspector(StringRedisTemplate template) {
        this.template = template;
    }

    public Properties persistenceInfo() {
        return template.execute((RedisCallback<Properties>) conn ->
                conn.serverCommands().info("persistence"));
    }

    public void triggerSnapshot() {
        template.execute((RedisCallback<Object>) conn ->
                conn.execute("BGSAVE", "SCHEDULE".getBytes()));
    }

    public long waitForReplicas(int replicas, long timeoutMs) {
        Long acked = template.execute((RedisCallback<Long>) conn ->
                (Long) conn.execute("WAIT",
                        String.valueOf(replicas).getBytes(),
                        String.valueOf(timeoutMs).getBytes()));
        return acked == null ? 0 : acked;
    }
}

Explicación de cada método:

  • persistenceInfo() ejecuta INFO persistence y devuelve un Properties con campos como aof_enabled y rdb_last_bgsave_status.
  • triggerSnapshot() utiliza BGSAVE SCHEDULE. Si un AOF rewrite está en ejecución, Redis programará el snapshot en lugar de devolver un error.
  • waitForReplicas() ejecuta WAIT y devuelve el número de replicas que han recibido los comandos de escritura anteriores. La sección 6 explicará por qué necesitas este comando.

Una parte de la salida de INFO persistence cuando AOF está habilitado:

loading:0
rdb_changes_since_last_save:42
rdb_bgsave_in_progress:0
rdb_last_save_time:1760169600
rdb_last_bgsave_status:ok
aof_enabled:1
aof_rewrite_in_progress:0
aof_last_bgrewrite_status:ok
aof_last_write_status:ok

Tres campos que deberías incluir en monitoreo y establecer alertas cuando el valor no sea ok: rdb_last_bgsave_status, aof_last_write_status y aof_last_bgrewrite_status. El campo rdb_changes_since_last_save indica cuántos cambios perderías si solo confías en RDB.

5.1. Test con Testcontainers

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RedisPersistenceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4"))
            .withExposedPorts(6379)
            .withCommand("redis-server", "--appendonly", "yes", "--appendfsync", "everysec");

    @Autowired
    StringRedisTemplate template;

    @Autowired
    PersistenceInspector inspector;

    @Test
    void aofShouldBeEnabledWithEverySecPolicy() {
        template.opsForValue().set("order:1001", "PAID");

        Properties info = inspector.persistenceInfo();
        List<String> fsync = template.execute((RedisCallback<List<String>>) conn ->
                conn.serverCommands().getConfig("appendfsync")
                        .values().stream().map(Object::toString).toList());

        assertThat(info.getProperty("aof_enabled")).isEqualTo("1");
        assertThat(info.getProperty("aof_last_write_status")).isEqualTo("ok");
        assertThat(fsync).containsExactly("everysec");
    }

    @Test
    void backgroundSnapshotShouldSucceed() throws InterruptedException {
        template.opsForValue().set("customer:42", "Lan");
        long before = Long.parseLong(inspector.persistenceInfo().getProperty("rdb_last_save_time"));

        Thread.sleep(1100); // asegura que el nuevo timestamp sea diferente
        inspector.triggerSnapshot();

        Properties info = inspector.persistenceInfo();
        for (int i = 0; i < 50 && Long.parseLong(info.getProperty("rdb_last_save_time")) <= before; i++) {
            Thread.sleep(100);
            info = inspector.persistenceInfo();
        }

        assertThat(info.getProperty("rdb_last_bgsave_status")).isEqualTo("ok");
        assertThat(Long.parseLong(info.getProperty("rdb_last_save_time"))).isGreaterThan(before);
    }
}

La primera prueba inicia Redis 7.4 con AOF everysec y verifica que la configuración está activa. @ServiceConnection apunta automáticamente spring.data.redis.host/port al contenedor, así que no necesitas declarar propiedades adicionales. La segunda prueba espera hasta 5 segundos a que rdb_last_save_time aumente, luego comprueba que el snapshot fue exitoso.

6. ¿Cuándo aún pierdes datos?

Habilitar persistencia no es suficiente. Aquí están las situaciones más comunes en production.

1. Fallo entre snapshots. Si solo usas RDB, pierdes todo lo escrito después de rdb_last_save_time.

2. Ventana fsync. Con AOF everysec, pierdes aproximadamente 1–2 segundos cuando la máquina falla. Con no, este número puede llegar a decenas de segundos.

3. Failover con replicación asincrónica. El master devuelve OK al cliente antes de que el replica reciba el comando. Si el master falla y Sentinel o Cluster promueve el replica a master, los comandos que no fueron replicados desaparecerán, incluso si el master tiene AOF always. WAIT ayuda a reducir este riesgo:

template.opsForValue().set("payment:7788", "CAPTURED");
long acked = inspector.waitForReplicas(1, 100);
if (acked < 1) {
    log.warn("payment:7788 aún no fue confirmado por el replica");
}

WAIT 1 100 bloquea por un máximo de 100 ms hasta que al menos 1 replica confirme. WAIT aún no convierte a Redis en un sistema strongly consistent: el replica puede recibir el comando pero aún no hacer fsync. Considéralo una forma de reducir la probabilidad de pérdida de datos, no una garantía.

4. Master se reinicia con dataset vacío. Supón que el master tiene persistencia deshabilitada, se reinicia automáticamente y arranca de nuevo con datos vacíos. El replica se sincronizará con el master y borrará sus propios datos. Si el master no tiene persistencia habilitada, no lo dejes reiniciarse automáticamente.

5. Container sin volumen montado. Redis aún escribe dump.rdb y AOF, pero en el filesystem del contenedor. Cuando el contenedor se elimina y se recrea, esos archivos se pierden también.

services:
  redis:
    image: redis:7.4
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
volumes:
  redis-data:

En Kubernetes, la situación equivalente es usar emptyDir en lugar de PersistentVolumeClaim.

6. Disco lleno o fork() falla. Cuando BGSAVE falla y stop-writes-on-bgsave-error yes está habilitado, Redis rechaza todos los comandos de escritura con un error MISCONF. Este comportamiento protege los datos pero hace que tu aplicación falle. Muchas personas lo solucionan deshabilitando esta opción, y luego Redis continúa sin persistencia.

7. Ejecutar accidentalmente FLUSHALL. Con AOF, el comando FLUSHALL es solo una línea al final del archivo incr. Si el AOF aún no ha sido reescrito, puedes detener Redis, eliminar esa línea y reiniciar. Si la reescritura ya ocurrió, no hay forma de recuperarse. Por eso, cambia el nombre o bloquea este comando con ACL en production.

8. Eviction. Este no es un error de persistencia, pero el resultado es el mismo. Con maxmemory-policy allkeys-lru, Redis eliminará claves cuando la RAM esté llena. Los datos que no pueden perderse necesitan noeviction.

Nota: no uses Redis como el único almacenamiento para dinero o datos de pedidos. Si necesitas garantizar “si devuelvo OK, no se pierden datos”, usa una base de datos relacional o un sistema con consenso. Redis debe ser cache o una capa de aceleración.

7. Conclusión

RDB proporciona un archivo de backup compacto e inicio rápido, pero podrías perder varios minutos de datos. AOF con everysec reduce la pérdida a aproximadamente 1–2 segundos, a cambio de más I/O. El modo híbrido (aof-use-rdb-preamble) combina las ventajas de ambos. La mayoría de los riesgos reales están fuera de estos dos mecanismos: replicación asincrónica, volúmenes no montados, disco lleno, FLUSHALL y eviction. Monitorea los campos en INFO persistence, usa WAIT para comandos de escritura importantes, y prueba tu configuración con Testcontainers como en la sección 5.

redispersistencerdbaofspring-boot