SQLite WAL y concurrencia: cómo evitar database is locked

SQLite WAL y concurrencia entre lectores y escritores

SQLite suele empezar como una base de datos sencilla para una herramienta local, una API pequeña o un prototipo. El problema aparece cuando llegan varios workers, lecturas largas y escrituras simultáneas: comienzan los errores database is locked y es fácil aplicar soluciones que solo esconden la causa.

El modo WAL (Write-Ahead Logging) mejora la convivencia entre lectores y escritores, pero no convierte SQLite en una base de datos con escritores ilimitados. En esta guía aprenderás qué cambia con WAL, cuándo usar busy_timeout, cuándo elegir BEGIN IMMEDIATE, cómo cuidar los checkpoints y cuándo dejar de ajustar SQLite para pasar a PostgreSQL o MySQL.

Índice de Contenido 💻
  1. El límite que no desaparece: un escritor a la vez
  2. Activar WAL y comprobar el resultado
  3. Qué hace busy_timeout
  4. BEGIN frente a BEGIN IMMEDIATE
  5. Un patrón seguro para una aplicación web
  6. Reintentar sin duplicar operaciones
  7. Lecturas largas y checkpoints
  8. Una conexión compartida no es un pool seguro
  9. Cómo reproducir un database is locked
  10. Cuándo SQLite deja de ser la elección adecuada
  11. Checklist de producción
  12. Conclusión

El límite que no desaparece: un escritor a la vez

En WAL, los lectores pueden continuar mientras un escritor agrega cambios al archivo de log. Sin embargo, SQLite sigue permitiendo un solo escritor activo a la vez. La documentación oficial de SQLite sobre WAL describe precisamente esa ventaja y ese límite.

Por eso WAL no arregla una aplicación que abre transacciones de escritura durante mucho tiempo. Si una petición mantiene un UPDATE abierto mientras hace llamadas de red, el siguiente escritor seguirá esperando o recibirá un error de ocupado.

Activar WAL y comprobar el resultado

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;

journal_mode=WAL cambia el modo de diario de la base. synchronous=NORMAL puede ser una elección razonable para muchas aplicaciones, pero debes decidirla según el nivel de durabilidad que necesitas ante un corte de energía. No copies esta combinación sin entender el coste.

Estas instrucciones se aplican por conexión o quedan persistidas según el pragma. Comprueba el valor real después de abrir la base y configura cada conexión del pool. No asumas que una conexión nueva heredará todos los ajustes temporales.

Qué hace busy_timeout

PRAGMA busy_timeout = 5000 indica a SQLite que espere hasta cinco segundos cuando encuentra un bloqueo antes de devolver SQLITE_BUSY. La documentación de SQLite sobre PRAGMA aclara que cada conexión solo puede tener un busy handler, por lo que este ajuste puede reemplazar otro que tu binding ya hubiera instalado.

El timeout no libera una transacción bloqueada ni convierte una escritura lenta en rápida. Solo evita fallar inmediatamente ante una contención breve. Si los errores aparecen después de cinco segundos, mide la duración de las transacciones y busca al escritor que mantiene el bloqueo.

BEGIN frente a BEGIN IMMEDIATE

Una transacción que comienza con BEGIN puede iniciar como lectura y después intentar escalar a escritura. Si otro escritor ya confirmó cambios, en WAL esa lectura puede estar viendo una instantánea antigua y el intento de escalar puede terminar en SQLITE_BUSY_SNAPSHOT.

-- Lectura que quizá intente escalar más tarde
BEGIN;
SELECT balance FROM account WHERE account_id = ?;
UPDATE account SET balance = balance - ? WHERE account_id = ?;
COMMIT;

Si desde el principio sabes que habrá una escritura, declara la intención:

BEGIN IMMEDIATE;

SELECT balance FROM account WHERE account_id = ?;
UPDATE account
SET balance = balance - ?
WHERE account_id = ?;

COMMIT;

SQLite explica que BEGIN IMMEDIATE inicia una transacción de escritura y bloquea a otros escritores desde el principio. No lo uses para todas las lecturas: ocupar el turno de escritura sin necesidad reduce la concurrencia.

Un patrón seguro para una aplicación web

Una función de escritura debe abrir la conexión, configurar el timeout, iniciar una transacción corta, ejecutar solo trabajo de base de datos y cerrarla. Las llamadas HTTP, el procesamiento pesado y la generación de archivos deben ocurrir fuera de la transacción:

import sqlite3;

export function openDatabase(filename) {
  const db = new sqlite3.Database(filename);
  db.exec(`
    PRAGMA journal_mode = WAL;
    PRAGMA busy_timeout = 5000;
    PRAGMA foreign_keys = ON;
  `);
  return db;
}

export function reserveStock(db, productId, amount) {
  return new Promise((resolve, reject) => {
    db.serialize(() => {
      db.run('BEGIN IMMEDIATE', (beginError) => {
        if (beginError) return reject(beginError);
        db.run(
          `UPDATE stock
           SET available = available - ?
           WHERE product_id = ? AND available >= ?`,
          [amount, productId, amount],
          function (updateError) {
            if (updateError) {
              return db.run('ROLLBACK', () => reject(updateError));
            }
            db.run('COMMIT', commitError => {
              if (commitError) return reject(commitError);
              resolve(this.changes === 1);
            });
          }
        );
      });
    });
  });
}

El ejemplo debe adaptarse al driver que uses. La idea importante es que el cambio y la comprobación de disponibilidad estén en la misma transacción. Si changes es cero, no había stock suficiente y no debes crear la reserva.

Reintentar sin duplicar operaciones

Un reintento puede ser válido para una escritura idempotente, pero no debes volver a ejecutar ciegamente un pago, un correo o una reserva sin una clave de idempotencia. Separa el error de contención temporal de un error lógico o de integridad.

async function withBusyRetry(operation, attempts = 4) {
  for (let i = 0; i < attempts; i += 1) {
    try {
      return await operation();
    } catch (error) {
      const busy = error.code === 'SQLITE_BUSY' ||
                   error.code === 'SQLITE_BUSY_SNAPSHOT';
      if (!busy || i === attempts - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 40 * 2 ** i));
    }
  }
}

El backoff solo tiene sentido si la operación puede repetirse sin producir dos efectos. Añade una restricción única o una clave de idempotencia cuando el dominio lo necesite. Aumentar los reintentos sin límites puede convertir la saturación en una cola más larga.

Lecturas largas y checkpoints

Un checkpoint transfiere páginas del WAL al archivo principal. Puede ejecutarse mientras hay lectores, pero una lectura larga puede impedir que avance más allá del punto que esa lectura necesita. Además, cuando el WAL crece, los lectores tienen más trabajo de consulta.

Busca consultas que mantengan un cursor abierto mientras generan una respuesta lenta, exportan miles de filas o esperan a otro servicio. Pagina los resultados, cierra la transacción pronto y ejecuta checkpoints de forma controlada. No borres manualmente el archivo -wal con conexiones activas: podrías dañar la recuperación de la base.

PRAGMA wal_checkpoint(PASSIVE);

El modo exacto del checkpoint depende del caso. PASSIVE intenta avanzar sin esperar a los lectores. Antes de cambiar a opciones más agresivas, mide el tamaño del WAL y la duración de las transacciones.

Una conexión compartida no es un pool seguro

SQLite permite varias conexiones al mismo archivo, pero un objeto de conexión compartido entre operaciones concurrentes puede crear problemas de orden y aislamiento dentro de la propia conexión. Diseña una política explícita: una conexión por unidad de trabajo, un serializador de escrituras o un pool pequeño que el driver soporte correctamente.

En particular, evita mezclar una transacción abierta por una petición con consultas de otra petición en la misma conexión. La frontera de la transacción debe coincidir con la frontera de la operación que estás ejecutando.

Cómo reproducir un database is locked

  1. Abre dos conexiones al mismo archivo.
  2. Activa WAL y un timeout bajo para que el fallo aparezca rápido.
  3. En la primera conexión inicia BEGIN IMMEDIATE y deja la transacción abierta.
  4. En la segunda intenta escribir y mide cuánto tarda en devolver SQLITE_BUSY.
  5. Repite el test cerrando la primera transacción rápidamente.
  6. Registra inicio, commit, rollback, duración y código de error de cada transacción.

Esta prueba te permite distinguir contención normal de una transacción que quedó abierta por un bug. Sin mediciones, subir el timeout solo cambia el momento en que el usuario ve el error.

Cuándo SQLite deja de ser la elección adecuada

SQLite encaja muy bien en aplicaciones locales, herramientas de escritorio, servicios pequeños y cargas con un único escritor coordinado. Reconsidera la arquitectura cuando necesitas muchos writers concurrentes, varias máquinas escribiendo al mismo archivo, transacciones que duran segundos, replicación entre servidores o una administración central de usuarios y permisos.

WAL funciona mejor cuando todos los procesos están en el mismo host y el sistema de archivos cumple las condiciones documentadas por SQLite. No trates un archivo compartido por red como si fuera una base de datos cliente-servidor sin probar las garantías del entorno.

Checklist de producción

  • WAL activado y verificado en las conexiones que realmente usa la aplicación.
  • Transacciones de escritura cortas, sin llamadas de red dentro.
  • busy_timeout configurado por conexión y acompañado de métricas.
  • BEGIN IMMEDIATE solo para operaciones que saben que escribirán.
  • Reintentos limitados y solo para operaciones idempotentes.
  • Lecturas paginadas para evitar snapshots prolongados.
  • Monitoreo del tamaño del WAL, checkpoints y duración de transacciones.
  • Plan claro para migrar a PostgreSQL o MySQL si crece la concurrencia de escritores.

Conclusión

WAL no elimina el límite de un escritor, pero sí permite que muchos lectores convivan con una escritura bien diseñada. La combinación práctica es sencilla: transacciones cortas, timeout moderado, intención de escritura explícita, reintentos idempotentes y checkpoints observables. Cuando los síntomas muestran que el archivo necesita coordinar demasiados escritores o servidores, el problema ya no es encontrar otro pragma: es elegir una arquitectura de base de datos distinta.

Fuentes técnicas: SQLite Write-Ahead Logging, SQLite Isolation y SQLite PRAGMA.

Si quieres conocer otros artículos parecidos a SQLite WAL y concurrencia: cómo evitar database is locked puedes visitar la categoría Blog.

También puede gustarte:

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Subir