20世纪80年代早期,UNIX系统被认为不适合运行多用户数据库系统(见Stonebraker[1981]和Weinberger[1982])。早期的系统(如V7),因为没有提供任何形式的IPC机制(除了半双工管道),也没有提供任何形式的字节范围锁机制,所以确实不适合运行多用户数据库系统。但是,这些缺陷中的大多数都已得到纠正。到20世纪了80年代后期,UNIX系统已为运行可靠的、多用户的数据库系统提供了一个适合的环境。自那时以来,很多商业公司都已提供这种数据库系统。
本章将开发一个简单的、多用户数据库的C函数库。调用此函数库提供的C语言函数,其他程序可以获取和存储数据库中的记录。(这类数据库通常被称为键-值存储。)这 个 C 函数库只是一个完整的数据库系统的一部分,我们并不开发其他部分(如查询语言等),关于其他部分可以参阅专门介绍数据库的教科书。我们感兴趣的是数据库函数库与UNIX的接口,以及这些接口与前面各章节所涉及主题的关系(如14.3节的字节范围锁)。
dbm(3)是一个在UNIX系统中很流行的数据库函数库,它由Ken Thompson开发,使用了动态散列结构。最初,它与V7一起提供,并出现在所有BSD版本中,也包含在SVR4的BSD兼容函数库中[AT&T 1990c]。BSD的开发者扩充了dbm函数库,并将它称为ndbm。ndbm函数库包括在BSD和SVR4中。ndbm函数是Single UNIX Specification的XSI扩展标准的一部分。
Seltzer和Yigit[1991]中详细介绍了dbm函数库使用的动态散列算法的历史,以及这个库的其他实现方法,如dbm函数库的GNU版本gdbm。但是,这些实现的一个根本限制是它们都不支持多个进程对数据库的并发更新。它们都没有提供并发控制(如记录锁机制)。
4.4BSD提供了一个新的库——db(3),该库支持3种不同的访问模式:面向记录、散列和B树。同样,db也没有提供并发控制(这一点在db(3)手册页的BUGS部分说得很清楚)。
Oracle(http://www.oracle.com)提供了几个版本的 db 函数库,它们支持并发访问、锁机制和事务。
大部分商用数据库函数库提供多进程同时更新数据库所需要的并发控制。这些系统一般都使用14.3节中介绍的建议记录锁机制,但是,它们也常常实现自己的锁原语,以避免为获得一把无竞争锁而需的系统调用开销。这些商用系统通常用B+树[Comer 1979]或某种动态散列技术,如线性散列[Litwin 1980]或者可扩展的散列[Fagin et al. 1979]来实现数据库。
图20-1列出了本书说明的4种操作系统常用的数据库函数库。注意在Linux上,gdbm库既支持dbm函数库,又支持ndbm函数库。
图20-1 多种平台支持的数据库函数库
本章开发的函数库类似于ndbm函数库,但增加了并发控制机制,从而允许多进程同时更新同一数据库。本节将首先描述数据库函数库的C语言接口,下一节再讨论其实现。
当打开一个数据库时,通过返回值得到一个代表数据库的句柄(一个不透明指针)。将用此句柄作为参数来调用其他数据库函数。
#include "apue_db.h"
DBHANDLE db_open(const char *pathname, int oflag, ... /* int mode */);
返回值:若成功,返回数据库句柄;若失败,返回NULL
void db_close(DBHANDLE db);
如果db_open成功返回,则将建立两个文件:pathname.idx和pathname.dat,pathname.idx是索引文件,pathname.dat是数据文件。参数oflag作为传递给open(见3.3节)的第二个参数,来指定这些文件的打开模式(只读、读/写或如果文件不存在则创建等)。如果需要建立新的数据库,mode将作为第三个参数传递给open(文件访问权限)。
当不再使用数据库时,调用db_close来关闭数据库。db_close将关闭索引文件和数据文件,并释放数据库使用过程中分配到的所有用于内部缓冲区的存储空间。
当向数据库中存入一条新的记录时,必须提供一个此记录的键,以及与此键相关联的数据。如果此数据库存储的是人事信息,键可以是员工ID,数据可以是此员工的姓名、地址、电话号码以及受聘日期等。实现要求每条记录的键必须是唯一的(例如,不会有两个员工记录有同样的员工ID)。
#include "apue_db.h"
int db_store(DBHANDLE db, const char *key, const char *data, int flag);
返回值:若成功,返回0;若出错,返回非0值(见下)
key和data是由null字符终止的字符串。它们可以包含除了null字符外的任何字符,如换行符。
flag参数只能是DB_INSERT(插入一条新记录)、DB_REPLACE(替换一条已有的记录)或DB_STORE(插入一条新记录或替換一条已有的记录,只要合适无论哪一种都可以)。这3个常数定义在apue_db.h 头文件中。如果使用 DB_INSERT 或DB_STORE,并且记录并不存在,则插入一条新记录。如果使用DB_REPLACE或DB_STORE,并且该记录已经存在,则用新记录替换已有记录。如果使用DB_REPLACE,而记录不存在,则将errno设置为ENOENT,返回值为−1,并且不加入新记录。如果使用 DB_INSERT,而记录已经存在,则不插入新记录,返回值为 1。在这里,返回1以区别于一般的出错返回(−1)。
通过指定键key可以从数据库中获取一条记录。
#include "apue_db.h"
char *db_fetch(DBHANDLE db, const char *key);
返回值:若成功,返回指向数据的指针;若没有找到记录,返回NULL
如果找到了记录,返回指向通过key存放的数据的指针。通过指定key,也可以在数据库中删除一条记录。
#include "apue_db.h"
int db_delete(DBHANDLE db, const char *key);
返回值:若成功,返回0;若没有找到记录,返回−1
除了通过指定key获取记录外,还可以逐条记录地访问数据库。为此,首先调用db_rewind回滚到数据库的第一条记录,然后在每一次循环中调用db_nextrec,顺序地读每条记录。
#include "apue_db.h"
void db_rewind(DBHANDLE db);
char *db_nextrec(DBHANDLE db, char *key);
返回值:若成功,返回指向数据的指针;若到达数据库文件的尾端,返回NULL
如果key是非空指针,db_nextrec将这个指针复制到存储区域开始的内存位置,然后返回这个指针。
db_nextrec不保证其返回记录的顺序,只保证对数据库中的每一条记录只读取一次。如果顺序存储3条键分别为A、B、C的记录,则无法确定db_nextrec将按什么顺序返回这3条记录。它可能按B、A、C的顺序返回,也可能按其他顺序。实际的顺序由数据库的实现决定。
这7个函数提供了数据库函数库的接口。接下来介绍实现。
访问数据库的函数库通常使用两个文件来存储信息:一个索引文件和一个数据文件。索引文件包括实际的索引值(键)和一个指向数据文件中对应数据记录的指针。有许多技术可用来组织索引文件以提高按键查询的速度和效率,散列表和 B+树是两种常用的技术。我们采用固定大小的散列表来组织索引文件结构,并采用链表法解决散列冲突。在介绍 db_open 时,曾提到将创建两个文件:一个以.idx为后缀的索引文件和一个以.dat为后缀的数据文件。
我们将键和索引以null结尾的字符串形式存储,它们不能包含任意的二进制数据。有些数据库系统用二进制形式存储数值数据(如用1个、2个或4个字节存储一个整数)以节省存储空间,这样一来使函数复杂化,也使数据库文件在不同的平台间移植比较困难。例如,网络上有两个系统使用不同的二进制格式存储整数,如果想要这两个系统都能够访问数据库,就必须解决不同存储格式的问题(今天不同体系结构的系统在网络上共享文件已经很常见了)。按照字符串形式存储所有的记录,包括键和数据,能使这一切变得简单。这确实需要使用更多的磁盘空间,但降低了获得可移植性需要付出的代价。
db_store要求对于每个键,只有一条对应的记录。有些数据库系统允许多条记录使用同样的键,并提供方法访问与一个键相关的所有记录。另外,我们只有一个索引文件,这意味着每个数据记录只能有一个键(我们不支持次键)。有些数据库允许一条记录拥有多个键,并且对每一个键使用一个索引文件。当插入或删除一条记录时,要对所有的索引文件进行相应的修改。(一个拥有多个索引的例子是员工库文件。可以将员工 ID 作为键,也可以将员工的社会保险号作为键。由于员工的名字并不保证唯一,所以名字不能作为键。)
图20-2是数据库实现的基本结构。
图20-2 索引文件和数据文件结构
索引文件由3部分组成:空闲链表指针、散列表和索引记录。图20-2中,所有指针字段中实际存储的是ASCII码数字形式的文件偏移量。
当给定一个键,要在数据库中寻找一条记录时,db_fetch根据该键计算散列值,由此散列值可确定一条散列链(链表指针字段可以为0,表示一条空的散列链)。沿着这条散列链,可以找到所有具有这一散列值的索引记录。当遇到一个索引记录的链表指针字段为0时,表示到达了此散列链的末尾。
下面来看一个实际的数据库文件。图20-3所示的程序建立了一个新的数据库,并且写入了3条记录。由于所有的字段都以ASCII字符的形式存储在数据库中,所以可以用任何标准的UNIX系统工具来查看索引文件和数据文件:
$ ls -l db4.*
-rw-r--r-- 1 sar 28 Oct 19 21:33 db4.dat
-rw-r--r-- 1 sar 72 Oct 19 21:33 db4.idx
$ cat db4.idx
0 53 35 0
0 10Alpha:0:6
0 10beta:6:14
17 11gamma:20:8
$ cat db4.dat
data1
Data for beta
record3
为了使这个例子紧凑,将每个指针字段的大小设置为4个ASCII字符,将散列链的数量设置为3条。由于每一个指针中记录的是一个文件偏移量,所以4个ASCII字符限制了一个索引文件或数据文件的大小最多只能为10 000字节。当在20.9节做性能测试时,将指针字段的大小设为6个字符(这样文件大小可以达到1 000 000字节),将散列链数量设为100。
图20-3 建立一个数据库并写入3条记录
索引文件的第一行为:
0 53 35 0
分别为空闲链表指针(0表示空闲链表为空)和3个散列链的指针:53、35和0。下一行:
0 10Alpha:0:6
显示了一条索引记录的结构。第一个 4 字符字段(0)为链表指针,表示这一条记录是此散列链的最后一条。下一个4字符字段(10)为idx len(索引记录长度),表示此索引记录剩余部分的长度。用两个read操作来读取一条索引记录:第一个read读取这两个固定长度的字段(链表指针和索引记录长度),然后再根据索引记录长度来读取后面的不定长部分。剩下的3个字段为:键、数据记录的偏移量和数据记录的长度。这 3 个字段用分隔符隔开,此处使用的分隔符是冒号。由于这 3个字段都是不定长的,所以需要一个专门的分隔符,而且这个分隔符不能出现在键中。最后用一个\n(换行符)结束这一条索引记录。由于在索引记录长度字段中已经有了记录的长度,所以这个换行符并不是必需的,加上换行符是为了把各条索引记录分开,这样就可以用标准的UNIX系统工具(如cat和more)来查看索引文件。键字段是将记录写入数据库时指定的值。数据记录在数据文件中的偏移量为0,长度为6。从数据文件中可看到数据记录确实从0开始,长度为6个字节。(与索引文件一样,这里自动在每条数据记录的后面追加一个换行符,以便于使用UNIX系统工具。在调用db_fetch时,此换行符不作为数据返回。)
如果在这个例子中跟踪 3 条散列链,可以看到第一条散列链上第一条记录的偏移量是 53 (gamma)。这条链上下一条记录的偏移量为 17(alpha),并且是这条链上的最后一条记录。第二条散列链上的第一条记录的偏移量是35(beta),且是此链上最后一条记录。第三条散列链为空。
请注意,索引文件中键的顺序和数据文件中对应数据记录的顺序与图 20-3 程序中调用 db_store的顺序一样。由于在调用db_open时使用了O_TRUNC标志,索引文件和数据文件都被截断了,整个数据库相当于重新初始化。在这种情况下,db_store将新的索引记录和数据记录追加到对应的文件末尾。后面将看到,db_store还可以重复使用这两个文件中已删除记录原来对应的空间。
使用固定大小的散列表作为索引是一个妥协。当每个散列链都不太长时,这个方法能保证快速地访问。我们的目的是能够快速地查找任一键,同时又不使用太复杂的数据结构(如B树或动态散列表)。动态散列表的优点是能保证仅用两次磁盘存取就能找到数据记录(详见Litwin[1980]或Fagin等[1979])。B树能够用(已排序的)键的顺序来遍历数据库(采用散列表的db_nextrec函数就做不到这一点)。
当有多个进程访问同一数据库时,有两种方法可实现库函数。
(1)集中式。由一个进程作为数据库管理者,所有的数据库访问工作由此进程完成。其他进程通过IPC机制与此中心进程进行联系。
(2)非集中式。每个库函数使用要求的并发控制(加锁),然后发起自己的I/O函数调用。
使用这两种技术的数据库系统都有。如果有适当的加锁例程,因为避免了使用 IPC,那么非集中式方法一般要快一些。图20-4描绘了集中式方法的操作。
图中特意表示出IPC像绝大多数UNIX系统的消息传递一样需要经过操作系统内核(15.9节中说明的共享存储不需要这种经过内核的复制)。在集中方式下,中心控制进程将记录读出,然后通过IPC机制将数据传递给请求进程。这是这种设计的不足之处。注意,集中式数据库管理进程是唯一对数据库文件进行I/O操作的进程。
集中式的优点是能够根据需要来对操作模式进行调整。例如,可以通过中心进程给不同的进程赋予不同的优先级,这会影响到中心进程对I/O操作的调度。而用非集中式方法则很难做到这一点。在这种情况下,只能依赖于操作系统内核的磁盘I/O调度策略和加锁策略(例如,当3个进程同时等待一个即将可用的锁时,我们无法确定哪个进程将得到这个锁)。
集中式方法的另一个优点是,恢复要比非集中式方法容易。在集中式方法中,所有状态信息都集中存放在一处,所以如若杀死了数据库进程,只需在该处查看以识别出需要解决的未完成事务,然后将数据库恢复到一致状态。
图20-4 集中式数据库访问
图20-5描绘了非集中式方法,本章的实现就是采用这种方法。
图20-5 非集中式数据库访问
调用数据库库函数执行I/O的用户进程是合作进程,它们使用字节范围记录锁机制来实现并发控制。
由于很多系统的实现都采用两个文件(一个索引文件和一个数据文件)的方法,所以在此也使用这种方法,这要求能够控制对两个文件的加锁。有很多方法可用来对两个文件进行加锁。
1.粗粒度锁
最简单的加锁方法是将这两个文件中的一个作为整个数据库的锁,并要求调用者在对数据库进行操作前必须获得这个锁。这种加锁方式称为粗粒度锁(coarse-grained locking)。例如,可以认为一个进程对索引文件的0字节加了读锁后,才能读整个数据库;一个进程对索引文件的0字节加了写锁后,就能写整个数据库。可以使用UNIX系统的字节范围锁机制来控制每次可以有多个读进程,而只能有一个写进程(见图14-3)。db_fetch和db_nextrec函数要求具有读锁,而db_delete、db_store和db_open则要求具有写锁。(db_open要求写锁的原因是如果要创建新文件的话,要在索引文件前端建立空闲区链表以及散列链表。)
粗粒度锁的问题是它限制了并发。用粗粒度锁时,当一个进程向一条散列链中添加一条记录时,其他进程无法访问另一条散列链上的记录。
2.细粒度锁
细粒度锁(fine-grained locking)的方法改进了粗粒度锁,提供了更高的并发性。一个读进程或写进程在操作一条记录前必须先获得此记录所在散列链的读锁或写锁。一条散列链允许同时有多个读进程,但只能有一个写进程。其次,一个写进程在访问空闲区链表(如 db_delete 或db_store)前,必须获得空闲区链表的写锁。最后,当db_store向索引文件或数据文件末尾追加一条新记录时,必须获得对应文件相应区域的写锁。
期望细粒度锁能比粗粒度锁能提供更高的并发性。20.9 节将给出一些实际的比较测试结果。20.8 节给出了细粒度锁实现的源代码,并讨论锁的实现细节(粗粒度锁是这个细粒度锁实现的简化)。
在源代码中,直接调用了read、readv、write和writev。没有使用标准I/O函数库。虽然使用标准I/O函数库也可以使用字节范围锁,但是需要非常复杂的缓冲管理。例如,标准I/O缓冲区的数据在5分钟之前被另一个进程修改了,那么我们就不希望fgets返回的数据是10分钟之前读入标准I/O缓冲区的数据。
以上对并发的讨论依据的是对数据库函数库的简单需求。商业系统一般有更多的需要。关于并发更多的细节可以参见Data[2004]的第16章。
数据库的函数库由两个文件构成,一个公用的C头文件以及一个C源文件。我们可以用下列命令构造一个静态函数库。
gcc -I../include -Wall -c db.c
ar rsv libapue_db.a db.o
因为我们在数据库函数库中使用了一些我们自己的公共函数,所以希望与libapue_db.a相连接的应用程序也需要与libapue.a相连接。
另一方面,如果想构建数据库函数库的动态共享库版本,可使用下列命令:
gcc -I../include -Wall -fPIC -c db.c
gcc -shared -Wl,-soname,libapue_db.so.1 -o libapue_db.so.1 \
-L../lib -lapue -lc db.o
构建成的共享库 libapue_db.so.1 需放置在动态连接程序/载入程序(dynamic linker/loader)能够找到的一个公用目录中。还可以将共享库放置在一个私有目录中,修改LD_LIBRARY_PATH 环境变量,使动态连接程序/载入程序的搜索路径包含该私有目录。
在不同平台间,构建共享库的步骤会有所不同。这里说明的步骤是在带GNU C编译器的Linux系统中进行的。
本节解释我们编写的数据库函数库源代码,先从头文件apue_db.h开始。函数库源代码以及调用此函数库的所有应用程序都包含这一头文件。
从此处开始,实例程序的编排方式在很多方面与前面的实例程序编排有所不同。首先,因为源代码较长,为此加了行号,这使得通过行号联系相应的源代码进行讨论更加方便。其次,对源代码的说明紧随相关源代码之后。
这种风格受到John Lions解释UNIX V6操作系统源代码的书[Lions 1977, 1996]的影响,这使得解释说明大量源代码更为简易。
注意,此处对空白行不编号。虽然某些工具(如 pr(1))的正常操作与这些空白行是有关的,但是我们对它们并无任何兴趣。
1 #ifndef _APUE_DB_H
2 #define _APUE_DB_H
3 typedef void * DBHANDLE;
4 DBHANDLE db_open(const char *, int, ...);
5 void db_close(DBHANDLE);
6 char *db_fetch(DBHANDLE, const char *);
7 int db_store(DBHANDLE, const char *, const char *, int);
8 int db_delete(DBHANDLE, const char *);
9 void db_rewind(DBHANDLE);
10 char *db_nextrec(DBHANDLE, char *);
11 /*
12 * Flags for db_store().13 */
14 #define DB_INSERT 1 /* insert new record only */
15 #define DB_REPLACE 2 /* replace existing record */
16 #define DB_STORE 3 /* replace or insert */
17 /*
18 * Implementation limits.
19 */
20 #define IDXLEN_MIN 6 /* key, sep, start, sep, length, \n */
21 #define IDXLEN_MAX 1024 /* arbitrary */
22 #define DATLEN_MIN 2 /* data byte, newline */
23 #define DATLEN_MAX 1024 /* arbitrary */
24 #endif /* _APUE_DB_H */
[1~3] 使用符号_APUE_DB_H以保证只包括该头文件一次。DBHANDLE类型表示对数据库的一个有效引用,用于隔离应用程序和数据库的实现细节。将此技术与标准I/O库向应用程序提供FILE结构相比较,两者相似。
[4~10] 接着,声明了数据库函数库公有函数的原型。因为使用函数库的应用程序包括了此头文件,所以这里不再声明函数库私有函数的原型。
[11~24] 定义了可以传送给 db_store 函数的合法标志。其后是实现的基本限制。如果希望支持更大的数据库,可以更改这些限制。
最小索引记录长度由IDXLEN_MIN指定。这表示1字节键、1字节分隔符、1字节起始偏移量,另一个1字节分隔符、1字节长度和终止换行符。(回忆图20-2中索引记录的格式。)一条索引记录通常长于IDXLEN_MIN字节,这只是最小长度。
下一个文件是db.c,它是库函数的C源文件。为简化起见,将所有函数都放在一个文件中。这样处理的优点是只要将私有函数声明为static,就可对外将它隐蔽起来。
1 #include "apue.h"
2 #include "apue_db.h"
3 #include <fcntl.h> /* open & db_open flags */
4 #include <stdarg.h>
5 #include <errno.h>
6 #include <sys/uio.h> /* struct iovec */
7 /*
8 * Internal index file constants.
9 * These are used to construct records in the
10 * index file and data file.
11 */
12 #define IDXLEN_SZ 4 /* index record length (ASCII chars) */
13 #define SEP ':' /* separator char in index record */
14 #define SPACE ' ' /* space character */
15 #define NEWLINE '\n' /* newline character */
16 /*
17 * The following definitions are for hash chains and free
18 * list chain in the index file.
19 */
20 #define PTR_SZ 7 /* size of ptr field in hash chain */
21 #define PTR_MAX 999999 /* max file offset = 10**PTR_SZ - 1 */
22 #define NHASH_DEF 137 /* default hash table size */
23 #define FREE_OFF 0 /* free list offset in index file */
24 #define HASH_OFF PTR_SZ /* hash table offset in index file */
25 typedef unsigned long DBHASH; /* hash values */
26 typedef unsigned long COUNT; /* unsigned counter */
[1~6] 使用了一些私有函数库中的函数,所以程序中包括了 apue.h。当然,apue.h 也包括若干标准头文件,包括<stdio.h>和<unistd.h>。因为 db_open 函数使用由<stdarg.h>定义的可变参数函数,所以程序中也包括了<stdarg.h>。
[7~26] 索引记录的长度说明为 IDXLEN_SZ。我们用某些字符(如冒号、换行符)作为数据库中的分隔符。当删除一记录时,在其中全部填入空格符。
其中一些定义为常量的值也可定义为变量,只是会使实现复杂一些。例如,设定散列表的大小为 137 记录项,也许更好的方法是让 db_open 的调用者根据预期的数据库大小通过参数来设定这个值,然后将该值存在索引文件的最前面。
27 /*
28 *Library's private representation of the database.
29 */
30 typedef struct {
31 int idxfd; /* fd for index file */
32 int datfd; /* fd for data file */
33 char *idxbuf; /* malloc'ed buffer for index record */
34 char *datbuf; /* malloc'ed buffer for data record*/
35 char *name; /* name db was opened under */
36 off_t idxoff; /* offset in index file of index record */
37 /* key is at (idxoff + PTR_SZ + IDXLEN_SZ) */
38 size_t idxlen; /* length of index record */
39 /* excludes IDXLEN_SZ bytes at front of record */
40 /* includes newline at end of index record */
41 off_t datoff; /* offset in data file of data record */
42 size_t datlen; /* length of data record */
43 /* includes newline at end */
44 off_t ptrval; /* contents of chain ptr in index record */
45 off_t ptroff; /* chain ptr offset pointing to this idx record */
46 off_t chainoff; /* offset of hash chain for this index record */
47 off_t hashoff; /* offset in index file of hash table */
48 DBHASH nhash; /* current hash table size */
49 COUNT cnt_delok; /* delete OK */
50 COUNT cnt_delerr; /* delete error */
51 COUNT cnt_fetchok; /* fetch OK */
52 COUNT cnt_fetcherr; /* fetch error */
53 COUNT cnt_nextrec; /* nextrec */
54 COUNT cnt_stor1; /* store: DB_INSERT, no empty, appended */
55 COUNT cnt_stor2; /* store: DB_INSERT, found empty, reused */
56 COUNT cnt_stor3; /* store: DB_REPLACE, diff len, appended */
57 COUNT cnt_stor4; /* store: DB_REPLACE, same len, overwrote */
58 COUNT cnt_storerr; /* store error */
59 } DB;
[27~48] 在 DB 结构中记录一个打开数据库的所有信息。db_open 函数返回 DB 结构的指针DBHANDLE值。这个指针被用于其他所有函数,而该结构本身则不面向调用者。
因为在数据库中以 ASCII 形式存放指针和长度,所以将这些转换为数字值,并存放在DB结构中。也存放散列表长度,虽然一般而言,这是定长的,但也有可能为加强该函数库,允许调用者在创建数据库时指定该长度(见习题20.7)。
[49~59] DB结构的最后10个字段对成功和不成功的操作进行计数。如果想要分析数据库的性能,则可编写一个函数返回这些统计值。但目前我们仅保持这些计数器,并未编写此种函数。
60 /*
61 *Internal functions.
62 */
63 static DB *_db_alloc(int);
64 static void _db_dodelete(DB *);
65 static int _db_find_and_lock(DB *, const char *, int);
66 static int _db_findfree(DB *, int, int);
67 static void _db_free(DB *);
68 static DBHASH _db_hash(DB *, const char *);
69 static char *_db_readdat(DB *);
70 static off_t _db_readidx(DB *, off_t);
71 static off_t _db_readptr(DB *, off_t);
72 static void _db_writedat(DB *, const char *, off_t, int);
73 static void _db_writeidx(DB *, const char *, off_t, int, off_t);
74 static void _db_writeptr(DB *, off_t, off_t);
75 /*
76 *Open or create a database. Same arguments as open(2).
77 */
78 DBHANDLE
79 db_open(const char *pathname, int oflag, ...)
80 {
81 DB *db;
82 int len, mode;
83 size_t i;
84 char asciiptr[PTR_SZ + 1],
85 hash[(NHASH_DEF + 1) * PTR_SZ + 2];
86 /* +2 for newline and null */
87 struct stat statbuff;
88 /*
89 * Allocate a DB structure, and the buffers it needs.
90 */
91 len = strlen(pathname);
92 if ((db = _db_alloc(len)) == NULL)
93 err_dump("db_open: _db_alloc error for DB");
[60~74] 选择用db_开头来命名用户可调用(公有)的所有函数,用_db_开头来命名内部(私有)函数。公有函数在函数库头文件apue_db.h中声明。内部函数声明为 static,所以只有同一文件中的其他函数才能调用它们(该文件包含函数库实现)。
[75~93] db_open函数的参数与open(2)相同。如果调用者想要创建数据库文件,那么用可选择的第三个参数指定文件权限。db_open函数打开索引文件和数据文件,在必要时初始化索引文件。该函数调用_db_alloc来为DB结构分配空间,并初始化此结构。
94 db->nhash = NHASH_DEF;/* hash table size */
95 db->hashoff = HASH_OFF; /* offset in index file of hash table */
96 strcpy(db->name, pathname);
97 strcat(db->name, ".idx");
98 if (oflag & O_CREAT) {
99 va_list ap;
100 va_start(ap, oflag);
101 mode = va_arg(ap, int);
102 va_end(ap);
103 /*
104 * Open index file and data file.
105 */
106 db->idxfd = open(db->name, oflag, mode);
107 strcpy(db->name + len, ".dat");
108 db->datfd = open(db->name, oflag, mode);
109 } else {
110 /*
111 * Open index file and data file.
112 */
113 db->idxfd = open(db->name, oflag);
114 strcpy(db->name + len, ".dat");
115 db->datfd = open(db->name, oflag);
116 }
117 if (db->idxfd < 0 || db->datfd < 0) {
118 _db_free(db);
119 return(NULL);
120 }
[94~97] 继续初始化 DB 结构。调用者传入的路径名指定数据库文件名的前缀。追加后缀.idx以构成数据库索引文件的名字。
[98~108] 如果调用者想要创建数据库文件,那么使用<stdarg.h>中的可变参数函数以找到可选的第三个参数。然后,使用 open 创建并打开索引文件和数据文件。注意,数据文件的文件名以索引文件同样的前缀开始,但后缀为.dat。
[109~116] 如果调用者没有指定O_CREAT标志,那么正在打开已有的数据库文件。此时,只用两个参数调用open。
[117~120] 如果在打开或创建任一数据库文件时出错,则调用_db_free清除DB结构,然后对调用者返回NULL。如果一个文件open成功而另一个失败,_db_free将关闭该打开文件描述符。我们很快就会见到这一操作。
121 if ((oflag & (O_CREAT | O_TRUNC)) == (O_CREAT | O_TRUNC)) {
122 /*
123 * If the database was created, we have to initialize
124 * it. Write lock the entire file so that we can stat
125 * it, check its size, and initialize it, atomically.
126 */
127 if (writew_lock(db->idxfd, 0, SEEK_SET, 0) < 0)
128 err_dump("db_open: writew_lock error");
129 if (fstat(db->idxfd, &statbuff) < 0)
130 err_sys("db_open: fstat error");
131 if (statbuff.st_size == 0) {
132 /*
133 * We have to build a list of (NHASH_DEF + 1) chain
134 * ptrs with a value of 0. The +1 is for the free
135 * list pointer that precedes the hash table.
136 */
137 sprintf(asciiptr, "%*d", PTR_SZ, 0);
[121~130] 如果正在建立数据库,则必须正确地加锁。考虑两个进程试图同时建立同一个数据库的情况。第一个进程运行到调用fstat,并且在fstat返回后被内核阻塞。
这时第二个进程调用db_open,发现索引文件的长度为0,然后初始化空闲链表和散列链表。第二个进程继续运行,向数据库中写入了一条记录。这时第二个进程被阻塞,第一个进程在调用fstat后立刻继续运行,它发现索引文件的长度为0(因为第一个进程调用fstat在前,然后第二个进程再初始化索引文件),所以第一个进程重新初始化空闲链表和散列链表,第二个进程写入的记录就被抹去了。
避免发生这种情况的方法是进行加锁,为此可以使用14.3节中的readw_lock、writew_lock和un_lock这3个宏。
[131~137] 如果索引文件的长度是 0,那么这是刚刚被创建的,所以需要初始化它所包含的空闲列表指针和散列链指针。注意,使用格式字符串%*d 将数据库指针从整型转换为ASCII字符串。(在_db_writeidx和_db_writeptr中还将使用这种格式字符串。)这一格式告诉sprintf取PTR_SZ参数,用它作为下一个参数的最小字段宽度,在此例中,它是 0(此处,因为正在创建一数据库,所以将指针初始化为0)。其作用是强迫创建的字符串至少包含PTR_SZ个字符(在左边用空格充填)。在_db_writeidx和_db_writeptr中,将传送一个非0指针值,但是首先将验证指针值不大于 PTR_MAX,以保证写入数据库的指针字符串恰好为PTR_SZ(7)个字符。
138 hash[0] = 0;
139 for (i = 0; i < NHASH_DEF + 1; i++)
140 strcat(hash, asciiptr);
141 strcat(hash, "\n");
142 i = strlen(hash);
143 if (write(db->idxfd, hash, i) != i)
144 err_dump("db_open: index file init write error");
145 }
146 if (un_lock(db->idxfd, 0, SEEK_SET, 0) < 0)
147 err_dump("db_open: un_lock error");
148 }
149 db_rewind(db);
150 return(db);
151 }
152 /*
153 * Allocate & initialize a DB structure and its buffers.
154 */
155 static DB *
156 _db_alloc(int namelen)
157 {
158 DB *db;
159 /*
160 * Use calloc, to initialize the structure to zero.
161 */
162 if ((db = calloc(1, sizeof(DB))) == NULL)
163 err_dump("_db_alloc: calloc error for DB");
164 db->idxfd = db->datfd = -1; /* descriptors */
165 /*
166 * Allocate room for the name.
167 * +5 for ".idx" or ".dat" plus null at end.
168 */
169 if ((db->name = malloc(namelen + 5)) == NULL)
170 err_dump("_db_alloc: malloc error for name");
[138~151] 继续初始化新创建的数据库。构造散列表,将它写到索引文件中。然后,解锁索引文件,重置数据库文件指针,返回DB结构指针作为句柄,以便调用者以后用于其他数据库函数。
[152~164] db_open调用函数_db_alloc为DB结构分配空间,包括一个索引缓冲区和一个数据缓冲区。用 calloc 分配存储区来存放 DB 结构,并将该存储区各存储单元全部初始化为0。这产生了一个副作用,也就是将数据库文件描述符也设置为0,为此需将它们重新设置为−1,表示它们至此还不是有效的。
[165~170] 分配空间以存放数据库索引文件和数据文件的名字。如 db_open 中所说明的那样,更改它们的名字后缀以便引用索引文件或数据文件。
171 /*
172 * Allocate an index buffer and a data buffer.
173 * +2 for newline and null at end.
174 */
175 if ((db->idxbuf = malloc(IDXLEN_MAX + 2)) == NULL)
176 err_dump("_db_alloc: malloc error for index buffer");
177 if ((db->datbuf = malloc(DATLEN_MAX + 2)) == NULL)
178 err_dump("_db_alloc: malloc error for data buffer");
179 return(db);
180 }
181 /*
182 * Relinquish access to the database.
183 */
184 void
185 db_close(DBHANDLE h)
186 {
187 _db_free((DB *)h); /* closes fds, free buffers & struct */
188 }
189 /*
190 * Free up a DB structure, and all the malloc'ed buffers it
191 * may point to. Also close the file descriptors if still open.
192 */
193 static void
194 _db_free(DB *db)
195 {
196 if (db->idxfd >= 0)
197 close(db->idxfd);
198 if (db->datfd >= 0)
199 close(db->datfd);
[171~180] 为索引文件和数据文件的缓冲区分配空间。索引缓冲区和数据缓冲区的大小在apue_db.h 中定义。可以通过让这些缓冲区按需要动态扩张来增强数据库函数库。其方法可以是记录这两个缓冲区的大小,然后在需要更大的缓冲区时调用realloc。最后,返回指向已分配到的DB结构的指针。
[181~188] db_close函数只是一个包装,它将数据库句柄强制类型转换为DB结构的指针,将它传送给_db_free函数,由该函数释放资源以及DB结构。
[189~199] db_open在打开索引文件和数据文件时如果发生错误,会调用_db_free函数释放资源。应用程序在结束对数据库的使用后,db_close也会调用_db_free。如果数据库索引文件的文件描述符有效,那么关闭该文件。对数据文件描述符也进行同样处理。(回忆在_db_alloc中分配一个新的DB结构时,将每个文件描述符都初始化为−1。如果不能打开两个数据库文件中的一个,相应文件描述符仍为−1,也就是无需关闭它。)
200 if (db->idxbuf != NULL)
201 free(db->idxbuf);
202 if (db->datbuf != NULL)
203 free(db->datbuf);
204 if (db->name != NULL)
205 free(db->name);
206 free(db);
207 }
208 /*
209 * Fetch a record. Return a pointer to the null-terminated data.
210 */
211 char *
212 db_fetch(DBHANDLE h, const char *key)
213 {
214 DB *db = h;
215 char *ptr;
216 if (_db_find_and_lock(db, key, 0) < 0) {
217 ptr = NULL; /* error, record not found */
218 db->cnt_fetcherr++;
219 } else {
220 ptr = _db_readdat(db); /* return pointer to data */
221 db->cnt_fetchok++;
222 }
223 /*
224 * Unlock the hash chain that _db_find_and_lock locked.
225 */
226 if (un_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
227 err_dump("db_fetch: un_lock error");
228 return(ptr);
229 }
[200~207] 接着,释放动态分配的缓冲区。可以安全地将一个空指针传递给 free 函数,这样也就无需事先检查每个缓冲区指针的值,但是我们认为只释放已分配的对象是一种较好的编程风格。(并非所有释放程序都像 free 那样容忍差错。)最后,释放DB结构占用的存储区。
[208~218] 函数db_fetch根据给定的键来读取一条记录。它调用_db_find_and_lock在数据库中查找记录。若不能找到该记录,则将返回值(ptr)设置为NULL,将不成功的记录搜索计数器值加 1。因为从_db_find_and_lock 返回时,数据库索引文件是加锁的,所以先要解锁,然后再返回。
[219~229] 如果找到了记录,调用_db_readdat读相应的数据记录,并将成功记录搜索计数器值加1。在返回前,调用un_lock对索引文件解锁。然后,返回所找到记录的指针(如果没有找到所需记录,则返回NULL)。
230 /*
231 * Find the specified record. Called by db_delete, db_fetch,
232 * and db_store. Returns with the hash chain locked.233 */
234 static int
235 _db_find_and_lock(DB *db, const char *key, int writelock)236 {
237 off_t offset, nextoffset;
238 /*
239 * Calculate the hash value for this key, then calculate the
240 * byte offset of corresponding chain ptr in hash table.
241 * This is where our search starts. First we calculate the
242 * offset in the hash table for this key.
243 */
244 db->chainoff = (_db_hash(db, key) * PTR_SZ) + db->hashoff;
245 db->ptroff = db->chainoff;
246 /*
247 * We lock the hash chain here. The caller must unlock it
248 * when done. Note we lock and unlock only the first byte.
249 */
250 if (writelock) {
251 if (writew_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
252 err_dump("_db_find_and_lock: writew_lock error");
253 } else {
254 if (readw_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
255 err_dump("_db_find_and_lock: readw_lock error");
256 }
257 /*
258 * Get the offset in the index file of first record
259 * on the hash chain (can be 0).
260 */
261 offset = _db_readptr(db, db->ptroff);
[230~237] _db_find_and_lock 函数在函数库内部用于按给定的键查找记录。在搜索记录时,如果想在索引文件上加一把写锁,则将writelock参数设置为非0值。如果将writelock参数设置为0,则在搜索记录时,在索引文件上加读锁。
[238~256] 在_db_find_and_lock 中准备遍历散列链。将键转换为散列值,用其计算在文件中相应散列链的起始地址(chainoff)。在遍历散列链前,等待获得锁。注意,只锁该散列链开始处的第 1 个字节。这种方式允许多个进程同时搜索不同的散列链,因此增加了并发性。
[257~261] 调用_db_readptr读散列链中的第一个指针。如果该函数返回0,则该散列链为空。
262 while (offset != 0) {
263 nextoffset = _db_readidx(db, offset);
264 if (strcmp(db->idxbuf, key) == 0)
265 break; /* found a match */
266 db->ptroff = offset; /* offset of this (unequal) record */
267 offset = nextoffset; /* next one to compare */
268 }
269 /*
270 * offset == 0 on error (record not found).
271 */
272 return(offset == 0 ? -1 : 0);
273 }
274 /*
275 * Calculate the hash value for a key.
276 */
277 static DBHASH
278 _db_hash(DB *db, const char *key)
279 {
280 DBHASH hval = 0;
281 char c;
282 int i;
283 for (i = 1; (c = *key++) != 0; i++)
284 hval += c * i; /* ascii char times its 1-based index */
285 return(hval % db->nhash);
286 }
[262~268] while循环遍历散列链中的每一条索引记录,并比较键。调用函数_db_readidx读取每条索引记录。它将当前记录的键填入 DB 结构中的 idxbuf 字段。如果_db_readidx返回0,则已到达散列链的最后一记录项。
[269~273] 如果在循环后,offset 为 0,说明已达到散列链末端而且没有找到匹配键,于是返回−1。否则,找到了匹配记录(用break语句退出了循环),所以返回0表示成功。此时,ptroff字段包含前一索引记录的地址,datoff包含数据记录的地址, datlen是数据记录的长度。当沿着散列链进行遍历时,必须始终保存当前索引记录的前一条索引记录,其中有一个指针指向当前索引记录。这样做在删除一条记录时很有用,因为必须修改当前索引记录的前一条记录的链指针以删除当前记录。
[274~286] _db_hash根据给定的键计算散列值。它将键中的每一个 ASCII字符乘以这个字符在字符串中以 1 开始的索引号,将这些结果加起来,除以散列表记录项数,将余数作为这个键的散列值。回忆散列表记录项数是 137,它是一个素数,按Knuth[1998],素数散列通常能提供良好的分布特性。
287 /*
288 * Read a chain ptr field from anywhere in the index file:
289 * the free list pointer, a hash table chain ptr, or an
290 * index record chain ptr.
291 */
292 static off_t
293 _db_readptr(DB *db, off_t offset)
294 {
295 char asciiptr[PTR_SZ + 1];
296 if (lseek(db->idxfd, offset, SEEK_SET) == -1)
297 err_dump("_db_readptr: lseek error to ptr field");
298 if (read(db->idxfd, asciiptr, PTR_SZ) != PTR_SZ)
299 err_dump("_db_readptr: read error of ptr field");
300 asciiptr[PTR_SZ] = 0; /* null terminate */
301 return(atol(asciiptr));
302 }
303 /*
304 * Read the next index record. We start at the specified offset
305 * in the index file. We read the index record into db->idxbuf
306 * and replace the separators with null bytes. If all is OK we
307 * set db->datoff and db->datlen to the offset and length of the
308 * corresponding data record in the data file.
309 */
310 static off_t
311 _db_readidx(DB *db, off_t offset)
312 {
313 ssize_t i;
314 char *ptr1, *ptr2;
315 char asciiptr[PTR_SZ + 1], asciilen[IDXLEN_SZ + 1];
316 struct iovec iov[2];
[287~302] _db_readptr函数读取以下3种不同链表指针中的任意一种:(a)索引文件最开始处指向空闲链表中第一个索引记录的指针,(b)散列表中指向散列链的第一条索引记录的指针,(c)存放在每条索引记录开始处、指向下一条记录的指针(这里的索引记录既可以处于一条散列链表中,也可以处于空闲链表中)。返回前,将指针从ASCII形式转换为长整型。此函数不进行任何加锁操作,所以其调用者应事先做好必要的加锁。
[303~316] _db_readidx函数用于从索引文件的指定偏移量处读取索引记录。如果成功,该函数将返回链表中下一条记录的偏移量。该函数还填充 DB 结构的许多字段:idxoff包含索引文件中当前记录的偏移量,ptrval包含在散列链表中下一个索引项的偏移量,idxlen包含当前索引记录的长度,idxbuf包含实际索引记录, datoff包含数据文件中该记录的偏移量,datlen包含该数据记录的长度。
317 /*
318 * Position index file and record the offset. db_nextrec
319 * calls us with offset==0, meaning read from current offset.
320 * We still need to call lseek to record the current offset.
321 */
322 if ((db->idxoff = lseek(db->idxfd, offset,
323 offset == 0 ? SEEK_CUR : SEEK_SET)) == -1)
324 err_dump("_db_readidx: lseek error");
325 /*
326 * Read the ascii chain ptr and the ascii length at
327 * the front of the index record. This tells us the
328 * remaining size of the index record.
329 */
330 iov[0].iov_base = asciiptr;
331 iov[0].iov_len = PTR_SZ;
332 iov[1].iov_base = asciilen;
333 iov[1].iov_len = IDXLEN_SZ;
334 if ((i = readv(db->idxfd, &iov[0], 2)) != PTR_SZ + IDXLEN_SZ) {
335 if (i == 0 && offset == 0)
336 return(-1); /* EOF for db_nextrec */
337 err_dump("_db_readidx: readv error of index record");
338 }
339 /*
340 * This is our return value; always >= 0.
341 */
342 asciiptr[PTR_SZ] = 0; /* null terminate */
343 db->ptrval = toll(asciiptr); /* offset of next key in chain */
344 asciilen[IDXLEN_SZ] = 0; /* null terminate */
345 if ((db->idxlen = atoi(asciilen)) < IDXLEN_MIN ||
346 db->idxlen > IDXLEN_MAX)
347 err_dump("_db_readidx: invalid length");
[317~324] 按调用者提供的参数查找索引文件偏移量。在DB结构中记录该偏移量,为此即使调用者想要在当前文件偏移量处读记录(设置offset为0),仍需要调用lseek以确定当前偏移量。因为在索引文件中,索引记录决不会存放在偏移量为 0 处,所以可以放心地使用0表示“从当前偏移量处读”。
[325~338] 调用readv读在索引记录开始处的两个定长字段:指向下一索引记录的链指针和该索引记录余下部分的长度(余下部分是变长的)。
[339~347] 将下一记录的偏移量转换为整型,并存放到ptrval字段中(这将被用作此函数的返回值)。然后将索引记录的长度转换为整型,并存放到idxlen字段中。
348 /*
349 * Now read the actual index record. We read it into the key
350 * buffer that we malloced when we opened the database.
351 */
352 if ((i = read(db->idxfd, db->idxbuf, db->idxlen)) != db->idxlen)
353 err_dump("_db_readidx: read error of index record");
354 if (db->idxbuf[db->idxlen-1] != NEWLINE) /* sanity check */
355 err_dump("_db_readidx: missing newline");
356 db->idxbuf[db->idxlen-1] = 0; /* replace newline with null */
357 /*
358 * Find the separators in the index record.
359 */
360 if ((ptr1 = strchr(db->idxbuf, SEP)) == NULL)
361 err_dump("_db_readidx: missing first separator");
362 *ptr1++ = 0; /* replace SEP with null */
363 if ((ptr2 = strchr(ptr1, SEP)) == NULL)
364 err_dump("_db_readidx: missing second separator");
365 *ptr2++ = 0; /* replace SEP with null */
366 if (strchr(ptr2, SEP) != NULL)
367 err_dump("_db_readidx: too many separators");
368 /*
369 * Get the starting offset and length of the data record.
370 */
371 if ((db->datoff = atol(ptr1)) < 0)
372 err_dump("_db_readidx: starting offset < 0");
373 if ((db->datlen = atol(ptr2)) <= 0 || db->datlen > DATLEN_MAX)
374 err_dump("_db_readidx: invalid length");
375 return(db->ptrval); /* return offset of next key in chain */
376 }
[348~356] 将索引记录的变长部分读入DB结构中的idxbuf字段。该记录应以换行符结尾。
用null字符代替换行符。如果索引文件已遭破坏,那么调用err_dump函数终止core文件。
[357~367] 将索引记录划分成 3 个字段:键、对应数据记录的偏移量和数据记录的长度。
strchr 函数在给定字符串中找到第一个指定字符。这里,我们要寻找的是记录中分隔字段的字符(SEP,此处定义为冒号)。
[368~376] 将数据记录偏移量和数据记录长度转换为整型,并将它们存放在DB结构中。然后,返回在散列链中下一条记录的偏移量。注意,我们并不读数据记录,这由调用者自己完成。例如,在db_fetch中,在_db_find_and_lock按键找到索引记录前是不读取数据记录的。
377 /*
378 * Read the current data record into the data buffer.
379 * Return a pointer to the null-terminated data buffer.
380 */
381 static char *
382 _db_readdat(DB *db)
383 {
384 if (lseek(db->datfd, db->datoff, SEEK_SET) == -1)
385 err_dump("_db_readdat: lseek error");
386 if (read(db->datfd, db->datbuf, db->datlen) != db->datlen)
387 err_dump("_db_readdat: read error");
388 if (db->datbuf[db->datlen-1] != NEWLINE) /* sanity check */
389 err_dump("_db_readdat: missing newline");
390 db->datbuf[db->datlen-1] = 0; /* replace newline with null */
391 return(db->datbuf); /* return pointer to data record */
392 }
393 /*
394 * Delete the specified record.
395 */
396 int
397 db_delete(DBHANDLE h, const char *key)398 {
399 DB *db = h;
400 int rc = 0; /* assume record will be found */
401 if (_db_find_and_lock(db, key, 1) == 0) {
402 _db_dodelete(db);
403 db->cnt_delok++;
404 } else {
405 rc = -1; /* not found */
406 db->cnt_delerr++;
407 }
408 if (un_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
409 err_dump("db_delete: un_lock error");
410 return(rc);
411 }
[377~392] 在datoff和datlen已经被正确初始化后,_db_readdat函数将数据记录的内容读入DB结构中的datbuf字段指向的缓冲区。
[393~411] db_delete函数用于删除与给定键匹配的一条记录。使用_db_find_and_lock来判断在数据库中该记录是否存在。如果存在,则调用_db_dodelete函数执行删除该记录的操作。_db_find_and_lock 的第三个参数控制对散列链是加读锁还是写锁。此处,因为可能执行更改该链表的操作,所以要加一把写锁。_db_find_and_lock 返回时,这把锁仍旧存在,为此不管是否找到了所需的记录,都需要解除这把锁。
412 /*
413 * Delete the current record specified by the DB structure.
414 * This function is called by db_delete and db_store, after
415 * the record has been located by _db_find_and_lock.416 */
417 static void
418 _db_dodelete(DB *db)419 {
420 int i;
421 char *ptr;
422 off_t freeptr, saveptr;
423 /*
424 * Set data buffer and key to all blanks.
425 */
426 for (ptr = db->datbuf, i = 0; i < db->datlen - 1; i++)
427 *ptr++ = SPACE;
428 *ptr = 0; /* null terminate for _db_writedat */
429 ptr = db->idxbuf;
430 while (*ptr)
431 *ptr++ = SPACE;
432 /*
433 * We have to lock the free list.
434 */
435 if (writew_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
436 err_dump("_db_dodelete: writew_lock error");
437 /*
438 * Write the data record with all blanks.
439 */
440 _db_writedat(db, db->datbuf, db->datoff, SEEK_SET);
[412~431] _db_dodelete 函数执行从数据库中删除一条记录的所有操作。(该函数也可以由db_store调用。)此函数的大部分工作仅仅是更新空闲链表以及与键对应的散列链。当一条记录被删除后,将其键和数据记录设为空。本章后面将提到的函数db_nextrec要用到这一点。
[432~440] 调用 writew_lock 对空闲链表加写锁,这样能防止两个进程同时删除不同链表上的记录时产生相互影响,因为要将被删除的记录添加到空闲链表中,这将改变空闲链表指针,而一次只能有一个进程能这样做。
调用函数_db_writedat清空数据记录。这时_db_writedat并不对数据文件加写锁,这是因为 db_delete 对这条记录的散列链已经加了写锁,这保证不会再有其他进程能够读、写这条记录。
441 /*
442 * Read the free list pointer. Its value becomes the
443 * chain ptr field of the deleted index record. This means
444 * the deleted record becomes the head of the free list.
445 */
446 freeptr = _db_readptr(db, FREE_OFF);
447 /*
448 * Save the contents of index record chain ptr,
449 * before it's rewritten by _db_writeidx.
450 */
451 saveptr = db->ptrval;
452 /*
453 * Rewrite the index record. This also rewrites the length
454 * of the index record, the data offset, and the data length,
455 * none of which has changed, but that's OK.
456 */
457 _db_writeidx(db, db->idxbuf, db->idxoff, SEEK_SET, freeptr);
458 /*
459 * Write the new free list pointer.
460 */
461 _db_writeptr(db, FREE_OFF, db->idxoff);
462 /*
463 * Rewrite the chain ptr that pointed to this record being
464 * deleted. Recall that _db_find_and_lock sets db->ptroff to
465 * point to this chain ptr. We set this chain ptr to the
466 * contents of the deleted record's chain ptr, saveptr.
467 */
468 _db_writeptr(db, db->ptroff, saveptr);
469 if (un_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
470 err_dump("_db_dodelete: un_lock error");
471 }
[441~461] 读空闲链表指针,接着修改索引记录。让这条记录的下一条记录指针指向空闲链表的第一条记录(如果空闲链表为空,则这个新的链表指针置为0)。清除键之后用正被删除索引记录的偏移量更新空闲链表指针,也就是使其指向当前删除的这条记录。这意味着空闲链表的处理基于后进先出(虽然是以首次适应算法来删除空闲链表项),也就是说被删除的记录都被添加到空闲链表头部。
没有为每个文件分别设置空闲链表。将一个删除的索引记录添加到空闲链表时,该索引记录仍指向已删除的数据记录。当然还有更好的处理方法,但复杂性会增加。[462~471] 修改散列链中前一条记录的指针,使其指向正删除记录之后的记录,这样就从散列链中移除了要删除的记录。最后对空闲链表解锁。770
472 /*
473 * Write a data record. Called by _db_dodelete (to write
474 * the record with blanks) and db_store.475 */
476 static void
477 _db_writedat(DB *db, const char *data, off_t offset, int whence)478 {
479 struct iovec iov[2];
480 static char newline = NEWLINE;
481 /*
482 * If we're appending, we have to lock before doing the lseek
483 * and write to make the two an atomic operation. If we're
484 * overwriting an existing record, we don't have to lock.
485 */
486 if (whence == SEEK_END) /* we're appending, lock entire file */
487 if (writew_lock(db->datfd, 0, SEEK_SET, 0) < 0)
488 err_dump("_db_writedat: writew_lock error");
489 if ((db->datoff = lseek(db->datfd, offset, whence)) == -1)
490 err_dump("_db_writedat: lseek error");
491 db->datlen = strlen(data) + 1; /* datlen includes newline */
492 iov[0].iov_base = (char *) data;
493 iov[0].iov_len = db->datlen - 1;
494 iov[1].iov_base = &newline;
495 iov[1].iov_len = 1;
496 if (writev(db->datfd, &iov[0], 2) != db->datlen)
497 err_dump("_db_writedat: writev error of data record");
498 if (whence == SEEK_END)
499 if (un_lock(db->datfd, 0, SEEK_SET, 0) < 0)
500 err_dump("_db_writedat: un_lock error");
501 }
[472~491] 调用函数_db_writedat 写一个数据记录。当删除一记录时,调用函数_db_writedat 清空数据记录;这时_db_writedat 并不对数据文件加写锁,因为db_delete 对这条记录的散列链已经加了写锁,这保证不会再有其他进程能够读、写这条记录。在本节稍后处说明db_store函数时,会遇到_db_writedat函数追加写数据文件的情况,此时就必需对该文件加锁。
定位到要写数据记录的位置。要写的字节数是记录长度加1个字节,这1个字节是表示记录终止的换行符。
[492~501] 设置iovec数组,调用writev写数据记录和换行符。不能想当然地认为调用者缓冲区的尾端有空间可以追加换行符,所以应该将换行符写入另一个缓冲区,然后再从该缓冲区写至数据记录。如果正在对文件追加一条记录,那么就释放早先获得的锁。
502 /*
503 * Write an index record. _db_writedat is called before
504 * this function to set the datoff and datlen fields in the
505 * DB structure, which we need to write the index record.
506 */
507 static void
508 _db_writeidx(DB *db, const char *key,
509 off_t offset, int whence, off_t ptrval)
511 struct iovec iov[2];
512 char asciiptrlen[PTR_SZ + IDXLEN_SZ + 1];
513 int len;
510 {
514 if ((db->ptrval = ptrval) < 0 || ptrval > PTR_MAX)
515 err_quit("_db_writeidx: invalid ptr: %d", ptrval);
516 sprintf(db->idxbuf, "%s%c%lld%c%ld\n", key, SEP,
517 (long long)db->datoff, SEP, (long)db->datlen);
518 len = strlen(db->idxbuf);
519 if (len < IDXLEN_MIN || len > IDXLEN_MAX)
520 err_dump("_db_writeidx: invalid length");
521 sprintf(asciiptrlen, "%*lld%*d", PTR_SZ, (long long)ptrval,
522 IDXLEN_SZ, len);
523 /*
524 * If we’re appending, we have to lock before doing the lseek
525 * and write to make the two an atomic operation. If we’re
526 * overwriting an existing record, we don’t have to lock.
527 */
528 if (whence == SEEK_END) /* we’re appending */
529 if (writew_lock(db->idxfd, ((db->nhash+1)*PTR_SZ)+1,
530 SEEK_SET, 0) < 0)
531 err_dump("_db_writeidx: writew_lock error");
[502~522] 调用_db_writeidx函数写一条索引记录。在验证散列链中下一个指针有效后,创建索引记录,并将它的后半部分存放到idxbuf中。需要索引记录这一部分的长度以创建该记录的前半部分,而前半部分被存放到局部变量asciiptrlen中。
注意,使用强制类型转换使得sprintf语句的参数的长度与格式说明中相匹配,这样做是因为off_t和size_t数据类型的长度因平台不同而不同。32位系统也能提供64位文件偏移量,所以不能假定off_t数据类型的长度。
[523~531] 和_db_writedat 一样,只有在追加新索引记录时这一函数才需要加锁。
_db_dodelete调用此函数是为了重写一条已有的索引记录。在这种情况下,调用者已经在散列链上加了写锁,所以不再需要加另外的锁。
532 /*
533 * Position the index file and record the offset.
534 */
535 if ((db->idxoff = lseek(db->idxfd, offset, whence)) == -1)
536 err_dump("_db_writeidx: lseek error");
537 iov[0].iov_base = asciiptrlen;
538 iov[0].iov_len = PTR_SZ + IDXLEN_SZ;
539 iov[1].iov_base = db->idxbuf;
540 iov[1].iov_len = len;
541 if (writev(db->idxfd, &iov[0], 2) != PTR_SZ + IDXLEN_SZ + len)
542 err_dump("_db_writeidx: writev error of index record");
543 if (whence == SEEK_END)
544 if (un_lock(db->idxfd, ((db->nhash+1)*PTR_SZ)+1,
545 SEEK_SET, 0) < 0)
546 err_dump("_db_writeidx: un_lock error");
547 }
548 /*
549 * Write a chain ptr field somewhere in the index file:
550 * the free list, the hash table, or in an index record.
551 */
552 static void
553 _db_writeptr(DB *db, off_t offset, off_t ptrval)554 {
555 char asciiptr[PTR_SZ + 1];
556 if (ptrval < 0 || ptrval > PTR_MAX)
557 err_quit("_db_writeptr: invalid ptr: %d", ptrval);
558 sprintf(asciiptr, "%*lld", PTR_SZ, (long long)ptrval);
559 if (lseek(db->idxfd, offset, SEEK_SET) == -1)
560 err_dump("_db_writeptr: lseek error to ptr field");
561 if (write(db->idxfd, asciiptr, PTR_SZ) != PTR_SZ)
562 err_dump("_db_writeptr: write error of ptr field");
563 }
[532~547] 定位到开始写索引记录的位置,将该偏移量存入 DB 结构的 idxoff 字段。因为在两个独立的缓冲区中构建索引记录,所以调用writev将它存放到索引文件中。
如果是追加写该文件,则释放在定位操作前获得的锁。从并发运行进程追加新记录到数据库的角度思考问题,那么这把锁使定位操作和写操作成为原子操作。
[548~563] _db_writeptr被用于将一散列链指针写至索引文件中。验证该指针在索引文件的边界范围内,然后将它转换成ASCII字符串。按指定的偏移量在索引文件中定位,然后将该指针ASCII字符串写入索引文件。
564 /*
565 * Store a record in the database. Return 0 if OK, 1 if record
566 * exists and DB_INSERT specified, -1 on error.
567 */
568 int
569 db_store(DBHANDLE h, const char *key, const char *data, int flag)
570 {
571 DB *db = h;
572 int rc, keylen, datlen;
573 off_t ptrval;
574 if (flag != DB_INSERT && flag != DB_REPLACE &&
575 flag != DB_STORE) {
576 errno = EINVAL;
577 return(-1);
578 }
579 keylen = strlen(key);
580 datlen = strlen(data) + 1; /* +1 for newline at end */
581 if (datlen < DATLEN_MIN || datlen > DATLEN_MAX)
582 err_dump("db_store: invalid data length");
583 /*
584 * _db_find_and_lock calculates which hash table this new record
585 * goes into (db->chainoff), regardless of whether it already
586 * exists or not. The following calls to _db_writeptr change the
587 * hash table entry for this chain to point to the new record.
588 * The new record is added to the front of the hash chain.
589 */
590 if (_db_find_and_lock(db, key, 1) < 0) { /* record not found */
591 if (flag == DB_REPLACE) {
592 rc = -1;
593 db->cnt_storerr++;
594 errno = ENOENT; /* error, record does not exist */
595 goto doreturn;
596 }
[564~582] db_store函数的功能是将一条记录添加到数据库中。首先验证参数flag的值。然后,检查数据记录长度是否有效。如果无效,则删除core文件并终止。作为一个例子这样处理无可厚非,但如果构造正式应用的函数库,那么最好返回出错状态而非终止,这样可以给应用程序一个恢复的机会。
[583~596] 调用_db_find_and_lock以查看这个记录是否已经存在。如果记录并不存在且指定的标志为 DB_INSERT 或 DB_STORE,或者记录存在且指定的标志为 DB_REPLACE或 DB_STORE,那么这些都是允许的。替换一条已有的记录意味着键不变,而数据记录很可能不同。注意,因为 db_store 很可能会改变散列链,所以调用_db_find_and_lock的最后一个参数指明要对散列链加写锁。
597 /*
598 * _db_find_and_lock locked the hash chain for us; read
599 * the chain ptr to the first index record on hash chain.
600 */
601 ptrval = _db_readptr(db, db->chainoff);
602 if (_db_findfree(db, keylen, datlen) < 0) {
603 /*
604 * Can't find an empty record big enough. Append the
605 * new record to the ends of the index and data files.
606 */
607 _db_writedat(db, data, 0, SEEK_END);
608 _db_writeidx(db, key, 0, SEEK_END, ptrval);
609 /*
610 * db->idxoff was set by _db_writeidx. The new
611 * record goes to the front of the hash chain.
612 */
613 _db_writeptr(db, db->chainoff, db->idxoff);
614 db->cnt_stor1++;
615 } else {
616 /*
617 * Reuse an empty record. _db_findfree removed it from
618 * the free list and set both db->datoff and db->idxoff.
619 * Reused record goes to the front of the hash chain.
620 */
621 _db_writedat(db, data, db->datoff, SEEK_SET);
622 _db_writeidx(db, key, db->idxoff, SEEK_SET, ptrval);
623 _db_writeptr(db, db->chainoff, db->idxoff);
624 db->cnt_stor2++;
625 }
[597~601] 在调用_db_find_and_lock后,代码分成4种情况。前两种情况中,没有找到足够大的空闲记录,所以添加一条新纪录。读散列链上第一项的偏移量。
[602~614] 第1种情况:调用_db_findfree在空闲链表中搜索一条已删除的记录,它的键长度和数据长度与参数keylen和datlen相同。如果没有找到对应大小的空闲记录,这意味着要将这条新记录追加到索引文件和数据文件的末尾。调用_db_writedat写数据部分,调用_db_writeidx写索引部分,调用_db_writeptr将新记录添加到对应的散列链的头部。将执行此种情况的计数器(cnt_stor1)值加1,以便观察数据库的运行状况。
[615~625] 第2种情况:_db_findfree找到对应大小的空记录,然后将这条空记录从空闲链表中移除(稍后就会看到_db_findfree的实现),写入新的索引记录和数据记录,然后,如同第 1 种情况一样,将新记录添加到对应的散列链的头部。将执行此种情况的计数器(cnt_stor2)值加 1,以便观察数据库的运行状况。
626 } else { /* record found */
627 if (flag == DB_INSERT) {
628 rc = 1; /* error, record already in db */
629 db->cnt_storerr++;
630 goto doreturn;
631 }
632 /*
633 * We are replacing an existing record. We know the new
634 * key equals the existing key, but we need to check if
635 * the data records are the same size.
636 */
637 if (datlen != db->datlen) {
638 _db_dodelete(db); /* delete the existing record */
639 /*
640 * Reread the chain ptr in the hash table
641 * (it may change with the deletion).
642 */
643 ptrval = _db_readptr(db, db->chainoff);
644 /*
645 * Append new index and data records to end of files.
646 */
647 _db_writedat(db, data, 0, SEEK_END);
648 _db_writeidx(db, key, 0, SEEK_END, ptrval);
649 /*
650 * New record goes to the front of the hash chain.
651 */
652 _db_writeptr(db, db->chainoff, db->idxoff);
653 db->cnt_stor3++;
654 } else {
[626~631] 另两种情况是具有相同键的记录在数据库中已存在,如果不想替换该记录,则设置表示一条记录已经存在的返回码,将存储出错计数的计数器 cnt_storerr 值加1,然后跳转至函数末尾,在此处理公共返回逻辑。
[632~654] 第 3 种情况:要替换一条已有记录,而新数据记录的长度与已有记录的长度不一样。调用_db_dodelete删除已有记录,将该删除记录放在空闲链表头部。然后,调用_db_writedat 和_db_writeidx 将新记录追加到索引文件和数据文件的末尾(也可以用其他方法,如可以再找一找是否有数据大小正好的已删除的记录项)。最后调用_db_writeptr将新记录添加到对应的散列链的头部。DB结构中的cnt_stor3计数器记录发生此种情况的次数。
655 /*
656 * Same size data, just replace data record.
657 */
658 _db_writedat(db, data, db->datoff, SEEK_SET);
659 db->cnt_stor4++;
660 }
661 }
662 rc = 0; /* OK */
663 doreturn: /* unlock hash chain locked by _db_find_and_lock */
664 if (un_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
665 err_dump("db_store: un_lock error");
666 return(rc);667 }668 /*
669 * Try to find a free index record and accompanying data record
670 * of the correct sizes. We're only called by db_store.671 */
672 static int
673 _db_findfree(DB *db, int keylen, int datlen)674 {
675 int rc;
676 off_t offset, nextoffset, saveoffset;
677 /*
678 * Lock the free list.
679 */
680 if (writew_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
681 err_dump("_db_findfree: writew_lock error");
682 /*
683 * Read the free list pointer.
684 */
685 saveoffset = FREE_OFF;
686 offset = _db_readptr(db, saveoffset);
[655~661] 第 4 种情况:替换一条已有记录,而新数据记录的长度与已有记录的长度恰好一样。这是最容易的情况,只需要重写数据记录即可,并将这种情况的计数器(cnt_stor4)值加1。
[662~667] 在正常情况下,设置表示成功的返回码,然后进入公共返回逻辑。对散列链解锁(这把锁是由调用_db_find_and_lock而加上的),然后返回调用者。
[668~686] dbfindfree函数试图找到一个指定大小的空闲索引记录和相关联的数据记录。需要对空闲链表加写锁以避免与其他使用空闲链表的进程互相影响。在对空闲链表加写锁后,得到空闲链表的头指针地址。
687 while (offset != 0) {
688 nextoffset = _db_readidx(db, offset);
689 if (strlen(db->idxbuf) == keylen && db->datlen == datlen)
690 break; /* found a match */
691 saveoffset = offset;
692 offset = nextoffset;
693 }
694 if (offset == 0) {
695 rc = -1; /* no match found */
696 } else {
697 /*
698 * Found a free record with matching sizes.
699 * The index record was read in by _db_readidx above,
700 * which sets db->ptrval. Also, saveoffset points to
701 * the chain ptr that pointed to this empty record on
702 * the free list. We set this chain ptr to db->ptrval,
703 * which removes the empty record from the free list.
704 */
705 _db_writeptr(db, saveoffset, db->ptrval);
706 rc = 0;
707 /*
708 * Notice also that _db_readidx set both db->idxoff
709 * and db->datoff. This is used by the caller, db_store,
710 * to write the new index record and data record.
711 */
712 }
713 /*
714 * Unlock the free list.
715 */
716 if (un_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
717 err_dump("_db_findfree: un_lock error");
718 return(rc);
719 }
[687~693] _db_findfree 中的 while 循环遍历空闲链表以搜寻一个能够匹配键长度和数据长度的索引记录项。在这个简单的实现中,只有当一个已删除记录的键长度及数据长度与要插入的新记录的键长度及数据长度一样时才重用已删除记录的空间。还有其他更好的算法,但复杂度会增加。
[694~712] 如果找不到所要求键长度和数据长度的可用记录,则设置表示失败的返回码。否则,将已找到记录的下一个链指针写至前一记录的链表指针。这样就从空闲链表中移除了该记录。[713~719] 一旦结束对空闲链表的操作,立即释放写锁。然后对调用者返回状态码。
720 /*
721 * Rewind the index file for db_nextrec.722 * Automatically called by db_open.723 * Must be called before first db_nextrec.724 */
725 void
726 db_rewind(DBHANDLE h)727 {
728 DB *db = h;
729 off_t offset;
730 offset = (db->nhash + 1) * PTR_SZ; /* +1 for free list ptr */
731 /*
732 * We're just setting the file offset for this process
733 * to the start of the index records; no need to lock.
734 * +1 below for newline at end of hash table.
735 */
736 if ((db->idxoff = lseek(db->idxfd, offset+1, SEEK_SET)) == -1)
737 err_dump("db_rewind: lseek error");738 }739 /*
740 * Return the next sequential record.
741 * We just step our way through the index file, ignoring deleted
742 * records. db_rewind must be called before this function is
743 * called the first time.
744 */
745 char *
746 db_nextrec(DBHANDLE h, char *key)747 {
748 DB *db = h;
749 char c;
750 char *ptr;
[720~738] db_rewind函数用于把数据库重置到“起始状态”,将索引文件的文件偏移量设置为指向第一条索引记录(紧跟在散列表之后)。(回忆图20-2中索引文件的结构。)
[739~750] db_nextrec 函数返回数据库的下一条记录。返回值是指向数据缓冲区的指针。如果调用者提供的key参数非空,将相应的键复制到该缓冲区中。调用者负责分配可以存放键的足够大的缓冲区。大小为IDXLEN_MAX字节的缓冲区足够存放任意键。记录按数据库文件中存放的顺序逐一返回。也就是说,记录并不按键值大小排序。另外,db_nextrec并不跟随散列链表,所以已删除的记录也会被读取,但是不向调用者返回这种已删除记录。
751 /*
752 * We read lock the free list so that we don't read
753 * a record in the middle of its being deleted.
754 */
755 if (readw_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
756 err_dump("db_nextrec: readw_lock error");
757 do {
758 /*
759 * Read next sequential index record.
760 */
761 if (_db_readidx(db, 0) < 0) {
762 ptr = NULL; /* end of index file, EOF */
763 goto doreturn;
764 }
765 /*
766 * Check if key is all blank (empty record).
767 */
768 ptr = db->idxbuf;
769 while ((c = *ptr++) != 0 && c == SPACE)
770 ; /* skip until null byte or nonblank */
771 } while (c == 0);/* loop until a nonblank key is found */
772 if (key != NULL)
773 strcpy(key, db->idxbuf); /* return key */
774 ptr = _db_readdat(db);/* return pointer to data buffer */
775 db->cnt_nextrec++;
776 doreturn:
777 if (un_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
778 err_dump("db_nextrec: un_lock error");
779 return(ptr);
780 }
[751~756] 对空闲链表加读锁,使得正在读该链表时,其他进程不能从中移除记录。
[757~771] 调用_db_readidx读下一个记录。传送给该函数的偏移量参数值为0,以此通知该函数从当前偏移量继续读索引记录。因为正在逐条顺序读索引文件,所以会读到已删除的记录。仅需返回有效记录,所以跳过键是全空格的记录(回忆_db_dodelete函数以设置全空格方式清除键)。
[772~780] 当找到一有效键时,如果调用者已提供缓冲区,则将该键复制到该缓冲区。然后读数据记录,并将返回值设置为指向包含数据记录的内部缓冲区的指针值。将统计计数器值加1,对空闲链表解锁,最后返回指向数据记录的指针。
通常在下列形式的循环中使用db_rewind和db_nextrec这两个函数:
db_rewind(db);
while ((ptr = db_nextrec(db, key)) != NULL) {
/* process record */
}
前面曾警告过,记录的返回没有一定的顺序,它们并不按键的顺序返回。
如果db_nextrec函数在循环中被调用时数据库正在被修改,则db_nextrec返回的记录只是变化中的数据库在某一时间点的快照(snapshot)。db_nextrec被调用时总是返回一条“正确”的记录,也就是说它不会返回一条已删除的记录。但有可能一条记录刚被db_nextrec返回后就被删除。类似地,如果db_nextrec刚跳过一条已删除的记录,这条记录的空间就被一条新记录重用,除非用db_rewind重新遍历一遍,否则在结果中看不到这条新的记录。如果通过db_nextrec获得一份数据库的准确的“冻结”的快照很重要,则在这段时间内应该不做插入和删除操作。
下面来看db_nextrec使用的加锁。因为并不使用任何散列链表,也不能判断每条记录属于哪条散列链。所以有可能当db_nextrec读取一条记录时,其索引记录正在被删除。为了防止这种情况,db_nextrec 对空闲链表加读锁,这样就可避免与_db_dodelete 和_db_findfree相互影响。
在结束对 db.c 源文件的说明之前,对向文件末尾追加索引记录或数据记录时的加锁再做一些说明。在第1种和第3种情况中,db_store调用_db_writeidx和_db_writedat时,第3个参数为0,第4个参数为SEEK_END。这里,第4个参数作为一个标志用来告诉这两个函数,新的记录将被追加到文件的末尾。_db_writeidx用到的技术是对索引文件加写锁,加锁的范围从散列链的末尾到文件的末尾。这不会影响其他数据库的读进程和写进程(这些进程将对散列链加锁),但如果其他进程此时调用 db_store 来追加数据则会被锁住。_db_writedat使用的方法是对整个数据文件加写锁。同样这也不会影响其他数据库的读进程和写进程(它们甚至不对数据文件加锁),但如果其他用户此时调用 db_store 来向数据文件追加数据则会被锁住(见习题20.3)。
为了测试这一数据库函数库,也为了获得一些与典型应用的数据访问模式有关的时间测量数据,编写了一个测试程序。该程序接受两个命令行参数:要创建的子进程的个数和每个子进程向数据库写的数据记录的条数(nrec)。然后(通过调用db_open)创建一个空的数据库,通过fork创建指定数目的子进程,等待所有子进程结束。每个子进程执行以下步骤。
(1)向数据库写nrec条记录。
(2)通过键值读回nrec条记录。
(3)执行下面的循环nrec×5次。
(a)随机读一条记录。
(b)每循环37次,随机删除一条记录。
(c)每循环11次,随机插入一条记录并读取这条记录。
(d)每循环 17 次,随机替换一条记录为新记录。在连续两次替换中,一次用同样大小的记录替换,一次用比以前更长的记录替换。
(4)将此子进程写的所有记录删除。每删除一条记录,随机地查找10条记录。
DB结构的cnt_xxx变量记录对数据库进行的操作数,这些变量的值在函数中增加。每个子进程的操作数一般都会与其他子进程不一样,因为每个子进程用来选择记录的随机数生成器是根据其进程ID来初始化的。每个子进程操作的典型计数值见图20-6。
读取的次数大约是存储和删除的10倍,这可能是许多数据库应用程序的典型情况。
每一个子进程只对该子进程所写的记录执行这些操作(读取、存储和删除)。由于所有的子进程对同一个数据库进行操作(虽然对不同的记录),所以会使用并发控制。数据库中的记录总数与子进程数成比例。(当只有一个子进程时,一开始有nrec条记录写入数据库;当有两个子进程时,一开始有nrec ×2条记录写入数据库,依此类推。)
通过运行测试程序的3个不同版本来比较加粗粒度锁和加细粒度锁提供的并发,并且比较3种不同的加锁方式(不加锁、建议性锁和强制性锁)。第一个版本使用 20.8 节中的源代码,称为细粒度锁版本。第二个版本通过改变加锁调用而使用粗粒度锁,20.6节对此已介绍过。第三个版本将所有加锁例程均去掉,这样可以计算出加锁的开销。通过改变数据库文件的权限标志位,还可以使第一个版本和第二个版本(加细粒度锁和加粗粒度锁)使用建议性锁或强制性锁(本节所有的测试中,仅对加细粒度锁的实现测量了采用强制性锁的时间)。
图20-6 每个子进程操作的典型计数值
本节所有的测试都是在一台运行Linux 3.2.0的Intel Core-i5系统上运行的。这个系统拥有4个内核,因此可以允许至多4个进程并发运行。
1.单进程的结果
图20-7显示了只有一个子进程运行的结果,nrec分别为2 000、6 000和12 000。
图20-7 单子进程、不同的nrec和不同的加锁方法
最后12列显示的是以秒为单位的时间。在所有的情况下,用户CPU时间加上系统CPU时间都基本上等于时钟时间。这一组测试受CPU限制而不是受磁盘操作限制。
中间6列(建议性锁)对加粗粒度锁和加细粒度锁的结果基本一样。这是可以理解的,因为对于单个进程来说加粗粒度锁和加细粒度锁并没有区别,除了额外的fcntl调用。
比较不加锁和加建议性锁,可以看到加锁调用在系统CPU时间上增加了32%~73%。即使这些锁实际上并没有使用过(因为只有一个进程运行),fcntl 系统调用仍会有一些时间的开销。用户CPU时间对4种不同的加锁方法基本上一样,这是因为用户代码基本上是一样的(除了调用fcntl的次数有些不同)。
关于图20-7要注意的最后一点是强制性锁比建议性锁增加了13%~19%的系统CPU时间。由于对加强制性细粒度锁和加建议性细粒度锁的调用次数是一样的,所以增加的系统开销来自读和写。
最后的测试是有多个子进程的不加锁的程序。与预期的一样,结果是随机的错误。一般错误情况包括:添加到数据库中的记录找不到、测试程序异常退出等。几乎每次运行测试程序,都有不同的错误发生。这是典型的竞争条件—多个进程在没有任何加锁的情况下修改同一个文件,错误情况不可预测。
2.多进程的结果
下一组测试主要目的是比较粗粒度锁和细粒度锁的不同。前面说过,由于加细粒度锁时数据库的各个部分被锁住的时间比加粗粒度锁少,所以从直觉上说,加细粒度锁应该能提供更好的并发性。图20-8显示了nrec取2 000,子进程数从1~16的测试结果。
图20-8 nrec=2000时不同加锁方法的比较
所有的用户时间、系统时间和时钟时间的单位均为秒。所有这些时间均是父进程与所有子进程的总和。关于这些数据有许多需要考虑。
首先要注意的是,当使用多进程时,用户时间和系统时间之和超过了时钟时间。乍看起来这有点奇怪,不过当采用多核时是正常的。此时,所有并发的进程在运行时其时间会累积起来;所显示的CPU处理时间是程序运行的所有核运转的时间之和。因为可以并发多个进程(每个核运行一个进程),所以CPU处理时间会超过时钟时间。
第 8 列(标记为“Δ时钟”),是加建议性粗粒度锁与加建议性细粒度锁的运行时钟时间的百分比差。从中可以看到使用细粒度锁得到了多大的并发性。在运行测试的系统上,对于单一进程加粗粒度锁与加细粒度锁相比效果几乎相同。而对于多进程,使用粗粒度锁的时间消耗会增大(约30%)。
我们希望从粗粒度锁到细粒度锁时钟时间会减少,当启用多进程后结果也确实如此。然而,我们预期当对任意数量的进程使用细粒度锁时系统时间仍然会保持较高值,因为使用细粒度锁会发出更多的fcntl调用。如果将图20-6中的fcntl调用次数加在一起,会发现对于粗粒度锁其平均值为87 858,对于细粒度锁其平均值为115 520。基于此,我们认为由于增加了31%的fcntl调用,所以会增加细粒度锁的系统时间。然而,在测试中加细粒度锁的两个进程其系统时间减少了,超过两个进程的系统时间只有小幅增加,这让人困惑。
出现这种情况有两个原因。首先,图 20-7 显示,当没有对锁进行竞争时,粗粒度锁和细粒度锁的时间之间没有显著的差别。这说明对于额外的fcntl调用所引起的CPU负载并没有影响测试程序的性能。其次,使用粗粒度锁时,持有锁的时间较长,这也就增加了其他进程因等待该锁而陷入阻塞的可能性;而使用细粒度锁时,加锁的时间较短,进程被阻塞的可能性就降低了。如果计算 fcntl 的阻塞次数,会发现在使用粗粒度锁时,进程阻塞频率更高。例如,当有 4 个进程时,使用粗粒度锁的阻塞次数几乎是使用细粒度锁的阻塞次数的5倍。正是这些粗粒度锁需要休眠和唤醒进程的额外时间增加了系统时间,最终降低了两种锁的系统时间差异。
最后一列(标记为“△系统”),是从加建议性细粒度锁到加强制性细粒度锁的系统 CPU时间百分比的增量。从这些值可以看到,随着并发数的增加,强制性锁显著增加了系统时间(20%~76%)。
由于所有这些测试的用户代码几乎一样(对加建议性细粒度锁和强制性细粒度锁增加了一些fcntl调用),因此预期对每一行的用户CPU时间应基本一样。
当我们第一次运行这些测试时,测试显示对于多进程完成锁的使用,其粗粒度锁的用户时间几乎是细粒度锁的两倍。因为两个数据库版本是相同的,除了调用 fcntl 的次数不同,因此这说不通。在调查研究之后,我们发现使用粗粒度锁时会有更多的竞争,进程也就会等待更久,操作系统于是就决定降低CPU时钟频率来节约电量。在使用细粒度锁时,会有更多的活动,于是系统提高了 CPU 时钟频率。这使得使用粗粒度锁比使用细粒度锁运行得慢。在禁用系统频率调整特性后,我们的测试结果就没有这些偏差了,用户时间的差别也就小多了。
图20-8的第一行与图20-7中的nrec取2 000的那一行很相似。这与预期一致。
图20-9是图20-8中加建议性细粒度锁的数据图。我们绘制了进程数从1~16的时钟时间,也绘制了用户CPU时间除以进程数后的每进程用户CPU时间,另外还绘制了每进程系统CPU时间。
注意,这两个每进程CPU时间都是线性的,但时钟时间是非线性的。可能的原因是:当进程数增大时,操作系统用于进程切换的CPU时间增多。操作系统的开销会增加时钟时间,但不会影响单个进程的CPU时间。
用户 CPU 时间随进程数增加的原因可能是因为数据库中有了更多的记录。每一条散列链更长,所以_db_find_and_lock函数平均要运行更长时间来找到一条记录。
图20-9 图20-8中使用建议性细粒度锁的数据
本章详细介绍了一个数据库函数库的设计与实现。考虑到篇幅,这个函数库尽可能小和简单,但也包括了多进程并发访问需要的对记录加锁的功能。
此外,还使用不同数量的进程以及不同的加锁方法:不加锁、建议性锁(细粒度锁和粗粒度锁)和强制性锁,研究了这个函数库的性能。可以看到加建议性锁比不加锁在时钟时间上增加了29%~59%,加强制性锁比加建议性锁耗时再增加约15%。
20.1 在_db_dodelete 中使用的加锁是比较保守的。例如,如果等到真正要用空闲链表时再加锁,则可获得更大的并发性。如果将调用 writew_lock 移到调用_db_writedat 和_db_readptr之间会发生什么呢?
20.2 如果db_nextrec不对空闲链表加读锁而被读的记录正在被删除,描述在怎样的情况下, db_nextrec 会返回正确的键但是空的(不正确的)数据记录。(提示:查看_db_dodelete。)
20.3 20.8节的结尾部分描述了_db_writeidx和_db_writedat的加锁。我们说过这种加锁不会干涉除了调用 db_store 之外的其他的读进程和写进程。如果改为强制性锁,这还成立吗?
20.4 怎样把fsync集成到这个数据库函数库中?
20.5 在db_store中,先写数据记录,然后再写索引记录。如果将顺序颠倒,会发生什么?
20.6 建立一个新的数据库并写入一些记录。写一个程序调用db_nextrec来读数据库中的每条记录,并调用_db_hash来计算每条记录的散列值。根据每条散列链上的记录数画出直方图。_db_hash中的散列函数是否能满足需求?
20.7 修改数据库函数,使得索引文件中散列链的数目可以在数据库建立时指定。
20.8 比较两种情况下数据库函数的性能:(a)数据库与测试程序在同一台机器上;(b)数据库与测试程序在不同的机器上,经由NFS进行访问。这个数据库函数库提供的记录锁机制还能工作吗?
20.9 只有当键缓冲区和数据缓冲区与其所需的大小精确匹配时,数据库才会返回空闲链表记录。请修改数据库以使空闲链表可以使用于较大的缓冲区来满足需求。应该如何更改数据库的永久格式来支持这种特性呢?
20.10 在实现了习题20.9的方案后,编写一个工具以使数据库格式可以从一种转换为另一种。