MySQL vs SQLite Concurrencia: Por qué las escrituras se convierten en un precipicio de tráfico

¿Te fue útil?

La gráfica parece bien, hasta que deja de estarlo. La latencia está plana, la CPU está aburrida, y entonces un pequeño aumento en el tráfico de escrituras convierte tu app en una vitrina de museo para colas:
todo el mundo espera a que le permitan entrar.

Si alguna vez viste un prototipo de “SQLite es rápido” llegar a producción y de repente empezar a lanzar errores database is locked, te has topado con el precipicio de tráfico.
Esto no es un fallo moral. Es física, bloqueos y un conjunto de compensaciones que SQLite y MySQL manejan de forma muy distinta.

El precipicio de tráfico: qué es lo que realmente ves

“Precipicio de tráfico” es la sensación cuando el rendimiento de un sistema no se degrada de forma gradual. Añades un 10% más de carga de escritura y de repente obtienes una latencia en cola 10× peor,
timeouts, reintentos y una manada atronadora de clientes que cortésmente vuelven a intentar lo mismo que acaban de hacer.

En SQLite, el precipicio suele ser causado por la contención de bloqueos y el diseño de escritor único. Hay matices (el modo WAL cambia el panorama, y el sistema de ficheros del SO importa),
pero la idea principal se mantiene: los escritores concurrentes se amontonan detrás de un bloqueo. A medida que la cola crece, la latencia se dispara. No es sutil.

En MySQL (específicamente InnoDB), el precipicio también existe—pero tienes más engranajes. El bloqueo a nivel de fila, MVCC, el vaciado en background y un motor diseñado para conexiones concurrentes
hacen que las escrituras tiendan a degradarse de forma más gradual, y tienes más perillas para ajustar y más formas de dispararte en el pie.

Un modelo mental útil: SQLite es un puente de un solo carril, muy rápido y muy fiable, con señalización excelente. MySQL es un intercambio de autopista con múltiples rampas, límites de velocidad
y una sorprendente cantidad de maneras de causar un atasco si pones un sofá en el carril izquierdo.

Hechos e historia interesantes que importan operativamente

  • SQLite empezó en 2000 como una base de datos embebida para uso interno, diseñada para ser pequeña, fiable y no requerir administración—sigue siendo su superpotencia.
  • SQLite está en dominio público, lo que explica en gran medida su ubicuidad: menos dudas de licencia y más adopción de “simplemente enviarlo”.
  • La cultura de pruebas de SQLite es extrema: es famosa por pruebas automatizadas intensivas, incluyendo inyección de fallos y simulación de errores de I/O—buena noticia cuando el disco miente.
  • El modo WAL llegó más tarde (mediados/finales de los 2000) para mejorar la concurrencia de lectura separando lecturas y escrituras mediante un write-ahead log.
  • InnoDB de MySQL se convirtió en el predeterminado (históricamente después de años de uso común de MyISAM), orientando el comportamiento “normal” hacia transacciones y bloqueos a nivel de fila.
  • InnoDB usa MVCC (control de concurrencia por múltiples versiones): los lectores no bloquean a los escritores de la misma forma, por eso las cargas dominadas por lecturas pueden mantenerse tranquilas bajo escrituras.
  • El “escritor único” de SQLite es una elección de diseño, no una característica ausente. Simplifica la corrección entre plataformas y sistemas de ficheros.
  • La semántica del sistema de ficheros importa más para SQLite de lo que la gente espera. Los sistemas de ficheros en red pueden romper las suposiciones de bloqueo o hacerlas dolorosamente lentas.
  • La durabilidad de MySQL es configurable mediante políticas de vaciado de logs, lo que significa que puedes elegir entre “seguro” y “rápido” y luego olvidar qué elegiste.

Modelos de concurrencia: SQLite vs MySQL en términos sencillos

SQLite: un archivo de base de datos con un protocolo de bloqueo

SQLite es una librería que lee y escribe un único archivo de base de datos (más archivos secundarios como WAL e índices de memoria compartida en modo WAL). Tu proceso de aplicación
la enlaza (directamente o mediante un wrapper), y las consultas operan mediante I/O de ficheros normales con un mecanismo de coordinación para mantener el archivo consistente.

La historia de concurrencia pivota en torno a los bloqueos. En el modo rollback-journal (el estilo por defecto más antiguo), SQLite toma bloqueos sobre el archivo de base de datos a medida que
avanza por estados: shared, reserved, pending, exclusive. Los detalles son precisos, pero operativamente significa esto: en algún punto, un escritor necesita un bloqueo exclusivo para confirmar,
y mientras ese bloqueo esté tomado, otros escritores quedan bloqueados—y, dependiendo del modo y del timing, los lectores también pueden bloquearse.

En modo WAL, mejora para lecturas: múltiples lectores pueden leer la base de datos principal mientras un escritor anexa al WAL. Pero las escrituras siguen serializándose: solo un escritor
a la vez puede confirmar en el WAL. WAL te da concurrencia de lectura y a menudo mejor rendimiento en cargas mixtas, pero no convierte a SQLite en un motor multi-escritor.

MySQL InnoDB: un servidor con maquinaria de transacciones concurrentes

MySQL es una base de datos cliente/servidor. Te conectas por un socket, y el servidor gestiona la concurrencia dentro de un motor de almacenamiento (normalmente InnoDB).
InnoDB usa bloqueos a nivel de fila, transacciones, logs de deshacer, logs de rehacer, vaciado en background y snapshots MVCC. Está construido para mantener a muchos clientes en movimiento a la vez.

Las escrituras también compiten—en filas calientes, en índices, en bloqueos de autoincremento (menos que antes), en bloqueos de metadatos y en el vaciado del log de rehacer.
Pero frecuentemente puedes repartir el dolor. Con buen diseño de esquema y consultas, los escritores concurrentes pueden proceder siempre que no estén golpeando los mismos registros.

SQLite pregunta: “¿Cómo mantenemos el archivo consistente y portátil?” InnoDB pregunta: “¿Cómo mantenemos felices a 500 clientes mientras el disco está en llamas?”

Por qué las escrituras en SQLite hacen cliff: la explicación por colas

Hablemos de por qué esto falla de forma repentina en lugar de gradual.

Cuando tienes una sección crítica única (el bloqueo de escritor), has construido una cola. Mientras tu tasa de llegada de transacciones de escritura sea menor que tu tasa de servicio
(qué tan rápido el único escritor puede completar cada transacción), todo parece normal. El momento en que te acercas a la saturación, la longitud de la cola empieza a crecer rápidamente.
Con llegadas aleatorias y tiempos de transacción variables, la cola tarda en ponerse fea.

Las escrituras en SQLite tienden a ser “explosivas” por los límites de transacción y el comportamiento de fsync. Muchas aplicaciones hacen transacciones diminutas:
insertar una fila, confirmar; actualizar una fila, confirmar. Cada commit es un evento de durabilidad que puede requerir sincronización. Eso añade una gran componente de tiempo de servicio en forma de picos.
De repente tu puente de un solo carril tiene una cabina de peaje que de vez en cuando detiene el tráfico para contar monedas.

El precipicio se amplifica por el comportamiento de reintentos del cliente. Cuando SQLite devuelve SQLITE_BUSY o database is locked, librerías y apps suelen reintentar.
Reintentar está bien cuando la contención es breve. Bajo carga sostenida, se vuelve autolesivo: aumentas la tasa de llegada justo cuando el sistema está saturado.

Aquí va tu primer chiste: Una base de datos de escritor único bajo carga máxima es como una reunión de una sola persona donde todos interrumpen—de alguna forma nadie avanza, pero todos se van cansados.

Cómo cambia el precipicio con WAL (pero no lo elimina)

El modo WAL permite que los lectores eviten bloquearse con un escritor. Eso es enorme en cargas dominadas por lecturas: el precipicio puede desplazarse hacia la derecha.
Pero el escritor sigue siendo único. Peor aún, WAL introduce el checkpointing, que es una especie de trabajo diferido: vas anexando al WAL hasta que decides hacer un checkpoint y fusionar cambios
de vuelta en la base de datos principal. El checkpointing cuesta I/O y puede bloquear o ralentizar según la configuración y la presión.

Si tienes mala fortuna, has cambiado “cada escritura bloquea lecturas a veces” por “las lecturas son suaves hasta que el monstruo del checkpoint despierta”.
Eso puede parecer picos de latencia periódicos, o un precipicio que aparece con horario.

Sistema de ficheros y pila de almacenamiento: la dependencia oculta de SQLite

Los bloqueos de SQLite dependen de las semánticas de bloqueo de archivos del SO. En discos locales con sistemas de ficheros sensatos, es predecible. En algunos sistemas de ficheros de red,
está o roto, o emulado pobremente, o extremadamente lento. Incluso cuando “está soportado”, la variación de latencia puede convertir una contención menor en un bucle constante de busy.

MySQL también se preocupa por la latencia de I/O, obviamente, pero su control de concurrencia no está construido sobre “cada proceso cliente coordina con bloqueos de archivos sobre el mismo archivo”.
Está construido sobre un proceso servidor dedicado que controla el acceso a las estructuras de almacenamiento.

Por qué MySQL normalmente no hace el mismo precipicio

El motor InnoDB de MySQL tiene un perfil de fallo distinto. Puede, sin duda, venirse abajo, pero la vía común no es “un bloqueo de escritor único bloquea a todos”.
En su lugar, es una mezcla de:

  • Contención en fila/índice caliente: muchos escritores actualizando las mismas filas o la misma página de hoja de índice.
  • Presión del log de transacciones: los vaciados de redo log se vuelven el limitador, especialmente con durabilidad estricta.
  • Buffer pool y vaciado: las páginas sucias se acumulan; el flushing se vuelve urgente; el rendimiento colapsa si el I/O no puede seguir el ritmo.
  • Espera de bloqueos y deadlocks: no es un bloqueo global de escritura, pero suficientes esperas pueden detener la aplicación.
  • Tormentas de conexiones: demasiados hilos cliente, cambio de contexto, consumo excesivo de memoria.

La diferencia clave: si 50 clientes escriben en 50 filas distintas, InnoDB a menudo puede hacerlo de forma concurrente. Si 50 clientes escriben en la misma fila “contador”, se serializará.
Pero puedes rediseñarlo (shardear contadores, usar tablas append-only, agrupar actualizaciones). Con SQLite, rediseñar a menudo significa “reducir escritores o agrupar más”.

Requisito de cita, manejado con cuidado:
La esperanza no es una estrategia — comúnmente atribuido a la cultura de operaciones (idea parafraseada).
Se aplica perfectamente aquí: “no tendremos escrituras concurrentes” no es una arquitectura.

WAL, checkpoints y la “tormenta de escrituras sorpresa”

El modo WAL en SQLite se recomienda comúnmente, y por una buena razón: mejora la concurrencia lectura/escritura al permitir que los lectores lean la base estable mientras las escrituras van al log.
Pero WAL introduce trabajo operativo que no puedes ignorar: los checkpoints.

Un checkpoint fusiona el contenido del WAL de vuelta al archivo principal de la base de datos. Eso es intensivo en I/O. Si no puede seguir el ritmo, el WAL crece. Si el WAL crece, puede ralentizar lecturas
(porque los lectores pueden necesitar consultar el WAL), aumentar el uso de disco y alargar el tiempo de recuperación tras un fallo.

Bajo carga de escritura sostenida, puedes acabar con picos periódicos cuando se dispara el checkpointing. Bajo carga explosiva, puedes recibir “tormentas de escrituras” donde el sistema intenta ponerse al día,
y de repente tu rendimiento previamente suave se convierte en una sierra.

El patrón de precipicio a menudo se ve así:

  • Carga baja/moderada: todo es rápido, el WAL es pequeño, los checkpoints son baratos.
  • Acercándose a la saturación: los escritores esperan más, el WAL crece, el coste del checkpoint crece.
  • Sobre saturación: los escritores se apilan, el checkpointing compite por el mismo ancho de banda de disco, aumentan las esperas de bloqueo, los clientes reintentan, y estás llamando a alguien a las 02:00.

Transacciones, autocommit y el patrón muerte-por-escrituras-pequeñas

El precipicio de escritura de SQLite frecuentemente es autoinfligido por la forma de las transacciones.

Muchas apps funcionan en modo autocommit: cada INSERT/UPDATE es su propia transacción. Eso significa que cada escritura paga el coste completo del “commit”: adquisición de bloqueo, trabajo de journal/WAL,
y comportamiento de sincronización para durabilidad. Si haces 1,000 actualizaciones una por una, no estás haciendo 1,000 actualizaciones—estás haciendo 1,000 commits. Tu disco se convierte en un metrónomo.

Agrupa escrituras dentro de transacciones explícitas. No es opcional. Si tu aplicación no puede hacer eso, SQLite es la herramienta equivocada cuando la concurrencia y el rendimiento importan.

MySQL también se beneficia de agrupar, pero tolera transacciones más pequeñas mejor porque está diseñado en torno a commits concurrentes, group commit y I/O en background.
Aun así quieres buena higiene de transacciones, pero es menos probable que sufras un precipicio inmediato por el comportamiento “normal” multiusuario.

Tareas prácticas: comandos, salidas y decisiones (12+)

Estas son las comprobaciones que realmente ejecuto cuando alguien dice “las escrituras se volvieron lentas” o “SQLite se bloqueó” o “MySQL está expirando”. Cada tarea incluye:
comando, qué significa la salida y qué decisión tomas después.

Task 1: Confirmar modo journal de SQLite y pragmas críticos

cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA journal_mode; PRAGMA synchronous; PRAGMA busy_timeout;'
wal
2
0

Significado: journal_mode es WAL. synchronous=2 es FULL (durable, más lento). busy_timeout=0 significa “fallar rápido” ante contención.
Decisión: Establecer un busy timeout sensato (p. ej., 2000–10000 ms) para reducir fallos inmediatos, y revisar synchronous según las necesidades de durabilidad.

Task 2: Comprobar tamaño del WAL y presión de checkpoints

cr0x@server:~$ ls -lh /var/lib/app/app.db*
-rw-r----- 1 app app 1.2G Dec 30 01:12 /var/lib/app/app.db
-rw-r----- 1 app app 768M Dec 30 01:13 /var/lib/app/app.db-wal
-rw-r----- 1 app app  32K Dec 30 01:12 /var/lib/app/app.db-shm

Significado: Un WAL de 768M sugiere que los checkpoints no están siguiendo el ritmo o no se están ejecutando.
Decisión: Investigar la configuración de checkpoints y si la app deja transacciones de lectura de larga duración abiertas (lo cual puede impedir checkpoints).

Task 3: Inspeccionar SQLite por transacciones de larga duración (comprobación a nivel de app)

cr0x@server:~$ lsof /var/lib/app/app.db | head
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
app      2140  app   17u   REG  252,0 1291845632 1048577 /var/lib/app/app.db
app      2140  app   18u   REG  252,0  805306368 1048578 /var/lib/app/app.db-wal
app      2199  app   17u   REG  252,0 1291845632 1048577 /var/lib/app/app.db

Significado: Múltiples procesos/hilos tienen la BD abierta; eso es normal. No muestra bloqueos directamente, pero te dice quién está en el juego.
Decisión: Si ves procesos inesperados, detenlos. Si muchos workers comparten una BD en almacenamiento de red, reevalúa la arquitectura inmediatamente.

Task 4: Reproducción rápida y sucia de contención con busy timeouts

cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA busy_timeout=2000; BEGIN IMMEDIATE; SELECT 1;'
1

Significado: BEGIN IMMEDIATE intenta obtener una reserva de escritura temprano. Si esto bloquea o devuelve BUSY rápidamente, tienes contención de escritores.
Decisión: Si esto lucha consistentemente durante incidentes, reduce escritores concurrentes o agrupa escrituras; WAL por sí solo no te salvará.

Task 5: Comprobar tipo de sistema de ficheros (a SQLite le importa más de lo que quieres)

cr0x@server:~$ findmnt -no SOURCE,FSTYPE,OPTIONS /var/lib/app
/dev/nvme0n1p2 ext4 rw,relatime

Significado: ext4 local: buena línea base. Si ves nfs/cifs/fuse, trátalo como un riesgo mayor para bloqueos y latencia.
Decisión: Mueve SQLite a almacenamiento local o migra a una base de datos servidor.

Task 6: Medir latencia de disco durante picos de escritura

cr0x@server:~$ iostat -xz 1 5
Linux 6.8.0 (server)  12/30/2025  _x86_64_ (8 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           6.12    0.00    2.10    9.80    0.00   81.98

Device            r/s     w/s   rKB/s   wKB/s  avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
nvme0n1         120.0   980.0   6400  51200      97.5     8.20    8.40    2.10    9.20   0.62  68.0

Significado: iowait está alto y write await es ~9ms. Eso no es catastrófico, pero si salta a decenas/centenas de ms, los commits se detendrán y las colas de bloqueo crecerán.
Decisión: Si la latencia de almacenamiento se correlaciona con errores de bloqueo, prioriza I/O: disco más rápido, menos sync, transacciones más grandes, menos escritores.

Task 7: Comprobar motor MySQL y configuración base

cr0x@server:~$ mysql -uroot -p -e "SHOW VARIABLES LIKE 'default_storage_engine'; SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';"
+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| default_storage_engine | InnoDB |
+------------------------+-------+
+--------------------------------+-------+
| Variable_name                  | Value |
+--------------------------------+-------+
| innodb_flush_log_at_trx_commit | 1     |
+--------------------------------+-------+

Significado: InnoDB con flush-at-commit=1 es durable pero más sensible a la latencia de fsync.
Decisión: Si puedes tolerar perder hasta ~1 segundo de commits en un fallo, considera el valor 2; si no, invierte en almacenamiento de baja latencia y group commit.

Task 8: Comprobar MySQL por esperas de bloqueos y contención

cr0x@server:~$ mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,120p'
*************************** 1. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2025-12-30 01:15:02 0x7f1c6c0d9700 INNODB MONITOR OUTPUT
=====================================
------------
TRANSACTIONS
------------
Trx id counter 5829101
Purge done for trx's n:o < 5829000 undo n:o < 0 state: running
History list length 2113
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 5829088, ACTIVE 12 sec
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 118, OS thread handle 139759, query id 99102 app 10.0.0.24 updating
UPDATE counters SET value=value+1 WHERE id=1

Significado: Una transacción actualizando la misma fila contador es contención clásica de fila caliente. Activa 12 segundos es un mal olor.
Decisión: Arreglar esquema/carga: shardear contadores, usar incrementos en buffer o rediseñar para evitar una fila caliente única.

Task 9: Comprobar lista de procesos de MySQL por acumulaciones

cr0x@server:~$ mysql -uroot -p -e "SHOW FULL PROCESSLIST;" | head
Id	User	Host	db	Command	Time	State	Info
118	app	10.0.0.24:51122	prod	Query	12	updating	UPDATE counters SET value=value+1 WHERE id=1
119	app	10.0.0.25:51140	prod	Query	11	Waiting for row lock	UPDATE counters SET value=value+1 WHERE id=1
120	app	10.0.0.26:51188	prod	Query	11	Waiting for row lock	UPDATE counters SET value=value+1 WHERE id=1

Significado: Muchas sesiones esperando por un row lock: no es un problema de “MySQL lento”; es un problema de contención en la aplicación.
Decisión: Reducir concurrencia hacia ese hotspot, cambiar patrón de consultas o mover a append-only con agregación.

Task 10: Comprobar deadlocks de InnoDB rápidamente

cr0x@server:~$ mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | grep -n "LATEST DETECTED DEADLOCK" -A25
247:LATEST DETECTED DEADLOCK
248:------------------------
249:2025-12-30 01:10:44 0x7f1c6c1da700
250:*** (1) TRANSACTION:
251:TRANSACTION 5829051, ACTIVE 1 sec starting index read
252:mysql tables in use 1, locked 1
253:LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)

Significado: Los deadlocks no son “malos”, son una señal de concurrencia. InnoDB elegirá una víctima y la revertirá.
Decisión: Haz transacciones más cortas y consistentes en el orden de bloqueo; añade índices apropiados; maneja reintentos por deadlock en la aplicación.

Task 11: Observar throughput de MySQL y presión de flushing

cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_dirty'; SHOW GLOBAL STATUS LIKE 'Innodb_log_waits';"
+-------------------------------+-------+
| Variable_name                 | Value |
+-------------------------------+-------+
| Innodb_buffer_pool_pages_dirty| 8421  |
+-------------------------------+-------+
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| Innodb_log_waits | 187   |
+------------------+-------+

Significado: Páginas sucias indican flush pendiente; log waits indican transacciones esperando espacio para el flush del redo log.
Decisión: Si los log waits suben, estás limitado por I/O del redo log o por el dimensionamiento; considera ajustar el tamaño de los archivos de log y mejorar la latencia de almacenamiento.

Task 12: Validar cuenta de conexiones y comportamiento de hilos en MySQL

cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections';"
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_connected | 812   |
+-------------------+-------+
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 1000  |
+-----------------+-------+

Significado: 812 conexiones es mucho a menos que lo planificaste. Las tormentas de conexiones pueden parecer “la base de datos está lenta” cuando en realidad es planificación de hilos.
Decisión: Usa pooling de conexiones, limita la concurrencia y valida que la app no esté creando una conexión por petición.

Task 13: Comprobar versión de compilación/tiempo de ejecución de SQLite (porque el comportamiento cambia)

cr0x@server:~$ sqlite3 --version
3.45.2 2024-03-12 11:06:23 df5c253c0b3dd24916e4ec7cf77d3db5294cc9fd3f24a94d9c0b6ec8d1c4a2c7

Significado: La versión te dice qué correcciones de WAL y bloqueo existen. Versiones antiguas pueden tener defaults y características de rendimiento distintas.
Decisión: Si estás en algo antiguo empaquetado en una imagen OS, actualiza la librería/tiempo de ejecución donde sea posible.

Task 14: Determinar si estás accidentalmente en un sistema de ficheros de red

cr0x@server:~$ mount | grep -E ' /var/lib/app |nfs|cifs|fuse' | head
/dev/nvme0n1p2 on /var/lib/app type ext4 (rw,relatime)

Significado: Montaje local. Bien. Si vieras NFS/CIFS/FUSE aquí, tendrías una acción inmediata.
Decisión: Si es remoto, detente y re-arquitecta: disco local para SQLite o migrar a MySQL/Postgres.

Task 15: Detectar “muerte por autocommit” en logs de la aplicación (comprobación de patrón)

cr0x@server:~$ grep -E "SQLITE_BUSY|database is locked|BEGIN|COMMIT" /var/log/app/app.log | tail -n 8
2025-12-30T01:12:10Z db=sqlite msg="BEGIN"
2025-12-30T01:12:10Z db=sqlite msg="COMMIT"
2025-12-30T01:12:10Z db=sqlite err="database is locked"
2025-12-30T01:12:10Z db=sqlite msg="BEGIN"
2025-12-30T01:12:10Z db=sqlite msg="COMMIT"

Significado: Pares BEGIN/COMMIT por operación y errores inmediatos de bloqueo: estás haciendo transacciones diminutas y chocando.
Decisión: Agrupa escrituras dentro de transacciones explícitas; añade retropresión; reduce la concurrencia de workers.

Guion de diagnóstico rápido

Cuando el rendimiento de escrituras colapsa, no tienes tiempo para filosofar sobre ACID. Necesitas una ruta de ramificación rápida.
Aquí está el orden que suele encontrar el cuello de botella rápidamente.

Primero: identifica el tipo de bloqueo y dónde ocurre la contención

  • SQLite: ¿Ves SQLITE_BUSY / database is locked? Revisa PRAGMA busy_timeout, tamaño del WAL y si tienes múltiples escritores.
  • MySQL: ¿Las consultas están “Waiting for row lock” o “Waiting for table metadata lock”? Usa SHOW FULL PROCESSLIST y SHOW ENGINE INNODB STATUS.

Segundo: comprueba latencia de I/O y ajustes de durabilidad

  • Ejecuta iostat -xz. Si write await pega picos, los commits se estancarán.
  • SQLite: revisa PRAGMA synchronous. MySQL: revisa innodb_flush_log_at_trx_commit.

Tercero: confirma la forma de las transacciones y los límites de concurrencia

  • Busca bucles de autocommit: muchas transacciones diminutas.
  • Verifica tamaños de pool de workers, concurrencia de peticiones y comportamiento de reintentos.
  • En MySQL, revisa el conteo de conexiones y si existe un pool.

Cuarto: decide “tunar” vs “rediseñar”

  • Si es SQLite y necesitas múltiples escritores concurrentes: asume rediseño/migración.
  • Si es MySQL y tienes una fila/índice caliente: rediseña patrones de consulta y modelo de datos; el tuning por sí solo no vencerá a la física.
  • Si es I/O: compra latencia, reduce la frecuencia de sync de forma segura, o reduce volumen de escrituras.

Errores comunes: síntomas → causa raíz → solución

1) “database is locked” aparece solo en tráfico pico

Síntomas: Errores de bloqueo esporádicos, reintentos, timeouts y un salto repentino en la cola de latencia.

Causa raíz: Contención del bloqueo de escritor y encolamiento; demasiados escritores concurrentes; busy_timeout demasiado bajo o cero.

Solución: Agrupa escrituras en transacciones explícitas; establece un busy_timeout sensato; reduce la concurrencia de escritores; migra a MySQL si necesitas multi-writer.

2) El archivo WAL crece sin fin

Síntomas: .db-wal crece mucho; uso de disco sube; picos de latencia periódicos.

Causa raíz: Checkpoints no ejecutándose o bloqueados por lectores de larga duración; configuración de checkpoints no adecuada al workload.

Solución: Asegurar que los lectores no mantengan transacciones abiertas; programar/disparar checkpoints apropiadamente; considerar transacciones de lectura más pequeñas o patrones de snapshot.

3) “SQLite es rápido en mi portátil” pero lento en contenedores

Síntomas: Gran rendimiento en dev; producción muestra timeouts de bloqueo y stalls de I/O.

Causa raíz: Almacenamiento diferente: sistemas de ficheros overlay, volúmenes de red, IOPS limitadas o mayor latencia de fsync.

Solución: Poner SQLite en almacenamiento persistente local y de baja latencia; medir coste de fsync; o migrar a MySQL donde el servidor puede absorber mejor la variabilidad.

4) Latencia de escrituras MySQL con picos sin bloqueos obvios

Síntomas: Consultas “ejecutan” pero tardan mucho; CPU moderada; disco ocupado.

Causa raíz: Presión en flush del redo log o flushing agresivo de páginas sucias; latencia de almacenamiento.

Solución: Mejorar latencia de disco; ajustar tamaño del redo log; verificar políticas de flushing; reducir tasa de commits mediante batch.

5) MySQL está “caído” pero el problema real son las conexiones

Síntomas: Errores por demasiadas conexiones; tiempos de respuesta degradan en todo el sistema.

Causa raíz: Comportamiento conexión-por-petición, mal config de pools o tormentas de reintentos.

Solución: Hacer cumplir pooling, limitar concurrencia y aplicar retropresión; monitorizar Threads_connected y la profundidad de cola de la app.

6) “Añadimos un índice y las escrituras empeoraron”

Síntomas: Lecturas mejoraron, pero throughput de insert/update cayó; aumento del tiempo de bloqueo.

Causa raíz: Mantenimiento extra de índices incrementa la amplificación de escritura y la contención (especialmente en claves monotónicas).

Solución: Mantener índices mínimos; considerar índices compuestos con cuidado; evitar índices redundantes; usar claves amigables para append cuando sea posible.

Tres mini-historias corporativas desde el terreno

Mini-historia 1: El incidente causado por una suposición errónea

Un equipo de producto mediano construyó un ejecutor de trabajos que procesaba eventos de una cola de mensajes. Cada worker escribía una fila de estado en SQLite: tiempo de encolado, inicio, fin
y un par de contadores. Funcionó genial en staging. Funcionó genial el primer mes en producción.

La suposición fue silenciosa y letal: “Los workers son independientes, así que las escrituras de base de datos son independientes.” No lo eran. Todos los workers escribían en el mismo archivo,
en el mismo volumen, y cada trabajo generaba múltiples transacciones pequeñas porque el ORM estaba en modo autocommit.

Llegó el tráfico pico (predecible, al mismo tiempo que marketing programó una campaña). Los workers pasaron de unos pocos a docenas. De repente la longitud de la cola creció más rápido
que el procesamiento. Los workers reintentaban por errores de bloqueo, lo que aumentó los intentos de escritura. La CPU se mantuvo baja. El servicio parecía “bien” si solo mirabas la CPU.

La pista fue el log: una pared de database is locked y transacciones diminutas. La solución no fue heroica: reducir la concurrencia de escritores, agrupar las escrituras por trabajo
en una transacción y mover el almacenamiento de estado a MySQL para durabilidad multi-writer. El resto de la semana se pasó explicando a dirección por qué “BD embebida rápida” no significa
“servidor de base de datos compartida para escrituras”.

Mini-historia 2: La optimización que salió mal

Otro equipo quiso “menos saltos de red” y movió una pequeña funcionalidad heavy-write de MySQL a SQLite embebida en un servicio API. Su lógica fue seductora:
sin red, sin overhead TCP, sin servidor, sin pools de conexiones. Solo un archivo. Las pruebas de rendimiento se veían geniales—en una instancia.

Entonces la funcionalidad se popularizó. Escalaron la API horizontalmente, porque eso es lo que haces con servicios sin estado. Cada instancia apuntó al mismo volumen compartido
(un sistema de ficheros de red gestionado) para “compartir la base de datos”. Ahí fue donde el optimismo se topó con la realidad del sistema de ficheros.

El sistema no solo se volvió más lento. Desarrolló una personalidad: pausas ocasionales, timeouts de bloqueo y comportamientos extraños por los que un deploy “lo arreglaba” por una hora.
Los archivos WAL crecieron, el checkpointing se volvió errático y las semánticas de bloqueo del sistema de ficheros de red introdujeron variación extra en la latencia. Depurar fue miserable
porque cada instancia estaba técnicamente “sana”, simplemente bloqueada.

Revirtieron el cambio. No porque SQLite sea malo, sino porque intentaron usarlo como una base de datos compartida multi-nodo. La “optimización” quitó un salto de red y lo reemplazó
por un bloqueo distribuido y latencia de almacenamiento impredecible. No puedes engañar a la física; solo puedes elegir qué parte de la física pagar.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Un equipo relacionado con pagos usaba MySQL para cargas transaccionales y SQLite en dispositivos edge para cache local. Tenían una regla: ningún archivo SQLite podía tener más de un escritor activo
por grupo de procesos, y cada ruta de escritura tenía un límite de transacción explícito y una cola acotada.

Era aburrido. Requirió reviews de código que hacían preguntas molestas como “¿Cuál es tu tiempo máximo de transacción?” y “¿Dónde se aplica la retropresión?”
También tenían tests sintéticos que modelaban tasas máximas de escritura y forzaban contención de bloqueo en pre-prod.

Un día, una nueva funcionalidad introdujo accidentalmente un patrón write-on-read: un endpoint de lectura actualizaba un campo “last_seen” en cada petición. En MySQL, eso habría sido ruidoso pero sobrevivible;
en las cachés SQLite en edge, convirtió lecturas en escrituras y amenazó con serializarlo todo.

Las protecciones funcionaron. La cola acotada se llenó, el servicio degradó de forma predecible (algunas actualizaciones se descartaron, las lecturas siguieron rápidas) y saltaron alertas antes de que los dispositivos
fueran inutilizables. Revirtieron el cambio y reemplazaron “write last_seen” por una actualización periódica por lotes. Nadie fue despertado a las 02:00. Esto es lo que compra lo “aburrido”.

Listas de verificación / plan paso a paso

Si insistes en ejecutar SQLite bajo carga concurrente

  1. Activa WAL a menos que tengas una razón específica para no hacerlo. Verifica con PRAGMA journal_mode;.
  2. Agrupa escrituras: envuelve múltiples sentencias en una transacción explícita. Mide la tasa de commits, no la tasa de consultas.
  3. Configura busy_timeout para esperar controladamente en lugar de fallar de inmediato; luego limita reintentos a nivel de aplicación.
  4. Aplica un escritor único con una cola (en proceso o mediante un servicio escritor dedicado). Múltiples escritores no son “paralelo”, son “compitiendo”.
  5. Mantén transacciones cortas; no mantengas transacciones de lectura abiertas durante lógica de aplicación lenta.
  6. Fija la base de datos en almacenamiento local. Evita sistemas de ficheros de red para escrituras compartidas.
  7. Vigila WAL y comportamiento de checkpoints: alerta sobre crecimiento del WAL y stalls en checkpoints.
  8. Prueba con carga realista de concurrencia, incluyendo reintentos y timeouts, no solo “benchmarks monohilo”.

Si eliges MySQL por concurrencia

  1. Usa InnoDB (sí, sigue valiendo la pena decirlo) y confirma que los ajustes de durabilidad coinciden con los requisitos del negocio.
  2. Diseña para evitar hotspots: evita contadores de fila única; evita bloqueos globales disfrazados como “tablas de estado”.
  3. Indexa responsablemente: cada índice es un impuesto sobre las escrituras; paga solo los impuestos que necesitas.
  4. Pooled conexiones y limita la concurrencia; no permitas que cada petición cree una sesión nueva.
  5. Monitorea esperas de bloqueo y deadlocks y trátalos como retroalimentación sobre el diseño de tus transacciones.
  6. Testea bajo picos de escritura, no solo en estado estable. Muchos fallos ocurren cuando los buffers se llenan y el flushing se vuelve urgente.

Checklist de disparo para migración: cuando SQLite es la colina equivocada para morir

  • Necesitas múltiples escritores concurrentes entre hilos/procesos y no puedes serializarlos sin perjudicar la latencia.
  • Necesitas escalado horizontal con estado compartido entre nodos.
  • Dependes de almacenamiento en red para el archivo de base de datos.
  • No puedes agrupar escrituras porque el producto exige commit inmediato por evento a alta tasa.
  • Necesitas cambios de esquema online y herramientas operativas que esperan una base de datos servidor.

Segundo chiste: SQLite es una herramienta fantástica, pero usarla como base de datos compartida para escrituras es como usar un destornillador como cincel—funciona hasta que se convierte en una historia que cuentas en el onboarding.

Preguntas frecuentes

1) ¿SQLite es realmente de un solo escritor?

Prácticamente, sí: solo un escritor puede confirmar a la vez. WAL mejora la concurrencia de lectura, no el rendimiento multi-writer.
Puedes tener múltiples conexiones que emitan escrituras, pero se serializan en el bloqueo de escritor.

2) ¿El modo WAL hace a SQLite seguro para alta concurrencia?

Lo hace más seguro para concurrencia dominada por lecturas y cargas mixtas, porque los lectores no se bloquean con un escritor de la misma manera.
No elimina el cuello de botella de escritor único; a menudo solo lo mueve o cambia su forma (picos por checkpoints).

3) ¿Por qué las escrituras hacen cliff en vez de empeorar lentamente?

Porque estás saturando un recurso serializado. Teoría de colas: a medida que la utilización se acerca al 100%, los tiempos de espera se disparan.
Añade reintentos y tiempo de commit variable, y la explosión se vuelve espectacular.

4) ¿Puedo arreglar la concurrencia de SQLite añadiendo más CPU?

Normalmente no. El limitador es la serialización de bloqueo y la latencia de fsync/I/O, no la CPU. El almacenamiento más rápido y menos commits ayudan más que más núcleos.

5) ¿“database is locked” siempre es malo?

Es una señal. Unas pocas respuestas busy bajo ráfagas pueden ser aceptables si tienes retropresión y reintentos acotados.
Errores de bloqueo persistentes significan que la tasa de llegada de escrituras excede lo que el escritor único puede atender, o que las transacciones son demasiado largas.

6) ¿Por qué MySQL maneja mejor las escrituras concurrentes?

InnoDB soporta bloqueos a nivel de fila y MVCC, así que escrituras independientes pueden proceder concurrentemente si no tocan las mismas filas/páginas de índice.
Aún tiene cuellos de botella, pero suelen ser localizables y rediseñables.

7) ¿Cuándo MySQL aún se comporta como un “precipicio de tráfico”?

Filas calientes (contadores únicos), índices secundarios grandes bajo insert masivo, presión del redo log o tormentas de conexiones.
MySQL puede degradarse gradualmente, pero también puede caer por un precipicio si construyes un hotspot y encima añades reintentos.

8) ¿Debería reducir la durabilidad para mejorar el throughput de escrituras?

A veces, pero hazlo deliberadamente. PRAGMA synchronous de SQLite y innodb_flush_log_at_trx_commit de MySQL son decisiones del negocio.
Si no puedes tolerar pérdida de datos, no te “optimices” hacia un incidente de cumplimiento.

9) ¿Puedo ejecutar SQLite en NFS si tengo cuidado?

Puedes intentarlo, y quizá tengas suerte por un tiempo. Pero las semánticas de bloqueo y la variación de latencia lo hacen una apuesta de alto riesgo.
Si necesitas escrituras compartidas entre nodos, prefiere un servidor de base de datos.

10) ¿Cuál es el patrón más simple y seguro para SQLite en producción?

Escritor único (cola), modo WAL, transacciones agrupadas, disco local, transacciones de lectura de corta duración y reintentos acotados con retropresión.
Si eso suena a que estás construyendo un servidor de base de datos, estás aprendiendo la lección correcta.

Próximos pasos que realmente puedes hacer esta semana

Si ya estás en SQLite y sientes el precipicio:

  • Medir: confirma journal mode, synchronous, busy_timeout; inspecciona tamaño del WAL; correlaciona errores de bloqueo con latencia de disco.
  • Arreglar la forma: agrupa escrituras en transacciones explícitas; limita la concurrencia de escritores; añade retropresión; detén reintentos infinitos.
  • Estabilizar: asegura almacenamiento local; evita transacciones de lectura de larga duración; vigila comportamiento de checkpoints.
  • Decidir: si necesitas concurrencia multi-writer entre procesos/nodos, planifica migración a MySQL (u otra BD servidor) en lugar de “tunear más”.

Si estás eligiendo entre MySQL y SQLite para un sistema nuevo:

  • Usa SQLite cuando quieras simplicidad embebida, mayormente lecturas, concurrencia de escritura acotada y durabilidad local.
  • Usa MySQL cuando necesites muchos escritores concurrentes, estado centralizado entre instancias de app, herramientas operativas y escalado predecible bajo carga.

El precipicio no es misterioso. Es un contrato que firmaste por accidente: “un escritor a la vez”. Si ese contrato coincide con tu carga, SQLite es brillante.
Si no, el precipicio no es un bug. Es la factura.

← Anterior
Control de acceso oficina a oficina: aplicar la regla «solo servidores, no toda la LAN»
Siguiente →
MariaDB vs Percona Server: velocidad de respaldo/recuperación en una VPS pequeña

Deja un comentario