Recovering to a particular time in the past is called Point-In-Time Recovery (PITR). With PITR you can rollback unwanted
DELETE
without WHERE
clause or any other harmful command.
PITR with Percona XtraBackup is pretty straightforward and perfectly described in the user manual. You need to restore the data from the backup, then apply all binary logs created or updated after the backup was taken, but skip harmful event(s).
However, if your data set is large you may want to recover only the affected database or table. This is possible but you need to be smart when filtering events from the binary log. In this post, I will show how to perform such a partial recovery using Percona XtraBackup, mysql
command-line client, and mysqlbinlog
programs only. There is an alternative approach that involves creating a fake source server, that is described in MySQL Point in Time Recovery the Right Way. You may consider it, especially if you need to apply changes to a single table.
Percona XtraBackup Point-In-Time Recovery
For our example we will create data first, then run DROP
and DELETE
commands on two different tables. Then we will rollback these commands.
First, let’s assume we have a server with two databases: test
and sbtest
. We are using GTIDs and row-based binary log format. We also run the server with the option innodb_file_per_table=1
and all our InnoDB tables use individual tablespaces. Otherwise, the individual restore method would not work.
mysql> show tables from sbtest; +------------------+ | Tables_in_sbtest | +------------------+ | sbtest1 | | sbtest2 | | sbtest3 | | sbtest4 | | sbtest5 | | sbtest6 | | sbtest7 | | sbtest8 | +------------------+ 8 rows in set (0.00 sec) mysql> show tables from test; +----------------+ | Tables_in_test | +----------------+ | bar | | baz | | foo | +----------------+ 3 rows in set (0.00 sec)
We will experiment with tables foo
and bar
. We assume that at the time of our first backup, each of the tables contained five rows. Tables in the database sbtest
also contain data, but it does not really matter for our experiment.
mysql> select count(*) from foo; +----------+ | count(*) | +----------+ | 5 | +----------+ 1 row in set (0.00 sec) mysql> select count(*) from bar; +----------+ | count(*) | +----------+ | 5 | +----------+ 1 row in set (0.00 sec) mysql> select count(*) from baz; +----------+ | count(*) | +----------+ | 0 | +----------+ 1 row in set (0.00 sec)
Since we want to restore individual tables, we need to make a preparation before taking a backup: store database structure. We will do it with help of the mysqldump
command. In this example, I store structure per database to make partial PITR easier, but you are free to use the option --all-databases
.
mysqldump --no-data --set-gtid-purged=OFF --triggers --routines --events test > test_structure.sql mysqldump --no-data --set-gtid-purged=OFF --triggers --routines --events sbtest > sbtest_structure.sql
Then we are ready to take the backup.
xtrabackup --parallel=8 --target-dir=./full_backup --backup
I am using the option --parallel
to speed up the backup process.
Now let’s do some testing. First, let’s update rows in the table foo.
mysql> update foo set f1=f1*2; Query OK, 5 rows affected (0.01 sec) Rows matched: 5 Changed: 5 Warnings: 0 mysql> select * from foo; +----+------+ | id | f1 | +----+------+ | 1 | 2 | | 2 | 4 | | 3 | 6 | | 4 | 8 | | 5 | 10 | +----+------+ 5 rows in set (0.00 sec)
And then drop it and delete all rows from the table bar.
mysql> drop table foo; Query OK, 0 rows affected (0.02 sec) mysql> delete from bar; Query OK, 5 rows affected (0.01 sec)
Finally, let’s insert a few rows into the tables bar and baz.
mysql> insert into bar(f1) values(6),(7),(8),(9),(10); Query OK, 5 rows affected (0.01 sec) Records: 5 Duplicates: 0 Warnings: 0 mysql> insert into baz(f1) values(1),(2),(3),(4),(5); Query OK, 5 rows affected (0.01 sec) Records: 5 Duplicates: 0 Warnings: 0
Assume that the DROP TABLE
and DELETE
command was an accident and we want to restore the state of the tables foo
and bar
as they were before these unlucky statements.
First, we need to prepare the backup.
Since we are interested in restoring only tables in the database test
we need to prepare the backup with a special option --export
that exports tablespaces in a way that they could be later imported:
xtrabackup --prepare --export --target-dir=./full_backup
Now the directory for the database test
contains not only table definition files (.frm
, only before 8.0) and tablespace files (.ibd
) but also configuration files (.cfg
).
Since we want all changes that happened after backup and before the problematic DROP TABLE
and DELETE
statements were applied, we need to identify which binary log and position were actual at the backup time. We can find it in the xtrabackup_binlog_info
file:
$ cat full_backup/xtrabackup_binlog_info master-bin.000004 1601 0ec00eed-87f3-11eb-acd9-98af65266957:1-56
Now we are ready to perform restore.
First, let’s restore the table foo
from the backup. Restoring individual tablespaces requires the ALTER TABLE ... IMPORT TABLESPACE
command. This command assumes that the table exists in the server. However, in our case, it was dropped and therefore we need to re-create it.
We will recreate the full database test from the file test_structure.sql
Since we do not want these administrative tasks to be re-applied, I suggest disabling binary logging for the session which will recreate the database structure.
mysql> set sql_log_bin=0; Query OK, 0 rows affected (0.00 sec) mysql> source test_structure.sql Query OK, 0 rows affected (0.00 sec) Query OK, 0 rows affected (0.00 sec) ....
Once tables are recreated discard their tablespaces. I will show an example for the table foo
. Adjust the code for the rest of the tables.
mysql> alter table foo discard tablespace; Query OK, 0 rows affected (0.01 sec)
Then, in another terminal, copy the tablespace and configuration files from the backup to the database directory:
cp full_backup/test/foo.{ibd,cfg} var/mysqld.1/data/test/
And, finally, import the tablespace:
mysql> alter table foo import tablespace; Query OK, 0 rows affected (0.05 sec)
Repeat for the other tables in the database test.
Now you can enable binary logging back.
You can do the same task in a script. For example:
for table in `mysql test --skip-column-names --silent -e "show tables"` > do > mysql test -e "set sql_log_bin=0; alter table $table discard tablespace" > cp full_backup/test/$table.{ibd,cfg} var/mysqld.1/data/test/ > mysql test -e "set sql_log_bin=0; alter table $table import tablespace" > done
Our tables are recovered but do not have the updates made after the backup.
mysql> select * from foo; +----+------+ | id | f1 | +----+------+ | 1 | 1 | | 2 | 2 | | 3 | 3 | | 4 | 4 | | 5 | 5 | +----+------+ 5 rows in set (0.00 sec) mysql> select * from bar; +----+------+ | id | f1 | +----+------+ | 1 | 1 | | 2 | 2 | | 3 | 3 | | 4 | 4 | | 5 | 5 | +----+------+ 5 rows in set (0.00 sec) mysql> select * from baz; Empty set (0.00 sec)
Therefore, we need to restore data from the binary logs.
To do it we first need to identify the GTID of the disaster event. It can be done if we dump all binary logs updated and created after backup into a dump file and then search for the DROP TABLE
and DELETE
statements and skipping them.
First, let’s check which binary logs do we have.
mysql> show binary logs; +-------------------+-----------+ | Log_name | File_size | +-------------------+-----------+ | master-bin.000001 | 1527476 | | master-bin.000002 | 3035 | | master-bin.000003 | 1987 | | master-bin.000004 | 2466 | | master-bin.000005 | 784 | +-------------------+-----------+ 5 rows in set (0.00 sec)
So we need to parse them, starting from the log master-bin.000004
and position 1601:
mysqlbinlog --start-position=1601 -vvv --base64-output=decode-rows --database=test master-bin.000004 master-bin.000005 > binlog_test.sql
I used options -vvv
that prints SQL representation of row events, so we can find the one which we want to skip and --base64-output=decode-rows
to not print row events at all. We will not use this file for the restore, only for searching the DROP TABLE
and DELETE
events.
Here they are, at the positions 2007 and 2123, with GTID 0ec00eed-87f3-11eb-acd9-98af65266957:58
and 0ec00eed-87f3-11eb-acd9-98af65266957:59
# at 2007 #210321 13:29:58 server id 1 end_log_pos 2123 CRC32 0xd1eb9854 Query thread_id=138 exec_time=0 error_code=0 use `test`/*!*/; SET TIMESTAMP=1616322598/*!*/; DROP TABLE `foo` /* generated by server */ /*!*/; # at 2123 #210321 13:30:08 server id 1 end_log_pos 2188 CRC32 0xfc9b2088 GTID last_committed=7 sequence_number=8 rbr_only=yes original_committed_timestamp=0 immediate_commit_timestamp=0 transaction_length=0 /*!50718 SET TRANSACTION ISOLATION LEVEL READ COMMITTED*//*!*/; # original_commit_timestamp=0 (1970-01-01 02:00:00.000000 EET) # immediate_commit_timestamp=0 (1970-01-01 02:00:00.000000 EET) /*!80001 SET @@session.original_commit_timestamp=0*//*!*/; /*!80014 SET @@session.original_server_version=0*//*!*/; /*!80014 SET @@session.immediate_server_version=0*//*!*/; SET @@SESSION.GTID_NEXT= '0ec00eed-87f3-11eb-acd9-98af65266957:59'/*!*/; # at 2188 #210321 13:30:08 server id 1 end_log_pos 2260 CRC32 0x1d525b11 Query thread_id=138 exec_time=0 error_code=0 SET TIMESTAMP=1616322608/*!*/; BEGIN /*!*/; # at 2260 #210321 13:30:08 server id 1 end_log_pos 2307 CRC32 0xb57ecb73 Table_map: `test`.`bar` mapped to number 226 # at 2307 #210321 13:30:08 server id 1 end_log_pos 2387 CRC32 0x6770a7e2 Delete_rows: table id 226 flags: STMT_END_F ### DELETE FROM `test`.`bar` ### WHERE ### @1=1 /* INT meta=0 nullable=0 is_null=0 */ ### @2=1 /* INT meta=0 nullable=1 is_null=0 */ ### DELETE FROM `test`.`bar` ### WHERE ...
Note that decoded row event contains a DELETE
command for each affected row.
We may also find to which binary log this event belongs if search for the "Rotate to"
event. In our case “Rotate to master-bin.000005” happened after the found positions, so we only need file master-bin.000004
In your case, you may need to skip events from the previous log files too.
So to restore the data we need to run mysqlbinlog
one more time, this time with parameters:
mysqlbinlog --start-position=1601 --exclude-gtids=0ec00eed-87f3-11eb-acd9-98af65266957:58-59 --database=test --skip-gtids=true master-bin.000004 master-bin.000005 > binlog_restore.sql
I removed options -vvv
because we are not going to examine this restore file and option --base64-output=decode-rows
because we need row events to present in the resulting file. I also used option --exclude-gtids=0ec00eed-87f3-11eb-acd9-98af65266957:58-59
to exclude GTIDs that we do not want to re-apply. We also need to use --skip-gtids=true
because otherwise updates will be skipped since such GTIDs already exist on the server.
Now binlog_restore.sql
contains all updates to the database test
made after the backup and before the DROP
statement. Let’s restore it.
mysql test < binlog_restore.sql
Restore went successfully. Our tables have all past updates.
mysql> select * from foo; +----+------+ | id | f1 | +----+------+ | 1 | 2 | | 2 | 4 | | 3 | 6 | | 4 | 8 | | 5 | 10 | +----+------+ 5 rows in set (0.01 sec) mysql> select count(*) from bar; +----------+ | count(*) | +----------+ | 10 | +----------+ 1 row in set (0.00 sec) mysql> select count(*) from baz; +----------+ | count(*) | +----------+ | 5 | +----------+ 1 row in set (0.00 sec)
Conclusion
You may save the time required for PITR if use the per-database restore method. However, you need to take into account the following considerations:
mysqlbinlog
does not support filtering per table, therefore you either need to restore the full database or use a fake server method, described in MySQL Point in Time Recovery the Right Way.- Per-database filters depend on the
USE
statement in the statement-based binary log format. Therefore option--database
can only be considered safe with a row-based format. - If you do not use GTID you still can use this method. You will need to combine options
--start-position
and--stop-position
to skip the event.