No “superas” SQLite un martes a las 14:00. Lo superas a las 2:07 a. m. cuando un despliegue inocuo añade un escritor más, tus gráficas de latencia se convierten en arte moderno y alguien pregunta por qué “un archivo” está bloqueando una flota de servidores web.
Esta es la ruta pragmática de escalado: cómo pasar de SQLite (una fantástica base de datos embebida) a PostgreSQL (una fantástica base de datos servidor) sin tiempo de inactividad—o, más realista, con un tiempo de inactividad tan pequeño que puede esconderse entre las comprobaciones de salud del balanceador de carga. Hablaremos de escrituras duales, captura de cambios, paridad de datos, mecánica de cutover y los modos de fallo que solo aprendes haciendo esto en producción.
Cuando SQLite es la respuesta correcta (y cuando no lo es)
SQLite no es una “base de datos de juguete”. Es una pieza seria de ingeniería: una base de datos SQL transaccional en una sola biblioteca que escribe en un único archivo. Es embebida. Es portable. Es extremadamente simple de desplegar. Esa combinación es tan potente que es fácil usarla mal.
SQLite es la mejor opción cuando
- Tu aplicación es mayormente de un solo proceso o un solo escritor y la base de datos vive en disco local.
- Necesitas poca sobrecarga operativa: sin daemon, sin red, sin gestión de usuarios, sin backups que requieran snapshots consistentes entre hosts.
- Distribuyes software a dispositivos edge, escritorios, móviles, kioscos o appliances. SQLite es básicamente la lingua franca allí.
- Tu “base de datos” forma parte del artefacto: un catálogo empaquetado, un dataset estático, una caché reconstruible o una cola local.
SQLite empieza a doler cuando
- Introduces múltiples escritores concurrentes (o pensabas que no, pero tu código discrepa).
- Escalas horizontalmente y ahora varias instancias de la app necesitan compartir la misma verdad.
- Tu sistema de archivos se comporta raro: sistemas de archivos en red, capas overlay en contenedores, discos lentos, vecinos ruidosos.
- Necesitas funcionalidades operativas como HA gestionada, réplicas de solo lectura, controles de acceso finos, cambios de esquema online y observabilidad integrada en el servidor.
El modelo de bloqueo de SQLite es la clave: es excelente para serializar un escritor y muchos lectores, pero “excelente” no es lo mismo que “infinito”. Si tu carga evoluciona hacia “muchas escrituras pequeñas desde múltiples hosts”, PostgreSQL se convierte en el adulto en la sala.
Un chiste corto (1/2): SQLite es como una bicicleta: perfecta hasta que intentas remolcar un barco con ella.
PostgreSQL vs SQLite: puntos de decisión que importan en producción
Concurrencia y bloqueo: la verdadera razón de las migraciones
SQLite coordina el acceso a un único archivo de base de datos. Los lectores pueden ser concurrentes; los escritores están más limitados. Con el modo WAL obtienes una concurrencia significativamente mejorada—los lectores no bloquean tanto a los escritores—pero sigues teniendo un único lugar primario donde reside la verdad: ese archivo.
PostgreSQL es un servidor multiproceso con MVCC diseñado para escrituras concurrentes desde muchos clientes, con bloqueos a nivel de fila, visibilidad por snapshot y WAL separado para durabilidad y replicación. Si estás construyendo algo multi-tenant, con múltiples workers o multi-host, PostgreSQL está simplemente diseñado para eso.
Durabilidad y recuperación: “se comprometió” debe significar lo mismo siempre
SQLite puede ser extremadamente durable si se configura correctamente, pero también es fácil configurarlo en un modo “rápido pero quizá” (synchronous=NORMAL/OFF, ajustes de journal, comportamiento inseguro de fsync en ciertos entornos). PostgreSQL tiene sus propios riesgos, pero en general sus valores por defecto están orientados a la durabilidad de servidor con WAL y recuperación tras caídas como característica de primera clase.
Ergonomía operativa
SQLite convierte a tu app en el operador de la base de datos. Eso está bien hasta que estás haciendo el trabajo de operador mal. PostgreSQL pone al servidor de base de datos como operador, que aún necesitas ejecutar, parchear, respaldar y observar—pero al menos los límites son claros y el ecosistema de herramientas es maduro.
Planificador de consultas y características
El planificador de PostgreSQL, los índices y las funciones avanzadas (CTE, funciones de ventana, índices parciales, GIN/GiST, JSONB, columnas generadas, restricciones robustas) cambian cómo modelas datos y cómo mantienes la latencia predecible. SQLite también tiene muchas características, pero el playbook de “gran sistema bajo carga” generalmente asume una base de datos servidor.
Nada de esto es superioridad moral. Es ajuste de arquitectura. SQLite no es “peor”. Es “diferente”, y la diferencia empieza a cobrarse factura cuando tu carga cambia.
Hechos interesantes y breve historia (que realmente afectan tus decisiones)
- SQLite se creó en 2000 por D. Richard Hipp para evitar la sobrecarga administrativa y reemplazar archivos planos ad-hoc en sistemas embebidos.
- SQLite es de dominio público, lo cual es inusual para un motor de base de datos y una razón importante por la que se distribuye ampliamente sin drama de licencias.
- SQLite busca “pequeño, rápido, confiable” como biblioteca—sin proceso de servidor separado—así que tu app hereda responsabilidades de base de datos (permisos de archivo, espacio en disco, semánticas de bloqueo).
- PostgreSQL desciende de POSTGRES (1986), un proyecto de investigación que evolucionó hasta convertirse en un sistema relacional de grado de producción con fuerte énfasis en corrección y extensibilidad.
- El modelo MVCC de PostgreSQL se convirtió en piedra angular para alta concurrencia sin bloqueo lector/escritor en cargas OLTP típicas.
- El modo WAL de SQLite fue un paso importante para la concurrencia; muchos equipos “descubren” WAL solo después de su primera tormenta de bloqueos.
- La replicación de PostgreSQL maduró con el tiempo: la replicación física por streaming es antigua y probada; la replicación lógica es más nueva y cambia cómo haces migraciones y despliegues.
- SQLite es el motor de base de datos más desplegado por número de dispositivos/paquetes, aun cuando nadie “lo ejecuta” como servidor.
La ruta de escalado: de base de datos en archivo a servidor sin downtime
Aquí está el objetivo de la migración dicho claramente: seguir atendiendo peticiones mientras mueves el conjunto de datos autoritativo desde un archivo SQLite a PostgreSQL, y luego cambiar lecturas/escrituras a PostgreSQL con riesgo mínimo.
Si tu aplicación puede tolerar una ventana de mantenimiento, genial. Haz eso. Pero si no puedes—porque eres un SaaS B2B con clientes en cada zona horaria, o porque ejecutas una flota de appliances que nunca duerme—entonces necesitas un plan que trate la migración como un despliegue de producción: incremental, observable y reversible.
Paso 0: Decide qué significa realmente “sin downtime”
Las migraciones sin downtime a menudo significan una de estas opciones:
- Sin downtime visible: los usuarios no notan porque los reintentos y las comprobaciones de salud absorben el pequeño fallo.
- Sin downtime de escritura: las lecturas pueden degradarse pero las escrituras continúan (o viceversa).
- Sin downtime planificado: no programas una ventana, pero aún puedes tener un breve incidente de cutover si eres descuidado.
Elige un objetivo. Ponle un número. Si tu SLO dice 99.9% de disponibilidad mensual, tu presupuesto es alrededor de 43 minutos. Si ya estás gastando ese presupuesto en despliegues, “migración sin downtime” deja de ser un lucimiento y pasa a ser una estrategia de supervivencia.
Paso 1: Deja la parte SQLite lo más sana posible
Antes de migrar, estabiliza. Una migración amplifica el desorden existente. Si tu base de datos SQLite ya se corrompe ocasionalmente, o tienes media docena de patrones de conexión ad-hoc, copiarás el desorden a PostgreSQL y luego lo llamarás “inestabilidad de Postgres”. La física no funciona así.
Como mínimo:
- Habilita el modo WAL si aún no lo estás usando (a menos que tu entorno lo haga inseguro).
- Estandariza el comportamiento de apertura/cierre de conexiones y el busy timeout.
- Identifica cada camino de escritura en el código. Siempre hay más de los que crees.
Paso 2: Traduce el esquema y tipos deliberadamente
SQLite es de tipado dinámico. PostgreSQL no lo es. Si has estado almacenando timestamps como “lo que funcionara”, PostgreSQL te obligará a elegir: timestamptz o timestamp, tipos numéricos, text vs jsonb, restricciones que antes pasabas por alto.
Elige el rigor ahora. Las migraciones son cuando pagas la deuda de tipos. Si no, la pagarás después con interés de incidentes.
Paso 3: Construye un backfill (copia masiva) que puedas volver a ejecutar
La primera copia grande es un backfill: extraer de SQLite y cargar en PostgreSQL. Trátalo como cualquier otro trabajo en producción:
- Idempotente: seguro de ejecutar varias veces.
- Fragmentado: no explota memoria ni los logs de transacciones.
- Observable: registra contadores, duraciones y errores.
Poder volver a ejecutar importa porque descubrirás desajustes. Siempre los descubres.
Paso 4: Mantén PostgreSQL sincronizado: escrituras duales o captura de cambios
Tienes dos patrones prácticos:
Patrón A: Escrituras duales (la aplicación escribe en ambos)
Pros: concepto simple. Contras: manejo de fallos complicado; puedes crear split-brain a nivel de aplicación si no lo diseñas con cuidado.
Reglas para escrituras duales que te mantienen fuera de problemas:
- Elige una única fuente de verdad durante la fase de escritura dual. Normalmente SQLite permanece autoritativo hasta el cutover.
- Haz las escrituras idempotentes. Usa claves primarias estables y reintenta de forma segura.
- Registra y reconcilia fallos. “Disparar y olvidar” en escrituras duales es cómo se genera pérdida silenciosa de datos.
Patrón B: Captura de datos de cambio (CDC) desde SQLite
SQLite no tiene replicación lógica integrada como PostgreSQL. Pero puedes aproximar CDC mediante:
- Agregar triggers que escriban en una tabla de cambios append-only.
- Usar un enfoque de lector de write-ahead log (más difícil, frágil y dependiente del entorno).
- Escribir cambios en una outbox table en SQLite que un worker envíe a PostgreSQL.
Triggers + un outbox suele ser la forma menos mala. No es glamoroso. Funciona.
Paso 5: Sombreados de lectura y comprobaciones de paridad
Antes de cambiar las lecturas, haz sombreados: lee de SQLite como de costumbre, también lee de PostgreSQL en segundo plano, compara resultados y emite métricas. Esto detecta problemas de tipos, diferencias de colación y diferencias semánticas en consultas.
No compares cargas útiles enteras a ciegas. Compara lo que importa: claves primarias devueltas, conteos, agregados, campos específicos con orden determinista. PostgreSQL y SQLite pueden discrepar en orden salvo que lo especifiques.
Paso 6: Cortar lecturas primero, luego escrituras (por lo general)
Una secuencia común de bajo riesgo:
- Backfill a PostgreSQL.
- Escrituras duales con SQLite como fuente de verdad.
- Lecturas sombreadas y comparaciones.
- Cambiar lecturas a PostgreSQL (mantén escrituras duales).
- Cambiar escrituras a PostgreSQL (SQLite pasa a ser fallback / auditoría, temporalmente).
- Eliminar escritura dual después de ganar confianza y un tiempo de consolidación.
El cutover de escrituras es el momento que puede doler. El cutover de lecturas es donde aprendes. Etápalo.
Paso 7: Mantén un plan de rollback realista
Los planes de rollback fallan porque asumen simetría perfecta. Después de cambiar escrituras a PostgreSQL, volver a SQLite significa tener que reproducir las escrituras. Si no construiste esa canalización, el rollback es una presentación, no una capacidad.
Un plan realista es: después del cutover de escrituras, mantén la escritura dual o el outbox el tiempo suficiente para que el rollback siga siendo factible. Define un horizonte temporal (horas/días). Mide lo difícil que es reproducir. Practícalo una vez en staging con un dataset que sea suficientemente real.
Una cita (idea parafraseada): Werner Vogels (CTO de Amazon) ha defendido la idea de que “todo falla, todo el tiempo”, así que diseñas sistemas asumiendo fallos, no esperando que no ocurran.
Tareas prácticas: comandos, salidas y qué decisión tomar
Estas son tareas reales que puedes ejecutar mientras planificas, ejecutas o solucionas problemas de la migración. Cada una incluye (1) un comando, (2) una salida de ejemplo y (3) la decisión que tomas a partir de ello.
Tarea 1: Identificar el modo de journal y el comportamiento de busy timeout en SQLite
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA journal_mode; PRAGMA synchronous; PRAGMA busy_timeout;"
wal
2
5000
Qué significa: El modo WAL está habilitado; synchronous=2 (FULL); busy_timeout=5000ms.
Decisión: WAL es bueno para concurrencia; FULL es más seguro pero más lento. Si ves contención por bloqueo, aumenta busy_timeout y arregla la frecuencia de escritores. No pongas synchronous=OFF como “solución de rendimiento” a menos que disfrutes explicar pérdida de datos.
Tarea 2: Detectar contención de bloqueo en SQLite rápidamente
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA compile_options;" | grep -i threads
THREADSAFE=1
Qué significa: SQLite fue compilado como threadsafe, así que tus problemas de bloqueo no son “porque sqlite es single-threaded”. Son impulsados por la carga de trabajo y patrones de acceso.
Decisión: Enfócate en el alcance de las transacciones y el número de escritores, no en el folklore. Instrumenta dónde ocurren las escrituras.
Tarea 3: Obtener tamaños de tablas en SQLite (para estimar tiempo de backfill)
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "SELECT name, SUM(pgsize) AS bytes FROM dbstat GROUP BY name ORDER BY bytes DESC LIMIT 5;"
events|2147483648
users|402653184
sessions|268435456
sqlite_schema|4096
Qué significa: events es ~2 GiB; la estrategia de backfill necesita fragmentación y quizá particionado en PostgreSQL.
Decisión: Si haces el backfill en una sola transacción, explotarás el WAL y quizá el disco. Planea copia por fragmentos por clave primaria o ventanas temporales.
Tarea 4: Verificar integridad de SQLite antes de copiar
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA integrity_check;"
ok
Qué significa: La comprobación básica de corrupción pasa.
Decisión: Si no es “ok”, detente. Repara la corrupción primero (restaurar desde backup, reconstruir desde eventos fuente). Migrar corrupción solo mueve la culpa.
Tarea 5: Volcar esquema de SQLite (no confíes en lo que recuerdas)
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db ".schema users"
CREATE TABLE users(
id TEXT PRIMARY KEY,
email TEXT,
created_at TEXT
);
CREATE INDEX idx_users_email ON users(email);
Qué significa: Los timestamps están almacenados como TEXT. Eso se convertirá en un argumento más adelante.
Decisión: Decide cómo mapear created_at a timestamptz y cómo parsear valores heredados. Construye una función de conversión y pruebas.
Tarea 6: Crear roles de PostgreSQL y una base de datos dedicada
cr0x@server:~$ sudo -u postgres psql -c "CREATE ROLE myapp LOGIN PASSWORD 'REDACTED' NOSUPERUSER NOCREATEDB NOCREATEROLE;"
CREATE ROLE
Qué significa: Ahora tienes un rol de aplicación con privilegios mínimos.
Decisión: No ejecutes tu app como postgres. Si lo haces, eventualmente harás algo emocionante e irreversible.
Tarea 7: Verificar que PostgreSQL es realmente durable (fsync on, full_page_writes on)
cr0x@server:~$ sudo -u postgres psql -tAc "SHOW fsync; SHOW synchronous_commit; SHOW full_page_writes;"
on
on
on
Qué significa: No estás en modo “YOLO durability”.
Decisión: Mantén estas opciones activas en producción. Si el rendimiento es malo, arregla esquema e I/O, no la corrección.
Tarea 8: Estimar capacidad de PostgreSQL y riesgo de bloat (tamaño base de DB)
cr0x@server:~$ sudo -u postgres psql -c "SELECT pg_database.datname, pg_size_pretty(pg_database_size(pg_database.datname)) FROM pg_database ORDER BY pg_database_size(pg_database.datname) DESC;"
datname | pg_size_pretty
-----------+----------------
postgres | 8456 kB
template1 | 8280 kB
template0 | 8208 kB
(3 rows)
Qué significa: La base es pequeña; tienes espacio. Estás a punto de hacerla no pequeña.
Decisión: Confirma espacio en disco para: datos + índices + WAL + overhead + backups. Si no puedes articular eso, no estás listo para el cutover.
Tarea 9: Backfill usando una canalización segura y fragmentada (ejemplo: CSV con COPY)
cr0x@server:~$ sqlite3 -header -csv /var/lib/myapp/app.db "SELECT id,email,created_at FROM users;" > /tmp/users.csv
cr0x@server:~$ sudo -u postgres psql myapp -c "\copy users(id,email,created_at) FROM '/tmp/users.csv' WITH (FORMAT csv, HEADER true);"
COPY 120543
Qué significa: 120,543 filas cargadas.
Decisión: Si COPY es lento, revisa índices y restricciones. Durante el backfill, carga en una tabla staging sin índices pesados y luego crea índices después.
Tarea 10: Verificar que los conteos de filas coinciden (comprobación de paridad gruesa)
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "SELECT COUNT(*) FROM users;"
120543
cr0x@server:~$ sudo -u postgres psql myapp -tAc "SELECT COUNT(*) FROM users;"
120543
Qué significa: Los conteos coinciden para esta tabla.
Decisión: Los conteos son necesarios pero no suficientes. Si los conteos coinciden pero los datos difieren, aún pierdes datos.
Tarea 11: Verificar cobertura de claves e IDs faltantes (detectar pérdida silenciosa)
cr0x@server:~$ sqlite3 -csv /var/lib/myapp/app.db "SELECT id FROM users ORDER BY id LIMIT 5;"
001a,00b9,00c1,00d0,00f2
cr0x@server:~$ sudo -u postgres psql myapp -tAc "SELECT id FROM users ORDER BY id LIMIT 5;"
001a
00b9
00c1
00d0
00f2
Qué significa: Los IDs bajos existen; el orden parece consistente para esta muestra.
Decisión: También revisa muestras aleatorias y rangos máximos; los errores se esconden en la cola larga.
Tarea 12: Comprobar consultas lentas en PostgreSQL durante lecturas sombreadas
cr0x@server:~$ sudo -u postgres psql myapp -c "SELECT query, calls, mean_exec_time FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 5;"
query | calls | mean_exec_time
--------------------------------------+-------+---------------
SELECT * FROM events WHERE user_id=$1 | 9321 | 187.42
SELECT * FROM sessions WHERE id=$1 | 21144 | 12.11
(2 rows)
Qué significa: Tu consulta más caliente es lenta en PostgreSQL; probablemente falta un índice o la forma de la consulta es deficiente.
Decisión: Añade un índice (events(user_id, created_at) quizá), o cambia la consulta para seleccionar columnas específicas. No cambies las lecturas hasta controlar esto.
Tarea 13: Confirmar uso de índices con EXPLAIN (evita adivinar)
cr0x@server:~$ sudo -u postgres psql myapp -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM events WHERE user_id='00f2' ORDER BY created_at DESC LIMIT 50;"
Limit (cost=0.42..12.55 rows=50 width=128) (actual time=0.311..0.829 rows=50 loops=1)
Buffers: shared hit=210
-> Index Scan Backward using idx_events_user_created_at on events (cost=0.42..812.11 rows=3400 width=128) (actual time=0.309..0.814 rows=50 loops=1)
Index Cond: (user_id = '00f2'::text)
Planning Time: 0.220 ms
Execution Time: 0.901 ms
Qué significa: Escaneo por índice; ejecución submilisegundo. Eso es lo que quieres.
Decisión: Si ves escaneos secuenciales en tablas grandes, no discutas con el planificador—arregla el esquema/índices o la consulta.
Tarea 14: Vigilar saturación de conexiones (un clásico tropiezo en cutover a Postgres)
cr0x@server:~$ sudo -u postgres psql -tAc "SHOW max_connections; SELECT count(*) FROM pg_stat_activity;"
200
187
Qué significa: Ya estás cerca del máximo de conexiones.
Decisión: Añade pooling de conexiones (PgBouncer), reduce tamaños de pool en la app o escala la BD. Si cortas ahora, tendrás tormentas de conexiones y outages autoinfligidos.
Tarea 15: Monitoriza crecimiento de WAL durante el backfill (no llenes disco silenciosamente)
cr0x@server:~$ sudo -u postgres psql -tAc "SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) AS wal_bytes_since_start;"
42 GB
Qué significa: Mucho WAL generado (salida de ejemplo). Durante cargas masivas, el WAL puede inflarse.
Decisión: Asegura espacio en disco; considera batchs y checkpoints. Si replicas, asegúrate de que las réplicas puedan seguir o retendrás WAL y llenarás disco.
Tarea 16: Confirmar lag de replicación (si usas réplicas para seguridad)
cr0x@server:~$ sudo -u postgres psql -c "SELECT application_name, state, write_lag, flush_lag, replay_lag FROM pg_stat_replication;"
application_name | state | write_lag | flush_lag | replay_lag
------------------+-----------+-----------+-----------+------------
standby1 | streaming | 00:00:01 | 00:00:02 | 00:00:03
(1 row)
Qué significa: La replicación está sana con poco lag.
Decisión: No cortes escrituras si las réplicas van minutos atrás y tu plan de failover asume que están al día.
Tarea 17: Validar restricciones que SQLite no aplicó como asumías
cr0x@server:~$ sudo -u postgres psql myapp -c "SELECT COUNT(*) FROM users WHERE email IS NULL;"
14
Qué significa: Tienes emails nulos. Si pensabas email NOT NULL, la migración fallará o tu app lo hará.
Decisión: Decide: arreglar datos (rellenar defaults), cambiar la restricción o añadir una restricción escalonada después. No la ignores “temporalmente” para siempre.
Tarea 18: Comprobación de cutover: la app apunta a Postgres y los health checks pasan
cr0x@server:~$ systemctl restart myapp
cr0x@server:~$ journalctl -u myapp -n 20 --no-pager
Dec 30 12:11:02 server myapp[19422]: db: connected to postgres host=pg1 dbname=myapp
Dec 30 12:11:02 server myapp[19422]: migrations: none pending
Dec 30 12:11:03 server myapp[19422]: http: listening on :8080
Qué significa: La app está usando PostgreSQL y arrancó sin problemas.
Decisión: Procede con un despliegue canario primero. Si solo una instancia usa Postgres, puedes retroceder rápido.
Un chiste corto (2/2): Nada es más permanente que una “escritura dual temporal” que nadie se atreve a eliminar.
Playbook de diagnóstico rápido: encuentra el cuello de botella en minutos
Este es el camino de “el pager suena, Slack está en llamas”. Estás a mitad de migración o post-cutover y algo está lento o atascado. Revisa esto en orden.
Primero: ¿Es contención de bloqueo o agotamiento de conexiones?
- Síntomas en SQLite: errores “database is locked”, latencia de escritura elevada, hilos atascados en commit.
- Síntomas en Postgres: timeouts de conexión, demasiados clientes, pool de app esperando.
cr0x@server:~$ sudo -u postgres psql -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state;"
state | count
--------+-------
active | 42
idle | 131
(2 rows)
Decisión: Si active es alto e idle también, probablemente tienes pools de app sobreaprovisionados. Si active está al máximo y las consultas son lentas, tienes problemas de consultas/índices.
Segundo: ¿Está la base de datos limitada por I/O?
cr0x@server:~$ iostat -x 1 3
Device r/s w/s rkB/s wkB/s await %util
nvme0n1 120.0 340.0 8200.0 42000.0 9.4 92.1
Decisión: Si %util está cerca de 100% y await es alto, estás limitado por I/O. Deja de añadir índices en caliente. Reduce la tasa de escrituras, haz batch o escala almacenamiento/instancia.
Tercero: ¿Son las consultas lentas el culpable (y son nuevas)?
cr0x@server:~$ sudo -u postgres psql myapp -c "SELECT query, calls, total_exec_time FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 3;"
query | calls | total_exec_time
------------------------------------------+-------+----------------
SELECT * FROM events WHERE user_id=$1 | 9233 | 1730042.0
UPDATE users SET last_seen_at=$1 WHERE id=$2 | 60321 | 402112.3
(2 rows)
Decisión: Si una consulta domina el tiempo total, arregla esa primera. Añade el índice que falta, reduce columnas retornadas o cachea. No “tunes Postgres” genéricamente.
Cuarto: ¿Está autovacuum al día (problema lento específico de Postgres)?
cr0x@server:~$ sudo -u postgres psql myapp -c "SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
relname | n_dead_tup | last_autovacuum
---------+------------+-------------------------------
events | 18200421 | 2025-12-30 11:40:22.12345+00
(1 row)
Decisión: Si las tuplas muertas son enormes y autovacuum está retrasado, tu tabla con muchas actualizaciones degradará. Ajusta autovacuum para esa tabla o rediseña las actualizaciones.
Quinto: ¿El lag de replicación rompe tus suposiciones de seguridad?
cr0x@server:~$ sudo -u postgres psql -c "SELECT application_name, replay_lag FROM pg_stat_replication;"
application_name | replay_lag
------------------+------------
standby1 | 00:05:12
(1 row)
Decisión: Si el lag son minutos, el failover perderá datos respecto al primario a menos que uses replicación síncrona (que tiene sus propios tradeoffs). Ajusta tu cutover y supuestos de durabilidad.
Tres mini-historias corporativas (dolor, arrepentimiento y una pequeña victoria)
Mini-historia 1: El incidente causado por una suposición equivocada
Eran un SaaS de tamaño medio con un scheduler “simple”. El scheduler almacenaba el estado de trabajos en SQLite porque empezó como una utilidad de una sola VM. Con el tiempo el scheduler se convirtió en un servicio: múltiples workers, autoscaling y un balanceador de carga. El archivo SQLite se colocó en un sistema de archivos en red compartido porque “todos los workers necesitan verlo”.
La suposición equivocada fue: un filesystem compartido convierte una base de datos en archivo en una base de datos compartida. En un día tranquilo funcionaba. En un día ocupado se comportó como una rotonda con un solo carril y mil coches. Los escritores se amontonaban detrás de locks. Los lectores reintentaban. La latencia subió. La app empezó a hacer timeouts, lo que reintentó trabajos, lo que aumentó escrituras, lo que aumentó bloqueos. Un bucle de retroalimentación que parecía “crecimiento repentino”.
Cuando finalmente lo miraron, el arma humeante estaba a la vista: errores que literalmente decían que la base de datos estaba bloqueada. Pero como el sistema era distribuido, cada nodo veía una pequeña porción del problema. Nadie tenía la vista completa hasta que agregaron logs. Para entonces, el daño visible para clientes ya estaba hecho.
La solución no fue heroica. Dejaron de fingir que el filesystem compartido era una base de datos. Movieron el estado del scheduler a PostgreSQL, pusieron PgBouncer delante y usaron un patrón de escritor único para algunas tablas críticas. Lo más divertido: una vez desplegada la solución, encontraron un montón de “optimizaciones” (bucles de sleep, hacks de backoff) que solo existían para soportar locks de SQLite. Esos hacks entonces se convirtieron en bugs de latencia en PostgreSQL porque demoraban trabajo legítimo.
Mini-historia 2: La optimización que salió mal
Una compañía retail tenía una caché SQLite local en cada host de la app. Querían migrar la tienda autoritativa a PostgreSQL, pero no querían “perder tiempo” construyendo semántica correcta de escrituras duales. Así que hicieron lo que la gente hace bajo presión de plazo: hicieron escrituras asíncronas en SQLite.
Específicamente, pusieron escrituras en memoria y las vaciaban en batch cada pocos segundos. Funcionó en benchmarks. Producción, por supuesto, hizo otra cosa. Bajo carga, los batch crecieron. La memoria aumentó. Entonces un despliegue reinició el servicio y la cola en memoria desapareció. Los usuarios vieron carritos obsoletos y actualizaciones faltantes. El titular del postmortem fue básicamente: “Inventamos pérdida de datos para ahorrar 15% de CPU”.
Cuando intentaron parchear, añadieron una cola en disco. Mejor, pero aún sutil: la cola estaba en el mismo disco que SQLite, y los picos de flush volvieron a causar contención de locks. Habían reducido overhead de syscalls e incrementado latencias peores. Fue una pérdida neta: el sistema se volvió más difícil de razonar y aun así no escaló.
Finalmente, hicieron lo aburrido que deberían haber hecho primero: una tabla outbox en SQLite con triggers, un shipper fiable a PostgreSQL y un tablero de paridad. No fue “rápido”, pero fue determinista. Su tasa de incidentes cayó principalmente porque dejaron de sorprenderse.
Mini-historia 3: La práctica aburrida pero correcta que salvó el día
Una compañía B2B planeó un cutover sin downtime de SQLite a PostgreSQL para un servicio de configuración. El dataset era pequeño, pero el radio de impacto era enorme: cada petición en su plataforma leía configuración. Trataron la migración como un ejercicio de fiabilidad, no como un ejercicio de datos.
Construyeron shadow reads temprano. Cada lectura de configuración seguía viniendo de SQLite, pero una goroutine en background buscaba la misma clave en PostgreSQL y comparaba un hash. Las discrepancias se contaban y etiquetaban por prefijo de clave. Sonaba paranoico. Lo fue. Y fue correcto.
Dos días antes del cutover vieron un pico de discrepancias en claves que contenían Unicode. Las diferencias de colación y normalización entre SQLite y PostgreSQL les estaban causando problemas: las comparaciones de cadenas y el orden no eran idénticos, y un camino de código dependía de un “primer match gana” sin orden explícito. Sin shadow reads habrían cortado y lentamente corrompido lecturas de configuración de forma que parecen “fallos aleatorios”.
Arreglaron la consulta para ser explícita, añadieron un orden determinista y escribieron una aserción en tiempo de migración que rechazaba claves ambiguas. El día del cutover fue aburrido. Nadie lo notó. Ese es el mayor elogio que puedes dar al trabajo de operaciones.
Errores comunes: síntoma → causa raíz → solución
Estos son patrones que aparecen repetidamente en migraciones de SQLite a Postgres. Si te reconoces aquí, bien. Significa que puedes dejar de hacerlo.
1) Tormentas “database is locked” después de añadir una sola función
- Síntoma: Latencia en picos, fallos intermitentes de escritura, muchos reintentos.
- Causa raíz: Aumentaron los escritores y la superposición de transacciones; transacciones largas o alcance de transacción sin límites.
- Solución: Acorta transacciones; habilita WAL; añade busy_timeout; consolida escrituras; o acepta la realidad y mueve las escrituras a PostgreSQL.
2) Postgres está “lento” justo después del cutover
- Síntoma: Salto en latencia P95; CPU en buen estado; I/O moderado; consultas más lentas de lo esperado.
- Causa raíz: Falta de índices por huecos en traducción de esquema; consultas que usaban rarezas de SQLite (casts implícitos, tipado laxo).
- Solución: Usa
pg_stat_statementsyEXPLAIN (ANALYZE); añade índices correctos; aplica tipos; reescribe consultas para ser explícitas.
3) Restricciones únicas fallan de repente durante el backfill
- Síntoma: COPY falla con errores de clave duplicada.
- Causa raíz: SQLite permitió duplicados porque la restricción no existía, o porque valores diferían solo por colación/mayúsculas/espacios.
- Solución: Audita duplicados en SQLite antes de migrar; decide reglas de canonicalización; implementa en la app y en los scripts de migración.
4) Timestamps se vuelven sin sentido (con desfases horarias o inválidos)
- Síntoma: Datos aparentan desplazarse; consultas por ventanas de tiempo no encuentran registros.
- Causa raíz: SQLite almacenó timestamps como TEXT sin zona horaria; Postgres parsea en
timestampotimestamptzcon supuestos distintos. - Solución: Elige
timestamptza menos que tengas razón para no hacerlo; parsea strings legacy explícitamente; configura zonas horarias de conexión intencionalmente.
5) La migración “funcionó” pero faltan datos sutilmente
- Síntoma: Los conteos coinciden; los usuarios reportan ítems faltantes; auditorías muestran huecos.
- Causa raíz: El backfill corrió una vez, pero las nuevas escrituras durante el backfill nunca se copiaron; la escritura dual no manejó fallos; no hay comprobaciones de paridad.
- Solución: Implementa captura de cambios (outbox/trigger) y verifica el lag; ejecuta comprobaciones de paridad; bloquea el cutover hasta que la deriva sea cero (o esté explicada).
6) Postgres se queda sin conexiones durante picos de tráfico
- Síntoma: “too many clients”, timeouts, fallos en cascada.
- Causa raíz: Cada instancia de la app mantiene un pool grande; además jobs en background; además scripts de admin; Postgres no es un toy de thread-per-connection.
- Solución: Usa PgBouncer; dimensiona pools; reduce conexiones idle; establece statement timeouts y circuit breakers.
7) Autovacuum no da abasto después del cutover
- Síntoma: Rendimiento empeora con los días; bloat de tablas; índices crecen; VACUUM dura eternamente.
- Causa raíz: Carga con muchas actualizaciones (común para campos de “status”) y ajustes de autovacuum no sintonizados por tabla.
- Solución: Ajusta umbrales de autovacuum para tablas calientes; evita actualizaciones innecesarias (solo actualiza si cambió); considera modelado append-only para datos tipo evento.
Listas de verificación / plan paso a paso (aburrido, por tanto eficaz)
Fase A: Lista de ingeniería pre-migración
- Inventario de todas las bases SQLite, archivos y caminos de escritura. Si no puedes listarlos, no puedes migrarlos.
- Estandariza ajustes de SQLite: journal_mode, synchronous, busy_timeout.
- Decide versión objetivo de PostgreSQL y modelo de despliegue (gestionado vs autoalojado).
- Define reglas de mapeo de tipos (timestamps TEXT, booleanos, blobs JSON, precisión numérica).
- Define una política de “fuente de verdad” para la fase de escritura dual.
- Construye comprobaciones de paridad: conteos, sumas, hashes y verificaciones puntuales para entidades de alto valor.
Fase B: Construir la maquinaria de migración
- Crear esquema de PostgreSQL con tipos y restricciones explícitas en etapas sensatas (comienza permisivo, endurece después).
- Backfill en tablas staging, luego swap/rename una vez cargadas e indexadas.
- Implementar captura de cambios (escrituras duales o outbox en SQLite con shipper).
- Implementar lecturas sombreadas para una muestra representativa de consultas.
- Operacionalizar: métricas para lag, conteos de mismatches, fallos de escritura y latencia de consultas.
Fase C: Plan de cutover que puedas ejecutar bajo estrés
- Canario: enruta un pequeño porcentaje de lecturas a PostgreSQL; mantiene SQLite como autoridad.
- Expandir cutover de lecturas gradualmente; vigila presupuestos de error y dashboards de queries lentas.
- Cutover de escrituras: cambia la ruta de escritura a PostgreSQL mientras aún capturas cambios para rollback (temporalmente).
- Congelar y validar: ventana corta donde aseguras que la deriva es cero y las restricciones se cumplen.
- Eliminar escritura dual solo después de un periodo de consolidación y un exitoso ejercicio de “replay a SQLite” (si la política exige rollback).
Fase D: Endurecimiento post-cutover
- Activa statement timeouts para el rol de la app.
- Añade monitorización para lag de replicación, uso de disco y salud de autovacuum.
- Ejecuta una campaña de endurecimiento de restricciones: NOT NULLs, CHECKs, FKs donde corresponda.
- Documenta runbooks operativos: restore, failover, rebuild de índices y reducción de tráfico en emergencia.
Preguntas frecuentes
1) ¿Puede SQLite manejar mucho tráfico si habilito WAL?
WAL ayuda mucho, especialmente para cargas con muchas lecturas y pocas escrituras. No convierte a SQLite en una base de datos multi-writer, multi-host. Si tu problema de escala es “muchos escritores desde muchas instancias”, WAL es un vendaje, no una cura.
2) ¿Cuál es la estrategia más segura para una migración “sin downtime”?
Backfill → captura de cambios (outbox o escrituras duales) → lecturas sombreadas → cortar lecturas → cortar escrituras. La versión más segura incluye dashboards de paridad y una ventana de rollback donde puedes reproducir escrituras hacia atrás si es necesario.
3) ¿Debo hacer escrituras duales desde la aplicación o usar triggers?
Si puedes modificar la app con seguridad y manejar reintentos/idempotencia, escribir dualmente en la app es sencillo. Si tienes múltiples apps escribiendo o quieres un mecanismo centralizado, triggers de SQLite hacia un outbox pueden ser más limpios. En cualquier caso, necesitas reconciliación y observabilidad.
4) ¿Por qué no simplemente parar el mundo y hacer un dump/restore grande?
Si puedes permitirlo, hazlo. La ruta “sin downtime” tiene más piezas en movimiento y más formas de crear deriva sutil. La única razón para evitar parar el mundo es que debes seguir atendiendo durante la migración.
5) ¿Cómo trato el tipado laxo de SQLite al mover a PostgreSQL?
Decide tipos explícitamente y escribe código de conversión. Espera basura: strings numéricos, timestamps vacíos, formatos mixtos. Construye una vía de cuarentena para filas malas en vez de fallar toda la migración.
6) ¿Qué pasa con funciones de SQLite que no mapean bien a Postgres?
Los bordes filosos suelen ser afinidad de tipos, conversiones implícitas, cláusulas de conflicto y funciones de fecha/hora. Audita consultas, añade casts explícitos en Postgres y no confíes en ordering no especificado.
7) ¿Necesito un pool de conexiones para PostgreSQL?
En la mayoría de entornos de producción: sí. Especialmente si tienes muchas instancias de app. PgBouncer es común porque el overhead de conexión de Postgres es real y “solo subir max_connections” suele acabar mal.
8) ¿Cómo pruebo la paridad de datos más allá de conteos de filas?
Usa múltiples comprobaciones: conteos por tabla, conteos por partición (por día/cliente), checksums/hashes de campos clave y verificación por muestreo aleatorio. También ejecuta lecturas sombreadas sobre consultas reales de la app y compara salidas.
9) ¿Puedo mantener SQLite como caché local después de migrar a Postgres?
Sí, pero trátalo como caché: reconstruible, expirable y nunca autoritativo salvo que diseñes explícitamente para modo offline. Si no, reintroduces split-brain con un nombre más bonito.
10) ¿Cuál es el mayor riesgo de migración que la gente subestima?
No es el backfill. Es la sincronización continua. La deriva es el asesino silencioso: parece estar bien hasta la primera auditoría, ejecución de facturación o incidente de seguridad. Construye detección de deriva desde el día uno.
Conclusión: siguientes pasos que puedes ejecutar esta semana
Si estás en SQLite y sientes dolor, no migres en pánico. Estabiliza primero y luego migra como un operador: medible, reversible y aburrido.
- Inventario de escrituras: encuentra cada camino de código que muta SQLite, incluyendo jobs de mantenimiento en background.
- Elige tu estrategia de sincronización: escrituras duales con idempotencia, o un outbox con triggers y un shipper.
- Construye backfill + comprobaciones de paridad que puedas volver a ejecutar sin drama.
- Levanta PostgreSQL con guardarraíles: pooling, monitorización, ajustes durables y margen de capacidad.
- Haz shadow reads antes de cortar. Si te saltas esto, estás apostando con unknown unknowns.
- Corta lecturas, luego escrituras, y mantiene rollback factible por un periodo definido.
La victoria no es solo “estamos en Postgres ahora”. La victoria es que dejas de tratar tu base de datos como un archivo con suerte, y empiezas a tratarla como un sistema que puedes observar, razonar y recuperar bajo presión.