Varios procesos escriben en el mismo archivo de SQLite (por ejemplo, un ciclo de ingesta continuo y trabajos de reprocesado o de escaneo) y alguno falla con:
Traceback (most recent call last):
File "ingesta.py", line 42, in guardar
con.execute("INSERT INTO eventos(valor) VALUES (?)", (valor,))
sqlite3.OperationalError: database is locked
Lo peor es el efecto silencioso: un trabajo lanzado a mano y olvidado puede competir con el ciclo programado y dejar la canalización parada durante horas, sin más rastro que este error en un registro. Este artículo explica por qué ocurre, qué configurar en SQLite y cómo encontrar al proceso que estorba.
Por qué pasa
SQLite admite un solo escritor a la vez. La documentación de los códigos de resultado lo describe así: si el proceso A está en mitad de una transacción de escritura y el proceso B intenta iniciar otra, B recibe SQLITE_BUSY y debe esperar a que A termine. El módulo sqlite3 de Python lo muestra como OperationalError: database is locked.
En el modo de diario por defecto (rollback journal), además, lectores y escritores se estorban: varios procesos pueden leer a la vez (bloqueo SHARED), pero para confirmar una escritura hace falta un bloqueo EXCLUSIVE, con el que no puede coexistir ningún otro bloqueo. Un escritor puede tener que esperar a que terminen los lectores, y mientras confirma bloquea las lecturas nuevas.
La espera depende de dos cosas:
- SQLite, por defecto, no tiene ningún manejador de ocupado (el valor por defecto es
NULL), así que devolvería el error al instante. - Python lo compensa con el parámetro
timeoutdesqlite3.connect(), cuyo valor por defecto son 5 segundos. Pasado ese tiempo, lanza el error.
Cualquier transacción que retenga el bloqueo más de esos 5 segundos (un proceso que lee, procesa durante minutos y escribe, o uno que quedó colgado con una transacción abierta) provoca el error en los demás.
Solución
-
Activa el modo WAL una sola vez. La documentación de SQLite explica que, con WAL, los lectores no bloquean a los escritores ni al revés, y que el modo es persistente: una vez fijado, la base sigue en WAL aunque se cierre y se reabra.
import sqlite3 con = sqlite3.connect("datos.db") print(con.execute("PRAGMA journal_mode=WAL").fetchone()) # ('wal',) con.close()WAL no elimina la regla de un solo escritor: según la documentación, solo puede haber un escritor a la vez. Por eso el paso siguiente sigue siendo necesario. Conviene ejecutar este paso con los demás procesos detenidos o, al menos, sin transacciones activas.
-
Define cuánto debe esperar cada conexión. La espera es por conexión y no se hereda: debes fijarla en cada proceso. Con Python, el parámetro
timeout(en segundos) lo hace; también puedes usar el pragma, en milisegundos:def abrir(ruta: str) -> sqlite3.Connection: con = sqlite3.connect(ruta, timeout=30, isolation_level=None) # transacciones explícitas con.execute("PRAGMA busy_timeout=30000") # equivale a timeout=30 return conUna conexión solo puede tener un manejador de ocupado:
busy_timeoutreemplaza cualquier otro definido antes. -
Haz transacciones de escritura cortas y con
BEGIN IMMEDIATE. Prepara los datos antes (lecturas, análisis, llamadas externas) y abre la transacción solo para escribir:con = abrir("datos.db") lote = [(v,) for v in valores] # preparado antes de bloquear nada con.execute("BEGIN IMMEDIATE") try: con.executemany("INSERT INTO eventos(valor) VALUES (?)", lote) con.execute("COMMIT") except Exception: con.execute("ROLLBACK") raiseLa documentación de SQLite recomienda
BEGIN IMMEDIATEpara no encontrarSQLITE_BUSYa mitad de una transacción: puede fallar al iniciar, pero si tiene éxito, ninguna operación posterior hasta elCOMMITdevolverá ese error. Hay un motivo más: si una transacción lee primero y luego escribe, y otra conexión escribió entremedias, SQLite devuelve el error de inmediato aunque eltimeoutsea de 30 segundos, porque esperar causaría un bloqueo mutuo. En WAL ese caso esSQLITE_BUSY_SNAPSHOT. -
Reduce el número de escritores. Si varios procesos solo producen datos, que envíen sus filas por una cola a un único proceso escritor. Las lecturas pueden seguir en paralelo.
-
Encuentra procesos olvidados y evita duplicados. En Linux,
ps -ef --forestmuestra el árbol de procesos con su PID y su PPID (el proceso padre); un trabajo huérfano suele colgar de PID 1. Para saber quién tiene abierta la base:ps -ef --forest | grep -E 'ingesta|reprocesar|escaneo' | grep -v grep fuser datos.db lsof datos.dbDetén el que sobra con
kill <PID>. Para que no vuelva a pasar, ejecuta los trabajos largos como servicios (por ejemplo, con systemd) y protege los lanzamientos manuales conflock -n /tmp/escaneo.lock python escaneo.py, que no arranca si ya hay otra instancia con el mismo archivo de bloqueo.
Ten en cuenta las restricciones de WAL que indica la documentación: todos los procesos deben estar en el mismo equipo, WAL no funciona sobre un sistema de archivos de red, y el archivo -wal forma parte del estado persistente de la base. Si copias o mueves datos.db, hazlo junto con su -wal; sin él podrías perder transacciones ya confirmadas. Además, si siempre hay al menos un lector activo, los puntos de control no pueden completarse y el archivo -wal crece sin límite.
Cómo verificar que quedó resuelto
con = abrir("datos.db")
print(con.execute("PRAGMA journal_mode").fetchone()[0]) # wal
print(con.execute("PRAGMA busy_timeout").fetchone()[0]) # 30000
Después simula la concurrencia con varios procesos que escriben a la vez:
import multiprocessing as mp
def escribir(n):
con = abrir("datos.db")
for i in range(200):
con.execute("BEGIN IMMEDIATE")
con.execute("INSERT INTO eventos(valor) VALUES (?)", (f"{n}-{i}",))
con.execute("COMMIT")
con.close()
if __name__ == "__main__":
with mp.Pool(4) as pool:
pool.map(escribir, range(4))
Debe terminar sin OperationalError y añadir 800 filas. En producción, comprueba que el registro de tus procesos no vuelve a mostrar database is locked tras una noche de trabajo y que tu contador de elementos procesados sigue creciendo:
sqlite3 datos.db "SELECT count(*) FROM procesados;"
sleep 60
sqlite3 datos.db "SELECT count(*) FROM procesados;"
Si el segundo número es mayor que el primero, el proceso avanza. Por último, ps -ef --forest no debe mostrar más de una instancia de cada trabajo.
Variantes y errores relacionados
database table is locked
Es otro código: SQLITE_LOCKED indica un conflicto dentro de la misma conexión (o con una conexión que comparte caché), no entre procesos. Se reproduce, por ejemplo, ejecutando DROP TABLE mientras un cursor de la misma conexión aún lee esa tabla. Cerrar o terminar de leer el cursor lo resuelve; busy_timeout no ayuda.
ProgrammingError: SQLite objects created in a thread can only be used in that same thread
Aparece si compartes una conexión entre hilos. Abre una conexión por hilo o por proceso, que además encaja con la configuración por conexión de busy_timeout.
La base está en un volumen de red
WAL no funciona sobre un sistema de archivos de red. Si varios servidores deben escribir en la misma base, SQLite deja de ser la herramienta adecuada y conviene considerar un motor cliente-servidor.
Transacciones implícitas del módulo sqlite3
Con el control de transacciones heredado (el valor por defecto), el módulo abre una transacción implícita antes de INSERT, UPDATE, DELETE o REPLACE, de tipo DEFERRED salvo que indiques otra, y with con: confirma al salir. Si prefieres ese estilo, pasa isolation_level="IMMEDIATE" a connect() para que esa transacción implícita sea IMMEDIATE. Los ejemplos de este artículo usan isolation_level=None y BEGIN/COMMIT explícitos para que el momento del bloqueo sea evidente. Desde Python 3.12 existe además el atributo autocommit.
Resumen
- SQLite permite un solo escritor a la vez; el segundo recibe
SQLITE_BUSY, que Python muestra comodatabase is lockedtras agotar sutimeoutpor defecto de 5 segundos. - Activa
PRAGMA journal_mode=WALuna vez (es persistente) y fija la espera en cada conexión contimeout=30oPRAGMA busy_timeout=30000. - Usa transacciones cortas con
BEGIN IMMEDIATEy prepara los datos antes de abrirlas. - Busca procesos olvidados con
ps -ef --forest,fuserolsofy evita duplicados con servicios oflock. - WAL exige que todos los procesos estén en el mismo equipo y no funciona sobre sistemas de archivos de red.
Comentarios
Publicar un comentario