El modo Write-Ahead Logging (WAL) de SQLite se promociona como la solucion a las limitaciones de concurrencia de SQLite. En lugar de bloquear toda la base de datos para escrituras, el modo WAL escribe los cambios en un archivo de log separado. Los lectores pueden continuar leyendo la base de datos principal mientras un escritor agrega al log. Suena perfecto. En la practica, introdujo condiciones de carrera sutiles que tomaron multiples sesiones diagnosticar y fueron finalmente una de las razones principales por las que migramos a PostgreSQL.
Este articulo documenta los errores relacionados con WAL que encontramos, los parches que aplicamos y por que esos parches nunca fueron del todo suficientes.
Como funciona el modo WAL
En el modo de journal predeterminado, SQLite bloquea el archivo de base de datos durante las escrituras. Cualquier lector concurrente se bloquea hasta que la escritura se complete. El modo WAL cambia esto:
Archivo de base de datos principal (.db)
|
+-- Archivo WAL (.db-wal) -- log secuencial de cambios no confirmados
|
+-- Archivo SHM (.db-shm) -- memoria compartida para coordinacion
Operacion de escritura:
1. Agregar cambios al archivo WAL
2. Los lectores con conexiones antiguas ven el archivo .db principal (pre-cambio)
3. Cuando WAL alcanza un umbral, el checkpoint fusiona WAL de vuelta al .dbLa propiedad clave: las nuevas conexiones abiertas despues de una escritura pueden todavia ver datos obsoletos si se conectan antes de que el WAL sea checkpointed. El archivo de base de datos principal solo se actualiza durante operaciones de checkpoint.
El error get_provider() vs. get_instance()
La sesion 060 revelo uno de los errores mas confusos que encontramos. El registro de proveedores tenia dos metodos: get_provider() (que devolvia una clase) y get_instance() (que devolvia una instancia inicializada). Algunas rutas de codigo llamaban a get_provider() esperando una instancia y obtenian una clase, llevando a errores TypeError: 'type' object is not callable.
Pero el error era intermitente. Funcionaba a veces y fallaba otras. La conexion WAL explicaba por que:
python# El registro de proveedores cargaba proveedores de la base de datos al arranque
class ProviderRegistry:
def __init__(self):
self._providers = {}
self._instances = {}
async def load_providers(self):
"""Cargar todas las configuraciones de proveedores de la base de datos."""
# Esta consulta podria ver datos obsoletos si WAL no ha hecho checkpoint
providers = await db.execute(text("SELECT * FROM providers"))
for provider in providers:
self._providers[provider.name] = load_provider_class(provider.type)
self._instances[provider.name] = self._providers[provider.name](
config=provider.config
)
def get_provider(self, name: str):
"""Devuelve la CLASE del proveedor."""
return self._providers.get(name)
def get_instance(self, name: str):
"""Devuelve una INSTANCIA inicializada del proveedor."""
return self._instances.get(name)Cuando un nuevo proveedor era agregado a la base de datos en una conexion, y el registro se recargaba en otra conexion, el modo WAL podia servir el estado antiguo de la base de datos. El registro cargaria sin el nuevo proveedor. Las llamadas posteriores a get_instance("new_provider") devolverian None, causando fallas posteriores.
La correccion inmediata fue usar get_instance() consistentemente:
python# ANTES: Uso mixto
provider_class = registry.get_provider(provider_name) # Obtiene clase
result = provider_class.process(...) # A veces funciona, a veces TypeError
# DESPUES: Uso consistente
provider = registry.get_instance(provider_name) # Obtiene instancia
if not provider:
raise HTTPException(400, f"Provider '{provider_name}' not found or not configured")
result = await provider.process(...)PRAGMA wal_checkpoint(PASSIVE) despues de commits criticos
La sesion 072 introdujo checkpointing WAL explicito despues de operaciones donde las lecturas obsoletas serian peligrosas:
python# database.py
async def checkpoint_wal():
"""Forzar un checkpoint WAL para sincronizar datos al archivo principal de base de datos."""
async with get_db_connection() as conn:
await conn.execute(text("PRAGMA wal_checkpoint(PASSIVE)"))
# Usado despues de operaciones criticas async def create_transaction(data: dict) -> Transaction: transaction = Transaction(**data) db.add(transaction) await db.commit() BLANK # Forzar checkpoint para que los manejadores de webhook vean la transaccion await checkpoint_wal() BLANK return transaction ```
Identificamos seis puntos criticos de commit que requerian checkpointing:
| Operacion | Por que se necesita checkpoint |
|---|---|
| Creacion de transaccion | Los manejadores de webhook deben ver la transaccion inmediatamente |
| Actualizacion de estado de transaccion | Las consultas del panel deben reflejar el nuevo estado |
| Configuracion de proveedor | La recarga del registro de proveedores debe ver nuevas configs |
| Creacion de clave API | El middleware de autenticacion debe validar nuevas claves |
| Creacion de app | El motor de enrutamiento debe ver nuevas configuraciones de app |
| Creacion de usuario | El callback OAuth debe encontrar al usuario recien creado |
¿Por que PASSIVE y no FULL?
SQLite ofrece tres modos de checkpoint:
sqlPRAGMA wal_checkpoint(PASSIVE); -- Checkpoint todo lo posible sin bloquear
PRAGMA wal_checkpoint(FULL); -- Checkpoint todo, bloqueando escritores
PRAGMA wal_checkpoint(TRUNCATE); -- Checkpoint y truncar archivo WALSe eligio PASSIVE porque no bloquea operaciones concurrentes. Hace checkpoint de todas las paginas que no estan actualmente en uso por lectores o escritores activos. En la mayoria de los casos, esto sincroniza todas las escrituras recientes. FULL garantizaria sincronizacion completa pero bloquearia todo otro acceso a la base de datos durante el checkpoint -- inaceptable para un servidor web manejando solicitudes concurrentes.
La columna updated_at invalida
Durante la depuracion de WAL, descubrimos que la columna updated_at en varios modelos no se estaba configurando correctamente. El trigger ON UPDATE de SQLite se comportaba diferente de lo esperado con el modo WAL:
python# El problema: updated_at se quedaba como la hora de creacion original
class Transaction(Base):
updated_at = Column(DateTime, onupdate=func.now())
# En SQLite con WAL, el trigger onupdate a veces se disparaba
# contra una version obsoleta de la fila, produciendo marcas de tiempo incorrectasLa correccion fue establecer updated_at explicitamente en el codigo de la aplicacion en lugar de depender de triggers a nivel de base de datos:
pythonasync def update_transaction_status(tx_id: str, new_status: str):
transaction = await get_transaction(tx_id)
transaction.status = new_status
transaction.updated_at = datetime.utcnow() # Explicito, no basado en trigger
await db.commit()
await checkpoint_wal()Correcciones de sincronizacion no bloqueantes
El desafio central con el modo WAL es que no hay forma confiable de garantizar que un lector vea las ultimas escrituras sin coordinacion explicita. Implementamos varios patrones para sortear esto:
Patron 1: Leer despues de escribir en la misma conexion
python# Usar la MISMA conexion para escritura y lectura posterior
async def create_and_return_transaction(data: dict) -> Transaction:
async with get_db_connection() as conn:
# Escribir
await conn.execute(
text("INSERT INTO transactions (...) VALUES (...)"),
data
)
await conn.commit()
# Leer en la misma conexion -- garantizado ver la escritura
result = await conn.execute(
text("SELECT * FROM transactions WHERE id = :id"),
{"id": data["id"]}
)
return result.fetchone()Patron 2: Reintento con retroceso exponencial
pythonasync def get_transaction_with_retry(tx_id: str, max_retries: int = 3) -> Transaction:
"""Reintentar si WAL no ha sincronizado aun."""
for attempt in range(max_retries):
transaction = await get_transaction(tx_id)
if transaction:
return transaction
if attempt < max_retries - 1:
await asyncio.sleep(0.1 * (2 ** attempt)) # 100ms, 200ms, 400ms
raise HTTPException(404, f"Transaction {tx_id} not found")Patron 3: Cache de escritura directa
python# Para datos accedidos frecuentemente (proveedores, monedas),
# mantener un cache en memoria actualizado sincronicamente en escrituras
class WriteThrough:
def __init__(self):
self._cache = {}
async def write(self, key: str, value: any):
# Escribir a base de datos
await db.execute(...)
await db.commit()
# Actualizar cache inmediatamente (evitar retraso WAL)
self._cache[key] = value
def read(self, key: str) -> any:
# Leer del cache (siempre actualizado)
return self._cache.get(key)Por que esto nos empujo hacia PostgreSQL
Cada correccion que aplicamos era un parche sobre una limitacion fundamental. El modo WAL de SQLite esta diseñado para escenarios de una sola aplicacion (apps moviles, software de escritorio) donde un proceso posee la base de datos. Un servidor web con multiples conexiones concurrentes empuja el modo WAL mas alla de su diseño.
Los problemas que encontramos:
| Problema | Solucion SQLite | Solucion PostgreSQL |
|---|---|---|
| Lecturas obsoletas despues de escrituras | Checkpoint PRAGMA explicito | MVCC -- los lectores siempre ven el estado confirmado |
| Un escritor a la vez | Escrituras serializadas con timeouts | Multiples escritores concurrentes |
| Conexion ve datos obsoletos | Leer-despues-de-escribir en misma conexion | Cualquier conexion ve los ultimos datos confirmados |
| Problemas con trigger updated_at | Configuracion explicita de marca de tiempo | Ejecucion confiable de triggers |
| Bloqueo de checkpoint | Modo PASSIVE (sincronizacion incompleta) | No se necesita checkpointing |
El Control de Concurrencia Multi-Version (MVCC) de PostgreSQL elimina cada problema relacionado con WAL que encontramos. Cada transaccion ve una instantanea consistente de la base de datos. Las escrituras son inmediatamente visibles para nuevas transacciones (despues del commit). No hay archivo de log separado que necesite sincronizacion periodica.
Lo que aprendimos
El modo WAL no hace de SQLite una base de datos concurrente. Mejora la concurrencia de lectura, pero el modelo fundamental sigue siendo de escritor unico. Para cualquier aplicacion que necesite escrituras concurrentes -- lo que incluye todo servidor web -- el modo WAL es una mitigacion, no una solucion.
Los errores intermitentes son los peores errores. Los problemas relacionados con WAL aparecian aleatoriamente. Una prueba pasaba 9 de 10 veces y fallaba en la decima. Esto los hacia extremadamente dificiles de reproducir y diagnosticar. Cuando veas fallas intermitentes con SQLite, sospecha de lecturas obsoletas de WAL.
El checkpointing explicito es un code smell. Cuando el codigo de tu aplicacion necesita llamadas PRAGMA wal_checkpoint() despues de operaciones criticas, estas peleando contra el motor de base de datos. La base de datos deberia garantizar consistencia sin intervencion a nivel de aplicacion.
SQLite es fenomenal para lo que fue diseñado. Es la mejor base de datos embebida del mundo. Pero una plataforma de pagos con solicitudes API concurrentes, manejadores de webhook y workers en segundo plano no es un caso de uso de base de datos embebida. Elegir la herramienta correcta para el trabajo correcto importa mas que cualquier cantidad de soluciones creativas.
Las condiciones de carrera WAL nos costaron aproximadamente 15 horas a traves de las sesiones 060, 072 y varias sesiones de depuracion intermedias. La migracion a PostgreSQL (sesion 081) elimino todos estos problemas permanentemente.
Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.