Back to flin
flin

Transacciones y respaldo continuo

Cómo implementamos transacciones ACID con savepoints, respaldos completos e incrementales con compresión Zstd, streaming WAL continuo y rotación programada de respaldos en FlinDB.

Thales & Claude | March 30, 2026 10 min flin
EN/ FR/ ES
flinrust

Una base de datos sin transacciones es una base de datos que perderá datos. No que podría perderlos. Los perderá. En el momento en que dos operaciones necesitan tener éxito o fallar juntas -- transferir dinero entre cuentas, crear un pedido con sus artículos, registrar un usuario y su perfil -- necesitas atomicidad. Sin ella, un fallo entre las dos operaciones deja la base de datos en un estado imposible: dinero debitado pero no acreditado, un pedido sin artículos, un usuario sin perfil.

Una base de datos sin respaldos es una base de datos esperando ser el tema de un post-mortem. El hardware falla. Los humanos cometen errores. El software tiene bugs. La pregunta no es si necesitarás restaurar datos, sino cuándo.

La Sesión 166 fue una maratón. Cuatro áreas principales de funcionalidades en una sola sesión: transacciones ACID, respaldo y restauración, consultas de grafos y búsqueda semántica. Noventa y cuatro pruebas agregadas. Este artículo cubre las dos primeras -- transacciones y respaldo -- porque son la base que hace a FlinDB listo para producción.

Transacciones ACID

El sistema de transacciones de FlinDB proporciona las cuatro garantías ACID:

  • Atomicidad: Todos los cambios en una transacción tienen éxito o ninguno lo tiene
  • Consistencia: La base de datos pasa de un estado válido a otro
  • Aislamiento: Las transacciones concurrentes no interfieren entre sí
  • Durabilidad: Los cambios confirmados sobreviven a fallos

La estructura Transaction

Cada transacción es una unidad de trabajo autocontenida:

rustpub struct Transaction {
    id: TransactionId,
    started_at: i64,
    timeout_ms: Option<u64>,
    state: TransactionState,
    savepoints: Vec<Savepoint>,
    pending_saves: Vec<PendingSave>,
    pending_deletes: Vec<PendingDelete>,
    read_versions: HashMap<(String, u64), u64>,
}

Los campos pending_saves y pending_deletes son la clave de la atomicidad. Durante una transacción, no se aplican cambios al almacén de datos principal. En su lugar, se acumulan en estas listas pendientes. Solo cuando se llama a commit() se aplican todos los cambios de una vez. Si se llama a rollback(), las listas pendientes se descartan y la base de datos permanece sin cambios.

El campo read_versions habilita el control de concurrencia optimista. Cuando una transacción lee una entidad, registra el número de versión de la entidad. En el momento del commit, ZeroCore verifica si alguna de estas versiones ha cambiado. Si otra transacción modificó una entidad que esta transacción leyó, el commit falla con un error de conflicto -- previniendo actualizaciones perdidas.

Begin, Commit, Rollback

El ciclo de vida de la transacción es directo:

flindb.begin_transaction()

order = db.save("Order", { total: 0 })
item1 = db.save("OrderItem", { order_id: order.id, product: "Laptop" })
item2 = db.save("OrderItem", { order_id: order.id, product: "Mouse" })

db.commit()

Si cualquier guardado falla, toda la transacción puede revertirse:

flindb.begin_transaction()

try {
    db.save("Transfer", { from: account_a, to: account_b, amount: 1000 })
    db.save("Account", account_a.id, { balance: account_a.balance - 1000 })
    db.save("Account", account_b.id, { balance: account_b.balance + 1000 })
    db.commit()
} catch (e) {
    db.rollback()
}

La implementación en Rust del commit aplica todas las operaciones pendientes atómicamente:

rustpub fn commit_transaction(
    &mut self,
    txn_id: TransactionId,
) -> DatabaseResult<TransactionCommitResult> {
    let txn = self.transactions.remove(&txn_id)
        .ok_or(DatabaseError::TransactionNotFound)?;

    // Check for optimistic locking conflicts
    for ((entity_type, entity_id), read_version) in &txn.read_versions {
        let current_version = self.get_current_version(entity_type, *entity_id)?;
        if current_version != *read_version {
            return Err(DatabaseError::OptimisticLockConflict {
                entity_type: entity_type.clone(),
                entity_id: *entity_id,
            });
        }
    }

    // Apply all pending saves
    for save in txn.pending_saves {
        self.save(&save.entity_type, save.id, save.fields)?;
    }

    // Apply all pending deletes
    for delete in txn.pending_deletes {
        self.delete(&delete.entity_type, delete.id)?;
    }

    Ok(TransactionCommitResult {
        saves: txn.pending_saves.len(),
        deletes: txn.pending_deletes.len(),
    })
}

Savepoints

Los savepoints permiten reversiones parciales dentro de una transacción. Esto es esencial para flujos de trabajo complejos donde quieres deshacer el último paso sin perder todo:

flindb.begin_transaction()
order = db.save("Order", { total: 0 })
db.create_savepoint("after_order")

try {
    db.save("OrderItem", { order_id: order.id, product: "Laptop" })
    db.commit()
} catch (e) {
    db.rollback_to_savepoint("after_order")
    db.save("Order", order.id, { status: "failed" })
    db.commit()
}

El método rollback_to_savepoint() descarta todas las operaciones pendientes agregadas después de que se creó el savepoint, mientras conserva las operaciones de antes del savepoint.

Tiempos límite de transacción

Las transacciones de larga duración son peligrosas. Retienen recursos, bloquean otras operaciones y a menudo indican un error de programación (una transacción que se inició pero nunca se confirmó). FlinDB soporta tiempos límite configurables:

rustlet txn = db.begin_transaction_with_timeout(5000); // 5 seconds

Si la transacción no se confirma o revierte dentro del período de tiempo límite, se revierte automáticamente. Esto previene fugas de recursos por transacciones olvidadas.

Respaldo y restauración

Con las transacciones proporcionando atomicidad, el sistema de respaldo asegura durabilidad más allá del tiempo de vida de un solo proceso. FlinDB soporta tres estrategias de respaldo: completo, incremental y continuo.

Respaldo completo

Un respaldo completo captura el estado completo de la base de datos -- todos los esquemas, todas las entidades, todo el historial de versiones:

rustlet options = BackupOptions::default();
Backup::full(&db, "backup.flindb.bak", options)?;

El formato del archivo de respaldo usa compresión Zstd:

.flindb.bak (Zstd compressed JSON)
+-- header: magic, version, type, timestamp, checksum
+-- metadata: schema count, entity count
+-- schemas: serialized EntitySchema[]
+-- entities: by type with full history

¿Por qué Zstd? Evaluamos tres algoritmos de compresión:

AlgoritmoRatio de compresiónVelocidad
Zstd (nivel 3)11% menor que gzip42% más rápido que Brotli
GzipLínea baseLínea base
Brotli8% menor que Zstd42% más lento que Zstd

Zstd ofreció el mejor equilibrio: casi el mejor ratio de compresión con compresión y descompresión significativamente más rápidas. Para un sistema de respaldo donde importan tanto la velocidad de creación como la velocidad de restauración, Zstd fue el claro ganador.

Cada respaldo incluye un checksum SHA-256 de la carga útil de datos. Al restaurar, el checksum se verifica antes de que se aplique cualquier dato. Un archivo de respaldo corrupto se rechaza en lugar de cargar datos ilegibles silenciosamente.

Respaldo incremental

Los respaldos incrementales capturan solo los cambios desde el último respaldo, usando el WAL como fuente de deltas:

rustlet options = BackupOptions::incremental(last_backup_version);
Backup::incremental(&db, "backup_incr.flindb.bak", options)?;

Los respaldos incrementales son más pequeños y rápidos que los respaldos completos, haciéndolos adecuados para intervalos de respaldo frecuentes. Para restaurar, aplicas el último respaldo completo seguido de todos los respaldos incrementales subsiguientes en orden.

Recuperación a un punto en el tiempo

FlinDB soporta restauración a una marca temporal específica:

rustlet options = RestoreOptions::point_in_time(target_timestamp);
let db = Backup::restore("backup.flindb.bak", options)?;

La recuperación a un punto en el tiempo reproduce las versiones de entidades hasta la marca temporal especificada, efectivamente rebobinando la base de datos a un estado pasado. Esto es posible porque el modelo temporal de FlinDB preserva todas las versiones -- el respaldo contiene el historial completo, y el proceso de restauración puede detenerse en cualquier punto de ese historial.

Respaldo continuo

La Sesión 170 extendió el sistema de respaldo con streaming WAL continuo. En lugar de snapshots periódicos, la estructura ContinuousBackup transmite cada entrada WAL a un destino de respaldo en tiempo real:

rustpub struct ContinuousBackup {
    source_wal: PathBuf,
    destination: BackupDestination,
    last_position: Arc<AtomicU64>,
    running: Arc<AtomicBool>,
    poll_interval: Duration,
}

El destino de respaldo puede ser local o compatible con S3:

rustpub enum BackupDestination {
    Local(PathBuf),
    S3 {
        bucket: String,
        region: String,
        endpoint: Option<String>,
        prefix: String,
        access_key: String,
        secret_key: String,
    },
}

El respaldo continuo se ejecuta en un hilo de fondo, sondeando el WAL en busca de nuevas entradas y transmitiéndolas al destino:

rustlet backup = ContinuousBackup::new(wal_path, BackupDestination::local(dest_path))
    .with_poll_interval(Duration::from_millis(50))
    .with_start_position(1000);  // Resume from position

let handle = backup.start();
// Application runs...
backup.stop();
handle.join().unwrap();

El método with_start_position() habilita la capacidad de reanudación. Si el proceso de respaldo se reinicia, retoma desde donde se quedó en lugar de retransmitir todo el WAL. Esto es crítico para uso en producción donde los procesos de respaldo pueden reiniciarse durante despliegues.

Para destinos S3, las entradas se agrupan en lotes de 1 MB antes de la carga para minimizar el número de llamadas a la API de S3 y los costos asociados.

Programación de respaldos

El BackupScheduler automatiza respaldos periódicos con políticas de retención:

rustlet scheduler = BackupScheduler::new(
    Duration::from_secs(3600),  // Every hour
    24,                          // Keep 24 backups
    "./backups",
)
.with_backup_type(BackupType::Full)
.with_compression(true);

let handle = scheduler.start(Arc::new(Mutex::new(db)));

El programador se ejecuta en un hilo de fondo, creando respaldos en el intervalo configurado y aplicando la política de retención eliminando los respaldos más antiguos cuando el conteo excede el límite.

En la sintaxis de configuración FLIN, la configuración de respaldo es declarativa:

flinapp {
    backup: {
        enabled: true
        continuous: {
            destination: "local"
            path: "./backups/"
        }
        schedule: {
            interval: "1h"
            retention: 24
            type: "full"
            compression: true
        }
    }
}

La suite de pruebas

Las transacciones y respaldos juntos representan 55 pruebas entre las Sesiones 166 y 170:

Pruebas de transacciones (12): - Ciclo de vida begin/commit/rollback - Creación de savepoints y reversión parcial - Aplicación de tiempos límite de transacción - Detección de conflictos de bloqueo optimista - Detalles del resultado del commit

Pruebas de respaldo (21 de la Sesión 166): - Creación y verificación de respaldo completo - Creación de respaldo incremental - Ciclo completo de compresión Zstd - Verificación de checksum SHA-256 - Recuperación a un punto en el tiempo - Opciones de configuración de restauración

Pruebas de respaldo continuo y programación (22 de la Sesión 170): - Validación de BackupDestination (local y S3) - Streaming y seguimiento de posición de ContinuousBackup - Creación, retención y limpieza de BackupScheduler - Capacidad de reanudación después de reinicio

Total de pruebas después de la Sesión 170: 2.365 (1.748 de biblioteca + 617 de integración).

Por qué importan tanto las transacciones como los respaldos

Las transacciones protegen contra fallos a nivel de aplicación -- un crash durante una operación de múltiples pasos. Los respaldos protegen contra fallos a nivel de infraestructura -- corrupción de disco, eliminación accidental, fallo de hardware.

Sin transacciones, una pérdida de energía durante la creación de un pedido podría dejar un pedido sin artículos. Sin respaldos, un fallo de disco podría perder todos los datos permanentemente. Juntos, forman una historia de durabilidad completa: las transacciones aseguran consistencia dentro de un sistema en ejecución, y los respaldos aseguran recuperabilidad cuando el sistema mismo falla.

FlinDB proporciona ambos, sin configuración requerida. Las transacciones siempre están disponibles. El WAL proporciona recuperación tras fallos por defecto. Y con unas pocas líneas de configuración, el respaldo continuo y la rotación programada aseguran que los datos sobrevivan a cualquier fallo.


Esta es la Parte 8 de la serie "Cómo construimos FlinDB", documentando cómo construimos un motor de base de datos embebido completo para el lenguaje de programación FLIN.

Navegación de la serie: - [061] Index Utilization: Making Queries Fast - [062] Relationships and Eager/Lazy Loading - [063] Transactions and Continuous Backup (estás aquí) - [064] Graph Queries and Semantic Search - [065] The EAVT Storage Model

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles