SQLite資料庫損壞及其修復探究

zxzhang發表於2022-06-21

資料庫如何發生損壞

  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並不會頻繁變更,只在表結構有變化時改變,因此可在升級時機覆蓋備份。

參考連結

相關文章