No notas la concurrencia de escrituras hasta que la notas. Todo funciona bien en desarrollo, bien en staging, bien a las 2 a.m. con un solo usuario.
Luego el negocio lanza una funcionalidad, tu cola de trabajos se calienta y de repente tu base de datos “simple” empieza a devolver
database is locked como si buscara un papel recurrente.
Aquí es donde la decisión PostgreSQL vs SQLite deja de ser filosófica y se vuelve operacional.
Los escritores concurrentes no son un extra deseable; son la diferencia entre un sistema que degrada de forma gradual y uno que
se convierte en una denegación de servicio educada contra sí mismo.
La respuesta directa: ¿quién gana?
Para escritores concurrentes, PostgreSQL gana. No por poco. Por diseño.
SQLite es una base de datos embebida brillante que optimiza la simplicidad, la ausencia de administración y la fiabilidad en un único archivo.
Puede manejar múltiples lectores concurrentes. Incluso puede manejar “múltiples escritores” en el sentido de que muchos procesos pueden intentar escribir.
Pero en última instancia serializa las escrituras mediante bloqueos alrededor del archivo de la base de datos, especialmente al confirmar transacciones.
PostgreSQL es una base de datos servidor diseñada para aceptar muchas conexiones de clientes y sostener un flujo constante de transacciones desde
muchas sesiones concurrentes. Usa MVCC (control de concurrencia multi-versión) para desacoplar lectores de escritores y cuenta con madura gestión de bloqueos,
procesos en segundo plano, opciones de envío de WAL (write-ahead log) y herramientas para mostrarte qué está atascado y por qué.
Si tu carga incluye múltiples workers independientes que insertan/actualizan con frecuencia—piensa en peticiones web, trabajos en background, pipelines de ingestión
o cualquier cosa tipo cola—PostgreSQL es la opción aburrida y correcta. SQLite aún puede ser correcta, pero solo cuando diseñas
alrededor de su serialización de escrituras (batching, patrón de escritor único, modo WAL con expectativas realistas).
Regla en una frase: si la contención de escrituras forma parte del problema, no elijas una base de datos de un solo archivo y luego te sorprendas
cuando el archivo se convierta en el cuello de botella.
Hechos interesantes y contexto histórico
- SQLite nació en 2000 como base de datos embebida para herramientas internas. Su diseño “sin servidor” es una característica, no una omisión.
- PostgreSQL se remonta a los años 80 (proyecto POSTGRES en UC Berkeley). Esa genealogía se nota en su foco en concurrencia y extensibilidad.
- La base de datos completa de SQLite es un solo archivo (más archivos opcionales de memoria compartida / WAL). Eso es conveniente operativamente y hostil a la concurrencia.
- El modo WAL de SQLite (introducido en 2010) mejoró dramáticamente la concurrencia lector/escritor, pero no lo convirtió en un motor multi-writer.
- El WAL de PostgreSQL existe para garantizar durabilidad y permitir recuperación ante fallos; también habilita replicación y recuperación hasta un punto en el tiempo.
- SQLite está en todas partes: teléfonos, navegadores, electrodomésticos, juegos. La carga típica es local, transacciones cortas, baja concurrencia de escritores.
- El MVCC de PostgreSQL significa que las actualizaciones crean nuevas versiones de fila; las versiones antiguas permanecen hasta que se vacuumiza. Ese es el precio de la alta concurrencia.
- “database is locked” en SQLite no es un misterio; es el motor diciéndote: “estoy escribiendo; espera tu turno”.
- El diagnóstico de bloqueos en PostgreSQL es de primera clase mediante catálogos del sistema; puedes ver bloqueadores, esperas y el texto de las consultas en producción.
Qué significa realmente “escritores concurrentes” en cada motor
Concurrencia no es paralelismo; es planificación bajo contención
Cuando la gente dice “escritores concurrentes”, a menudo quieren decir “tengo N workers y quiero un rendimiento aproximadamente proporcional a N.”
Esa expectativa es razonable en PostgreSQL (hasta que satures CPU, I/O, bloqueos o latencia de commit). En SQLite, es el
modelo mental equivocado a menos que hayas centralizado las escrituras.
Un escritor no es solo INSERT o UPDATE. Es:
- adquirir bloqueos,
- escribir páginas / registros WAL,
- hacer fsync (durabilidad),
- actualizar índices,
- y potencialmente esperar a otros escritores.
Si el sistema pasa 20 ms por transacción esperando el commit durable, no puedes forzar 10.000 TPS simplemente arrancando más hilos.
Solo crearás un festival de esperas por bloqueo.
SQLite: “un carril, fusílon cortesmente”
SQLite usa bloqueos sobre el archivo de la base de datos (y en modo WAL, archivos de coordinación) para asegurar consistencia. En el modo journal por rollback
(el antiguo por defecto), un escritor bloquea a los lectores durante fases de commit. El modo WAL mejora eso: los lectores no bloquean a los escritores
y los escritores no bloquean tanto a los lectores. Pero las escrituras siguen serializándose. Solo un escritor puede confirmar a la vez.
Puedes tener múltiples procesos intentando escribir concurrentemente. SQLite los pondrá en cola vía contención de bloqueos. Si no
configuras timeouts y lógica de reintento, verás fallos.
PostgreSQL: “múltiples carriles, semáforos y una sala de control”
PostgreSQL está diseñado para sesiones concurrentes. Los escritores generalmente no bloquean a los lectores gracias al MVCC. Los escritores pueden bloquearse entre sí,
pero normalmente solo sobre las mismas filas (o en bloqueos pesados como cambios de esquema).
PostgreSQL tiene:
- bloqueos a nivel de fila para actualizaciones conflictivas,
- instantáneas de transacción,
- procesos background writer y checkpointer,
- buffers de WAL y group commit,
- y vistas de introspección para diagnosticar esperas.
La clave: la concurrencia es parte de la arquitectura, no un modo “podría funcionar si tienes cuidado”.
SQLite bajo contención de escritura: qué ocurre realmente
Modos de bloqueo y por qué tu benchmark “rápido” miente
SQLite parece increíble en un benchmark single-thread. Está en proceso, no hay red, no hay context switches de servidor,
y el planificador de consultas es rápido. Luego añades escritores.
En modo rollback journal, un escritor necesita bloquear la base de datos para partes de la transacción de escritura. El bloqueo escala a través de
fases (shared, reserved, pending, exclusive), y otras conexiones pueden quedar bloqueadas o rechazadas según el timing.
En modo WAL, los lectores pueden continuar mientras un escritor anexa al WAL, pero los commits todavía se serializan.
Lo que sientes operativamente:
- picos de latencia de escritura bajo contención,
- aparecen errores “database is locked” a menos que esperes/reintentes,
- los checkpoints se convierten en una palanca de rendimiento oculta,
- y “solo agrega más workers” deja de funcionar rápidamente.
El modo WAL ayuda, pero no es un milagro multi-writer
El modo WAL suele ser la elección correcta si tienes alguna concurrencia en SQLite. Evita que los lectores bloqueen a los escritores
en el caso común. Pero WAL introduce una nueva parte móvil: los checkpoints. Si los checkpoints no pueden seguir el ritmo, el archivo WAL crece, y los lectores
pueden verse forzados a escanear más historial. Y tu sistema de archivos ahora tiene más patrones de escritura que manejar.
Aun así obtienes un único escritor en el punto de commit. Esa es la restricción arquitectónica. Puedes gestionarla. Incluso puedes hacer que funcione bien.
Pero no te “tunes” hasta convertirlo en escrituras concurrentes reales.
Configuraciones de durabilidad: intercambias seguridad por velocidad
PRAGMA synchronous y PRAGMA journal_mode de SQLite son donde los equipos silenciosamente hacen pactos con el diablo de la fiabilidad.
Si pones synchronous=OFF, puedes conseguir mayor rendimiento. También puedes perder transacciones que parecían confirmadas
en caso de fallo de energía. Decide como adulto: escribe el requisito de durabilidad y luego configura en consecuencia.
Chiste #1: SQLite es “sin servidor” del mismo modo que los fideos instantáneos son “cocina”. Funciona, pero no finjas que es la misma cocina.
PostgreSQL bajo contención de escritura: qué ocurre realmente
MVCC: la razón por la que los lectores no gritan cuando aparecen escritores
El MVCC de PostgreSQL hace que los lectores vean una instantánea de la base de datos desde el inicio de su transacción (o sentencia, según el aislamiento).
Los escritores crean nuevas versiones de fila. Los lectores siguen leyendo versiones antiguas hasta que terminan. Esa es la ganancia central en concurrencia.
El intercambio es la limpieza. Las versiones viejas de filas (dead tuples) deben ser vacuumizadas. Si ignoras el vacuum, pagarás después en bloat, churn de caché
e ineficiencia de índices. PostgreSQL no te salva de la negligencia; simplemente esperará hasta que estés más ocupado.
WAL, commits y por qué fsync es tu verdadero techo de rendimiento
Cada commit durable significa que el WAL debe llegar a almacenamiento estable. PostgreSQL puede hacer group commit: múltiples transacciones pueden compartir un fsync.
Así es como mantiene altas tasas de commit en almacenamiento decente. Pero la física subyacente sigue aplicando: los commits durables están limitados por la latencia de escritura.
Si tu subsistema de I/O tiene latencia de 3–10 ms para flushes bajo carga, tu número máximo de commits durables por segundo no es infinito, independientemente de la CPU.
Aquí aparece la realidad SRE: la afinación de rendimiento se vuelve rápidamente ingeniería de almacenamiento.
Bloqueos: el modo real de fallo no es “existen bloqueos”, es “los bloqueos te sorprenden”
PostgreSQL tiene semánticas de bloqueo robustas. Eso no es el problema. El problema es equipos que no entienden de dónde vienen los bloqueos:
- transacciones largas manteniendo bloqueos de fila,
- migraciones de esquema que toman bloqueos
ACCESS EXCLUSIVE, - autovacuum o vacuum full,
- chequeos de claves foráneas bajo altas tasas de escritura,
- puntos calientes en las mismas páginas de índice (especialmente con claves monotónicamente crecientes).
Puedes ver bloqueadores y esperantes en PostgreSQL. Úsalo. No adivines.
Cita (idea parafraseada), atribuida: Werner Vogels suele enfatizar que “todo falla, todo el tiempo”—diseña sistemas para que la falla sea normal, no excepcional.
Chiste #2: Si pones SQLite detrás de un servidor web y lo llamas “distribuido”, felicitaciones—has inventado un atasco de tráfico muy pequeño.
Latencia, fsync y la capa de almacenamiento (donde viven la mayoría de los “problemas de BD”)
Los escritores concurrentes no son solo una historia de motor de base de datos. Son una historia de almacenamiento. Las transacciones durables implican flush.
Flush significa que el kernel y el dispositivo acuerdan que los datos están en medio estable. Esa operación tiene latencia y variación.
La variación es el asesino: la latencia de commit p99 es lo que determina la acumulación en colas, la formación de esperas por bloqueos y la latencia de cola en servicios.
SQLite típicamente usa bloqueo del sistema de archivos y escribe en un solo archivo (más journal/WAL). En muchos sistemas de archivos, la contención en ese archivo
y su metadata aparece rápido con muchos escritores. PostgreSQL reparte trabajo en múltiples archivos (segmentos de relación) y mantiene WAL,
pero aún depende en última instancia del comportamiento de flush del almacenamiento.
La implicación práctica: si PostgreSQL va lento bajo carga de escritura, a menudo puedes arreglarlo con mejor IOPS/latencia, tuning de WAL,
ajuste de checkpoints, cambios de esquema/índices, batching o particionado. Si SQLite va lento con muchos escritores, la solución suele ser arquitectónica:
reduce la concurrencia sobre el archivo de la base de datos o cambia a una base de datos servidor.
Guion rápido de diagnóstico
Cuando “las escrituras son lentas” golpea en producción, no empieces con guerras de opinión. Empieza con evidencias. Aquí el orden que minimiza tiempo perdido.
Primero: confirma la clase del cuello de botella (bloqueo, CPU o I/O)
- SQLite: ¿ves
database is lockedo largas esperas en escrituras? Eso es contención, no “lentitud misteriosa”. - PostgreSQL: revisa wait events. Si las sesiones esperan bloqueos, es contención. Si esperan WAL o I/O, es almacenamiento/commit. Si la CPU está al máximo, es trabajo de cómputo/consulta/índices.
Segundo: aisla latencia de commit vs trabajo de consulta
- Si las transacciones son pequeñas pero los commits son lentos, mira fsync, WAL flush y latencia de almacenamiento.
- Si los commits son rápidos pero las sentencias son lentas, mira índices, filas calientes y planes de consulta.
Tercero: busca “una transacción grande” que tenga a todos de rehén
- Las transacciones de larga duración bloquean vacuum en PostgreSQL, lo que aumenta el bloat y puede desencadenar amplificación de escritura.
- En SQLite, una transacción abierta durante un lote puede mantener el bloqueo de escritor más tiempo del que esperabas.
Cuarto: verifica que tu control de concurrencia sea intencional
- SQLite: ¿tienes un busy timeout y reintentos/backoff? ¿Está activado el modo WAL? ¿Checkpointeas sensiblemente?
- PostgreSQL: ¿tienes un pool de conexiones? ¿Estás saturando el servidor con demasiados escritores concurrentes?
Tareas prácticas con comandos, salidas y decisiones
Estas son las tareas que ejecutas durante un incidente o una decisión de migración. Cada una incluye: el comando, qué significa la salida,
y qué decisión tomas a partir de ello. Los comandos son realistas; adapta rutas y nombres de servicio.
Tarea 1: Encontrar el journal mode y el nivel synchronous de SQLite
cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA journal_mode; PRAGMA synchronous;'
wal
2
Significado: el modo de journal es WAL; synchronous=2 (FULL). Estás pagando por durabilidad en cada transacción.
Decisión: Si la latencia es demasiado alta, primero prueba batching de escrituras o reducir la frecuencia de commits, no desactivar la durabilidad.
Solo relaja synchronous si el negocio acepta explícitamente pérdida de datos en caso de fallo.
Tarea 2: Buscar errores de bloqueo de SQLite en logs
cr0x@server:~$ journalctl -u app.service -n 200 | grep -E 'database is locked|SQLITE_BUSY' | tail
Dec 30 09:22:11 server app[1842]: ERROR db write failed: database is locked
Dec 30 09:22:12 server app[1842]: ERROR db write failed: SQLITE_BUSY in insert_event
Significado: la aplicación no espera lo suficiente, o la contención es tan alta que expiran los timeouts.
Decisión: Añade busy_timeout, implementa reintentos con jitter y reduce el número de escritores concurrentes (patrón de cola con escritor único).
Si los escritores son verdaderamente independientes y de alta tasa, planifica migrar a PostgreSQL.
Tarea 3: Confirmar busy timeout de SQLite en tiempo de ejecución
cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA busy_timeout;'
0
Significado: sin busy timeout. SQLite fallará inmediatamente cuando no pueda obtener el bloqueo.
Decisión: Establece un timeout sensato en la configuración de conexión de la aplicación (por ejemplo, 2000–10000 ms según SLO) y añade reintentos.
Tarea 4: Observar crecimiento del WAL (presión de checkpoints)
cr0x@server:~$ ls -lh /var/lib/app/app.db*
-rw-r----- 1 app app 1.2G Dec 30 09:23 /var/lib/app/app.db
-rw-r----- 1 app app 3.8G Dec 30 09:23 /var/lib/app/app.db-wal
-rw-r----- 1 app app 32K Dec 30 09:23 /var/lib/app/app.db-shm
Significado: el WAL es enorme en comparación con la base de datos. Los checkpoints no siguen el ritmo o están bloqueados por lectores largos.
Decisión: Identifica transacciones de lectura largas; ajusta la estrategia de checkpoints (checkpoint a nivel de aplicación con wal_checkpoint si procede),
o mueve la carga de escritura intensiva fuera de SQLite.
Tarea 5: Buscar lectores SQLite de larga duración (bloqueador común de checkpoints WAL)
cr0x@server:~$ lsof /var/lib/app/app.db | head
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
app 1842 app 12u REG 253,0 1288490189 49155 /var/lib/app/app.db
worker 1910 app 10u REG 253,0 1288490189 49155 /var/lib/app/app.db
report 2201 app 8u REG 253,0 1288490189 49155 /var/lib/app/app.db
Significado: varios procesos tienen la BD abierta. Eso es normal, pero trabajos de reporte de larga duración pueden mantener snapshots abiertos.
Decisión: Asegura que los reportes usen réplicas separadas (en PostgreSQL) o se ejecuten sobre datos exportados. En SQLite, acorta transacciones de lectura y evita “abrir transacción mientras se hacen streams de resultados.”
Tarea 6: PostgreSQL: ver quién está esperando y por qué
cr0x@server:~$ psql -d appdb -c "SELECT pid, usename, wait_event_type, wait_event, state, now()-query_start AS age, left(query,80) AS query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY age DESC LIMIT 8;"
pid | usename | wait_event_type | wait_event | state | age | query
------+--------+-----------------+------------+--------+----------+-----------------------------------------------
6241 | app | Lock | tuple | active | 00:01:12 | UPDATE orders SET status='paid' WHERE id=$1
6310 | app | IO | DataFileRead| active | 00:00:49 | INSERT INTO events(ts, type, payload) VALUES...
6188 | app | WAL | WALWrite | active | 00:00:20 | INSERT INTO events(ts, type, payload) VALUES...
Significado: tienes al menos tres tipos de cuello de botella: esperas por bloqueo de fila (tuple), lecturas de archivos de datos, y escrituras de WAL.
Decisión: Para Lock/tuple, encuentra al bloqueador y reduce la contención en filas calientes. Para WAL, investiga latencia del dispositivo de commit/WAL y ajustes de checkpoint. Para DataFileRead, revisa la tasa de aciertos de caché y la falta de índices.
Tarea 7: PostgreSQL: encontrar la consulta que bloquea
cr0x@server:~$ psql -d appdb -c "SELECT blocked.pid AS blocked_pid, blocker.pid AS blocker_pid, blocked.query AS blocked_query, blocker.query AS blocker_query FROM pg_stat_activity blocked JOIN pg_locks bl ON bl.pid=blocked.pid AND NOT bl.granted JOIN pg_locks kl ON kl.locktype=bl.locktype AND kl.database IS NOT DISTINCT FROM bl.database AND kl.relation IS NOT DISTINCT FROM bl.relation AND kl.page IS NOT DISTINCT FROM bl.page AND kl.tuple IS NOT DISTINCT FROM bl.tuple AND kl.virtualxid IS NOT DISTINCT FROM bl.virtualxid AND kl.transactionid IS NOT DISTINCT FROM bl.transactionid AND kl.classid IS NOT DISTINCT FROM bl.classid AND kl.objid IS NOT DISTINCT FROM bl.objid AND kl.objsubid IS NOT DISTINCT FROM bl.objsubid AND kl.pid != bl.pid JOIN pg_stat_activity blocker ON blocker.pid=kl.pid;"
blocked_pid | blocker_pid | blocked_query | blocker_query
------------+------------+------------------------------------+------------------------------------------
6241 | 6177 | UPDATE orders SET status='paid'... | UPDATE orders SET status='paid'...
Significado: los escritores están colisionando en las mismas filas (o filas adyacentes calientes).
Decisión: Arregla la lógica de la aplicación: evita que múltiples workers compitan por el mismo conjunto de filas; usa patrones de cola con SELECT ... FOR UPDATE SKIP LOCKED; particiona tablas calientes.
Tarea 8: PostgreSQL: comprobar presión de commits y WAL vía estadísticas
cr0x@server:~$ psql -d appdb -c "SELECT datname, xact_commit, xact_rollback, blks_read, blks_hit, temp_files, temp_bytes FROM pg_stat_database WHERE datname='appdb';"
datname | xact_commit | xact_rollback | blks_read | blks_hit | temp_files | temp_bytes
--------+-------------+---------------+----------+---------+-----------+-----------
appdb | 8921341 | 31221 | 1842290 | 44211987| 1842 | 987654321
Significado: volumen alto de commits y uso notable de archivos temporales. Los archivos temporales suelen significar spills a disco por sorts/hashes.
Decisión: Si los spills temporales se correlacionan con la ralentización de escritura, ajusta work_mem para las rutas de consulta específicas o añade índices para evitar grandes sorts.
El volumen de commits sugiere batching o usar menos transacciones si la aplicación lo tolera.
Tarea 9: PostgreSQL: vigilar el comportamiento de checkpoints (culpable clásico de latencia de escritura)
cr0x@server:~$ psql -d appdb -c "SELECT checkpoints_timed, checkpoints_req, checkpoint_write_time, checkpoint_sync_time, buffers_checkpoint, buffers_backend FROM pg_stat_bgwriter;"
checkpoints_timed | checkpoints_req | checkpoint_write_time | checkpoint_sync_time | buffers_checkpoint | buffers_backend
------------------+-----------------+-----------------------+----------------------+-------------------+----------------
122 | 987 | 932112 | 121009 | 8123344 | 223445
Significado: muchas solicitudes de checkpoint (forzadas por volumen WAL) y gran tiempo de escritura de checkpoint. También hay escrituras desde backends.
Decisión: Reduce picos de checkpoint: aumenta max_wal_size, ajusta checkpoint_timeout, sube checkpoint_completion_target.
Luego valida que el almacenamiento pueda sostener la tasa de escritura.
Tarea 10: PostgreSQL: medir rápidamente la ratio de aciertos de caché
cr0x@server:~$ psql -d appdb -c "SELECT round(100.0 * sum(blks_hit) / nullif(sum(blks_hit)+sum(blks_read),0),2) AS cache_hit_pct FROM pg_stat_database;"
cache_hit_pct
--------------
96.01
Significado: tasa de aciertos decente, pero 96% bajo carga puede seguir significando muchas lecturas.
Decisión: Si el rendimiento de escritura está limitado por lecturas (índices faltantes, I/O aleatorio), céntrate en planes de consulta e índices antes de comprar hardware.
Tarea 11: Nivel sistema: confirmar latencia de almacenamiento bajo carga
cr0x@server:~$ iostat -xz 1 3
Linux 6.8.0 (server) 12/30/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
18.22 0.00 6.11 14.90 0.00 60.77
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 120.0 12288.0 0.0 0.00 1.80 102.40 950.0 97280.0 0.0 0.00 12.40 102.40 4.10 96.00
Significado: write await ~12ms con 96% de utilización y iowait significativo. Tu “problema de base de datos” ahora es un problema de almacenamiento.
Decisión: Reduce la frecuencia de commits síncronos (batching/grouping), mueve el WAL a almacenamiento más rápido, o actualiza el dispositivo/RAID/configuración de caché.
También valida que no estés limitado por cuotas del volumen en la nube.
Tarea 12: PostgreSQL: comprobar la tasa de generación de WAL
cr0x@server:~$ psql -d appdb -c "SELECT now(), pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(),'0/0')) AS wal_since_boot;"
now | wal_since_boot
------------------------------+----------------
2025-12-30 09:25:33.11452+00 | 384 GB
Significado: estás produciendo mucho WAL. Eso es normal para escrituras intensas, pero impulsa checkpoints y ancho de banda de replicación.
Decisión: Si el volumen de WAL es inesperadamente alto, revisa churn de actualizaciones (actualizaciones frecuentes de las mismas filas), índices inflados o índices innecesarios.
Considera particionar o reducir índices secundarios en tablas de ingestión.
Tarea 13: PostgreSQL: encontrar rápidamente las consultas más escriturales
cr0x@server:~$ psql -d appdb -c "SELECT calls, rows, mean_exec_time, left(query,90) AS query FROM pg_stat_statements WHERE query ILIKE 'insert%' OR query ILIKE 'update%' ORDER BY mean_exec_time DESC LIMIT 5;"
calls | rows | mean_exec_time | query
-------+--------+----------------+------------------------------------------------------------------------------------------
8821 | 8821 | 14.22 | INSERT INTO events(ts, type, payload) VALUES ($1,$2,$3)
1102 | 1102 | 11.90 | UPDATE orders SET status=$1, updated_at=now() WHERE id=$2
Significado: los inserts son materialmente costosos. Podría ser WAL/fsync, índices o contención.
Decisión: Si el tiempo medio de insert se corresponde con esperas de WAL, céntrate en commit y dispositivo WAL. Si se corresponde con CPU, reduce índices o agrupa inserts usando multi-row insert o COPY.
Tarea 14: SQLite: probar contención simulando dos escritores (repro rápido)
cr0x@server:~$ bash -lc 'sqlite3 /tmp/t.db "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS t(id INTEGER PRIMARY KEY, v TEXT);"; (sqlite3 /tmp/t.db "BEGIN; INSERT INTO t(v) VALUES(\"a\"); SELECT \"writer1 holding\"; SELECT sleep(2); COMMIT;" &) ; (sqlite3 /tmp/t.db "PRAGMA busy_timeout=500; INSERT INTO t(v) VALUES(\"b\");" ; wait)'
wal
writer1 holding
Error: database is locked
Significado: incluso en modo WAL, si el primer escritor mantiene una transacción, el segundo escritor falla si no puede esperar lo suficiente.
Decisión: Mantén las transacciones cortas; configura busy timeout; no mantengas transacciones de escritura mientras haces trabajo de aplicación.
Si necesitas verdaderas escrituras concurrentes, deja de pelear contra la física: migra a PostgreSQL.
Tres mini-historias del mundo corporativo (anonimizadas, plausibles, dolorosas)
Incidente: la suposición equivocada (“SQLite soporta escrituras concurrentes, ¿verdad?”)
Un equipo mediano lanzó una funcionalidad de “analítica ligera” dentro de un servicio que ya manejaba peticiones de usuario.
Eligieron SQLite porque era fácil: un archivo, un script de migración y nada de nueva infraestructura. Incluso activaron el modo WAL.
En staging volaba. En producción, el servicio corría con 12 procesos worker y una cola de trabajos con picos.
El primer síntoma fue sutil: 500s esporádicos durante picos de tráfico, todos apuntando a inserts fallidos. El texto de error era claro:
database is locked. Pero el equipo lo leyó como un fallo transitorio más que como una restricción de diseño.
Añadieron reintentos. “Mejoró”, luego empeoró.
El problema real era que cada worker abría una transacción, hacía unos inserts y luego realizaba una llamada de red antes de confirmar.
Esa llamada de red a veces tardaba cientos de milisegundos. Durante ese tiempo, se mantenía el bloqueo de escritor. Otros workers se ponían en cola, expiraban timeouts,
reintentaban y crearon una tormenta autoamplificada de intentos de bloqueo.
Lo arreglaron en dos fases. Primero, movieron la llamada de red fuera de la transacción y acortaron el alcance de la transacción.
Eso estabilizó el servicio. Segundo, migraron las escrituras de analítica a PostgreSQL, dejando SQLite solo como caché local en nodos edge.
Dejó de ser dramático. Se volvió aburrido. Aburrido es la meta.
Optimización que salió mal: “Aumentemos concurrencia para acelerar ingestión”
Otra empresa tenía una tubería de ingestión respaldada por PostgreSQL escribiendo eventos en una tabla grande.
Para alcanzar un nuevo objetivo de throughput, aumentaron el número de workers de ingestión de “unos pocos” a “muchos”.
La CPU se mantuvo bien. La red se mantuvo bien. La latencia explotó igualmente.
Asumieron que la base de datos escalaría linealmente con el número de workers. En su lugar, el sistema chocó contra contención y límites de I/O.
Autovacuum empezó a trabajar más. Los checkpoints se volvieron más frecuentes debido al volumen de WAL.
El p99 de tiempo de commit subió, lo que hizo que la cola se llenara, lo que aumentó aún más el paralelismo porque “los workers estaban inactivos esperando”.
También tenían un índice bienintencionado sobre un campo JSON de alta cardinalidad que casi nunca se usaba para lecturas.
Cada insert lo pagaba. Con baja concurrencia era tolerable. Con alta concurrencia, fue el impuesto que los sacó del borde.
El sistema no estaba “lento”. Hacía exactamente lo que pidieron, pero no lo que querían.
La solución no fue “más tuning”. Fue: reducir el número de workers para coincidir con la capacidad de commit del almacenamiento, eliminar o retrasar el índice costoso,
y agrupar inserts usando COPY durante ventanas pico de ingestión. Tras eso, el throughput mejoró y la latencia se estabilizó.
Aprendieron la lección SRE vieja: la concurrencia es una herramienta, no una virtud.
Práctica aburrida pero correcta que salvó el día: “Dimensionamos WAL y practicamos restores”
Un sistema cercano a pagos (no el ledger, pero lo suficientemente cercano como para importar) corría PostgreSQL con carga de escritura sostenida.
El manager de ingeniería insistió en tres cosas que a nadie le parecían emocionantes:
revisión programada de vacuum, configuraciones de WAL y checkpoints documentadas con su razonamiento, y simulacros de restauración rutinarios a un entorno separado.
Una tarde, una migración de esquema desplegó con la creación de un nuevo índice. No fue una migración catastrófica, pero incrementó la amplificación de escritura y la generación de WAL más de lo esperado. La replicación se atrasó. Sonaron alertas. El on-call lo vio en las estadísticas:
más checkpoints solicitados, más volumen de WAL, aumento de latencia de commits.
Porque el equipo ya había dimensionado max_wal_size y objetivos de checkpoint de forma conservadora, el sistema no se volvió loco de inmediato.
Porque practicaron restores, revertir la migración y desplegar una versión más segura no fue aterrador.
Y porque la salud del vacuum se monitorizaba, el bloat no agravó el problema.
El incidente siguió siendo molesto, pero se mantuvo en “molesto”. Sin pérdida de datos. Sin epopeya de recuperación de varios días.
Las prácticas aburridas no hicieron titulares. Los previnieron.
Errores comunes: síntomas → causa raíz → solución
1) Síntoma: SQLite “database is locked” durante tráfico pico
Causa raíz: demasiados escritores concurrentes, sin busy timeout, transacciones largas, o escrituras realizadas manteniendo la transacción abierta.
Solución: habilitar modo WAL; establecer busy_timeout; mantener transacciones cortas; implementar reintentos con jitter; centralizar escrituras mediante un escritor único; migrar caminos de escritura intensiva a PostgreSQL.
2) Síntoma: archivo WAL de SQLite crece sin control
Causa raíz: checkpoints no ejecutándose, checkpoints bloqueados por lectores largos, o un patrón de aplicación que mantiene transacciones de lectura abiertas.
Solución: acortar transacciones de lectura; evitar lecturas en streaming mientras la transacción esté abierta; ejecutar checkpoints periódicos; considerar separar reporting de las escrituras OLTP.
3) Síntoma: inserts en PostgreSQL se ralentizan al aumentar la concurrencia
Causa raíz: latencia de commit limitada (WAL flush), saturación de almacenamiento, o demasiadas transacciones pequeñas.
Solución: agrupar inserts; usar COPY; asegurar que el WAL esté en almacenamiento de baja latencia; afinar checkpoints (max_wal_size, checkpoint_completion_target); considerar commit asíncrono solo si es aceptable.
4) Síntoma: escritores en PostgreSQL bloqueados esperando locks
Causa raíz: filas calientes, patrones de cola sin SKIP LOCKED, transacciones largas, o DDL que toma locks durante carga pico.
Solución: rediseñar puntos calientes; usar SELECT ... FOR UPDATE SKIP LOCKED; mantener transacciones cortas; ejecutar DDL con patrones que minimicen locks; programar migraciones pesadas fuera de picos.
5) Síntoma: bloat en PostgreSQL y amplificación de escritura empeorando con el tiempo
Causa raíz: autovacuum quedando atrás, transacciones largas impidiendo limpieza, o actualizaciones frecuentes de las mismas filas.
Solución: monitorizar vacuum; arreglar transacciones largas; ajustar umbrales de autovacuum por tabla; reducir churn; particionar datos de alta rotación.
6) Síntoma: “Añadimos índices y se volvió más lento”
Causa raíz: cada insert/update debe mantener cada índice; el camino de escritura se volvió más pesado de lo que justifica el beneficio de lectura.
Solución: conservar solo índices que se paguen por sí mismos; posponer la creación de índices hasta después de backfills; usar índices parciales; medir el coste en escrituras bajo concurrencia realista.
Listas de verificación / plan paso a paso
Checklist de decisión: ¿debe esta carga usar SQLite o PostgreSQL?
- Cuenta tus escritores. Si esperas múltiples procesos/hilos independientes escribiendo con frecuencia, por defecto usa PostgreSQL.
- Define la durabilidad. Si no puedes tolerar perder escrituras “exitosas” tras un fallo, no uses pragmas inseguros de SQLite. Los valores por defecto de PostgreSQL son más seguros.
- La forma de la transacción importa. Si puedes agrupar y tolerar un patrón de escritor único, SQLite puede funcionar.
- Requisitos operacionales. Si necesitas replicación, PITR, cambios de esquema online e introspección: PostgreSQL.
- Entorno de despliegue. Si no puedes correr un servidor DB (dispositivos edge, apps offline), SQLite es una bendición.
Paso a paso: hacer que SQLite se comporte con concurrencia de escritura modesta
- Habilitar modo WAL.
- Establecer un busy timeout e implementar reintentos con jitter.
- Mantener transacciones de escritura cortas; nunca mantenerlas durante llamadas de red.
- Agrupar escrituras: menos commits, más trabajo por transacción.
- Controlar el checkpoint si el WAL crece (y validar que lectores largos no lo bloqueen).
- Si tu cola de escrituras sigue creciendo, deja de optimizar y migra.
Paso a paso: estabilizar throughput de escrituras en PostgreSQL
- Medir wait events y contención de bloqueos en
pg_stat_activity. - Comprobar checkpoints y presión WAL en
pg_stat_bgwriter. - Validar latencia de almacenamiento con
iostat; confirmar rendimiento del dispositivo WAL. - Reducir número de transacciones agrupando; usar COPY para ingestión.
- Eliminar índices costosos no usados en tablas de ingestión caliente.
- Arreglar contención en filas calientes (patrones de cola, contadores, updates de estado).
- Asegurar que vacuum se mantiene; arreglar transacciones largas.
- Añadir capacidad solo después de confirmar qué está saturado.
Preguntas frecuentes
1) ¿Puede SQLite manejar múltiples escritores en absoluto?
Múltiples conexiones pueden intentar escribir, pero SQLite serializa el trabajo real de escritura/commit mediante bloqueos. Bajo contención verás SQLITE_BUSY.
Es viable si mantienes transacciones cortas y aceptas que las escrituras se encolen unas detrás de otras.
2) ¿El modo WAL hace a SQLite “concurrente” como PostgreSQL?
El modo WAL mejora la concurrencia lector/escritor. No convierte a SQLite en un motor multi-writer. Los escritores aún se serializan al confirmar.
WAL también introduce comportamiento de checkpoints que debes entender, o te sorprenderá el uso de disco y la latencia.
3) ¿Por qué PostgreSQL tolera mejor muchos escritores?
MVCC reduce el bloqueo lector/escritor, los bloqueos a nivel de fila localizan conflictos, y el sistema está construido en torno a sesiones concurrentes con procesos en segundo plano.
También soporta group commit, que ayuda a amortizar el coste de fsync entre muchas transacciones.
4) ¿Cuál es el límite real de throughput de escrituras en PostgreSQL?
A menudo: la latencia de commit durable (WAL flush) y el rendimiento del almacenamiento. Después vienen: contención en filas/índices calientes, comportamiento de checkpoints y CPU para mantenimiento de índices.
El límite usualmente no es “PostgreSQL no puede”, sino “tu almacenamiento y esquema no concuerdan con la carga”.
5) ¿Debería desactivar fsync/synchronous para ganar velocidad?
Solo si te sientes cómodo perdiendo datos ante un fallo/pérdida de energía. En SQLite, PRAGMA synchronous=OFF es una palanca real de pérdida de datos.
En PostgreSQL, cambiar ajustes de durabilidad tiene trade-offs similares. Escribe primero la ventana de pérdida aceptable.
6) ¿Es necesario el pool de conexiones para escritores en PostgreSQL?
Si tienes muchas instancias de aplicación, sí. No porque el pool mágicamente acelere consultas, sino porque demasiadas conexiones generan overhead
y pueden aumentar la contención. El pooling te ayuda a controlar la concurrencia y mantener la base de datos en su rango operativo eficiente.
7) ¿Cuándo es SQLite la elección correcta incluso con escrituras?
Apps local-first, sistemas de un solo usuario, dispositivos edge, tooling de CI/test, capas de caché, o cuando puedes aplicar un escritor único y agrupar actualizaciones.
Si no puedes imponer esas restricciones, SQLite se vuelve una dependencia frágil.
8) ¿Cuál es la ruta de migración más común de SQLite a PostgreSQL?
Normalmente: mantener SQLite para caché local/modo offline, mover escrituras autoritativas a PostgreSQL, añadir un proceso de sincronización y luego eliminar gradualmente las escrituras en SQLite.
Lo clave es dejar de tratar a SQLite como un hub de escrituras compartido para workers concurrentes.
9) ¿Por qué veo esperas por locks en PostgreSQL cuando “solo inserto”?
Los inserts también tocan índices, secuencias, claves foráneas y a veces páginas calientes. Si muchas sesiones insertan en la misma tabla con el mismo patrón de índice,
puedes ver contención. Además, inserts que actualizan filas de “estado” o contadores crean filas calientes inmediatamente.
10) ¿Cuál es el patrón más simple y seguro para una cola de trabajo?
En PostgreSQL: una tabla de cola con SELECT ... FOR UPDATE SKIP LOCKED y transacciones cortas. En SQLite: un proceso escritor único que desencola y escribe,
con lectores consumiendo snapshots según sea necesario.
Próximos pasos que puedes ejecutar
Si estás eligiendo entre PostgreSQL y SQLite para un sistema con escritores concurrentes, decide según quién se hace cargo de la contención.
SQLite hace que la aplicación lo gestione. PostgreSQL comparte la carga y te da paneles, palancas y vías de escape.
- Si estás en SQLite y ves errores de bloqueo: habilita WAL, configura busy timeout, acorta transacciones y centraliza escrituras. Si la carga aún necesita muchos escritores, programa una migración a PostgreSQL.
- Si estás en PostgreSQL y las escrituras son lentas: revisa wait events y bloqueadores, luego revisa WAL/checkpoints, luego la latencia de almacenamiento. Repara filas calientes y la forma de las transacciones antes de comprar hardware.
- En ambos casos: mide la latencia de commit p95/p99 y la tasa de transacciones. Eso es la verdad básica para “escritores concurrentes”.
El “ganador” no es un juicio moral. Es una decisión por ajuste al propósito. Para escritores concurrentes en producción, PostgreSQL es la herramienta adulta.
Usa SQLite donde su simplicidad de un solo archivo sea una superpotencia—no donde se convierta en tu generador de outages.