SQLite Backup API

SQLite Backup API

SQLite의 온라인 백업 API(Online Backup API)를 사용하면 실행 중인 데이터베이스를 다른 데이터베이스 파일로 복사할 수 있어요. 전통적인 파일 복사 방식이 가진 잠금·인메모리·손상 문제를 해결해 줘요. 이 글에서는 C 언어 예제 두 개로 백업 API의 흔한 사용법을 보여드릴게요.

출처: SQLite Backup API

본문

1. SQLite 온라인 백업 API 사용하기

역사적으로 SQLite 데이터베이스의 백업(복사)은 다음 방법으로 만들어졌어요:

  1. SQLite API(즉, 셸 도구)를 사용해 데이터베이스 파일에 공유 잠금(shared lock)을 설정한다.
  2. 외부 도구(예: unix 'cp' 유틸리티나 DOS 'copy' 명령)로 데이터베이스 파일을 복사한다.
  3. 1단계에서 얻은 데이터베이스 파일의 공유 잠금을 해제한다.

이 절차는 많은 시나리오에서 잘 동작하고 보통 매우 빠르지만, 다음과 같은 단점이 있어요:

  • 백업이 만들어지는 동안 데이터베이스 파일에 쓰고 싶어 하는 어떤 데이터베이스 클라이언트도 공유 잠금이 해제될 때까지 기다려야 해요.
  • 인메모리 데이터베이스로/로부터 데이터를 복사하는 데는 사용할 수 없어요.
  • 데이터베이스 파일을 복사하는 동안 정전이나 운영 체제 실패가 발생하면, 시스템 복구 후 백업 데이터베이스가 손상될 수 있어요.

온라인 백업 API는 이런 우려를 해결하기 위해 만들어졌어요. 온라인 백업 API는 한 데이터베이스의 내용을 다른 데이터베이스 파일로 복사해 대상 데이터베이스의 원래 내용을 대체할 수 있게 해 줘요. 복사 작업은 증분 방식으로 수행될 수 있는데, 이 경우 소스 데이터베이스는 복사가 진행되는 내내 잠글 필요 없이, 실제로 읽히는 짧은 순간에만 잠그면 돼요. 이는 온라인 데이터베이스의 백업을 만드는 동안 다른 데이터베이스 사용자들이 과도한 지연 없이 계속 작업할 수 있게 해 줘요.

백업 호출 시퀀스를 완료한 효과는, 복사를 시작했을 때의 소스 데이터베이스를 비트 단위로 동일하게 복사한 대상이 되는 거예요. (대상은 "스냅샷"이 돼요.)

온라인 백업 API는 여기에 문서화되어 있어요. 이 페이지의 나머지에는 API의 흔한 사용법을 보여주는 C 언어 예제 두 개와 그에 대한 논의가 있어요. 이 예제를 읽는 것이 API 문서를 읽는 것을 대신할 수는 없어요!

1.1. 다른 백업 기법

온라인 백업 API는 라이브 SQLite 데이터베이스의 백업을 만드는 원래 방법이에요. 같은 일을 달성하는 더 최근의 기법들은 다음과 같아요:

  • VACUUM INTO 명령은 라이브 SQLite 데이터베이스의 vacuum된 복사본을 별도 파일로 만들어요.
  • sqlite3_rsync 프로그램은 SSH 연결을 사용해 라이브 SQLite 데이터베이스의 복사본을 원격 시스템으로/로부터 만들어요.

2. 예제 1: 인메모리 데이터베이스 로드와 저장

/*
** This function is used to load the contents of a database file on disk 
** into the "main" database of open database connection pInMemory, or
** to save the current contents of the database opened by pInMemory into
** a database file on disk. pInMemory is probably an in-memory database, 
** but this function will also work fine if it is not.
**
** Parameter zFilename points to a nul-terminated string containing the
** name of the database file on disk to load from or save to. If parameter
** isSave is non-zero, then the contents of the file zFilename are 
** overwritten with the contents of the database opened by pInMemory. If
** parameter isSave is zero, then the contents of the database opened by
** pInMemory are replaced by data loaded from the file zFilename.
**
** If the operation is successful, SQLITE_OK is returned. Otherwise, if
** an error occurs, an SQLite error code is returned.
*/
int loadOrSaveDb(sqlite3 *pInMemory, const char *zFilename, int isSave){
  int rc;                   /* Function return code */
  sqlite3 *pFile;           /* Database connection opened on zFilename */
  sqlite3_backup *pBackup;  /* Backup object used to copy data */
  sqlite3 *pTo;             /* Database to copy to (pFile or pInMemory) */
  sqlite3 *pFrom;           /* Database to copy from (pFile or pInMemory) */

  /* Open the database file identified by zFilename. Exit early if this fails
  ** for any reason. */
  rc = sqlite3_open(zFilename, &pFile);
  if( rc==SQLITE_OK ){

    /* If this is a 'load' operation (isSave==0), then data is copied
    ** from the database file just opened to database pInMemory. 
    ** Otherwise, if this is a 'save' operation (isSave==1), then data
    ** is copied from pInMemory to pFile.  Set the variables pFrom and
    ** pTo accordingly. */
    pFrom = (isSave ? pInMemory : pFile);
    pTo   = (isSave ? pFile     : pInMemory);

    /* Set up the backup procedure to copy from the "main" database of 
    ** connection pFile to the main database of connection pInMemory.
    ** If something goes wrong, pBackup will be set to NULL and an error
    ** code and message left in connection pTo.
    **
    ** If the backup object is successfully created, call backup_step()
    ** to copy data from pFile to pInMemory. Then call backup_finish()
    ** to release resources associated with the pBackup object.  If an
    ** error occurred, then an error code and message will be left in
    ** connection pTo. If no error occurred, then the error code belonging
    ** to pTo is set to SQLITE_OK.
    */
    pBackup = sqlite3_backup_init(pTo, "main", pFrom, "main");
    if( pBackup ){
      (void)sqlite3_backup_step(pBackup, -1);
      (void)sqlite3_backup_finish(pBackup);
    }
    rc = sqlite3_errcode(pTo);
  }

  /* Close the database connection opened on database file zFilename
  ** and return the result of this function. */
  (void)sqlite3_close(pFile);
  return rc;
}

위의 C 함수는 백업 API의 가장 단순하고 가장 흔한 사용법 중 하나, 즉 인메모리 데이터베이스의 내용을 디스크 파일로 저장하거나 불러오는 것을 보여줘요. 이 예제에서 백업 API는 다음과 같이 사용돼요:

  1. 함수 sqlite3_backup_init()을 호출해 두 데이터베이스 사이에서 데이터를 복사할 sqlite3_backup 객체를 만든다 (파일에서 인메모리 데이터베이스로, 또는 그 반대로).
  2. 함수 sqlite3_backup_step()을 매개변수 -1로 호출해 전체 소스 데이터베이스를 대상으로 복사한다.
  3. 함수 sqlite3_backup_finish()을 호출해 sqlite3_backup_init()이 할당한 리소스를 정리한다.

2.1. 오류 처리

백업 API의 세 주요 루틴 중 하나에서 오류가 발생하면 오류 코드메시지가 대상 데이터베이스 연결에 첨부돼요. 추가로, sqlite3_backup_step()이 오류를 만나면 오류 코드sqlite3_backup_step() 호출 자체와 그 뒤의 sqlite3_backup_finish() 호출 양쪽에서 반환돼요. 그래서 sqlite3_backup_finish() 호출이 sqlite3_backup_step()이 대상 데이터베이스 연결에 저장한 오류 코드를 덮어쓰지 않아요. 이 기능은 예제 코드에서 필요한 오류 처리량을 줄이는 데 사용돼요. sqlite3_backup_step()sqlite3_backup_finish() 호출의 반환값은 무시되고, 나중에 대상 데이터베이스 연결에서 복사 작업의 성공·실패를 나타내는 오류 코드를 수집해요.

2.2. 가능한 개선

이 함수의 구현은 적어도 두 가지 방식으로 개선될 수 있어요:

  1. 데이터베이스 파일 zFilename에 대한 잠금 획득 실패(SQLITE_BUSY 오류)를 처리할 수 있고,
  2. 데이터베이스 pInMemory와 zFilename의 페이지 크기가 다른 경우를 더 잘 처리할 수 있어요.

데이터베이스 zFilename은 디스크의 파일이므로 다른 프로세스가 외부에서 접근할 수 있어요. 이는 sqlite3_backup_step() 호출이 거기서 데이터를 읽거나 쓸 때 필요한 파일 잠금을 얻지 못할 수 있다는 뜻이에요. 그런 일이 발생하면 이 구현은 즉시 SQLITE_BUSY를 반환하며 실패해요. 해결책은 데이터베이스 연결 pFile을 열자마자 sqlite3_busy_handler()sqlite3_busy_timeout()으로 busy-handler 콜백이나 타임아웃을 등록하는 것이에요. 필요한 잠금을 즉시 얻지 못하면 sqlite3_backup_step()sqlite3_step()이나 sqlite3_exec()과 같은 방식으로 등록된 busy-handler 콜백이나 타임아웃을 사용해요.

보통 대상의 내용이 덮어써지기 전에 소스 데이터베이스와 대상 데이터베이스의 페이지 크기가 달라도 문제가 되지 않아요. 대상 데이터베이스의 페이지 크기는 백업 작업의 일부로 그냥 바뀌어요. 예외는 대상 데이터베이스가 우연히 인메모리 데이터베이스인 경우예요. 이 경우 백업 작업 시작 시 페이지 크기가 같지 않으면 작업이 SQLITE_READONLY 오류로 실패해요. 안타깝게도 이는 loadOrSaveDb() 함수로 파일의 데이터베이스 이미지를 인메모리 데이터베이스에 불러올 때 발생할 수 있어요.

하지만 인메모리 데이터베이스 pInMemory가 loadOrSaveDb() 함수에 전달되기 전에 방금 열렸다면(따라서 완전히 비어 있다면), 여전히 SQLite "PRAGMA page_size" 명령으로 페이지 크기를 바꿀 수 있어요. loadOrSaveDb() 함수는 이 경우를 감지해, 온라인 백업 API 함수를 호출하기 전에 인메모리 데이터베이스의 페이지 크기를 데이터베이스 zFilename의 페이지 크기로 설정하려고 시도할 수 있어요.

3. 예제 2: 실행 중인 데이터베이스의 온라인 백업

/*
** Perform an online backup of database pDb to the database file named
** by zFilename. This function copies 5 database pages from pDb to
** zFilename, then unlocks pDb and sleeps for 250 ms, then repeats the
** process until the entire database is backed up.
** 
** The third argument passed to this function must be a pointer to a progress
** function. After each set of 5 pages is backed up, the progress function
** is invoked with two integer parameters: the number of pages left to
** copy, and the total number of pages in the source file. This information
** may be used, for example, to update a GUI progress bar.
**
** While this function is running, another thread may use the database pDb, or
** another process may access the underlying database file via a separate 
** connection.
**
** If the backup process is successfully completed, SQLITE_OK is returned.
** Otherwise, if an error occurs, an SQLite error code is returned.
*/
int backupDb(
  sqlite3 *pDb,               /* Database to back up */
  const char *zFilename,      /* Name of file to back up to */
  void(*xProgress)(int, int)  /* Progress function to invoke */     
){
  int rc;                     /* Function return code */
  sqlite3 *pFile;             /* Database connection opened on zFilename */
  sqlite3_backup *pBackup;    /* Backup handle used to copy data */

  /* Open the database file identified by zFilename. */
  rc = sqlite3_open(zFilename, &pFile);
  if( rc==SQLITE_OK ){

    /* Open the sqlite3_backup object used to accomplish the transfer */
    pBackup = sqlite3_backup_init(pFile, "main", pDb, "main");
    if( pBackup ){

      /* Each iteration of this loop copies 5 database pages from database
      ** pDb to the backup database. If the return value of backup_step()
      ** indicates that there are still further pages to copy, sleep for
      ** 250 ms before repeating. */
      do {
        rc = sqlite3_backup_step(pBackup, 5);
        xProgress(
            sqlite3_backup_remaining(pBackup),
            sqlite3_backup_pagecount(pBackup)
        );
        if( rc==SQLITE_OK || rc==SQLITE_BUSY || rc==SQLITE_LOCKED ){
          sqlite3_sleep(250);
        }
      } while( rc==SQLITE_OK || rc==SQLITE_BUSY || rc==SQLITE_LOCKED );

      /* Release resources allocated by backup_init(). */
      (void)sqlite3_backup_finish(pBackup);
    }
    rc = sqlite3_errcode(pFile);
  }
  
  /* Close the database connection opened on database file zFilename
  ** and return the result of this function. */
  (void)sqlite3_close(pFile);
  return rc;
}

이전 예제의 함수는 sqlite3_backup_step() 한 번 호출로 전체 소스 데이터베이스를 복사해요. 이는 작업이 진행되는 내내 소스 데이터베이스 파일에 읽기 잠금을 유지해야 해서, 다른 데이터베이스 사용자가 데이터베이스에 쓰지 못하게 해요. 또한 복사 내내 데이터베이스 pInMemory와 연관된 뮤텍스도 유지해서, 다른 스레드가 사용하지 못하게 해요. 이 섹션의 C 함수는 온라인 데이터베이스의 백업을 만들기 위해 백그라운드 스레드나 프로세스가 호출하도록 설계되었고, 다음 접근 방식을 사용해 이런 문제를 피해요:

  1. 함수 sqlite3_backup_init()을 호출해 데이터베이스 pDb에서 zFilename으로 식별된 백업 데이터베이스 파일로 데이터를 복사할 sqlite3_backup 객체를 만든다.
  2. 함수 sqlite3_backup_step()을 매개변수 5로 호출해 데이터베이스 pDb의 5페이지를 백업 데이터베이스(파일 zFilename)로 복사한다.
  3. 데이터베이스 pDb에서 복사할 페이지가 더 남아 있으면, 함수는 (sqlite3_sleep() 유틸리티로) 250밀리초 동안 잠들고 2단계로 돌아간다.
  4. 함수 sqlite3_backup_finish()을 호출해 sqlite3_backup_init()이 할당한 리소스를 정리한다.

3.1. 파일과 데이터베이스 연결 잠금

위 3단계의 250ms 잠자는 동안 데이터베이스 파일에는 읽기 잠금이 유지되지 않고, pDb와 연관된 뮤텍스도 유지되지 않아요. 이는 다른 스레드가 데이터베이스 연결 pDb를 사용하고 다른 연결이 기본 데이터베이스 파일에 쓸 수 있게 해 줘요.

이 함수가 잠자는 동안 다른 스레드나 프로세스가 소스 데이터베이스에 쓰면, SQLite는 이를 감지하고 보통 다음에 sqlite3_backup_step()이 호출될 때 백업 프로세스를 다시 시작해요. 이 규칙에는 예외가 하나 있어요: 소스 데이터베이스가 인메모리 데이터베이스가 아니고, 그 쓰기가 백업 작업과 같은 프로세스 안에서 같은 데이터베이스 핸들(pDb)을 사용해 수행된다면, 대상 데이터베이스(연결 pFile로 열린 것)는 소스와 함께 자동으로 갱신돼요. 그러면 백업 프로세스는 sqlite3_sleep() 호출이 반환된 후 아무 일도 없었던 것처럼 계속될 수 있어요.

백업 중간에 소스 데이터베이스에 대한 쓰기 때문에 백업 프로세스가 재시작되는지 여부와 관계없이, 사용자는 백업 작업이 완료될 때 백업 데이터베이스가 원본의 일관되고 최신의 스냅샷을 담고 있음을 확신할 수 있어요. 하지만:

  • 인메모리 소스 데이터베이스에 대한 쓰기, 또는 pDb가 아닌 데이터베이스 연결을 사용하는 외부 프로세스나 스레드가 파일 기반 소스 데이터베이스에 하는 쓰기는, pDb를 사용해 파일 기반 소스 데이터베이스에 하는 쓰기보다 훨씬 비싸요 (전자 두 경우에는 전체 백업 작업을 다시 시작해야 하므로).
  • 백업 프로세스가 충분히 자주 재시작되면, 결코 완료되지 않아 backupDb() 함수가 영원히 반환하지 않을 수 있어요.

3.2. backup_remaining() 과 backup_pagecount()

backupDb() 함수는 sqlite3_backup_remaining()과 sqlite3_backup_pagecount() 함수를 사용해 사용자 제공 xProgress() 콜백으로 진행 상황을 보고해요. sqlite3_backup_remaining() 함수는 복사할 남은 페이지 수를 반환하고, sqlite3_backup_pagecount()는 소스 데이터베이스(이 경우 pDb가 연 데이터베이스)의 총 페이지 수를 반환해요. 그래서 진행률은 다음과 같이 계산할 수 있어요:

Completion = 100% * (pagecount() - remaining()) / pagecount()

sqlite3_backup_remaining()과 sqlite3_backup_pagecount() API는 이전 sqlite3_backup_step() 호출이 저장한 값을 보고할 뿐, 실제로 소스 데이터베이스 파일을 검사하지 않아요. 이는 sqlite3_backup_step() 호출이 반환된 후 sqlite3_backup_remaining()과 sqlite3_backup_pagecount()가 반환한 값을 사용하기 전에 다른 스레드나 프로세스가 소스 데이터베이스에 쓰면, 그 값이 기술적으로 틀릴 수 있다는 뜻이에요. 이것은 보통 문제가 되지 않아요.

더 알아보기 (Learn more)