sqlite3 .backup frente a cp con la base de datos en uso: cuántas copias salen corruptas
copiando con la base en uso, cp dio 1 de 30 copias corruptas en modo journal y 6 de 30 en modo WAL (en otra ejecución del mismo script, 17 de 30); .backup dio 0 de 30 en ambos. En WAL las copias con cp que pasaban integrity_check tenían menos filas que la base. Con un escritor casi continuo, .backup falló con "database is locked" tras 5 s y dejó un fichero vacío de 0 bytes.
Contenido
Este artículo no contiene enlaces de afiliado.
Copiar con cp el fichero de una base SQLite que está en uso es habitual en scripts de copia. ¿Cuántas veces sale mal? Se midió con un proceso que escribe sin parar mientras se copia, y se comprobó cada copia.
Condiciones: SQLite 3.45.1 (el sqlite3 de Ubuntu 24.04) en Linux arm64. Base de 20 MB con una tabla de unas 20 000 filas de 1 KB y un índice. El escritor es un script de Python que, en cada transacción, hace 100 inserciones y 100 actualizaciones de filas al azar y luego espera 0,2 s. Se hicieron 30 copias con cp y 30 con sqlite3 base ".backup copia" en dos modos de la base: journal_mode=delete (el de por defecto) y journal_mode=wal. Cada copia se comprobó con PRAGMA integrity_check y con SELECT count(*). Disco local, un solo escritor. No se probó con varios escritores, con bases grandes, con VACUUM INTO, con .dump, con copias de -wal y -shm junto al fichero, con un disco de red ni con otras versiones. Que cp falle más o menos veces depende de la carga: los porcentajes son de esta prueba, no una probabilidad general. Script completo al final.
Resultado de las 30 copias
| Modo | cp: copias con integrity_check distinto de ok |
.backup: copias con integrity_check distinto de ok |
|---|---|---|
journal_mode=delete |
1 de 30 | 0 de 30 |
journal_mode=wal |
6 de 30 | 0 de 30 |
Ningún comando .backup terminó con error. La base original, tras parar el escritor, pasó integrity_check en los dos modos.
Primera línea de integrity_check en las copias malas con cp: *** in database main *** en modo journal y en WAL, y row 35 missing from index tk en WAL. En una ejecución anterior del mismo script, con las mismas condiciones, salieron 17 de 30 con cp en WAL, así que el número varía mucho entre ejecuciones.
Las filas: el cp en WAL pasa la comprobación y está desfasado
En modo WAL las copias con cp iban por detrás de la base. Es lo esperable si lo reciente está en el fichero -wal, que cp de la base no copia, pero no se inspeccionó su contenido. Las tres primeras pruebas:
| Prueba | Filas en la copia con cp |
Filas en la copia con .backup |
|---|---|---|
| 1 | 20000 | 20100 |
| 2 | 20000 | 20200 |
| 3 | 20000 | 20400 |
Las copias con cp que pasaron integrity_check tenían 20 000 filas, las del momento de crear la base, mientras que .backup tenía todo lo confirmado hasta ese instante. Es decir, integrity_check = ok no significa que la copia esté al día. En modo journal, las tres primeras pruebas dieron las mismas filas con ambos métodos (20100, 20200 y 20300).
Cuando el escritor casi no descansa
Con la pausa entre transacciones reducida a 0,02 s (modo journal), un solo .backup con .timeout 5000 falló las tres veces:
Error: database is locked
exit=1 en 5.7 s
tamaño de esa copia: 0 bytes; integrity_check: ok
El fichero de copia quedó con 0 bytes y PRAGMA integrity_check lo dio por bueno (una base vacía es válida). No se probó con un .timeout mayor ni con un escritor que haga transacciones más cortas.
Detalles que importan
- Con la base en uso,
.backupfue la única opción de las dos que dio copias consistentes en todas las pruebas con el escritor haciendo pausas. En esta prueba,cpfalló en ambos modos. - Comprueba siempre el exit de
.backupy el tamaño de la copia: un fichero de 0 bytes pasaintegrity_check. integrity_checkdetecta páginas rotas, no copias antiguas: cuenta también filas, o compara una marca de tiempo del último registro.- En WAL, copiar solo el
.dbperdió datos recientes. No se probó copiar a la vez-waly-shm. - Con el escritor casi continuo,
.backupno pudo coger el bloqueo en 5 s. Con un escritor muy activo en modo journal, la copia en caliente puede no llegar a hacerse; no se probó con otros valores de pausa. - Si lo que copias es un volcado de una base de datos de otro motor, mira restic con stdin. Para comprobar una copia, rclone check.
Script completo
#!/bin/bash
# sqlite3 .backup vs cp of a database being written to concurrently; integrity_check + row count on each copy.
# Usage: test-sqlite-backup-vs-cp.sh WORKDIR [TRIALS]
W=$1; N=${2:-30}; rm -rf "$W"; mkdir -p "$W"
sqlite3 --version | cut -d' ' -f1-2
python3 -c 'import sqlite3,sys; print("python sqlite3 module", sqlite3.sqlite_version)'
cat > "$W/writer.py" <<'PY'
import sqlite3, sys, random, time
db, mode, pause = sys.argv[1], sys.argv[2], float(sys.argv[3])
c = sqlite3.connect(db, timeout=30, isolation_level=None)
c.execute(f"PRAGMA journal_mode={mode}").fetchone()
c.execute("CREATE TABLE IF NOT EXISTS t(id INTEGER PRIMARY KEY, k INTEGER, v BLOB)")
c.execute("CREATE INDEX IF NOT EXISTS tk ON t(k)")
if c.execute("SELECT count(*) FROM t").fetchone()[0] == 0:
c.execute("BEGIN")
c.executemany("INSERT INTO t(k,v) VALUES(?,randomblob(1000))", [(random.randrange(10**6),) for _ in range(20000)])
c.execute("COMMIT")
print("ready", flush=True)
while True:
c.execute("BEGIN")
for _ in range(100):
c.execute("INSERT INTO t(k,v) VALUES(?,randomblob(1000))", (random.randrange(10**6),))
c.execute("UPDATE t SET k=?, v=randomblob(1000) WHERE id=?", (random.randrange(10**6), random.randrange(1, 20000)))
c.execute("COMMIT")
time.sleep(pause) # pause between transactions
PY
for MODE in delete wal; do
echo "=================== journal_mode=$MODE"
D="$W/$MODE"; mkdir -p "$D"
python3 "$W/writer.py" "$D/live.db" $MODE 0.2 > "$D/writer.out" 2>&1 &
WP=$!
while ! grep -q ready "$D/writer.out" 2>/dev/null; do sleep 0.2; done
ls -l "$D/live.db" | awk '{print "tamaño de la base al empezar: " $5 " bytes"}'
cp_bad=0; bk_bad=0; cp_err=0; bk_err=0
for i in $(seq 1 $N); do
cp "$D/live.db" "$D/cp$i.db"
r=$(sqlite3 "$D/cp$i.db" "PRAGMA integrity_check;" 2>&1 | head -1)
rows=$(sqlite3 "$D/cp$i.db" "SELECT count(*) FROM t;" 2>&1 | head -1)
[ "$r" = "ok" ] || { cp_bad=$((cp_bad+1)); [ $cp_bad -le 2 ] && echo "cp #$i: integrity_check=[$r] filas=[$rows]"; }
sqlite3 "$D/live.db" ".timeout 30000" ".backup '$D/bk$i.db'" >/dev/null 2>"$D/bk$i.err" || bk_err=$((bk_err+1))
r=$(sqlite3 "$D/bk$i.db" "PRAGMA integrity_check;" 2>&1 | head -1)
rows2=$(sqlite3 "$D/bk$i.db" "SELECT count(*) FROM t;" 2>&1 | head -1)
[ "$r" = "ok" ] || { bk_bad=$((bk_bad+1)); echo ".backup #$i: integrity_check=[$r] filas=[$rows2]"; }
[ $i -le 3 ] && echo "prueba $i: filas en cp=$rows, filas en .backup=$rows2"
done
kill $WP; wait $WP 2>/dev/null
echo "cp: $cp_bad de $N copias con integrity_check distinto de ok"
echo ".backup: $bk_bad de $N copias con integrity_check distinto de ok (comandos .backup con error: $bk_err)"
echo "-- la base original tras parar el escritor:"
sqlite3 "$D/live.db" "PRAGMA integrity_check;" | head -1
[ $MODE = wal ] && { ls "$D" | grep -E 'live.db-(wal|shm)' | tr '\n' ' '; echo; }
done
echo "=================== escritor casi continuo (pausa de 0.02 s entre transacciones), journal_mode=delete, un solo .backup con .timeout 5000"
D="$W/busy"; mkdir -p "$D"
python3 "$W/writer.py" "$D/live.db" delete 0.02 > "$D/writer.out" 2>&1 &
WP=$!
while ! grep -q ready "$D/writer.out" 2>/dev/null; do sleep 0.2; done
for i in 1 2 3; do
s0=$(date +%s.%N)
sqlite3 "$D/live.db" ".timeout 5000" ".backup '$D/bk$i.db'" 2>&1 | head -1; echo "exit=${PIPESTATUS[0]} en $(echo "$s0 $(date +%s.%N)" | awk '{printf "%.1f", $2-$1}') s"
echo "tamaño de esa copia: $(stat -c %s "$D/bk$i.db") bytes; integrity_check: $(sqlite3 "$D/bk$i.db" 'PRAGMA integrity_check;' 2>&1 | head -1)"
done
kill $WP; wait $WP 2>/dev/null