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 內置保護機制的示例:
- 設置 PRAGMA synchronous=OFF在出現作業系統崩潰或電源故障時可能導致資料庫損壞(這個設置不會因為應用程式崩潰而損壞資料庫)
- 當其他資料庫連接打開時,改變 PRAGMA schema_version
- 使用 PRAGMA journal_mode=OFF 或 PRAGMA journal_mode=MEMORY 並在寫入事務的中間應用程式崩潰。
- 設置 PRAGMA writable_schema=ON 然後使用 DML 語句更改資料庫模式可能會使資料庫完全不可讀。
資料庫異常在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
並不會頻繁變更,只在表結構有變化時改變,因此可在升級時機覆蓋備份。
參考鏈接
- How To Corrupt An SQLite Database File
- SQLite Result and Error Codes
- 微信 SQLite 資料庫修復實踐
- 微信移動端資料庫組件WCDB系列(二) — 資料庫修復三板斧