Berkeley DB 事务支持的应用架构

本文翻译自Berkeley DB 官方文档 Architecting Transactional Data Store applications 一节,主要讲述使用BDB作为存储引擎时提供事务支持的几种进程访问模式。每个BDB环境都对应一个文件系统目录,其中包涵存储的数据和运行时提供的锁资源,互斥变量资源,共享缓冲池等。每个目录可以被多个BDB进程打开访问,但使用复杂度有较大区别。需要说明的是,每一个目录在打开它的BDB进程中被称作一个数据库环境,一个目录可以对应多个环境,一个环境只能对应打开一个目录。几种架构模式分别为:

  • 一个进程
  • 多个相关的进程
  • 多个不相关的进程

正文:

当构建支持事务的应用程序,在架构方面就需要考虑应用程序的启动(是否需要执行recover动作),还要考虑如何处理系统或应用程序的错误。关于如何执行recover,请参看 Recovery procedures 一节。

数据库环境恢复(Recovery)是一个单控制线程的处理过程,也就是说,一个线程必须在其它任何线程访问数据库环境之前完成恢复动作。(注:几个线程打开的是相同的目录)

执行恢复首先会标记已存在的数据库环境为“失败”,然后删除它,这会导致任何正在访问这个环境的的控制线程失败,并返回到应用程序处理(注:任何调用BDB函数访问数据库的操作都会失败,如果应用程序没有处理失败的代码,整个进程都会退出)。这个特点使应用程序进行恢复时不用考虑还可能有其它的线程在访问已经被删除的环境。重新创建数据库环境的顺序是序列化的,因此多线程/进程试图创建数据库环境将会序列化的跟在一个创建线程之后。

另一个方面,当删除(当做是恢复的其中一步)一个数据库环境时,需要考虑其它正在访问环境的线程使用的mutex变量类型。假如当数据库环境失败的时候,正在使用test-and-set mutexs 类型互斥变量,其它正在等待互斥变量的控制线程就会在环境被标记为“失败”的时候立刻检测到,然后在 BDB API 调用中返回一个错。如果环境失败的时候使用 blocking mutexs 互斥变量,并且底层系统实现互斥变量并不会因为占有互斥变量的控制线程死亡而解锁 mutex waiter,那么当环境恢复的时候等待mutex的线程将会永远阻塞。如果应用程序阻塞在其它事件上(比如等待一个socket 网络连接或者一个 GUI 事件),可能一段时间内都无法检测环境的恢复(注:因为此时并没有调用任何BDB的API)。这种mutex实现的操作系统并不多,但是存在的;构建在这种操作系统的应用程序在架构的时候,需要执行recovery 的线程能够显示的中断正在使用相同环境的的进程,或者配置BDB使用 test-and-set mutexs,或者实现一些超时控制,看门狗进程用来唤醒或杀死一直在阻塞的线程。

即便如此,对于多个控制线程试图同时恢复一个数据库环境还是没有多大意义,因为最后一个执行的还是会删除所有之前控制线程创建的环境。然而,对于一些应用程序,首先在启动多个进程, 使用一个控制线程来执行recovery,然后其它进程打开环境继续执行,这样做是有意义的。

有三种常规的方式去构建一个BDB事务支持的应用程序。选择哪种主要基于应用程序是由单个进程组成或由多个相关的进程组成(例如,当系统启动时打开一个server),还是由多个不相关的进程组成(例如,由网络连接或用户打开的进程,记录到系统当中)。

1 单进程

第一种构建事务支持的应用程序方式是单进程模式。(进程可以是多线程的,也可以是单线程的)。

当进程启动的时候,对环境执行recovery然后打开数据库环境。应用程序可以选择性地接着创建多个线程。这些线程可以共享已经打开的 DB_ENV DB 句柄,也可以创建它们自己的(注:一般情况下一个进程只会打开一个DB_ENV句柄)。在这种架构下,当有多个线程运行的时候,数据库很少被打开或关闭,数据库在只有一个线程运行的时候打开,在只剩一个线程的时候退出。最后一个线程负责关闭数据库以及数据库环境。

这种架构是实现起来最简单的一种,因为线程的序列化开启很容易,错误检测也不需要监控多个进程。

如果应用程序的线程模型允许进程在线程失败后继续执行,函数 DB_ENV->failchk() 可以用来判断数据库环境在线程失败后是否可用。如果应用程序不执行 DB_ENV->failchk() ,或者 DB_ENV->failchk()  返回 DB_RUNRECOVERY ,那么程序必须当做系统失败来处理,执行 recovery 并且重建数据库环境。一旦这些操作完成,其它线程就可以继续执行(只要所有存在的BDB 句柄都已经丢弃)。(注:句柄重新创建即可)

 2 多个相关进程

第二种构建事务支持的应用程序方式是使用一组相关进程。(这些进程可以是单线程的,也可以是多线程的)

这种架构需要根据控制线程被创建的顺序,来保证顺序的执行数据库环境恢复工作。

除此之外,这种架构需要监控控制线程的行为。如果任何一个线程退出时有未关闭的 BDB 句柄,应用程序就需要调用 DB_ENV->failchk()  去检测是否有丢失的 mutexes 和 locks 资源,以此决定应用程序是否能够继续执行。如果应用程序不调用 DB_ENV->failchk()  或者 DB_ENV->failchk()  返回结果表示数据库不可以继续使用,应用程序就当做发生系统错误来处理,执行 recovery 并创建一个新的数据库环境。一旦这些操作完成,其它控制线程就可以继续执行(只要恢复之前的BDB句柄被丢弃了)(注:重新创建了新的环境,其它句柄就要根据这个环境重新创建,之前的要丢弃,因为执行恢复的时候会删除)。

构建一组相关进程最简单的方式是首先创建一个 “监控” 进程(通常是一个脚本),监控进程在整个系统启动的时候最先启动,执行recovery ,然后创建其它真正负责工作的进程。监控进程接下来只需要等待其它控制线程启动,确保它们不会意外退出。如果其中一个控制线程意外退出,监控进程可以选择性的调用 DB_ENV->failchk()  。如果应用程序不调用 DB_ENV->failchk()  或者 DB_ENV->failchk()  返回结果表示数据库不可以继续使用,监控进程负责使用此环境的所有控制线程,执行recovery,并且开启新的控制线程来执行工作。(注:控制线程可以是一个进程,也可以是进程中的一个线程,表示使用BDB句柄访问数据库的一个执行流)。

 3 多个无关进程

第三种方式就是使用多个无关的进程来架构(进程可以是多线程的也可以是单线程的)。这是实现起来最困难的一种架构方式,难度主要在于在一些系统上不容易寻找或监控不相关的进程。有许多技术去实现这种架构。

其中一个解决方式是当打开BDB 句柄的时候记录控制线程的ID。举个例子,监控进程负责执行 recovery ,执行完毕后立刻创建一个哨兵文件(注:这很类似PG的锁文件postmaster.pid)。其它任何想要使用这个数据库环境的工作进程都需要检测哨兵文件。如果哨兵文件不存在,工作进程可以选择等待或者退出。一旦检测到哨兵文件存在,就将自己的进程ID注册到哨兵文件里面(通过共享内存,IPC或者其他注册机制),然后工作进程尽可以打开它的 DB_ENV  句柄并执行。当工作进程使用完毕数据库环境,它需要注销自己(在哨兵文件中删除自己的进程ID)。监控进程需要不断的检查以确保在使用数据库环境的过程中没有失败。如果工作进程在使用数据库环境过程中失败,监控进程就删除哨兵文件,同时杀死所有正在使用这个环境的工作进程,执行recovery ,然后重新创建哨兵文件。

这种实现方式也有缺点,在某些系统上,很难判断不相关的进程是否仍然在运行。比如,POSIX 系统通常不允许给给不相关的进程发送信号。可以使用一些小手段,如使用一些在进程退出时状态会改变的系统资源,来实现对不相关进程的监控。在 POSIX 系统上,flock 或者 fcntl-style lock 都可以实现,就像Windows systems 上的LockFile 。其它系统可能会使用如文件引用计数,改变次数等进程相关的资源。在最糟糕的情况下,控制线程可能需要每隔一段时间就重新注册:如果监控进程在特定的时间间隔内没有收到工作进程的重新注册消息,就需要采取行动,恢复环境。

BDB库含有一种内建的实现方式,在调用 DB_ENV->open() 的时候使用 DB_REGISTER 标记:

如果使用了 DB_REGISTER 标记,每一个试图打开数据库环境的进程首先会检查是否需要执行recovery。如果因为任何原因需要执行(包括第一次创建初始化环境),并且使用了 DB_RECOVER 标记,那么将会执行 recovery 操作,接着正常开打数据库环境。如果如需要执行 recovery。但是没有指定 DB_RECOVER 标记,将会返回DB_RUNRECOVERY 错误。如果不需要执行recoveryDB_RECOVER 标记将会被忽略。

在真正的 recovery 操作执行之前,DB_EVENT_REG_PANIC 事件将会设置在数据库环境中。在进程中使用了 DB_ENV->set_event_notify() 方法的应用程序将会在下一次访问数据库环境的操作之前被更新(注:执行方法中设置的回调函数)。接收到这个事件的进程应该退出数据库环境。同样的,如果有其它进程加入到当前环境中,DB_EVENT_REG_ALIVE 事件将会被触发。只有正在执行 recovery 操作的进程才会收到事件通知。这些进程会代表其它正在访问当前环境的进程接收一次此事件。 DB_ENV->set_event_notify() 方法的回调函数参数中包含了正在访问当前环境的所有进程描述符。这样,执行 recovery 操作的进程可以给访问环境的其它进程发送信号,或者在 recovery 操作之前执行一些其它的操作(比如,杀死其它进程)。

DB_ENV->set_timeout() 方法的 DB_SET_REG_TIMEOUT 标记设置后,将会在执行 recovery 操作之前等待一段时间。这就为其它进程接收到 DB_EVENT_REG_PANIC 事件并退出环境提供了一个时间窗口。

接下来包含使用 DB_REGISTER 架构的三种额外需求:

  • 首先,所有使用同一个环境的应用程序都需要在打开数据库环境的时候使用 DB_REGISTER 标记,然而,如果应用程序仅仅选择使用一个进程来执行 recovery 操作,就不需要对其它进程使用 DB_REGISTER 标记,因为第一个打开数据库的进程将会执行recovery
  • 然后,每一个进程都只能拥有一个 DB_ENV  句柄,因为 DB_REGISTER 锁是进程私有的,而不是线程私有的,一个环境下的多个 DB_ENV  句柄将会彼此竞争,存在数据损坏的潜在因素。
  • 第三点,DB_REGISTER 在实现中并不会显示的终结正在恢复过程中的进程。而是需要靠进程自己注意到数据库环境已经默默的被丢弃了。根据这个原因,DB_REGISTER 标记应该在 mutex 变量不会在操作系统层面阻塞的环境中使用,否则就会存在一个控制线程等待一个永远无法获得的 mutex 变量而永远阻塞的风险。使用任何 test-and-set mutex 变量实现将确保这种情况不会发生,基于这个原因,DB_REGISTER 标记通常与 test-and-set mutext 实现一起使用。

(注:项目中使用 DB_REGISTER  的方式,除了上面说的缺点,还有可能发生另一种隐含的错误。使用 DB_REGISTER 标记会在数据库环境目录下创建文件__db.register,里面记录每一个打开环境的控制线程标记,也就线程号或者进程号,退出的时候会在其中注销掉自己的ID,这样表示安全使用环境并正常的关闭,不需要执行recovery 操作,如果异常退出没有注销,BDB会按照 ID 去判断对应的进程/线程是否存在,如果不存在则说明异常退出,需要执行recovery 操作。如果遇到这种情况:进程首先打开数据库环境并注册自己,接着因为应用程序需要,整个进程变成守护进程去后台执行,守护进程需要继承父进程的BDB资源,不能够退出并释放。但此时进程的ID已经变了,注册的是父进程的ID,父进程已经退出。正在运行的是子进程。此时BDB根据__db.register 文件判断进程异常退出了,执行recovery 操作,导致守护进程获得 DB_RUNRECOVERY 错误并退出。这种情况下需要控制应用程序的行为。)

 

对于多个无关进程的第二种解决方式也是基于“观察进程”的。这种实现方式适用于对共享环境的进程监控要求不是那么苛刻的情况下,但是仍然需要监控是否有一个拥有 BDB 句柄的控制线程执行失败。可以这样实现:使用一个“观察”进程每隔一段时间调用 DB_ENV->failchk() 。如果 DB_ENV->failchk()  返回表示环境不能继续被使用,“观察”进程就需要采取行动,恢复环境。

这种方式的缺点就是所有的控制线程必须使用方法 DB_ENV->set_thread_id() 来指定一个获得ID的方法和判断仍然在运行的方法(换句话说,BDB 库必须对每一个控制线程指定唯一的 ID ,除此之外还要能够判断控制线程是否仍然在运行。对于使用不同语言实现的,或运行在不同平台上的应用程序,想要提供这些消息还是有难度的)。

 

对于多个无关进程的第三种解决方式是上面两种方式的结合。使用 DB_ENV->open() 方法同时指定 DB_REGISTER 和 DB_FAILCHK 标记。当同时指定这两种标记的时候,每一个打开数据库环境的进程首先检查是否需要执行 recovery 。如果因为任何原因需要执行,首先判断是否有控制线程在退出时拥有数据库读锁,如果有,就释放它们。接着将会 abort 任何没有完成的事务。如果这几个步骤都成功的完成了,进程打开数据库环境的操作将会继续执行,不需要额外的恢复操作。如果这几步失败了,并且指定了DB_RECOVER 标记,额外的 recovery 操作就会被执行,如果没有指定 DB_RECOVER 标记,DB_RUNRECOVERY 错误就会被返回。

因为这种方式是前面两种方式的结合,前面两种方式所有的需求都必须实现(将会需要获得 ID 的方法,判断是否正在运行的方法,每个数据库唯一一个 DB_ENV  句柄等等)。

 

上面描述的三种方法是不同的,而且不应该被结合起来使用。应用程序应该使用 DB_REGISTER 方式,或者 DB_ENV->failchk() 方式,或者两种方式结合的实现方法,但是不应该在一个应用程序中使用超过其中一种。比如,一个 POSIX 的应用程序,在实现中使用了大量多种多样的接口和不同的 API,可能会使用 DB_REGISTER 的实现方式,有以下几个原因:1 不需要间歇性的调用函数 DB_ENV->failchk()  ;2 当使用多种语言实现的时候,每个控制线程获得唯一的 ID 会更加困难;3 判断一个控制线程是否仍然在运行更加困难,任何特殊的控制线程(注:指执行recovery操作的控制线程)可能没有足够的权利向其它进程发送信号。作为另一种选择,拥有专门的“监控”进程的应用程序,同时拥有适当的权力,为了支持更高的吞吐量和更好的可靠性,可能选择 DB_ENV->failchk()  方式,因为这种方式允许应用程序 abort 没有决定的事务并且不一定需要 recovery 就可以继续运行。第三种方式适用于使用“监控”进程并不是特别实用,但是在调用 DB_ENV->open() 之前执行 DB_ENV->failchk()  非常重要的情况之下。

显而易见,当使用一个独立的进程去监控其它控制线程,这个进程的需要尽可能的简单并且可调试,应为如果这个进程失败,整个应用程序都会挂起。

About zhangxiaojian