Una copia diaria te devuelve la base de datos tal como estaba anoche, pero si alguien ejecuta un DELETE sin WHERE a las 17:42, perderías todo lo escrito durante el día. La recuperación a un momento específico (PITR, point-in-time recovery) combina una copia base con el registro de transacciones que la base de datos ya genera, y reproduce ese registro hasta justo antes del error. En este tutorial configurarás PITR en Ubuntu 24.04 para MySQL 8.0, con los logs binarios, y para PostgreSQL 16, con el archivado de WAL, y harás una recuperación completa en cada uno.

Requisitos previos

Para seguir esta guía necesitas:

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con MySQL 8.0 (mysql-server) o PostgreSQL 16 (postgresql) instalados desde los repositorios de Ubuntu.
  • Un usuario no root con privilegios sudo.
  • Espacio en disco para la copia base y para los registros de transacciones de, al menos, el periodo entre dos copias base.
  • Idealmente, un servidor de pruebas donde ensayar la recuperación antes de necesitarla en producción.

Las secciones de MySQL y PostgreSQL son independientes: sigue solo la del motor que uses.

Cómo funciona PITR

Cualquier recuperación a un momento concreto tiene tres piezas:

  • Copia base: una copia consistente de toda la base de datos en un instante conocido.
  • Registro de transacciones: los logs binarios en MySQL o los segmentos WAL en PostgreSQL, que contienen cada cambio posterior a la copia base.
  • Objetivo de recuperación: el punto (una hora o una posición en el log) hasta el que se reproducen los cambios.

Si el error ocurrió a las 17:42 y la copia base es de las 03:00, restauras la copia de las 03:00 y reproduces los cambios de 03:00 a 17:41:59. Por eso es imprescindible conservar, sin huecos, todos los registros de transacciones desde la última copia base.

MySQL 8.0 con logs binarios

Paso 1: Configurar los logs binarios

MySQL 8.0 activa los logs binarios por defecto, en formato ROW, con archivos binlog.NNNNNN en /var/lib/mysql/ y una retención de 30 días. Comprueba el estado actual:

sudo mysql -e "SHOW VARIABLES WHERE Variable_name IN ('log_bin','binlog_format','binlog_expire_logs_seconds','server_id','gtid_mode');"
+----------------------------+---------+
| Variable_name              | Value   |
+----------------------------+---------+
| binlog_expire_logs_seconds | 2592000 |
| binlog_format              | ROW     |
| gtid_mode                  | OFF     |
| log_bin                    | ON      |
| server_id                  | 1       |
+----------------------------+---------+

Si log_bin está en OFF, algún archivo de configuración lo desactiva con skip-log-bin o disable-log-bin. Busca esa línea en /etc/mysql/mysql.conf.d/mysqld.cnf, elimínala y reinicia MySQL.

La retención debe cubrir con holgura el intervalo entre copias base. Para dejarla explícita en 14 días, edita el archivo de configuración:

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

Añade al final de la sección [mysqld]:

server-id                  = 1
binlog_expire_logs_seconds = 1209600
sync_binlog                = 1

sync_binlog = 1 (el valor por defecto en 8.0) fuerza la escritura a disco en cada commit, así que no perderás transacciones confirmadas si el servidor se apaga de golpe. Reinicia y comprueba que MySQL arranca:

sudo systemctl restart mysql
sudo mysql -e "SHOW BINARY LOGS;"

Paso 2: Crear la copia base

Crea un directorio para las copias:

sudo install -d -m 0700 /var/backups/mysql

Haz un volcado consistente de todas las bases de datos. --single-transaction lo hace sin bloquear tablas InnoDB, --flush-logs rota el log binario para que el siguiente archivo empiece justo después de la copia, y --source-data=2 anota como comentario el archivo y la posición del log en ese instante:

sudo sh -c 'mysqldump --all-databases --single-transaction --flush-logs --source-data=2 --routines --events --triggers | gzip > /var/backups/mysql/base-$(date +%F-%H%M).sql.gz'

Consulta las coordenadas guardadas en el volcado:

zcat /var/backups/mysql/base-*.sql.gz | grep -m1 'SOURCE_LOG_FILE'
-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='binlog.000004', SOURCE_LOG_POS=157;

Guarda estos dos valores: son el punto desde el que tendrás que reproducir los logs.

Paso 3: Guardar los logs binarios fuera del servidor

Los logs en /var/lib/mysql/ no sirven de nada si pierdes el disco. Copia periódicamente la copia base y los logs a otra máquina. Primero cierra el log actual para que quede completo:

sudo mysql -e "FLUSH BINARY LOGS;"

Después sincronízalos con tu servidor de backups (sustituye backup_user y your_backup_server):

sudo rsync -a /var/lib/mysql/binlog.[0-9]* /var/backups/mysql/ backup_user@your_backup_server:/srv/backups/mysql-host/

Programa estos dos comandos cada 15 minutos (con cron o un temporizador de systemd) y tu pérdida máxima de datos ante un fallo total del servidor será de unos 15 minutos.

Paso 4: Recuperar MySQL a un momento concreto

Supón que a las 17:42 se borró la tabla orders de la base de datos appdb. Primero, detén las escrituras de la aplicación para que no se sigan generando cambios.

Copia los logs a un lugar seguro antes de tocar nada, porque la restauración generará actividad nueva en el servidor:

sudo install -d -m 0700 /root/pitr
sudo cp /var/lib/mysql/binlog.[0-9]* /root/pitr/

Localiza la sentencia dañina en el log que estaba activo a esa hora (normalmente el más reciente; ls -l /root/pitr muestra la fecha de cada archivo). --base64-output=DECODE-ROWS -v muestra los cambios en un formato legible:

sudo mysqlbinlog --base64-output=DECODE-ROWS -v --start-datetime="2026-09-25 17:30:00" /root/pitr/binlog.000005 | grep -B 12 'DROP TABLE'
...
# at 48213
#260925 17:42:08 server id 1  end_log_pos 48361 CRC32 0x5f1c2a7e 	Query	thread_id=812	exec_time=0	error_code=0	Xid = 23117
use `appdb`/*!*/;
SET TIMESTAMP=1790350928/*!*/;
DROP TABLE `orders` /* generated by server */

La línea # at 48213 es la posición de binlog.000005 en la que empieza el evento dañino. Esa será la posición de parada: se aplica todo lo anterior y nada a partir de ella.

Restaura la copia base sin escribir en el log binario, para no mezclar la restauración con el historial original:

zcat /var/backups/mysql/base-2026-09-25-0300.sql.gz | sudo mysql --init-command="SET SESSION sql_log_bin=0"

Reproduce los logs desde la posición anotada en el paso 2 hasta justo antes del error. --start-position se aplica al primer archivo y --stop-position al último:

sudo mysqlbinlog --start-position=157 --stop-position=48213 /root/pitr/binlog.000004 /root/pitr/binlog.000005 | sudo mysql --init-command="SET SESSION sql_log_bin=0"

Si prefieres parar por hora, usa --stop-datetime="2026-09-25 17:42:00" en lugar de --stop-position. La posición es más precisa: dentro del mismo segundo puede haber transacciones buenas y malas.

Verifica el resultado:

sudo mysql -e "SELECT COUNT(*), MAX(created_at) FROM appdb.orders;"

La tabla debe existir y la última fila debe ser de poco antes de las 17:42. Cuando lo confirmes, reactiva la aplicación y haz una copia base nueva.

PostgreSQL 16 con archivado de WAL

Paso 1: Activar el archivado de WAL

PostgreSQL escribe cada cambio en el WAL (write-ahead log) y recicla los segmentos cuando ya no los necesita. Para PITR hay que copiar cada segmento completo a un archivo antes de que se recicle. Crea el directorio del archivo:

sudo install -d -m 0700 -o postgres -g postgres /var/lib/postgresql/wal_archive

En producción, este directorio debería estar en otro disco o sincronizarse con otra máquina. Crea un archivo de configuración propio; Ubuntu carga automáticamente todo lo que hay en conf.d:

sudo nano /etc/postgresql/16/main/conf.d/archiving.conf
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /var/lib/postgresql/wal_archive/%f && cp %p /var/lib/postgresql/wal_archive/%f'
archive_timeout = 300

archive_command se niega a sobrescribir un segmento ya archivado, y archive_timeout = 300 fuerza el cambio de segmento cada 5 minutos aunque haya poca actividad, lo que limita la pérdida de datos. archive_mode requiere reiniciar:

sudo systemctl restart postgresql@16-main

Fuerza un cambio de segmento y comprueba que se archiva:

sudo -u postgres psql -c "SELECT pg_switch_wal();"
sudo -u postgres psql -c "SELECT archived_count, last_archived_wal, failed_count FROM pg_stat_archiver;"
 archived_count |    last_archived_wal     | failed_count
----------------+--------------------------+--------------
              1 | 000000010000000000000003 |            0

Si failed_count sube, revisa /var/log/postgresql/postgresql-16-main.log: normalmente es un problema de permisos del directorio.

Paso 2: Crear la copia base

Crea el directorio de copias y lanza pg_basebackup. -X stream incluye en la copia el WAL necesario para que sea consistente por sí sola, y -c fast fuerza un checkpoint inmediato:

sudo install -d -m 0700 -o postgres -g postgres /var/lib/postgresql/basebackups
sudo -u postgres pg_basebackup -D /var/lib/postgresql/basebackups/base-$(date +%F-%H%M) -Fp -X stream -c fast -P

Verifica la copia con pg_verifybackup, que compara cada archivo con el manifiesto generado:

sudo -u postgres /usr/lib/postgresql/16/bin/pg_verifybackup /var/lib/postgresql/basebackups/base-2026-09-25-0300
backup successfully verified

Haz una copia base nueva cada día o cada semana. Los segmentos WAL anteriores a la copia base más antigua que conserves ya no son necesarios y puedes borrarlos del archivo.

Paso 3: Recuperar PostgreSQL a un momento concreto

Supón de nuevo un borrado accidental a las 17:42. Detén la aplicación y después el clúster:

sudo systemctl stop postgresql@16-main

Aparta el directorio de datos actual en lugar de borrarlo; si algo sale mal, podrás volver a él:

sudo mv /var/lib/postgresql/16/main /var/lib/postgresql/16/main.broken

Copia la copia base en su lugar con los permisos que exige PostgreSQL:

sudo cp -a /var/lib/postgresql/basebackups/base-2026-09-25-0300 /var/lib/postgresql/16/main
sudo chown -R postgres:postgres /var/lib/postgresql/16/main
sudo chmod 700 /var/lib/postgresql/16/main

Configura el objetivo de recuperación en un archivo aparte, para poder eliminarlo al terminar:

sudo nano /etc/postgresql/16/main/conf.d/pitr.conf
restore_command = 'cp /var/lib/postgresql/wal_archive/%f %p'
recovery_target_time = '2026-09-25 17:42:00+02'
recovery_target_action = 'promote'

Indica siempre la zona horaria en recovery_target_time. recovery_target_action = 'promote' hace que, al llegar al objetivo, el servidor termine la recuperación y acepte escrituras. Si quieres revisar los datos antes de promocionar, usa 'pause'.

Crea el archivo recovery.signal, que indica a PostgreSQL que arranque en modo recuperación:

sudo -u postgres touch /var/lib/postgresql/16/main/recovery.signal

Arranca el clúster y sigue el log:

sudo systemctl start postgresql@16-main
sudo tail -n 20 /var/log/postgresql/postgresql-16-main.log
LOG:  starting point-in-time recovery to 2026-09-25 17:42:00+02
LOG:  restored log file "000000010000000000000005" from archive
...
LOG:  recovery stopping before commit of transaction 91822, time 2026-09-25 17:42:08.114+02
LOG:  redo done at 0/7A1C3F8
LOG:  selected new timeline ID: 2
LOG:  database system is ready to accept connections

Comprueba que el servidor ya no está en recuperación y que los datos son los esperados:

sudo -u postgres psql -c "SELECT pg_is_in_recovery();"
sudo -u postgres psql -d appdb -c "SELECT COUNT(*), MAX(created_at) FROM orders;"

pg_is_in_recovery debe devolver f. Elimina la configuración de recuperación para que no se aplique en el próximo arranque, y haz una copia base nueva, ya que el servidor ha empezado una línea temporal nueva:

sudo rm /etc/postgresql/16/main/conf.d/pitr.conf
sudo -u postgres pg_basebackup -D /var/lib/postgresql/basebackups/base-$(date +%F-%H%M) -Fp -X stream -c fast -P

Cuando todo funcione, borra /var/lib/postgresql/16/main.broken.

Solución de problemas

MySQL: ERROR 1062 Duplicate entry al reproducir los logs. Has empezado en una posición anterior a la copia base, o has aplicado dos veces el mismo archivo. Usa exactamente el archivo y la posición de SOURCE_LOG_FILE y SOURCE_LOG_POS del volcado.

MySQL con GTID activado (gtid_mode = ON). Los eventos reproducidos se saltan porque sus GTID ya constan como ejecutados. Antes de reproducir, ejecuta RESET MASTER en el servidor restaurado (solo si no tiene réplicas) o reproduce con mysqlbinlog --skip-gtids.

PostgreSQL: recovery ended before configured recovery target was reached. Falta algún segmento WAL en el archivo o el objetivo es posterior al último WAL archivado. Comprueba que no hay huecos en /var/lib/postgresql/wal_archive/ y que failed_count de pg_stat_archiver era 0.

PostgreSQL: el objetivo se ignora y recupera hasta el final. Falta recovery.signal en el directorio de datos o pitr.conf no está en conf.d. Revisa el log al arrancar: debe aparecer starting point-in-time recovery to ....

Conclusión

Con los logs binarios de MySQL o el archivado de WAL de PostgreSQL, tu pérdida de datos máxima pasa de "desde la última copia" a unos minutos, y puedes deshacer un error humano sin perder el resto del día. Como siguientes pasos, envía los logs y el WAL fuera del servidor de forma continua, automatiza las copias base con un temporizador de systemd y ensaya la recuperación en un servidor de pruebas cada cierto tiempo. Para PostgreSQL en producción, herramientas como pgBackRest o Barman gestionan la retención y la compresión por ti.