分享

SQLITE数据库损坏分析及解决

 slimfeng 2020-03-17

1 前言

  本文主要阐述SQLTE数据库文件在异常场景下发生损坏的原因及提供相应的解决方案。本文涉及代码部分的SQLITE库使用SQLITE_VERSION 3.20.1。

2 数据库异常操作

  SQLTE数据库在应用程序崩溃,操作系统崩溃,甚至在处理事务过程中发生电源故障等场景下具有强抗破坏性。SQLite可以抵御数据库损坏,但它并不是免疫的。本章节描述了SQLite数据库可能损坏的各种操作。

2.1 非法访问数据库文件

  SQLTE数据库文件是普通的磁盘文件。这意味着任何进程都可以打开文件并用垃圾覆盖它。SQLite库没有什么能够防范这种情况。

2.1.1 文件描述符错误

  在一些数据库文件损坏报告中,会出现文件描述符错误。其场景发生在进程拥有多个文件描述符时,关闭其中的文件描述符后,打开SQLITE数据库。但一些线程继续写入旧的文件描述符,导致SQLITE数据库被其他数据覆盖。在SQLITE源文件中可以通过设置相关参数使文件描述符拒绝使用低编号从而避免出现上述问题。

  1. /*
  2. ** Do not accept any file descriptor less than this value, in order to avoid
  3. ** opening database file using file descriptors that are commonly used for
  4. ** standard input, output, and error.
  5. */
  6. #ifndef SQLITE_MINIMUM_FILE_DESCRIPTOR
  7. # define SQLITE_MINIMUM_FILE_DESCRIPTOR 3
  8. #endif

2.1.2 数据库备份错误

  SQLITE数据库的状态由数据库文件和日志文件控制,日志文件与数据库文件具有相同的名称,并添加了-journal或-wal后缀。在静态状态下,日志文件不存在,只有数据库文件。但是在数据库事务处理过程中,会产生日志文件。
  在一些数据库文件损坏报告中,会出现数据库备份错误。其场景发生在一些后台程序在数据库事务处理过程中进行数据库文件备份副本,备份文件中可能会存在新数据和旧数据导致数据库文件损坏。
  制作SQLITE数据库的可靠备份最佳方法是使用SQLITE库中的备份API,对正在运行的数据库进行在线备份可以通过sqlite3_backup_step函数设置页数后,每250us调用一次,直至备份完成。

  1. /* pDb为已打开的数据库链接,zFilename为数据库副本,xProgress为进度条回调函数,mode==1时为备份,mode!=1时为还原 */
  2. int backupDb(sqlite3 *pDb, const char *zFilename, void(*xProgress)(int, int), int mode)
  3. {
  4. int rc = 0;
  5. sqlite3 *pFile = 0;
  6. sqlite3_backup *pBackup = 0;
  7. rc = sqlite3_open(zFilename, &pFile);
  8. if( rc==SQLITE_OK )
  9. {
  10. if(mode == 1)
  11. {
  12. pBackup = sqlite3_backup_init(pDb, "main", pFile, "main");
  13. }
  14. else
  15. {
  16. pBackup = sqlite3_backup_init(pFile, "main", pDb, "main");
  17. }
  18. if( pBackup )
  19. {
  20. do
  21. {
  22. rc = sqlite3_backup_step(pBackup, 5);
  23. /* Completion = 100% * (pagecount() - remaining()) / pagecount() */
  24. xProgress(sqlite3_backup_remaining(pBackup), sqlite3_backup_pagecount(pBackup));
  25. if( rc==SQLITE_OK || rc==SQLITE_BUSY || rc==SQLITE_LOCKED )
  26. {
  27. sqlite3_sleep(250);
  28. }
  29. }
  30. while( rc==SQLITE_OK || rc==SQLITE_BUSY || rc==SQLITE_LOCKED );
  31. (void)sqlite3_backup_finish(pBackup);
  32. }
  33. rc = sqlite3_errcode(pFile);
  34. }
  35. (void)sqlite3_close(pFile);
  36. return rc;
  37. }

2.1.3 数据库日志错误
  SQLITE通常将所有内容存储在单个磁盘文件中,但在执行事务时会将恢复数据库所需要的信息存储在日志文件中。在程序崩溃或者电源故障后,SQLITE必须查看日志文件才能从崩溃或电源故障中恢复。
在一些数据库文件损坏报告中,会出现数据库日志错误。其场景发生在程序崩溃或电源故障后,用户移动或删除或重命名日志文件,则自动恢复将不起作用,并且数据库文件可能会损坏。
  为了避免出现数据库日志错误的出现,应当加强对用户的科普,并遵守下面几点:
  1  -journal或-wal 结尾的文件是日志文件而不是垃圾文件,不能删除。
  2    如果需要移动数据库文件的位置,请将其与日志文件一起移动。
  3    日志文件具有唯一性,不同数据库文件不能共用一个日志文件。
2.2 文件锁的破坏
  SQLITE在数据库文件、日志文件上使用文件锁来协调并发进程之间的访问。当文件锁被破坏时。二个线程或进程可能会尝试同时对数据库进行不兼容的更改,从而导致数据库损坏。
2.2.1 文件系统错误
  在一些数据库文件损坏报告中,会出现文件系统错误。SQLITE依赖底层文件来进行锁定,但是一些文件系统在锁定逻辑中包含错误,如果在该类文件系统上使用SQLITE,并且存在二个或二个以上的线程或进程尝试同时访问一个数据库,则可能造成数据库损坏。
  为了避免出现文件系统错误,在network filesystems 和NFS这类文件系统上使用SQLITE应注意锁协议问题。
2.2.2 Close错误
  SQLITE在UNIX平台使用的默认锁定机制为POSIX advisory locking。在同一进程中,一个数据库文件的多个文件描述符共用一个POSIX advisory locking。
  在一些数据库文件损坏报告中,会出现Close错误。其场景发生在当二个或二个以上的线程或进程访问同一数据库文件时,建立了不同的数据库链接。此时出现第三个线程,通过open、read、close函数获取同一数据库文件内容。由于close函数会破坏POSIX advisory locking。
  但其他线程并不知道POSIX已损坏,这可能导致二个或二个以上线程或进程同时尝试写入数据库,导致数据库文件损坏。
为了避免出现Close错误,程序对SQLITE数据库文件的访问应该使用SQLITE库提供的API接口。
2.2.3 多副本错误
  在一些数据库文件损坏报告中,会出现多副本错误。其场景出现在一个应用程序链接多个数据库副本。SQLITE在解决POSIX advisory locking问题上使用了全局变量(互斥保护)。
当存在多个副本链接时,全局列表将有多个实例,则无法解决POSIX advisory locking问题。
导致数据库文件损坏。
  为了避免出现多副本错误,应用程序在同一时间应该保证只链接一个SQLITE副本。
2.2.3 锁协议错误
  SQLITE在unix平台使用默认锁定机制时POSIX advisory locking。应用程序可以通过sqlite3_open_v2函数可以选择更适合某些文件系统的sqlite3_vfs。例如NFS文件系统不支持POSIX advisory locking,则应当选择dot-file locking。
在一些数据库文件损坏报告中,会出现锁协议错误。其场景出现在一个应用程序正在POSIX advisory locking访问数据库文件,另一个应用程序正在使用dot-file locking访问同一数据库文件,那么这二个应用程序将看不到彼此的锁定而无法协调数据库访问,导致数据库文件发生损坏。
  为了避免出现锁协议错误,当数据库文件被多个应用程序共享时,应当统一锁协议。
2.2.4 跨进程错误
  在一些数据库文件损坏报告中,会出现跨进程错误。其场景出现在一个应用程序打开SQLITE数据库连接后fork。然后尝试在子进程中使用该数据库连接。这种操作将导致各种锁定问题,很容易损坏数据库文件,

  为了避免出现跨进程错误,对于多进程的应用程序,子进程使用的数据库连接必须在该子进程中建立,同时不要尝试在父进程中建立SQLITE数据库连接,因为父进程调用sqlite3_close会清理子进程的内容,导致数据库损坏。
2.3 同步失败
  ACID是数据库事务正确执行的四个基本要素,即原子性、一致性、隔离性、持久性。
SQLITE支持事务处理,则必须支持ACID才能保证数据的正确性。
  计算机在数据库运行时可分为磁盘、磁盘缓存和用户空间,而数据库的数据存储在磁盘缓存或用户空间时,一旦系统崩溃或者电源故障则这些数据将会丢失。为了保证原子性和一致性,SQLITE在事务执行过程中会不断使用fsync系统调用将数据刷新到持久存储的磁盘中。
  SQLITE确定数据刷新到磁盘才返回处理结果。因此在用户空间层面,SQLITE的事务处理具备ACID要素。但是在一些场景下,数据从用户空间往磁盘实现同步的过程中,往往会违法ACID要素。
2.3.1 磁盘驱动器错误
  在一些数据库文件损坏报告中,会出现磁盘驱动器错误。其场景出现在部分磁盘驱动器一旦达到轨道缓冲区并且在实际写入氧化物之前就会报到内容在磁盘上是安全的。一旦在实际写入氧化物之前发生断电,而SQLITE却认为已实现数据同步,则可能会发生数据库文件损坏。
  为了避免出现磁盘驱动器错误,在对ACID要求高的场景下,应该检测SQLITE所运行的操作系统和硬件是否在同步上严谨。此外COMMIT期间的同步失败可能会导致数据库文件耐久丢失,但不会损坏数据库文件。


3 数据库存储过程


  本章主要阐述了SQLITE数据存储过程中的原子提交错觉技术。事务性数据库SQLITE的一个重要特性是“原子提交”。
  原子提交意味着在一个事务中,数据库要么发生改动,要么不发生改动,不存在中间态,对数据库文件的写入是瞬间发生的。在硬件层次上数据写入单个磁盘扇区需要时间,不可能同时将数据瞬间写入到不同扇区中。但是SQLITE数据库的原子提交逻辑使得事务看起来就是及时和同时写入的。这种特性即使是SQLITE处理事务过程中因操作系统崩溃或电源故障而中断也不影响其原子性。下面将详细说明SQLITE处理事务的流程。
3.1 初始状态
  如图3.1所示,最右侧表示大容量存储设备,每一个矩形表示一个扇区,蓝色表示数据库的原始数据。中间区域是操作系统的磁盘缓存,最左侧表示使用SQLITE的应用程序内存内容。当应用程序打开SQLITE连接时,尚未读写任何信息,因此用户空间和磁盘缓存为空。

 

                          图 3.1 初始状态

3.2 获取读锁定
  应用程序在对SQLITE进行写操作之前,需要对SQLITE进行读操作,获取sqlite_master表中的数据库模式。应用程序读取数据库文件的第一步是获取数据库文件的共享锁。
  共享锁允许数据库文件被二个或二个以上的数据库连接同时读取,但是共享锁会在应用程序读取数据库文件的过程中,阻止数据库文件被另一个数据库连接写入。共享锁存在于操作系统的磁盘缓存上,如果创建锁的进程退出或者操作系统崩溃或者断电,则锁定将立即消失。
 


                         图 3.2 获取读锁定
3.3 读取数据库信息
  应用程序获取到共享锁后,便从数据库文件中读取信息。这个过程首相是将信息从磁盘读取到磁盘缓存中,然后从磁盘缓存传输到用户空间中。
 


                         图 3.3 读取数据库信息
3.4 获取保留锁
  应用程序在读数据库进行写操作之前需要获取数据库文件的保留锁。保留锁类似与共享锁,保留锁和共享锁都允许其他进程从数据库文件中进行读操作,保留锁可以和其他进程的共享锁共存。但是一个数据库文件上只能有一个保留锁,所以在同一时间,数据库文件只允许一个进程对数据库进行写操作。
 


                         图 3.4 获取保留锁
3.5 创建日志文件
应用程序在对数据库文件进行写操作之前,需要创建一个单独的日志文件,并在日志中写入要更改的数据库页面的原始内容。日志文件包含数据库恢复数据库文件原始状态所需要的信息。
如图3.5,日志文件的绿色部分表示日志文件包含一个小标题,它记录了数据库文件的原始大下,日志文件中的数据库页面原始内容将与页码一起存储。因此应用程序在对数据库文件进行写入操作导致数据库文件增长时,SQLITE仍然知道数据库原始大小。
应用程序在创建新文件时,大多数操作系统将在磁盘缓存中创建它,直到操作系统空闲时,才会在磁盘上创建该文件。
 


                        图 3.5 创建日志文件
3.6 在用户空间的写操作
  每一个数据库连接都有自己的用户空间私有副本,应用程序在用户空间中所做的更改,仅对该数据库连接可见。因此一个进程忙于修改数据库,其他进程也可以继续读取原始状态下的数据库文件。
 


                        图 3.6 在用户空间的写操作
3.7 日志文件写入磁盘
  将日志文件刷新到非易失存储的磁盘上,这是确保数据库能够承受意外断电的关键步骤。此步骤需要二个单独的flush操作。第一次flush,日志的基本内容写入磁盘。第二次flush将标头写入磁盘。
 


                        图 3.7 日志文件写入磁盘
3.8 获取独家锁
  在更改数据库文件之前,应用程序必须获取数据库的独家锁。获取独家锁的第一步是获取数据库文件的挂起锁,第二步是将挂起锁升级为独家锁。
  挂起锁允许具有共享锁的进程继续读取数据库文件,但阻止建立新的共享锁。挂起锁的作用是防止出现writer starvation问题。当其他进程完成读取数据库文件操作后,该数据库文件上不存在共享锁,则将挂起锁升级为独家锁。
 


                          图 3.8 获取独家锁

3.9 写入数据库文件
  应用程序一旦获取到独家锁,那么数据库文件则不被其他进程所读取。此刻,应用程序对数据库进行写操作是安全的。通常这个写操作,只会将更改写入到磁盘缓存中。
 


                         图 3.9 写入数据库文件
3.10 刷新到磁盘
  SQLITE需要经过一次flash操作,以确保将所有数据库的更改写入到非易失性存储磁盘中。这是确保数据库在没有损坏的情况下承受断电的关键步骤。
 

                          图 3.10 刷新到磁盘
3.11 删除日志
  SQLITE在将数据库的更改安全的写入到磁盘后,下一步便是删除日志文件。这是事务提交的瞬间,相当于COMMIT操作。
  在删除日志之前发生电源故障或者系统崩毁,则应用程序重新与数据库建立连接后,SQLITE将通过日志文件自动将数据库文件恢复回原始状态。如果在删除日志之后发生电源故障或者系统崩溃,此时事务已完成,数据库文件不会发生损坏。
  在代码层面COMMIT操作是一个原子操作。从用户进程角度看,进程通过询问操作系统此文件是否存在,返回的只有是或否二种状态,不存在中间态。如果存在日志文件,则事务不完整进行回滚,如果存在日志文件,则事务已提交。
  但是删除文件实际上不是原子操作,许多系统上删除文件的行为是需要消耗时间的。在删除文件这个操作上,SQLITE进行了优化。SQLITE将日志文件截断为零字节或者用零覆盖日志文件头。日志标题的任何部分不正确则SQLITE不会进行回滚,SQLITE默认此刻事务已提交。SQLITE假设标头的第一个字节归零是原子操作。
 


                         图 3.11 删除日志文件
3.12 释放锁定
  应用程序提交事务的最后一步是释放独占锁,以便其他进程可以再次访问数据库文件。
如图3.12释放独占锁后,SQLITE将不清除用户空间的数据,以便于数据重用。
  在重用用户空间的数据之前,应用程序将先去获取共享锁,以保证在释放独占锁之后和获取共享锁之前,数据库文件没有进行修改。数据库文件的第一页存在计数器,每次对数据库文件进行的修改,都会使计数器递增,应用程序可以通过计数器查明用户空间的数据是否可以重用。
 


                 图3.12 释放锁定

 

 

 

 

 

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多