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
并不会频繁变更,只在表结构有变化时改变,因此可在升级时机覆盖备份。
参考链接
┆ 凉 ┆ 暖 ┆ 降 ┆ 等 ┆ 幸 ┆ 我 ┆ 我 ┆ 里 ┆ 将 ┆ ┆ 可 ┆ 有 ┆ 谦 ┆ 戮 ┆ 那 ┆ ┆ 大 ┆ ┆ 始 ┆ 然 ┆
┆ 薄 ┆ 一 ┆ 临 ┆ 你 ┆ 的 ┆ 还 ┆ 没 ┆ ┆ 来 ┆ ┆ 是 ┆ 来 ┆ 逊 ┆ 没 ┆ 些 ┆ ┆ 雁 ┆ ┆ 终 ┆ 而 ┆
┆ ┆ 暖 ┆ ┆ 如 ┆ 地 ┆ 站 ┆ 有 ┆ ┆ 也 ┆ ┆ 我 ┆ ┆ 的 ┆ 有 ┆ 精 ┆ ┆ 也 ┆ ┆ 没 ┆ 你 ┆
┆ ┆ 这 ┆ ┆ 试 ┆ 方 ┆ 在 ┆ 逃 ┆ ┆ 会 ┆ ┆ 在 ┆ ┆ 清 ┆ 来 ┆ 准 ┆ ┆ 没 ┆ ┆ 有 ┆ 没 ┆
┆ ┆ 生 ┆ ┆ 探 ┆ ┆ 最 ┆ 避 ┆ ┆ 在 ┆ ┆ 这 ┆ ┆ 晨 ┆ ┆ 的 ┆ ┆ 有 ┆ ┆ 来 ┆ 有 ┆
┆ ┆ 之 ┆ ┆ 般 ┆ ┆ 不 ┆ ┆ ┆ 这 ┆ ┆ 里 ┆ ┆ 没 ┆ ┆ 杀 ┆ ┆ 来 ┆ ┆ ┆ 来 ┆