SQLite數據庫損壞及其修復探究

數據庫如何發生損壞

  SQLite 數據庫具有很強的抗損壞能力。在執行事務時如果發生應用程序崩潰、操作系統崩潰甚至電源故障,那麼在下次訪問數據庫文件時,會自動回滾部分寫入的事務。恢復過程是全自動的,不需要用戶或應用程序的任何操作。儘管 SQLite 數據庫具有很強的抗損壞能力,但仍有可能發生損壞。

1. db文件被其他線程或進程破壞

  數據庫文件本身是磁盤文件的一種,因此任何進程都可以往這個文件中寫入數據。SQLite 自身對這種行為也無能為力。

1.1. 向已經關閉的文件描述符繼續寫入數據

  數據庫文件關閉後又被開啟,其他線程往舊的文件描述符寫入數據,導致覆蓋部分數據產生數據庫損壞。

延伸:不同系統對於多進程寫入同一個文件提供的處理能力。

1.2 事務處於活躍狀態下進行備份

  在後台對數據庫文件進行自動備份的時候,此時數據庫可能處於事務之中。這個備份可能包含一些臟數據(舊的或者新的處於被更改的內容)。

  實現可靠的數據庫備份方式是使用 SQLite 提供的 backup API。當前一個事務失敗時,將 journal 或 wal 日誌文件與數據庫文件一起拷貝非常重要。

1.3. 刪除Hot Journals

  SQLite通常將所有的內容存儲在單個文件中。但在執行事務時,當產生崩潰或者異常斷電,恢複數據庫的必要信息保存在了一個輔助文件中,這個文件與數據庫同名,並且添加了 journal 或者 wal 的文件後綴。當這個輔助文件被修改或者刪除,那麼數據庫就有可能崩潰。
  關於Hot Journals,官網是這麼闡述的:

當 journal 日誌或 wal 日誌文件包含恢複數據庫狀態所需的信息時,它們被稱為「熱日誌」或「熱 WAL 文件」,通常出現在應用程序或者設備在事務完成之前崩潰。熱日誌和熱 WAL 文件只是錯誤恢復場景中的一個因素,因此並不常見。但它們是 SQLite 數據庫狀態的一部分,因此不容忽視。

1.4. 數據庫文件與日誌文件不一致

   SQLite 數據庫受數據庫文件及日誌文件共同控制,當兩者受外部影響因素導致錯誤搭配時,可能導致數據庫損壞。以下這些行為則可能導致數據庫損壞:

  • 交換兩個不同數據庫的日誌文件
  • 將數據庫日誌文件複寫為其他數據庫的日誌文件
  • 將一個數據庫的日誌文件移動給其他數據庫
  • 覆蓋數據庫時卻沒有將其關聯的日誌文件一起刪除

2. 文件鎖問題

  SQLite 在數據庫文件、WAL 文件上使用文件鎖來協調並發進程之間的訪問。如果沒有加鎖機制,多個線程或進程可能會嘗試同時對數據庫文件進行不兼容的更改,從而導致數據庫損壞。

2.1 文件系統的鎖機制出問題或者未實現

  SQLite 依賴於底層文件系統對文件進行鎖處理。但是一些文件系統在其鎖邏輯中包含錯誤,因此文件加鎖並不總是如預期表現。對於網絡文件系統和 NFS 尤其如此。如果在鎖定原語包含錯誤的文件系統上使用 SQLite,並且如果多個線程或進程嘗試同時訪問同一個數據庫,則可能導致數據庫損壞。

3. 同步失敗

  為了保證數據庫文件始終保持一致,SQLite 偶爾會要求操作系統將所有掛起的寫入刷新到持久存儲,然後等待刷新完成。這是使用 unix 下的 fsync() 系統調用和 Windows 下的 FlushFileBuffers() 來完成的。我們將這種掛起的寫入刷新稱為「同步」。 實際上,如果一個人只關心原子性和一致性寫入並且願意放棄持久性寫入,那麼同步操作不需要等到內容完全存儲在持久性媒體上。相反,可以將同步操作視為 I/O 屏障。如果同步作為 I/O 屏障而不是真正的同步運行,則電源故障或系統崩潰可能會導致一個或多個先前提交的事務回滾(違反「ACID」的「持久」屬性),但數據庫至少會繼續保持一致,這是大多數人關心的。

3.1 不遵守同步請求的設備驅動器

  大多數消費級存儲設備對於寫入內容並不是嚴格同步的,當內容到達軌道緩衝區卻還未被寫入到磁盤時,設備驅動器就會反饋已經寫入磁盤,這使得設備驅動器看起來運行得更快。在大部分時候,這種行為並沒有什麼不妥。但當內容到達軌道緩衝區卻未寫入磁盤,此時發生斷電,那麼數據庫文件就可能發生損壞。相比較默認的日誌模式,WAL 日誌模式更能容忍亂序寫入。在 WAL 模式下。如果在 checkpoint 期間出現同步失敗,那麼這將是導致數據庫損壞的唯一原因。因此,防止由於同步失敗導致的數據庫損壞的一種方式是:在 WAL 日誌模式,不要頻繁的觸發 checkpoint。

3.2. 使用 PRAGMAs 禁用同步

  SQLite 確保完整性的同步操作可以在運行時使用 synchronous pragma 命令禁用。通過設置PRAGMA synchronous=OFF,所有同步操作都被省略。這使得 SQLite 看起來運行得更快,但它也允許操作系統自由地重新排序寫入,如果在所有內容到達持久存儲之前發生電源故障或硬重置,這可能會導致數據庫損壞。

4. 磁盤驅動器或者閃存故障

  磁盤驅動器或閃存故障導致文件內容而發生更改,則 SQLite 數據庫可能會損壞。雖然這種現象非常罕見,但磁盤仍可能意外翻轉扇區中的一點導致故障產生。

5. 內存損壞

  SQLite 是一個 C 庫,它與宿主應用運行在同一地址空間中。這意味着應用程序中的野指針、緩衝區溢出、堆損壞或其他故障可能會損壞 SQLite 內部的數據結構並最終導致數據庫文件損壞。通常,這些類型的問題在發生任何數據庫損壞之前都表現為段錯誤,但是在某些情況下,應用程序代碼錯誤會導致 SQLite 發生故障,從而損壞數據庫文件。
  使用內存映射 I/O 時,內存損壞問題變得更加嚴重。當數據庫文件的全部或部分映射到應用程序的地址空間時,覆蓋該映射空間的任何部分的野指針將立即損壞數據庫文件,而無需應用程序執行後續的 write() 系統調用。

6. 數據庫配置錯誤

  SQLite 具有許多針對數據庫損壞的內置保護。但是其中許多保護可以通過配置選項禁用。如果禁用保護,可能會發生數據庫損壞。 以下是禁用 SQLite 內置保護機制的示例:

數據庫異常在Android上的表現方式

  Android 基於 SQLite 提供了應用框架層的 API 供用戶使用,當操作異常時,通過特定的 Exception 提示用戶。一般有以下幾種數據庫錯誤:

數據庫文件被異常刪除

android.database.sqlite.SQLiteDatabaseCorruptException: file is not a database (Sqlite code 26 SQLITE_NOTADB): , while compiling: PRAGMA journal_mode, (OS error - 2:No such file or directory)
android.database.sqlite.SQLiteConnection.nativePrepareStatement(SQLiteConnection.java)
android.database.sqlite.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:1030)
android.database.sqlite.SQLiteConnection.executeForString(SQLiteConnection.java:773)
android.database.sqlite.SQLiteConnection.setJournalMode(SQLiteConnection.java:420)
android.database.sqlite.SQLiteConnection.setWalModeFromConfiguration(SQLiteConnection.java:334)
android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:238)
android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:211)
android.database.sqlite.SQLiteConnectionPool.openConnectionLocked(SQLiteConnectionPool.java:559)
android.database.sqlite.SQLiteConnectionPool.open(SQLiteConnectionPool.java:222)
android.database.sqlite.SQLiteConnectionPool.open(SQLiteConnectionPool.java:211)
android.database.sqlite.SQLiteDatabase.openInner(SQLiteDatabase.java:947)
android.database.sqlite.SQLiteDatabase.open(SQLiteDatabase.java:931)
android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:790)
android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:779)
android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked(SQLiteOpenHelper.java:389)
android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java:332)
android.arch.persistence.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.getWritableSupportDatabase(FrameworkSQLiteOpenHelper.java:96)
android.arch.persistence.db.framework.FrameworkSQLiteOpenHelper.getWritableDatabase(FrameworkSQLiteOpenHelper.java:54)

日誌文件問題

android.database.sqlite.SQLiteDatabaseCorruptException: file is encrypted or is not a database (code 26): , while compiling: PRAGMA journal_mode
android.database.sqlite.SQLiteConnection.nativePrepareStatement(SQLiteConnection.java)
android.database.sqlite.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:921)
android.database.sqlite.SQLiteConnection.executeForString(SQLiteConnection.java:648)
android.database.sqlite.SQLiteConnection.setJournalMode(SQLiteConnection.java:322)
android.database.sqlite.SQLiteConnection.setWalModeFromConfiguration(SQLiteConnection.java:293)
android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:217)
android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:195)
android.database.sqlite.SQLiteConnectionPool.openConnectionLocked(SQLiteConnectionPool.java:493)
android.database.sqlite.SQLiteConnectionPool.open(SQLiteConnectionPool.java:200)
android.database.sqlite.SQLiteConnectionPool.open(SQLiteConnectionPool.java:192)
android.database.sqlite.SQLiteDatabase.openInner(SQLiteDatabase.java:864)
android.database.sqlite.SQLiteDatabase.open(SQLiteDatabase.java:852)
android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:724)
android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:714)
android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked(SQLiteOpenHelper.java:295)
android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java:238)
android.arch.persistence.db.framework.FrameworkSQLiteOpenHelper$OpenHelper.getWritableSupportDatabase(FrameworkSQLiteOpenHelper.java:96)
android.arch.persistence.db.framework.FrameworkSQLiteOpenHelper.getWritableDatabase(FrameworkSQLiteOpenHelper.java:54)

存儲空間不足

android.database.sqlite.SQLiteFullException: database or disk is full (code 13 SQLITE_FULL)
android.database.sqlite.SQLiteConnection.nativeExecute(SQLiteConnection.java)
android.database.sqlite.SQLiteConnection.execute(SQLiteConnection.java:717)
android.database.sqlite.SQLiteSession.endTransactionUnchecked(SQLiteSession.java:439)
android.database.sqlite.SQLiteSession.endTransaction(SQLiteSession.java:403)
android.database.sqlite.SQLiteDatabase.endTransaction(SQLiteDatabase.java:592)
android.arch.persistence.db.framework.FrameworkSQLiteDatabase.endTransaction(FrameworkSQLiteDatabase.java:90)

android.database.sqlite.SQLiteDiskIOException: disk I/O error - SQLITE_IOERR_SHMSIZE (Sqlite code 4874): , while compiling: PRAGMA journal_mode, (OS error - 28:No space left on device)
android.database.sqlite.SQLiteConnection.nativePrepareStatement(SQLiteConnection.java)
android.database.sqlite.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:927)
android.database.sqlite.SQLiteConnection.executeForString(SQLiteConnection.java:672)
android.database.sqlite.SQLiteConnection.setJournalMode(SQLiteConnection.java:358)
android.database.sqlite.SQLiteConnection.setWalModeFromConfiguration(SQLiteConnection.java:332)
android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:231)
android.database.sqlite.SQLiteConnection.open(SQLiteConnection.java:209)
android.database.sqlite.SQLiteConnectionPool.openConnectionLocked(SQLiteConnectionPool.java:541)
android.database.sqlite.SQLiteConnectionPool.open(SQLiteConnectionPool.java:209)
android.database.sqlite.SQLiteConnectionPool.open(SQLiteConnectionPool.java:198)
android.database.sqlite.SQLiteDatabase.openInner(SQLiteDatabase.java:936)
android.database.sqlite.SQLiteDatabase.open(SQLiteDatabase.java:920)
android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:795)
android.database.sqlite.SQLiteDatabase.openDatabase(SQLiteDatabase.java:785)
android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked(SQLiteOpenHelper.java:307)
android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java:250)

損壞修復

優化應用磁盤空間佔用

  應用迭代中,每個業務團隊都有一些持久化的需求,然而大部分團隊只管文件的創建,文件使用完後沒有及時清理掉。如果不及時對各業務線文件創建進行監控和治理的話,會惡化由於空間不足導致的數據庫異常。
  除了 APP 本身對於磁盤空間的佔用外,用戶手機被其他文件佔用導致磁盤空間滿也是一大因素。因此,引導用戶釋放一定的空間也是一種方式。

備份恢復

  通過一定的手段對數據庫進行備份,同時為了減小備份的數據庫文件對於磁盤空間的佔用,進一步壓縮備份文件。這種方案能夠挽回一部分數據損失,主要取決於數據庫損壞時備份的日誌文件的時效性。

直接備份

  定期備份數據庫及日誌文件。當數據庫損壞時,恢復備份的數據庫文件。

.dump 命令

  .dump命令通過解析sqlite_master表拿到所有的表信息,然後遍歷每一張表的數據,對於每條記錄輸出一條相關的 SQLite 語句,當遇到錯誤無法解析出來則跳過繼續解析下一張表。恢復的話對空 DB 文件執行輸出的全部 SQLite 語句,這樣就能恢複數據。這種方式可以提前對沒有損壞的數據庫文件執行.dump命令,起到備份恢復的作用。

// 查詢完整的 sqlite_master 信息
SELECT * FROM sqlite_master

// 重定向到某個文件
.output sqlite_dump.txt
// dump數據庫
.dump

  .dump命令也可以直接執行於損壞的數據庫文件,當sqlite_master都無法讀取時,將導致無法恢復任何數據。

Backup API

  SQLite自身提供的一套備份機制,按 Page 為單位複製到新 DB, 支持熱備份。

RepairKit

  WCDB 提供的修復方案,實際是自實現了B+樹的解析邏輯,實現對數據的讀取,補齊了備份恢復方案有時效性的缺點。並且由於大部分case(來自WCDB的統計數據)都是因為sqlite_master表損壞導致.dump方案失效,因此增加了對sqlite_mater的備份。而由於sqlite_master並不會頻繁變更,只在表結構有變化時改變,因此可在升級時機覆蓋備份。

參考鏈接