Despliegas una funcionalidad perfectamente razonable. Funciona en staging. En producción obtienes ese tipo de error que suena como si la base de datos fuera demasiado educada para decir lo que piensa: database is locked, SQLITE_BUSY, o una pila de mensajes “Lock wait timeout exceeded”.
El bloqueo no es un bug. Es la forma en que las bases de datos evitan que tu dinero se gaste dos veces y que las filas se conviertan en arte moderno. Pero el modelo de bloqueo que elijas cambia los modos de fallo que verás. SQLite falla de forma ruidosa con “busy”. MariaDB suele fallar más tarde, con el lento apilamiento de hilos hasta que tu app parece moverse a través de cemento húmedo.
Playbook de diagnóstico rápido
Cuando estás de guardia, no quieres una clase de filosofía. Quieres «¿qué reviso primero para dejar de sangrar?» Aquí está la secuencia de triaje que uso para errores “busy” de SQLite y contención de locks en MariaDB.
Primero: confirma si estás bloqueado por locks o atascado por I/O
- SQLite: “busy” suele ser contención por locks, pero también puede ser “el escritor no puede checkpointear”, que parece contención y se siente como latencia.
- MariaDB: las esperas por locks y los stalls de disco pueden parecer idénticos en la capa de aplicación (las peticiones hacen timeout). Necesitas separar “hilos esperando locks” de “hilos esperando almacenamiento”.
Segundo: encuentra la transacción que mantiene la puerta cerrada
- SQLite: localiza transacciones de larga duración, conexiones abiertas o jobs en background que escriben mientras la app lee.
- MariaDB: identifica el hilo/transacción bloqueante y el grafo de esperas (estado de InnoDB y processlist).
Tercero: arregla el scope y la concurrencia antes de afinar timeouts
Los timeouts son una tirita. A veces la tirita correcta. Pero siguen siendo una tirita.
- Acorta las transacciones.
- Reduce la frecuencia de escrituras o agrúpalas inteligentemente.
- Añade el índice correcto para evitar amplificación de locks.
- Sólo entonces: ajusta
busy_timeout(SQLite) oinnodb_lock_wait_timeout(MariaDB), además de lógica de reintentos con jitter.
Una regla fiable: si tu “arreglo” es “aumentar el timeout”, solo estás escogiendo cuánto tiempo tardará en fallar.
Modelos de bloqueo: qué se bloquea realmente y cuándo
SQLite: un escritor a la vez, y en serio
SQLite es una base de datos embebida. Es una librería enlazada en tu proceso, escribiendo a un archivo. Ese archivo es el recurso compartido. Cuando ves “busy”, SQLite te está diciendo que no puede adquirir el lock que necesita en ese archivo (o en los archivos de coordinación WAL/memoria compartida que acompañan a WAL).
La realidad operativa clave:
- Muchos lectores pueden coexistir.
- Sólo un escritor puede hacer commit a la vez. Incluso si varias conexiones se encolan, sólo una será “el escritor”.
- Las transacciones definen la duración del lock. Una “pequeña actualización” dentro de una transacción que se mantiene abierta durante segundos se convierte en “un lock sostenido durante segundos”.
SQLite tiene múltiples modos de journaling. Dos importan en discusiones de producción:
- Rollback journal (el antiguo por defecto en muchos modelos mentales): los escritores pueden bloquear a los lectores más fácilmente dependiendo del estado del lock, y el checkpointing es más simple pero puede ser disruptivo bajo concurrencia.
- WAL (Write-Ahead Logging): los lectores por lo general no bloquean a los escritores y los escritores no suelen bloquear a los lectores, pero aparece un nuevo modo de fallo: el archivo WAL crece hasta que puede checkpointearse, y el propio checkpointing puede volverse contencioso.
La concurrencia de SQLite no es “mala”. Es explícita. Te obliga a ser honesto sobre los patrones de escritura. Eso es una ventaja hasta que finges que es un servidor OLTP en red.
MariaDB (InnoDB): locks por fila, gap locks y encolamiento silencioso
MariaDB con InnoDB es una base de datos clásica cliente-servidor. Tiene un buffer pool, un gestor de locks, hilos en background y mucho código dedicado a permitir que muchas sesiones trabajen concurrentemente sin pisarse entre sí.
La realidad operativa aquí:
- Varios escritores pueden ejecutarse concurrentemente siempre que no toquen las mismas filas (o rangos, gracias al next-key locking).
- Las esperas por locks son a menudo silenciosas. Tu consulta se ejecuta y luego se atasca. El cliente ve “está lento”, no “está ocupado”.
- Un mal indexado causa amplificación de locks. Un índice faltante puede convertir una operación por fila en un escaneo de rango que bloquea todo el vecindario.
InnoDB también tiene deadlocks, que no son el apocalipsis: InnoDB los detecta y aborta a un participante. El problema es cuando tu aplicación trata los deadlocks como “esto nunca debe pasar” y responde con “pánico, reintentar al instante, repetir para siempre”.
Idea parafraseada (no cita literal): Werner Vogels ha insistido en que se diseña para el fallo y se construyen sistemas que asumen que los componentes se comportarán mal. Los locks son uno de esos componentes.
Datos interesantes e historia corta para usar en una reunión
- SQLite es anterior a muchas pilas web “modernas”. El desarrollo comenzó en 2000 como un motor SQL autocontenido para uso embebido.
- El diseño “sin servidor” de SQLite es literal. No hay un demonio. Cada proceso es su propio cliente de base de datos y servidor a tiempo parcial, compitiendo por locks de archivo.
- El modo WAL se introdujo para mejorar la concurrencia separando lecturas y escrituras mediante un log append-only, pero introduce dinámicas de checkpointing que debes gestionar.
- InnoDB no fue originalmente “el motor por defecto de MySQL”. Se convirtió en estándar de facto porque aportó transacciones, recuperación tras fallo y locking a nivel de fila que MyISAM no ofrecía.
- MariaDB es un fork creado tras la adquisición de Sun por Oracle, en gran parte para mantener el desarrollo de MySQL abierto y comunitario.
- “database is locked” en SQLite suele ser un bug de la aplicación, como una transacción dejada abierta durante llamadas de red o una conexión filtrada en un pool.
- Los gap locks y next-key locks de InnoDB existen para evitar phantoms bajo ciertos niveles de aislamiento; pueden sorprender a ingenieros que asumen que sólo se bloquean “las filas que toqué”.
- SQLite usa primitivas de bloqueo POSIX/Win32, lo que significa que NFS y otros sistemas de archivos en red pueden convertir el locking de determinista a “emocionante”.
- SQLite se usa en más sitios de los que la gente imagina: navegadores, sistemas operativos móviles y un sinfín de apps de escritorio—porque la huella operativa es pequeña y la fiabilidad alta cuando se usa como corresponde.
Chiste #1: SQLite es como un puente de un carril—seguro, simple y todos cruzan, pero sólo un camión a la vez.
Qué significa realmente “busy” (y qué no)
SQLITE_BUSY es SQLite diciendo: “Intenté obtener el lock que necesito y no pude, y no voy a esperar para siempre a menos que me lo hayas dicho.” El comportamiento por defecto en muchos bindings es efectivamente “no esperar”. Por eso los errores “busy” aparecen tan pronto como la concurrencia aumenta un poco.
Los tres culpables habituales
- Una transacción de escritura de larga duración. Un job en background inicia una transacción, escribe muchas filas y se toma su tiempo. Todos los demás experimentan “busy”.
- Presión en el checkpoint de WAL. El archivo WAL crece. Eventualmente el checkpointing necesita cooperación de los lectores. Un lector largo puede impedir el progreso del checkpoint.
- Varios procesos en un sistema de archivos que miente sobre locks. Los sistemas de archivos en red y controladores de volúmenes de contenedores pueden convertir un uso correcto de SQLite en fallos aleatorios.
Lo que no es
- No es principalmente “SQLite es lento”. SQLite puede ser extremadamente rápido en SSD local, especialmente para cargas de solo lectura.
- No se arregla con “solo añade reintentos”. Si tus transacciones son largas y la concurrencia es sostenida, solo construirás una tormenta de reintentos.
- No es algo que debas silenciar. Trata “busy” como una señal de que tu modelo de concurrencia no coincide con el uso.
Contención en MariaDB/InnoDB: el primo más silencioso y sigiloso
MariaDB rara vez te lanza “busy” en la cara. Te deja encolarte. Eso parece más amable, hasta que los hilos de tu aplicación se apilan como aviones en espera. Tu p95 de latencia se dispara. Entonces tu pool de conexiones se satura. Entonces tus llamantes comienzan a reintentar. Y ahora te has inventado un propio ataque de denegación de servicio distribuido.
Cómo suele manifestarse el dolor de locks en MariaDB
- Consultas lentas sin uso de CPU (hilos “Sending data” o “Waiting for row lock”).
- Timeouts por espera de lock (eventualmente) y deadlocks (ocasionalmente, pero en ráfagas).
- Lag de replicación porque un hilo SQL del réplica está bloqueado detrás de una gran transacción o una espera por lock.
Las causas raíz comunes
- Transacciones demasiado grandes (actualizaciones masivas, cambios de esquema o código que “amablemente” envuelve todo en una transacción).
- Índices faltantes que convierten actualizaciones dirigidas en escaneos de rango y locking amplio.
- Sorpresas de nivel de aislamiento que introducen gap locks y bloquean inserts en “espacio vacío”.
- Filas calientes (contadores, “last_seen”, leaderboards) que provocan contención de escritura sin importar cuán buena sea la base de datos.
Chiste #2: Los deadlocks de InnoDB son como reuniones de oficina—alguien debe irse temprano para que algo avance.
Tareas prácticas: comandos, salidas y decisiones
Estas son las tareas que realmente ejecuto cuando diagnostico contención de locks. Cada una incluye: un comando que puedes ejecutar, qué significa la salida y la decisión que tomas a continuación.
Tareas de SQLite (comportamiento en base de datos local de archivo)
Task 1: Confirmar el modo de journal y si WAL está habilitado
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA journal_mode;"
wal
Significado: WAL está habilitado. Los lectores no deberían bloquear a los escritores en el caso común, pero el checkpointing se convierte en algo a vigilar.
Decisión: Si no estás en WAL y tienes alguna concurrencia, cambia a WAL a menos que estés en un sistema de archivos con locks poco fiables o tengas restricciones que requieran rollback journal.
Task 2: Comprobar el busy timeout configurado (lado SQLite)
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA busy_timeout;"
0
Significado: SQLite fallará inmediatamente con SQLITE_BUSY si no puede obtener el lock.
Decisión: Establece un busy timeout sensato (a menudo 2000–10000 ms) y corrige el scope de las transacciones. El timeout por sí solo no es una estrategia.
Task 3: Inspeccionar el umbral de autocheckpoint de WAL
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA wal_autocheckpoint;"
1000
Significado: SQLite intentará checkpointear después de ~1000 páginas en WAL (dependiendo del tamaño de página).
Decisión: Si ves crecimiento de WAL y stalls de checkpoint, ajusta esto y añade una rutina de checkpoint controlada durante el tráfico bajo.
Task 4: Comprobar si el archivo WAL está creciendo (síntoma de que el checkpoint no da abasto)
cr0x@server:~$ ls -lh /var/lib/myapp/app.db*
-rw-r----- 1 myapp myapp 64M Dec 30 11:40 /var/lib/myapp/app.db
-rw-r----- 1 myapp myapp 512M Dec 30 11:41 /var/lib/myapp/app.db-wal
-rw-r----- 1 myapp myapp 32K Dec 30 11:41 /var/lib/myapp/app.db-shm
Significado: El WAL es enorme en relación al archivo base; probablemente el checkpoint no está completando.
Decisión: Busca lectores de larga duración y considera checkpointing manual, acortar transacciones de lectura o mover lecturas pesadas de analítica a otro sitio.
Task 5: Encontrar procesos que mantienen los archivos de la base abiertos
cr0x@server:~$ sudo lsof /var/lib/myapp/app.db | head
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
myapp 2134 myapp 12u REG 253,0 67108864 91234 /var/lib/myapp/app.db
myapp 2134 myapp 13u REG 253,0 536870912 91235 /var/lib/myapp/app.db-wal
worker 2201 myapp 10u REG 253,0 67108864 91234 /var/lib/myapp/app.db
Significado: Dos procesos tienen la BD abierta; eso es normal, pero ahora sabes a quién culpar cuando ocurren locks.
Decisión: Si ves procesos inesperados (scripts de backup, cron jobs, “solo un reporte rápido”), detenlos o redirígelos a una réplica/exportación.
Task 6: Confirmar el tipo de sistema de archivos (SQLite en NFS es una tragedia recurrente)
cr0x@server:~$ findmnt -no FSTYPE,TARGET /var/lib/myapp
nfs4 /var/lib/myapp
Significado: Tu BD SQLite vive en NFS. Las semánticas de locking pueden ser poco fiables o lentas; los errores busy pueden ser aleatorios.
Decisión: Mueve la BD a almacenamiento local, o traslada la carga a MariaDB/Postgres. SQLite en NFS es un “funciona hasta que no funciona”.
Task 7: Medir si “busy” se correlaciona con flushes de disco lentos
cr0x@server:~$ iostat -x 1 3
Linux 6.8.0 (server) 12/30/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
3.10 0.00 1.20 22.50 0.00 73.20
Device r/s w/s rkB/s wkB/s await svctm %util
nvme0n1 5.0 420.0 200.0 5200.0 48.2 1.1 92.0
Significado: Alto %iowait, alto await, alto %util. Los escritores pueden mantener locks más tiempo porque fsync es lento.
Decisión: Arregla primero el almacenamiento (saturación de disco, vecino ruidoso, throttling) antes de reescribir la lógica de transacciones. Los locks no se liberan hasta que las escrituras se completan.
Task 8: Comprobar opciones de compilación de SQLite (a veces relevante en distros embebidas)
cr0x@server:~$ sqlite3 -cmd ".compile_options" ":memory:" | head
COMPILER=gcc-13.2.0
ENABLE_FTS5
ENABLE_RTREE
THREADSAFE=1
USE_URI
Significado: Seguridad de hilos habilitada; bien. En casos raros, builds inusuales pueden cambiar el comportamiento de locking.
Decisión: Si estás en una build embebida rara, estandariza el paquete SQLite entre entornos para reducir misterios de “solo pasa en prod”.
Tareas de MariaDB (locking y esperas en InnoDB)
Task 9: Ver quién está ejecutando y quién está esperando
cr0x@server:~$ mysql -uroot -p -e "SHOW FULL PROCESSLIST\G" | sed -n '1,60p'
*************************** 1. row ***************************
Id: 12431
User: app
Host: 10.0.2.15:53320
db: mydb
Command: Query
Time: 28
State: Waiting for row lock
Info: UPDATE accounts SET balance=balance-10 WHERE id=42
*************************** 2. row ***************************
Id: 12405
User: app
Host: 10.0.2.14:53112
db: mydb
Command: Query
Time: 61
State: Updating
Info: UPDATE accounts SET balance=balance+10 WHERE id=42
Significado: Una sesión está bloqueada esperando un row lock mientras otra está actualizando activamente la misma fila.
Decisión: Encuentra al bloqueador y acorta esa transacción; también considera cambios de diseño para filas calientes (sharding de contadores, batching o mover a primitivos atómicos).
Task 10: Comprobar el estado del engine InnoDB para esperas de locks y deadlocks
cr0x@server:~$ mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,120p'
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 987654, ACTIVE 3 sec starting index read
...
*** (2) TRANSACTION:
TRANSACTION 987655, ACTIVE 3 sec updating or deleting
...
WE ROLL BACK TRANSACTION (1)
------------
TRANSACTIONS
------------
Trx id counter 987700
History list length 1240
Significado: Ocurrió un deadlock; InnoDB eligió una víctima. También fíjate en history list length (purge lag), que puede indicar transacciones largas.
Decisión: Asegura que la app reintente transacciones deadlockeadas con backoff; además reduce el tamaño de las transacciones y garantiza orden de locking consistente en los caminos de código.
Task 11: Verificar innodb_lock_wait_timeout y si coincide con la realidad
cr0x@server:~$ mysql -uroot -p -e "SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';"
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
Significado: Las sesiones pueden esperar hasta 50 segundos antes de fallar una espera por lock.
Decisión: Si tus timeouts upstream son 5–10s, una espera de 50s simplemente ocupa hilos. Rebájalo para alinearlo con budgets de latencia end-to-end, pero solo después de identificar el patrón bloqueante.
Task 12: Comprobar nivel de aislamiento de transacciones (los gap locks sorprenden)
cr0x@server:~$ mysql -uroot -p -e "SHOW VARIABLES LIKE 'transaction_isolation';"
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
Significado: El aislamiento por defecto es repeatable read, que puede usar next-key locks y bloquear inserts en rangos.
Decisión: Si ves contención de inserts en rangos y puedes tolerarlo, considera READ-COMMITTED para cargas OLTP—tras probar la corrección.
Task 13: Identificar índices faltantes que causan locking amplio
cr0x@server:~$ mysql -uroot -p -e "EXPLAIN UPDATE orders SET status='paid' WHERE customer_id=123 AND status='pending'\G"
*************************** 1. row ***************************
id: 1
select_type: UPDATE
table: orders
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 842113
filtered: 10.00
Extra: Using where
Significado: Escaneo de tabla completo (type: ALL) en un update. Esto puede bloquear mucho más de lo que piensas y tardar una eternidad.
Decisión: Añade un índice compuesto (p. ej., (customer_id, status)) y vuelve a comprobar el plan. Esta es una de las correcciones de locks con mayor ROI.
Task 14: Monitorizar métricas de InnoDB para esperas de locks (señal rápida)
cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';"
+-------------------------------+----------+
| Variable_name | Value |
+-------------------------------+----------+
| Innodb_row_lock_current_waits | 12 |
| Innodb_row_lock_time | 184223 |
| Innodb_row_lock_time_avg | 15351 |
| Innodb_row_lock_time_max | 60000 |
| Innodb_row_lock_waits | 48 |
+-------------------------------+----------+
Significado: Están ocurriendo esperas por locks ahora (current_waits) y han sido caras en promedio.
Decisión: Escala a análisis de consultas/transacciones. Si current_waits permanece elevado durante incidentes, estás tratando con contención real, no un caso aislado.
Task 15: Comprobar si el servidor está saturado de hilos por esperas
cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Threads_running'; SHOW GLOBAL STATUS LIKE 'Max_used_connections';"
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| Threads_running | 187 |
+-----------------+-------+
+----------------------+-------+
| Variable_name | Value |
+----------------------+-------+
| Max_used_connections | 498 |
+----------------------+-------+
Significado: Muchos hilos están “running” (a menudo “running” incluye hilos en espera). Las conexiones se han usado intensamente.
Decisión: Si la app está reintentando agresivamente, limita la tasa y añade jitter de inmediato. Luego identifica y arregla la consulta bloqueante.
Task 16: Confirmación a nivel OS: ¿estamos ligados por CPU o por espera?
cr0x@server:~$ top -b -n 1 | sed -n '1,12p'
top - 11:44:12 up 17 days, 3:28, 1 user, load average: 22.15, 20.97, 18.44
Tasks: 312 total, 5 running, 307 sleeping, 0 stopped, 0 zombie
%Cpu(s): 4.3 us, 1.1 sy, 0.0 ni, 72.8 id, 21.7 wa, 0.0 hi, 0.1 si, 0.0 st
MiB Mem : 32112.0 total, 1120.3 free, 10234.8 used, 20756.9 buff/cache
MiB Swap: 2048.0 total, 1980.0 free, 68.0 used. 18940.2 avail Mem
Significado: Alto IO wait (wa). Esto a menudo alarga el tiempo de retención de locks porque los commits esperan fsync.
Decisión: Investiga la latencia del almacenamiento. Ajustar locks no arreglará escrituras durables lentas.
Tres micro-historias corporativas (anonimizadas, dolorosamente plausibles)
Incidente causado por una suposición equivocada: “SQLite estará bien; solo escribimos un poco”
En una compañía mediana, un equipo desplegó un servicio interno de “notebook de ops”. Almacenaba notas de incidentes, runbooks y un pequeño log de auditoría. Alguien eligió SQLite porque era un binario, sin infraestructura adicional, y las escrituras eran “pequeñas”. Se ejecutaba en una VM con un montaje de sistema de archivos compartido para que dos instancias de la app accedieran al mismo archivo DB.
La suposición equivocada no fue “SQLite no puede manejar escrituras”. SQLite puede manejar muchas escrituras. La suposición equivocada fue pensar que el sistema de archivos y el modelo de procesos no importaban. Con uso ligero, el servicio se veía genial. Bajo carga de incidentes—cuando varios ingenieros editaban notas y el log de auditoría se disparaba—las peticiones empezaron a fallar con database is locked. Naturalmente, esto ocurrió durante incidentes, cuando la herramienta más se necesitaba.
El primer arreglo fue el clásico: añadir reintentos. Eso convirtió “algunas peticiones fallan rápido” en “las peticiones cuelgan, luego fallan”. El pool de conexiones se llenó. La latencia creció de forma no lineal. Los ingenieros empezaron a copiar y pegar notas en el chat porque el notebook hacía timeout. El postmortem incluyó la frase “la herramienta se convirtió en un punto único de fallo”, que es una forma educada de decir “construimos una trampa”.
El arreglo real fue aburrido pero correcto: mover el archivo DB al disco local y ejecutar una sola instancia escritora (o cambiar a MariaDB). También cambiaron el log de auditoría para agrupar escrituras y acortaron transacciones en el código de la app. Los errores de lock cayeron a casi cero y, más importante, los remanentes eran señales accionables en vez de ruido de fondo.
Optimización que salió mal: WAL por todos lados, para siempre, con un lector eterno
Otra compañía tenía una base SQLite local embebida en un agente de edge. Habilitaron WAL en toda la flota para mejorar la concurrencia: el agente leía configuración mientras escribía métricas. Los benchmarks iniciales mejoraron. Todos celebraron. Luego, meses después, los dispositivos en campo empezaron a quedarse sin disco de maneras extrañas.
No era el archivo base. Era el archivo WAL. Seguía creciendo porque el checkpointing no podía completarse de forma fiable. El agente tenía un hilo “watcher” que mantenía una transacción de lectura abierta mientras hacía stream de cambios a otro componente. Esa transacción de lectura impedía que los frames viejos del WAL se checkpointearan. El WAL se convirtió en una fuga lenta de disco.
Operaciones notó dispositivos lentos. Alarmas de uso de disco se dispararon. Algunos dispositivos alcanzaron 100% de disco y empezaron a fallar en subsistemas no relacionados. El debugging fue doloroso porque el agente en sí estaba “bien” hasta que no lo estuvo. Cuando cruzó el umbral, todo falló a la vez.
El arreglo: rediseñar el watcher para evitar mantener una transacción abierta y checkpointear explícitamente durante periodos de baja actividad. También pusieron límites razonables al crecimiento del WAL mediante políticas (alertas sobre tamaño WAL relativo al tamaño de la BD). WAL no era el enemigo. WAL sin límites más un lector eterno sí lo era.
Práctica aburrida pero correcta que salvó el día: límites de transacción consistentes y backoff
Un equipo relacionado con pagos ejecutaba MariaDB. Tenían picos periódicos de locks, pero los incidentes eran raros. La razón no fue la suerte. Tenían un estándar interno: cada ruta de escritura tenía un scope máximo de transacción, sin llamadas de red dentro de transacciones, y todos los errores retryables usaban backoff exponencial con jitter.
Un día se desplegó un job de reconciliación con un update por cross-join accidental. Empezó a bloquear más filas de las previstas. En muchas organizaciones, eso se convierte en una manada atronadora: los reintentos de la app golpean la BD, la BD se vuelve inservible y el incidente crece.
Aquí, el job aún causó dolor, pero el radio de blast estuvo contenido. La app se desahogó en vez de amontonarse. Otros servicios degradaron con gracia. Los ingenieros tuvieron tiempo para identificar la consulta, matarla y desplegar un arreglo. Las acciones del postmortem fueron directas: añadir un guardrail, mejorar la revisión de consultas para jobs por lotes.
Esta es la verdad poco sexy de la fiabilidad: las prácticas que se sienten lentas en desarrollo son las que impiden que producción se convierta en arte performativo.
Cómo prevenir errores busy (SQLite) y acumulaciones de locks (MariaDB)
Elige el motor correcto para la topología de escrituras
Si tienes un proceso, disco local y concurrencia moderada: SQLite puede ser excelente. Si tienes múltiples instancias de app, múltiples hosts y “escrituras desde todas partes”: SQLite se convierte en un problema de coordinación que no pretendías adoptar.
- SQLite es una gran elección para cargas embebidas, single-node, disco local; sistemas con lectura intensiva; caches; colas durables con escritores controlados.
- MariaDB es una gran elección cuando necesitas escritores concurrentes reales, acceso remoto, herramientas operacionales y comportamiento predecible con múltiples clientes.
SQLite: tácticas concretas que realmente funcionan
- Habilita WAL para lectura/escritura concurrente (la mayor parte del tiempo). Luego monitoriza crecimiento de WAL y comportamiento de checkpoints.
- Establece
busy_timeouty/o un busy handler para que la contención corta no se convierta en error. Alinea con tu budget de petición (no pongas 60 segundos y pretendas que está bien). - Mantén las transacciones cortas. No mantengas una transacción abierta mientras haces llamadas de red, parseas JSON o esperas a que un usuario parpadee.
- Agrupa escrituras, pero no crees “mega-transacciones”. Agrupa en commits pequeños (p. ej., cientos o miles) en lugar de una transacción infinita.
- Usa patrón de un solo escritor si puedes. Un hilo/proceso escritor dedicado que serializa escrituras puede eliminar contención y simplificar reintentos.
- Evita ejecutar archivos DB SQLite en sistemas de archivos en red. Si debes hacerlo, prueba el comportamiento de locking bajo carga. La mayoría de equipos descubre la verdad demasiado tarde.
- Sé explícito sobre checkpointing en servicios de larga duración con lectores persistentes. Considera
PRAGMA wal_checkpoint(TRUNCATE);periódicamente durante tráfico bajo, pero prueba cuidadosamente.
MariaDB/InnoDB: tácticas concretas que realmente funcionan
- Arregla el indexado primero. La mayoría de “problemas de locking” son en realidad “estamos escaneando demasiado y por eso bloqueamos demasiado”.
- Reduce el scope de la transacción. Haz commit antes. Divide grandes updates. Evita patrones de “leer-modificar-escribir” sobre filas calientes.
- Diseña alrededor de filas calientes. Contadores y campos “last seen” pueden ser escritos por muchos hilos. Usa contadores shardeados, tablas append-only o agregación periódica.
- Haz que el comportamiento de reintentos sea civilizado. Los deadlocks ocurren. Tu app debe reintentar con jitter y un número limitado de intentos.
- Alinea los timeouts de espera de locks con timeouts upstream. Si tu API hace timeout a los 8 segundos, dejar que sesiones DB esperen 50 segundos es acaparamiento de recursos.
- Cuidado con cambios de aislamiento. Cambiar a
READ-COMMITTEDpuede reducir contención, pero valida la corrección (phantoms, lecturas repetidas) para tu carga.
Errores comunes: síntomas → causa raíz → solución
1) Picos de “database is locked” en SQLite tras añadir un job en background
Síntomas: Errores busy se correlacionan con la ejecución de un cron/worker; logs de la app muestran fallos incluso con bajo tráfico.
Causa raíz: El job abre una transacción y realiza muchas escrituras (o la mantiene abierta mientras hace otro trabajo), bloqueando a otros escritores.
Solución: Hacer commit en trozos más pequeños; mover el job a una cola de un solo escritor; añadir busy_timeout; asegurar que el job no se ejecute concurrentemente consigo mismo.
2) El archivo WAL crece sin control
Síntomas: app.db-wal sigue creciendo; uso de disco sube; stalls ocasionales durante intentos de checkpoint.
Causa raíz: Una transacción de lectura de larga duración impide que el checkpoint recicle los frames del WAL.
Solución: Evitar lecturas largas; cambiar el lector a snapshots de menor alcance; programar checkpoints; verificar que el código no mantenga un cursor abierto durante operaciones largas.
3) “busy” de SQLite ocurre mayormente en ciertos hosts
Síntomas: Misma carga, distinto comportamiento de locks según host o contenedor.
Causa raíz: Archivo DB en un sistema de archivos diferente (quirks de overlayfs, NFS, dispositivo de bloque en red), o latencia de almacenamiento que alarga el tiempo de retención de locks.
Solución: Estandariza la ubicación del almacenamiento y el sistema de archivos; mueve a SSD local; mide latencia de fsync; si necesitas acceso multi-host, muévete a MariaDB.
4) Timeouts por espera de lock en MariaDB durante updates “pequeños”
Síntomas: Lock wait timeout exceeded al actualizar una sola fila; intermitente pero recurrente.
Causa raíz: Contención por fila caliente (misma fila actualizada por muchas sesiones) o una transacción que toca la fila y luego mantiene el lock mientras hace otro trabajo.
Solución: Rediseña escrituras en filas calientes (shard, append-only, cache y luego agrega); reduce la duración de transacciones; evita llamadas externas dentro de transacciones.
5) MariaDB de repente “lenta por todas partes” tras un cambio de índice
Síntomas: Aumentaron las esperas por locks; lag de replicación; CPU no saturada, pero latencia alta.
Causa raíz: Regresión del plan de consultas que causa escaneos más amplios y más locking, o DDL online que causa contención de metadata en un esquema ocupado.
Solución: Valida planes con EXPLAIN antes y después; etapa cambios de índices; asegura que las consultas usan los índices previstos; modera cambios de esquema.
6) Reintentar lo empeora todo
Síntomas: Tras añadir reintentos, los incidentes duran más; la BD ve más QPS durante eventos de lock.
Causa raíz: Reintentos inmediatos crean una manada. Cada cliente reintenta a la vez, extendiendo las ventanas de contención.
Solución: Backoff exponencial + jitter; limitar reintentos; fallar rápido para operaciones no idempotentes salvo que estén diseñadas con cuidado.
Listas de verificación / plan paso a paso
Plan A: Estás en SQLite y quieres menos errores “busy” esta semana
- Verifica que el almacenamiento sea local y estable. Si está en NFS/remoto/overlay extraño, prioriza moverlo. Los errores busy allí son un estilo de vida.
- Habilita WAL (si procede). Confirma con
PRAGMA journal_mode;. - Establece un busy timeout sensato. Empieza con 2000–5000 ms para rutas interactivas; ajusta para coincidir con budgets de petición.
- Audita el scope de las transacciones. Asegura que no hay transacciones que abarquen llamadas de red, sleeps o “bucles de procesamiento”.
- Identifica tus escritores. Lista procesos con
lsof; asegúrate de no tener escritores sorpresa. - Vigila tamaño WAL vs tamaño DB. Alerta si el WAL excede una ratio que consideres peligrosa para tu presupuesto de disco.
- Introduce un patrón de single-writer para rutas de alto write. Encola escrituras en proceso o vía IPC; serializa commits.
- Añade reintentos limitados con jitter. Sólo para operaciones retryables; registra el conteo y la duración de reintentos para que no oculten problemas.
Plan B: Estás en MariaDB y las esperas por locks matan el p95
- Captura la consulta bloqueante. Processlist + estado de InnoDB durante el incidente.
- Comprueba índices faltantes en los paths de update calientes usando
EXPLAIN. - Reduce la duración de transacciones. Confirma que ningún path mantiene transacciones abiertas durante trabajo no-DB.
- Alinea timeouts. Ajusta
innodb_lock_wait_timeouta algo que coincida con budgets upstream y evite acaparamiento de hilos. - Arregla reintentos. Asegura que deadlocks/timeouts de lock se reintenten con backoff exponencial y jitter. Limita reintentos.
- Diseña para evitar filas calientes. Si una fila es un recurso muy contendido, la escala no lo solucionará. Cambia el modelo de datos.
- Valida latencia de almacenamiento. Si fsync es lento, los locks duran más; culparás al gestor de locks por un problema de almacenamiento.
Lista de decisión: cuándo pasar de SQLite a MariaDB
- Necesitas múltiples instancias de app escribiendo concurrentemente y no puedes o no quieres canalizar escrituras a través de un solo escritor.
- Necesitas acceso remoto desde múltiples hosts a la misma base de datos.
- Necesitas características operacionales: privilegios finos, replicación integrada, cambios de esquema online, monitorización madura de locks.
- Actualmente estás “arreglando” errores busy con timeouts más grandes y más reintentos.
FAQ
1) ¿“database is locked” en SQLite siempre es un bug?
Normalmente es un desajuste de diseño o un bug en el scope de la transacción. Ocasionalmente es una contención breve legítima que necesita un busy timeout. Si es frecuente, trátalo como un problema de diseño del sistema.
2) ¿Debería habilitar WAL por defecto?
Para la mayoría de cargas locales en disco con lectura/escritura concurrente: sí. Si estás en un sistema de archivos con semánticas de locking dudosas (común en almacenamiento en red), prueba cuidadosamente o evita WAL.
3) Si pongo busy_timeout, ¿he terminado?
No. Simplemente decidiste esperar en vez de fallar rápido. Si el escritor mantiene el lock 5 segundos y tu timeout es 2 segundos, sigues fallando. Si lo pones a 60 segundos, tu app simplemente colgará más tiempo.
4) ¿Por qué WAL a veces empeora las cosas?
WAL mejora la concurrencia lector/escritor, pero introduce comportamiento de checkpoint. Lectores de larga duración pueden bloquear el checkpointing, dejando que el WAL crezca y ocasionando stalls ocasionales.
5) ¿SQLite puede manejar múltiples escritores?
Varias conexiones pueden intentar escribir, pero sólo una puede commitear a la vez. Bajo concurrencia sostenida de escrituras obtendrás encolamiento y errores busy a menos que coordines a los escritores.
6) En MariaDB, ¿por qué veo inserts bloqueados cuando nadie toca las mismas filas?
Los gap/next-key locks bajo REPEATABLE-READ pueden bloquear rangos, no solo filas individuales. Índices faltantes también pueden ampliar escaneos y bloquear rangos que no pretendías.
7) ¿Debería bajar innodb_lock_wait_timeout para evitar acumulaciones?
A menudo, sí—después de confirmar la carga. Un timeout más bajo previene el acaparamiento de hilos y fuerza fallos más rápidos, lo que puede proteger el servidor. Pero si lo reduces sin arreglar al bloqueador, cambiarás latencia por errores.
8) ¿Cuál es la estrategia de reintento correcta para timeouts de lock y deadlocks?
Reintenta deadlocks y timeouts de lock con backoff exponencial y jitter, reintentos limitados y conciencia de idempotencia. Nunca reintentes instantáneamente en un bucle cerrado. Así es como un pequeño evento de lock se convierte en un outage.
9) ¿Puedo ejecutar SQLite en Kubernetes con un volume compartido?
Puedes, pero “puedes” no es “deberías”. Si ese volume compartido es en red, locking y latencia pueden ser impredecibles. Si necesitas escritores multi-pod, usa MariaDB (u otra BD cliente-servidor) o aplica una arquitectura de un solo escritor.
10) ¿Cómo sé si estoy limitado por locks o por I/O?
Mira si hay alto IO wait (top, iostat) y tiempos largos de commit, versus muchas sesiones atascadas en estados de espera por lock (processlist, estado de InnoDB). A menudo es ambos: I/O lento alarga la retención de locks, lo que aumenta la contención.
Conclusión: próximos pasos que realmente reducen el ruido del pager
Si recuerdas una cosa: los errores de bloqueo usualmente te están diciendo la verdad sobre recursos compartidos. SQLite te lo dice rápido y de forma contundente. MariaDB te lo deja fingir por más tiempo.
Haz esto a continuación:
- Ejecuta el playbook de diagnóstico rápido y decide si estás limitado por locks o por I/O.
- Encuentra la transacción más larga y hazla más corta. Esto arregla más incidentes que cualquier perilla.
- Detén las tormentas de reintentos con backoff + jitter y un tope duro.
- Para SQLite: WAL + busy_timeout sensato + escritores controlados + conciencia de checkpoints.
- Para MariaDB: corrección de índices, límites de transacción, rediseño de filas calientes y timeouts alineados con budgets reales de latencia.
Luego elige la base de datos que coincida con tu realidad de concurrencia, no con tu organigrama. Tu yo del futuro disfrutará la novedad de dormir por la noche.