利用Git来增量备份中小型数据库

数据库的备份与还原,是网络服务的重要一环。 现在各大云平台都推出了独立的数据库服务,包揽了这个问题。 但是对于中小型数据库来说,用这样的独立服务并不划算,所以还是得自己面对这个问题。

本文以SQLite为例,介绍一种用Git进行数据库增量备份的野路子。

思路

最简单的备份思路是,复制一份,这就是镜像备份。 这种做法简单有效,但有一个致命问题——历史丢了。 如果需要恢复到一段时间以前,而非上一次备份,就无法做到。

数据库备份一般的思路是,压缩打包,标上时间戳——这是全量备份。 全量备份的缺点是,占用的空间过大,往往需要定期删除。 假如数据库需要恢复到很久以前的版本,这种做法就失效了。

在此基础上,对空间占用进行优化,就是增量备份。 而无论以什么方法做增量,都会有一个问题:假如某一天的备份丢失了,那以后数据都无法恢复了。 而且,恢复的时间往往很长,因为需要从第一天备份开始合并。

在全量备份基础上,对恢复时间进行优化,就是差分备份。 以第一个备份为基础,其它备份都是这个备份的差分。 这降低了恢复的时间,因为只需要合并一次,但空间占用比增量备份大,甚至与全量备份相差无几。

还有一个方案是对以上思路的结合。 大周期进行全量备份,中周期进行差分备份,小周期进行增量备份。 这样可以尽量扬长避短。

有没有什么工具是既增量存储,又可以快速合并成任何时间节点,甚至能增量传输的?

——Git!

数据库有没有办法变成纯文本文件存储?

——SQL!

这个野路子的来历,就是这么简单。

备份

以SQLite为例,而非泛泛而谈,是因为有一个与数据库特性相关的功能,必须具体。 孤不是在说dump,而是说backup。

SQLite从db文件到sql文件很简单,就是一个dump。

sqlite3 some.db .dump > backup.sql

然而,当数据库在高频读写时,直接做dump操作可能会出一些问题。 因为dump相当于高频读操作,会用read-lock抢占数据库资源。 dump的核心逻辑,详见附录

所以,需要先备份一份,再做dump。 对于Online Backup,SQLite不仅提供了命令,也有API,详见《SQLite Backup API》。 由于有250ms的备份间隔,所以对正常使用的影响极小。 当然,单看时间,比dump或copy要慢得多。

dump完成后,需要提交到某个准备好的Git库中,并且推送到远程仓库,也即备份位置。 完整脚本示例如下:

#!/bin/bash

set -ex

cd $BACKUP_REPO

sqlite3 $TARGET_DB ".backup backup.db"
sqlite3 backup.db .dump > backup.sql
rm backup.db

git add backup.sql
if git commit -m 'Update backup.sql'
then
    git push
fi

这样,本地有一个备份,远程有一个备份。 历史清楚,信息详实。

还原

#!/bin/bash

set -ex

cd $BACKUP_REPO
sqlite3 $TARGET_DB < backup.sql

通常,需要还原的数据库,应该已经停止工作了。 如果需要一边工作一边还原,这种开飞机换引擎的事,SQLite当然……也支持。 这时需要使用.restore

#!/bin/bash

set -ex

cd $BACKUP_REPO
sqlite3 backup.db < backup.sql
sqlite3 $TARGET_DB ".restore backup.db"
rm backup.db

总结

对于SQLite以外的其它数据库,只要能够dump出来内容的SQL,并且SQL还是规整有序的,都可以使用这个方法。 只是在细节上,需要做出调整。

野路子之所以野,不是因为不好用,而是因为适用的情况不够广泛,在特殊情况下,也不够可靠。 并且,由于经过了dump,所以被还原的数据库与原先那个,已经不算同一个了。 比如,孤曾把一个540MB的数据库,还原成213MB。 看上去内容似乎一样,甚至还瘦身了! 至于到底有哪些差异,会不会有负面影响,则只有天知道。

无论如何,这个方法对中小企业、个人开发者来说,还是很合适的。

参考

附录

以下代码源于sqlite源码的ext/misc/dbdump.c文件,是dump操作的核心逻辑。

/*
** Convert an SQLite database into SQL statements that will recreate that
** database.
*/
int sqlite3_db_dump(
  sqlite3 *db,               /* The database connection */
  const char *zSchema,       /* Which schema to dump.  Usually "main". */
  const char *zTable,        /* Which table to dump.  NULL means everything. */
  int (*xCallback)(const char*,void*),   /* Output sent to this callback */
  void *pArg                             /* Second argument of the callback */
){
  DState x;
  memset(&x, 0, sizeof(x));
  x.rc = sqlite3_exec(db, "BEGIN", 0, 0, 0);
  if( x.rc ) return x.rc;
  x.db = db;
  x.xCallback = xCallback;
  x.pArg = pArg;
  xCallback("PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n", pArg);
  if( zTable==0 ){
    run_schema_dump_query(&x,
      "SELECT name, type, sql FROM \"%w\".sqlite_master "
      "WHERE sql NOT NULL AND type=='table' AND name!='sqlite_sequence'",
      zSchema
    );
    run_schema_dump_query(&x,
      "SELECT name, type, sql FROM \"%w\".sqlite_master "
      "WHERE name=='sqlite_sequence'", zSchema
    );
    output_sql_from_query(&x,
      "SELECT sql FROM sqlite_master "
      "WHERE sql NOT NULL AND type IN ('index','trigger','view')", 0
    );
  }else{
    run_schema_dump_query(&x,
      "SELECT name, type, sql FROM \"%w\".sqlite_master "
      "WHERE tbl_name=%Q COLLATE nocase AND type=='table'"
      "  AND sql NOT NULL",
      zSchema, zTable
    );
    output_sql_from_query(&x,
      "SELECT sql FROM \"%w\".sqlite_master "
      "WHERE sql NOT NULL"
      "  AND type IN ('index','trigger','view')"
      "  AND tbl_name=%Q COLLATE nocase",
      zSchema, zTable
    ); 
  }
  if( x.writableSchema ){
    xCallback("PRAGMA writable_schema=OFF;\n", pArg);
  }
  xCallback(x.nErr ? "ROLLBACK; -- due to errors\n" : "COMMIT;\n", pArg);
  sqlite3_exec(db, "COMMIT", 0, 0, 0);
  return x.rc;
}

不看不知道,一看吓一跳。 dump操作其实是几个外部SQL操作,和其它C的实现比,性能可想而知。 如果在生产环境上一边高频读写,一边dump……想想也是好可怕!


相关笔记