Todo va bien hasta que deja de ir. Tu sitio se lanza con SQLite, es rápido, desplegar es trivial y la base de datos es literalmente un archivo. Luego sube el tráfico, aparecen trabajos en background, se añade analítica y de repente te quedas mirando database is locked como si fuera un rasgo de personalidad.
Esta es la línea entre “SQLite es perfecto” y “SQLite me arruina el día”. El objetivo aquí no es criticar a SQLite: es delimitar con claridad dónde brilla, dónde se quiebra y qué revisar antes de migrar en pánico a MySQL (o, peor, antes de intentar “arreglar” SQLite para que sea MySQL).
La versión corta: elige la herramienta con criterio
Usa SQLite cuando quieres una aplicación de nodo único con operaciones sencillas, concurrencia de escritura modesta y una fuerte preferencia por no ejecutar un servidor de base de datos. Es excelente para prototipos, herramientas internas, dispositivos edge y muchos sitios en producción que son de lectura intensiva con escrituras controladas.
Usa MySQL cuando necesitas concurrencia predecible bajo carga, múltiples servidores de aplicación escribiendo a la vez, primitivas de replicación/failover, hábitos de evolución de esquema online y perillas operativas que te ayudan a recuperarte de errores sin downtime.
Si no puedes describir tu concurrencia de escrituras, tu objetivo de latencia p95, tu RTO de backup/restore y dónde vive físicamente el archivo de la base de datos, no estás eligiendo una base de datos: estás eligiendo un incidente futuro.
Lo que realmente eliges: arquitectura, no sintaxis
SQLite es una librería que escribe un archivo; MySQL es un servicio que habla por la red
SQLite se ejecuta en el mismo proceso. Tu aplicación llama a una librería y esa librería lee/escribe un archivo de base de datos en almacenamiento local. No hay un demonio de base de datos separado aceptando conexiones, gestionando memoria entre clientes o coordinando acceso remoto. Eso no es una desventaja; es la característica.
MySQL se ejecuta fuera del proceso. Posee los archivos de datos, el buffer pool, los logs redo/undo, hilos en background y la replicación. Tu app se conecta por TCP (o un socket local) y MySQL arbitra la concurrencia entre muchos clientes.
El verdadero intercambio: simplicidad operativa vs concurrencia y realidad multinodo
La simplicidad de SQLite es un multiplicador de fuerza hasta que la superas. Un archivo. Backups fáciles (en su mayoría). Sin proliferación de credenciales. Sin pools de conexiones que afinar. Si construyes un sitio donde las escrituras son raras y controladas, SQLite permanece aburrido en el mejor sentido.
Pero en el momento en que tienes múltiples escritores desde múltiples procesos (o múltiples hosts), pasas de “elección de base de datos” a “bloqueos distribuidos y semánticas de fallo”. SQLite puede seguir funcionando, pero debes respetar su modelo de concurrencia y la física del almacenamiento subyacente.
Un principio de fiabilidad que vale la pena colgar en la pared
Werner Vogels, en el mundo de la fiabilidad de AWS, tiene una frase que se parafrasea mucho: Todo falla todo el tiempo; diseña para que el sistema siga funcionando de todos modos
(idea parafraseada, Werner Vogels). SQLite y MySQL fallan ambos. Simplemente fallan de maneras distintas. Elige el fallo que puedas sobrevivir.
Datos e historial interesantes para reuniones
- SQLite se creó en 2000 por D. Richard Hipp para un contrato de la Marina de EE. UU.—la confiabilidad embebida era el objetivo, no el glamour web a escala.
- SQLite es famosamente “sin servidor”—no “serverless en la nube”, sino “no hay proceso servidor”. Es una librería.
- SQLite está en más dispositivos de los que puedes contar: teléfonos, navegadores, apps de escritorio, sistemas de infoentretenimiento. Esa presión de distribución lo mantiene conservador y estable.
- WAL (Write-Ahead Logging) llegó en 2010 y cambió dramáticamente la historia de concurrencia de SQLite para cargas con muchas lecturas.
- Las bases SQLite son archivos únicos (más los archivos opcionales WAL y shm), lo que facilita el envío y snapshotting—pero también convierte en tragedia recurrente la idea de “ponerlo en NFS”.
- InnoDB de MySQL se convirtió en el predeterminado a partir de MySQL 5.5, y ese cambio importó: transacciones, recuperación ante fallos, bloqueos a nivel de fila y defaults sensatos se volvieron la experiencia normal.
- La replicación de MySQL ha sido un patrón central durante décadas—no perfecta, pero entendida operativamente por un ecosistema enorme (y la mayoría de las herramientas asumen esto).
- SQLite tiene una cultura de pruebas sorprendentemente estricta: cobertura enorme, fuzzing y expectativas de estabilidad a largo plazo porque está embebido en todas partes.
- MySQL tiene una historia de “arma de doble filo” alrededor de defaults no determinísticos y deriva de configuración—menos ahora, pero el folklore existe por una razón.
Hasta dónde puede llegar SQLite (más de lo que tu equipo cree)
SQLite es extremadamente rápido cuando la carga encaja
SQLite puede ser ridículamente rápido para lecturas porque no hay salto de red y el motor de consultas está en el mismo proceso que tu código. Para una app de nodo único con la cache de páginas caliente, puedes lograr baja latencia con menos componentes en movimiento. Si tu aplicación lee mayoritariamente un conjunto de datos moderado y hace escrituras ocasionales, SQLite no es un compromiso. Es una solución limpia.
También maneja transacciones ACID, claves foráneas (cuando se habilitan), índices y un planificación de consultas decente. No es una base de datos de juguete. El problema es que la gente la trata como juguete hasta que está en producción, y luego la trata como base de datos distribuida cuando no lo es.
Dónde SQLite es un buen valor por defecto
- Despliegue en una sola VM / contenedor con un proceso de aplicación primario (o un número pequeño de procesos) y escrituras controladas.
- Cargas de trabajo de lectura intensiva con escrituras por lotes periódicas, especialmente con WAL activado.
- Apps embebidas, de escritorio, edge y offline-first donde ejecutar MySQL es una sobrecarga absurda.
- Herramientas internas donde quieres máxima “funciona sin pensar” y mínima “llamar al DBA”.
- Prototipos que pueden volverse reales, siempre que construyas disciplina de migración y backups desde temprano.
SQLite puede manejar tráfico real—si controlas las escrituras
Si puedes encauzar las escrituras a través de un único trabajador (o un pequeño número de trabajadores coordinados) y mantener las transacciones cortas, SQLite puede servir una cantidad sorprendente de tráfico de lectura. El modo WAL permite que los lectores continúen mientras un escritor añade al log. Eso es importante.
Pero debes dejar de pensar en “peticiones por segundo” y empezar a pensar en “contención de escrituras por segundo”. Diez mil lecturas pueden estar bien. Diez escrituras que colisionan pueden poner tu p95 en llamas.
Broma #1: SQLite es como una bibliotecaria muy competente—silenciosa, rápida, organizada. Pero aun así solo timbra una ficha de préstamo a la vez.
Dónde falla SQLite: modos de fallo que duelen en producción
1) Concurrencia: el bloqueo de escritura es el titular
SQLite permite múltiples lectores, pero la concurrencia de escrituras es limitada. En modo rollback journal, un escritor bloquea a los lectores en el commit. En modo WAL, los lectores no bloquean a los escritores y los escritores no bloquean a los lectores—pero sigue habiendo efectivamente un único escritor a la vez.
El síntoma clásico son peticiones que fallan o se quedan atascadas bajo escrituras en ráfaga. Verás SQLITE_BUSY, “database is locked”, o latencias aumentadas que parecen picos aleatorios. No es aleatorio. Es contención.
Algunos intentan “solucionarlo” con timeouts de busy más largos. Eso puede reducir la tasa de errores, y también convertir un pequeño problema de contención en un incidente de latencia a nivel de sitio. Felicidades, cambiaste un 500 por una carga de página de 30 segundos.
2) Semántica del almacenamiento: disco local vs sistema de archivos en red
SQLite confía en bloqueos de archivo y comportamiento predecible del sistema de archivos. En un sistema de archivos local (ext4, xfs, APFS, NTFS), eso suele estar bien. En un sistema de archivos en red, puede convertirse en una sopa de rendimiento o en un riesgo de corrección dependiendo de las semánticas de bloqueo y el caché.
Poner SQLite en NFS porque “queremos almacenamiento compartido para múltiples servidores de aplicación” es un movimiento común justo antes de que una rotación on-call se ponga interesante. Si necesitas escritores multi-host, por lo general necesitas una base de datos servidor, o implementar un patrón de single-writer con una cola y un servicio escritor dedicado.
3) Fallo operativo: backups que son “una copia del archivo” (hasta que no lo son)
Los backups de SQLite pueden ser sencillos: puedes copiar el archivo de la base de datos cuando está consistente. El problema es que la gente lo hace cuando no está consistente, o copia solo el archivo principal y olvida el WAL. O copian durante alta carga de escrituras sin usar las primitivas correctas.
MySQL tiene herramientas estructuradas y una norma cultural en torno a backups. SQLite te pide que tengas cuidado. Muchas organizaciones interpretan “tener cuidado” como “lo haremos más tarde”.
4) Cambios de esquema: apps pequeñas se vuelven grandes y ALTER TABLE se vuelve político
SQLite soporta muchos cambios de esquema, pero no todos son amigables en línea como la gente espera. Algunas operaciones requieren reconstruir la tabla, lo que puede bloquear o detener un sitio ocupado. Si haces migraciones frecuentes en un archivo de base de datos caliente, plánificalo explícitamente.
5) Observabilidad: menos perillas, menos contadores, más adivinanzas
Con MySQL obtienes un proceso con métricas: hit rate del buffer pool, presión del redo log, esperas de bloqueo, retraso de replicación, slow query log, performance schema. Con SQLite, normalmente instrumentas desde la capa de aplicación. Puedes hacerlo bien, pero debes hacerlo intencionalmente.
6) “Añadimos otro servidor de aplicación” no funciona igual
SQLite escala verticalmente muy bien en un solo nodo: CPU más rápida, SSD más rápido, más RAM, PRAGMAs afinados, mejores consultas. No escala horizontalmente por defecto. Si tu siguiente paso es añadir nodos detrás de un balanceador, MySQL (u otra BD servidor) se vuelve la elección directa.
Qué te compra MySQL (y qué cuesta)
MySQL te compra: concurrencia multi-cliente predecible
MySQL con InnoDB te da bloqueos a nivel de fila, múltiples escritores concurrentes y semánticas de aislamiento que son más fáciles de razonar a escala. Aún tendrás contención de bloqueos, pero es otro animal: puedes verlo, analizarlo y mitigarlo con índices, diseño de consultas y disciplina de transacciones.
MySQL te compra: replicación y failover como habilidad operativa
Incluso si nunca ejecutas una topología elegante, tener una réplica para backups, consultas de reporting y recuperación ante desastres es un patrón operativo maduro. También te da opciones cuando el primario está mal: drenar tráfico, promover o al menos leer desde una réplica mientras lo reparas.
MySQL cuesta: sobrecarga operativa y esquinas afiladas
Ahora ejecutas un servicio stateful con configuración, upgrades, postura de seguridad, monitorización, layout de disco y planificación de capacidad. Necesitas pooling de conexiones. Necesitas verificación de backups. Debes pensar en cambios de esquema y transacciones de larga duración. Estás adoptando un sistema que puede servirte bien, pero exigirá competencia.
Broma #2: MySQL es como adoptar un perro—leal, capaz y protector. Pero ahora eres responsable de su dieta, sus cambios de humor y de que a veces ladre a las 3 a.m.
Guía de diagnóstico rápido: encuentra el cuello de botella
Esta es la checklist de “estoy de on-call y el sitio está lento”. La forma más rápida de perder tiempo es debatir bases de datos filosóficamente mientras tu p95 grita.
Primero: decide si tienes contención, I/O o patología de consultas
- Revisa patrones de error: ¿ves
SQLITE_BUSY/ “database locked” o esperas de bloqueos en MySQL? - Revisa la forma de la latencia: lento constante (limitado por I/O) vs parones en picos (contención) vs “algunos endpoints terribles” (problema de consulta/índice).
- Revisa la tasa de escrituras: ¿añadió un deploy escrituras, jobs en background, eventos de analítica, escrituras de sesión o migraciones?
Segundo: identifica dónde se va el tiempo
- Timing a nivel de app: tiempo en llamadas a BD vs tiempo en otras cosas (renderizado, APIs externas).
- Señales a nivel BD:
- SQLite: comportamiento WAL/bloqueos, transacciones largas, tablas calientes, issues de vacuum/auto_vacuum.
- MySQL: slow query log, estado de InnoDB, esperas de bloqueo, presión del buffer pool, retraso de replicación.
- I/O a nivel host: ¿está el disco saturado o el sistema de archivos bajo estrés?
Tercero: elige la mitigación de menor riesgo
- Si SQLite está bloqueado: reduce la concurrencia de escrituras, acorta las transacciones, habilita WAL (si es seguro), agrupa escrituras, mueve escrituras a un trabajador único.
- Si MySQL está bloqueado: añade/ajusta índices, reduce el scope de las transacciones, mata consultas runaway, ajusta aislamiento cuando corresponda.
- Si está limitado por I/O: añade RAM (para cache), muévelo a SSD/NVMe más rápido, reduce la presión de fsync, afina logs redo de MySQL, ajusta cautelosamente el modo synchronous de SQLite.
Tareas prácticas: comandos, salidas y decisiones (12+)
Estas son tareas reales que puedes ejecutar en hosts Linux y despliegues comunes de MySQL/SQLite. Cada una incluye qué significa la salida y qué decisión tomar después.
Task 1: Confirmar dónde vive la base de datos SQLite y qué sistema de archivos usa
cr0x@server:~$ df -T /var/www/app/db/app.sqlite3
Filesystem Type 1K-blocks Used Available Use% Mounted on
/dev/nvme0n1p2 ext4 192152472 81324512 101245120 45% /
Significado: Está en ext4 local, lo cual es buena noticia. Si ves nfs o un overlay FUSE con semánticas extrañas, trata bloqueos y latencia como sospechosos por defecto.
Decisión: Si está en NFS y tienes múltiples escritores, para y rediseña: muévete a MySQL/Postgres o aplica un patrón de writer único.
Task 2: Comprobar archivos compañeros de SQLite (WAL/shm) y crecimiento
cr0x@server:~$ ls -lh /var/www/app/db/
total 2.3G
-rw-r----- 1 www-data www-data 1.7G Dec 30 10:12 app.sqlite3
-rw-r----- 1 www-data www-data 512M Dec 30 10:12 app.sqlite3-wal
-rw-r----- 1 www-data www-data 32K Dec 30 10:12 app.sqlite3-shm
Significado: El modo WAL está activo (tienes un archivo -wal). Un archivo WAL enorme puede significar que los checkpoints no están ocurriendo, que la app tiene lectores de larga vida o que el checkpointing está mal configurado.
Decisión: Si WAL crece sin control, investiga transacciones/lecturas de larga duración y la estrategia de checkpoints; si no puedes controlarlo, MySQL empieza a verse atractivo.
Task 3: Verificar el journal mode y synchronous de SQLite
cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 'PRAGMA journal_mode; PRAGMA synchronous;'
wal
2
Significado: wal está habilitado. synchronous=2 significa FULL. Eso es más seguro pero puede aumentar la latencia de fsync.
Decisión: Si estás limitado por I/O y puedes tolerar algo de riesgo de durabilidad, podrías considerar synchronous=NORMAL—pero hazlo solo con un modelo de fallo claro y recuperación probada.
Task 4: Identificar eventos “database is locked” en logs de aplicación
cr0x@server:~$ grep -R "database is locked" -n /var/log/app/ | tail -n 5
/var/log/app/app.log:44182 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44190 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44201 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44222 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44228 sqlite error: database is locked (SQLITE_BUSY)
Significado: Estos no son “fallos aleatorios”. Son presión de concurrencia o transacciones largas.
Decisión: Si esto se correlaciona con endpoints específicos o cron jobs, aisla a los escritores. Añade una cola, agrupa escrituras o mueve esa carga a MySQL.
Task 5: Encontrar transacciones de larga duración que retienen SQLite (inspección de procesos de la app)
cr0x@server:~$ ps -eo pid,etimes,cmd | grep -E "gunicorn|uwsgi|node|python" | head
2143 8123 /usr/bin/python3 /var/www/app/worker.py
2190 4201 /usr/bin/python3 /var/www/app/web.py
2211 233 /usr/bin/python3 /var/www/app/web.py
Significado: Procesos worker de larga vida suelen mantener conexiones abiertas y pueden mantener transacciones de lectura inadvertidamente.
Decisión: Audita el scope de conexiones/transacciones. Asegura que cada request/job use transacciones cortas y cierre cursores. Si tu ORM abre transacciones implícitas, forza autocommit donde sea seguro.
Task 6: Comprobar latencia de disco y saturación durante picos
cr0x@server:~$ iostat -xz 1 5
Linux 6.5.0 (server) 12/30/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
6.12 0.00 2.01 8.42 0.00 83.45
Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz aqu-sz %util
nvme0n1 85.0 2048.0 0.0 0.0 3.20 24.10 210.0 8192.0 0.0 0.0 18.70 39.01 4.12 92.5
Significado: %util cerca del 90% y w_await ~19ms sugieren que el disco es un cuello de botella para escrituras. SQLite con synchronous FULL lo sentirá.
Decisión: Si el disco está saturado, reduce la frecuencia de fsync (con cuidado), agrupa escrituras, mueve la BD a almacenamiento más rápido o migra a MySQL con mejor buffering y ajuste de logs.
Task 7: Comprobar comportamiento de locks de archivos abiertos (útil con NFS)
cr0x@server:~$ lsof /var/www/app/db/app.sqlite3 | head
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 2190 www-data 7u REG 259,2 1825368064 393226 /var/www/app/db/app.sqlite3
python3 2211 www-data 7u REG 259,2 1825368064 393226 /var/www/app/db/app.sqlite3
Significado: Múltiples procesos tienen el archivo abierto. Eso es normal, pero si esos procesos están en hosts diferentes y el archivo está en almacenamiento compartido, estás jugando con las semánticas de bloqueo.
Decisión: Si esto es acceso multi-host, para. Centraliza escrituras o migra a una BD servidor.
Task 8: Validar integridad de SQLite tras un crash o evento I/O sospechoso
cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 'PRAGMA integrity_check;'
ok
Significado: “ok” significa que las estructuras internas están consistentes.
Decisión: Si no es ok, restaura desde backups inmediatamente e investiga el almacenamiento y el comportamiento de crash de procesos.
Task 9: Observar el plan de consulta de SQLite para una consulta lenta conocida
cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 "EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id=42 ORDER BY created_at DESC LIMIT 20;"
QUERY PLAN
`--SCAN orders
`--USE TEMP B-TREE FOR ORDER BY
Significado: Está escaneando la tabla y ordenando con un b-tree temporal. Eso es caro.
Decisión: Añade un índice como (user_id, created_at). La mayoría de los reportes de “SQLite lento” son en realidad “olvidaste un índice”. Lo mismo ocurre con MySQL, por cierto.
Task 10: Revisar slow queries en MySQL (si ya migraste o estás evaluando)
cr0x@server:~$ sudo mysql -e "SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';"
+----------------+-------+
| Variable_name | Value |
+----------------+-------+
| slow_query_log | ON |
+----------------+-------+
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| long_query_time | 1.000 |
+-----------------+-------+
Significado: El slow query log está activado; las consultas más lentas que 1s se registran.
Decisión: Si no tienes esto activado en producción, eliges ceguera. Enciéndelo con un umbral sensato y rota los logs.
Task 11: Inspeccionar contención de locks y transacciones activas en MySQL
cr0x@server:~$ sudo mysql -e "SHOW FULL PROCESSLIST;"
+----+------+-----------+------+---------+------+------------------------+-------------------------------+
| Id | User | Host | db | Command | Time | State | Info |
+----+------+-----------+------+---------+------+------------------------+-------------------------------+
| 17 | app | 10.0.1.12 | app | Query | 12 | Waiting for table lock | UPDATE sessions SET ... |
| 23 | app | 10.0.1.13 | app | Query | 0 | Sending data | SELECT * FROM orders WHERE... |
+----+------+-----------+------+---------+------+------------------------+-------------------------------+
Significado: Una consulta está esperando un bloqueo de tabla desde hace 12 segundos. Eso no es normal.
Decisión: Encuentra el bloqueador (a menudo una migración o una transacción larga). Corrige el scope de la transacción, usa patrones de cambios de esquema online y añade índices para evitar escaneos que bloqueen.
Task 12: Snapshot de salud de InnoDB para MySQL (el comando “dime qué duele”)
cr0x@server:~$ sudo mysql -e "SHOW ENGINE INNODB STATUS\G" | head -n 40
*************************** 1. row ***************************
Type: InnoDB
Name:
Status:
=====================================
2025-12-30 10:20:11 0x7f3b6c1fe700 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 10 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 1120 srv_active, 0 srv_shutdown, 332 srv_idle
srv_master_thread log flush and writes: 1452
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 1021
OS WAIT ARRAY INFO: signal count 1004
Mutex spin waits 0, rounds 0, OS waits 0
RW-shared spins 112, OS waits 19
RW-excl spins 88, OS waits 25
Significado: Esta salida puede exponer esperas de bloqueo, presión del buffer pool y problemas de flush de logs. Incluso una lectura rápida muestra si el motor está esperando I/O o locks.
Decisión: Si ves muchas esperas de log o misses del buffer pool, ajusta MySQL y el almacenamiento. Si ves esperas de bloqueo, afina consultas/transacciones/esquema.
Task 13: Medir direccionalmente la tasa de aciertos del buffer pool de MySQL
cr0x@server:~$ sudo mysql -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';"
+---------------------------------------+------------+
| Variable_name | Value |
+---------------------------------------+------------+
| Innodb_buffer_pool_read_requests | 982345678 |
| Innodb_buffer_pool_reads | 1234567 |
+---------------------------------------+------------+
Significado: Reads vs read_requests indica eficiencia de cache. Aquí, las lecturas físicas son una fracción pequeña, lo cual es bueno.
Decisión: Si las lecturas físicas suben, añade RAM, ajusta el tamaño del buffer pool o corrige consultas/índices que causen escaneos grandes.
Task 14: Confirmar lag de replicación MySQL (si dependes de réplicas)
cr0x@server:~$ sudo mysql -e "SHOW SLAVE STATUS\G" | egrep "Seconds_Behind_Master|Slave_IO_Running|Slave_SQL_Running"
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 27
Significado: 27 segundos de retraso. Eso es suficiente para romper supuestos de “leer tus propias escrituras”.
Decisión: Si tu app lee desde réplicas, necesitas reglas de consistencia (stickiness, enrutamiento read-after-write) o reducir el lag con afinamiento y separación de cargas.
Task 15: Comprobar que SQLite no está usando hilos múltiples silenciosamente como no esperabas
cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 'PRAGMA compile_options;' | grep -E 'THREADSAFE|OMIT_WAL' | head
THREADSAFE=1
Significado: SQLite está compilado threadsafe (bien). Pero thread safety no es lo mismo que escalado por concurrencia; todavía tienes la limitación de escritor único.
Decisión: Si tu app asume “hilos = throughput”, rediseña las rutas de escritura antes de culpar a SQLite.
Tres microhistorias corporativas desde el terreno
1) Incidente causado por una suposición errónea: “Es un archivo, así que el almacenamiento compartido resuelve el escalado”
Una compañía mediana tenía una web que empezó en una sola VM con SQLite. Funcionaba: unas pocas escrituras por segundo, principalmente lecturas y despliegue simple. Luego añadieron un segundo servidor web detrás de un balanceador. La idea era amable: más capacidad, más redundancia. La base de datos siguió como un archivo en un sistema de archivos en red porque “ambos servidores necesitan acceso”.
El primer día todo pareció bien. El tráfico era bajo y el sistema de archivos compartido era suficientemente rápido en el happy path. Entonces llegó una campaña de marketing. El tráfico de escrituras aumentó: sesiones, tracking de eventos y una pequeña tabla “last seen” que se actualizaba en casi cada petición. De pronto la app empezó a devolver errores intermitentes. Lo peor: los errores no eran consistentes. Algunas peticiones fueron rápidas; otras se bloquearon y agotaron el tiempo.
El ingeniero on-call vio SQLITE_BUSY y aumentó el timeout de busy. Los errores bajaron. La latencia se duplicó. Entonces el balanceador empezó a marcar hosts como unhealthy porque las peticiones tardaban demasiado, lo que concentró tráfico en menos nodos, lo que aumentó la contención de locks. Así es como conviertes un pequeño problema de concurrencia en una falla en cascada con cara orgullosa.
Post-incident, la causa raíz fue doble: el comportamiento de locking de SQLite sobre sistemas de archivos en red no era lo que asumieron, y la concurrencia de escritura multi-host nunca fue un plan de escalado soportado. La solución fue aburrida y correcta: mover la base de datos a una instancia MySQL y tratarla como servicio autoritativo único, luego refactorizar escrituras de sesión/eventos para que fueran menos ruidosas.
La lección no fue “SQLite es malo”. La lección fue “almacenamiento compartido no es un clúster de base de datos”, y las semánticas de bloqueo de archivos no son una estrategia de escalado.
2) Optimización que salió mal: “Apagar durabilidad, es más rápido”
Un equipo SaaS corría SQLite en una VM potente para un dashboard interno de baja latencia. Las escrituras eran más frecuentes de lo que querían y se notaban picos de latencia del disco. Alguien sugirió cambiar PRAGMA synchronous a un ajuste menos estricto para reducir costos de fsync. Los benchmarks se vieron bien. Los gráficos se vieron bien. Todos disfrutaron la ilusión de la victoria.
Dos semanas después, el host tuvo un reinicio no planificado durante una actualización de kernel. La app volvió rápido. Luego los usuarios empezaron a reportar cambios recientes perdidos. No corrupción de estructuras—peor. Datos que parecían correctos, pero faltaban el último bloque de escrituras que habían sido “committed” desde la perspectiva de la aplicación.
El equipo había cambiado accidentalmente el contrato de durabilidad sin cambiar las expectativas del negocio. Los usuarios asumían que una vez que la UI confirmaba una actualización, esta sobreviviría a un crash. En la práctica, esas actualizaciones vivían en buffers volátiles y nunca llegaron a almacenamiento estable.
La limpieza fue dolorosa: reconstruir datos desde logs de la aplicación, conciliar reportes de usuarios y restaurar la confianza. Revirtieron a ajustes más seguros, añadieron mensajería visible al usuario donde la durabilidad eventual era aceptable e introdujeron un backend MySQL para los datos que realmente importaban a los usuarios.
Una optimización que cambia la corrección no es optimización. Es una decisión de producto. Si la tomas por accidente, producción se encargará del papeleo por ti.
3) Práctica aburrida pero correcta que salvó el día: pruebas de restauración y disciplina de “un solo escritor”
Otra organización ejecutó un sitio de contenido en SQLite durante años. Sí, años. Tenían un único nodo manejando el archivo de base de datos y eran estrictos con la disciplina de escrituras: solo un worker background realizaba escrituras y la capa web era mayormente de lectura, con actualizaciones encoladas.
Su hábito operativo más importante no fue tuning fancy. Fue probar restauraciones. Cada semana, un job traía el último backup, lo restauraba en un host de staging, ejecutaba PRAGMA integrity_check y corría una pequeña suite de consultas de aplicación. Si la restauración fallaba, se pagaba a alguien. Esto se consideraba normal, no heroico.
Un día, un deploy introdujo un bug de migración que infló el tamaño de la base y empujó el disco hacia lleno. El sitio empezó a enlentecerse y luego las escrituras empezaron a fallar. Hicieron rollback del deploy, pero el archivo ya había crecido. La presión de disco persistió.
Restauraron desde el último backup conocido a un filesystem nuevo con espacio suficiente, reprodujeron un pequeño conjunto de escrituras encoladas y volvieron online rápidamente. Sin drama de datos. Sin conjeturas. La práctica aburrida—restauraciones verificadas—convirtió un mal día en un incidente contenido.
SQLite no los salvó. La disciplina sí. SQLite simplemente no se interpuso.
Errores comunes: síntoma → causa raíz → solución
- Síntoma: picos de “database is locked” durante ráfagas de tráfico
- Causa raíz: Demasiados escritores concurrentes, transacciones largas o un job en background que choca con escrituras de requests.
- Solución: Habilitar WAL si corresponde; acortar el scope de transacciones; mover escrituras a un worker con cola; reducir frecuencia de escrituras (debounce de actualizaciones de sesión).
- Síntoma: SQLite es rápido localmente pero lento en producción
- Causa raíz: El almacenamiento de producción tiene mayor latencia de fsync; synchronous FULL la amplifica; el checkpointing WAL puede bloquear.
- Solución: Mide latencia de disco (
iostat); coloca la BD en SSD local; afina checkpointing; considera MySQL si la latencia de escrituras debe ser estable. - Síntoma: Timeouts aleatorios, especialmente durante backups
- Causa raíz: Copia de archivos naive durante escrituras activas; interacción checkpointing/locking; lectores de larga duración que impiden checkpoint.
- Solución: Usa la API de backup online de SQLite vía tooling; asegúrate que los backups incluyan el estado WAL; programa backups con throttling de escrituras.
- Síntoma: El archivo WAL crece mucho y se queda grande
- Causa raíz: Los checkpoints no pueden completarse por lectores de larga duración o autocheckpoint mal configurado; la app mantiene transacciones de lectura abiertas.
- Solución: Asegura conexiones/transacciones de corta duración; configura autocheckpoint; haz checkpoints explícitos en tráfico bajo; revisa el comportamiento del ORM.
- Síntoma: Tras añadir un segundo servidor de app, el rendimiento colapsa
- Causa raíz: SQLite en almacenamiento compartido con múltiples escritores; contención de locks entre procesos/hosts; semánticas de sistema de archivos.
- Solución: No lo hagas. Centraliza escrituras o migra a MySQL. Si necesitas múltiples servidores de app, una BD servidor es la respuesta normal.
- Síntoma: MySQL está “lento” después de la migración, peor que SQLite
- Causa raíz: Sin índices, sin pooling de conexiones, suposiciones de aislamiento equivocadas o un esquema diseñado para patrones de acceso basados en archivo.
- Solución: Activa el slow query log; añade índices según patrones de consulta; usa un pool de conexiones; dimensiona correctamente el buffer pool de InnoDB.
- Síntoma: CPU de MySQL bien, pero consultas se atascan
- Causa raíz: Esperas de bloqueo o I/O (presión de fsync del redo log, misses del buffer pool).
- Solución: Usa
SHOW ENGINE INNODB STATUS; acorta transacciones; afina redo log y buffer pool; mueve escrituras calientes fuera del primario si es posible.
Listas de verificación / plan paso a paso
Paso a paso: decidir si SQLite sigue siendo seguro para tu sitio
- Mapea tus escritores. Lista cada ruta de código que escribe: requests, cron, jobs en background, analítica, sesiones, invalidación de cache, herramientas admin.
- Mide la concurrencia de escrituras. No promedios—picos de escrituras solapadas. Si es “desconocido”, asume que es “demasiado alto” hasta probar lo contrario.
- Habilita WAL (si no está) y verifica. Confirma
PRAGMA journal_modees WAL y entiende el comportamiento de checkpoints. - Haz cumplir transacciones cortas. Prohíbe “transacción abierta durante llamada de red”. Si tu ORM lo facilita, eso no es un cumplido.
- Prueba tus backups. Restaura semanalmente. Ejecuta checks de integridad. Practica RTO, no solo RPO.
- Mantén la BD en almacenamiento local. Si no puedes, trátalo como riesgo mayor y planifica migración.
- Planea tu historia de scale-out. Si necesitas múltiples servidores de app escribiendo, programa la migración a MySQL mientras estás con calma.
Paso a paso: migrar de SQLite a MySQL sin que sea un evento de carrera
- Congela la semántica del esquema. Decide tipos, constraints y defaults explícitamente. SQLite es permisivo; MySQL te hará elegir.
- Elige una estrategia de migración. Para datasets pequeños: migración con downtime. Para más grandes: dual-write o captura de cambios (más duro, pero posible).
- Construye un export/import repetible. La primera ejecución es un ensayo; la segunda es cómo dormirás por la noche.
- Valida recuentos e invariantes. Conteos por tabla, checksum de campos críticos, ejecuta checks de consistencia a nivel de aplicación.
- Mueve lecturas primero (opcional). A veces puedes apuntar endpoints de sólo lectura a réplicas MySQL mientras las escrituras siguen en SQLite, pero cuidado con las suposiciones de consistencia.
- Cambia las escrituras con un plan de rollback claro. Rollback no es “pánico”. Es un procedimiento.
- Activa la observabilidad de MySQL desde el día uno. Slow query log, error log, métricas, backups y drills de restauración.
Preguntas frecuentes
1) ¿Puede SQLite manejar “alto tráfico”?
Sí—si por alto tráfico se entiende principalmente lecturas y las escrituras están controladas. La pregunta no es el tráfico; es las escrituras concurrentes y cuánto tiempo mantienen locks.
2) ¿El modo WAL siempre es mejor?
Usualmente para cargas web, sí. Mejora la concurrencia de lectura durante escrituras. Pero introduce dinámicas de WAL y checkpoint que debes comprender, y algunos casos límite (como ciertos sistemas de archivos o herramientas) requieren cuidado.
3) ¿Cuál es la señal más clara de que debo migrar a MySQL?
Múltiples servidores de aplicación escribiendo a la misma base, o contención de escrituras frecuente que no puedes eliminar sin distorsionar el producto. También: cuando necesitas replicación/failover como herramienta estándar, no como proyecto experimental.
4) ¿Puedo correr SQLite en NFS si soy cuidadoso?
A veces, pero “ser cuidadoso” necesita definición: semánticas de bloqueo verificadas, modos de fallo probados y usualmente disciplina de writer único. Si haces escrituras concurrentes multi-host, estás apostando tu sitio a detalles del sistema de archivos.
5) ¿No es MySQL “más pesado” y más lento por la red?
Añade overhead de red, sí. Pero compra control de concurrencia, buffering y herramientas operativas. Para cargas multi-cliente con muchas escrituras, MySQL suele ganar en latencia real end-to-end porque evita parones inducidos por locks.
6) Si SQLite bloquea en escrituras, ¿puedo simplemente aumentar el busy timeout?
Puedes hacerlo, y puede reducir errores. Pero también convierte contención en latencia. Si tu sitio tiene SLOs de respuesta estrictos, timeouts largos de busy son solo fallos lentos con mejores logs.
7) ¿Qué hay de usar SQLite para sesiones o eventos de analítica?
Esos son patrones clásicos de mucha escritura que generan contención. Si insistes en SQLite, agrupa y encola escrituras y evita actualizar filas en cada request. Si no, usa un sistema separado (MySQL, Redis o un pipeline de logs) diseñado para ese perfil de escritura.
8) ¿Cuál es el mayor “gotcha” al moverse de SQLite a MySQL?
La tipificación flexible y constraints permisivos de SQLite pueden ocultar problemas de calidad de datos. MySQL te obligará a elegir tipos y colaciones, y expondrá suposiciones erróneas en código y datos.
9) ¿Puedo usar SQLite con múltiples procesos en una sola máquina?
Sí. Es un despliegue común y válido. Pero aún necesitas gestionar la contención de escrituras: modo WAL, transacciones cortas y programación cuidadosa de jobs en background.
10) Si me quedo en SQLite, ¿cuál es la disciplina más importante?
Mantén las escrituras controladas y las transacciones cortas. Luego verifica backups con pruebas de restauración. Esos dos hábitos previenen la mayoría de las historias de “SQLite nos arruinó el sitio”.
Conclusión: siguientes pasos que no te avergonzarán
SQLite no es “solo para demos”. Es para cargas que respetan su diseño: nodo único, escrituras controladas y almacenamiento sensato. Cuando falla, suele fallar porque le pediste que se comporte como una base de datos servidor multi-host mientras preservabas la conveniencia de “solo un archivo”. Eso no es un plan técnico; es desear que funcione.
Haz el trabajo práctico:
- Si estás en SQLite: confirma modo WAL, mide latencia de disco, audita escritores, acorta transacciones y prueba restauraciones. Si necesitas múltiples servidores de app escribiendo, programa la migración mientras aún tienes tiempo.
- Si estás en MySQL (o te mueves allí): activa slow query logging, vigila esperas de bloqueo, dimensiona el buffer pool y trata backups/restauraciones como una característica de producción, no una casilla de cumplimiento.
- En cualquier caso: elige tu base de datos según concurrencia y recuperación ante fallos, no por sensaciones.