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并不会频繁变更,只在表结构有变化时改变,因此可在升级时机覆盖备份。

参考链接