可扩展存储引擎






4.94/5 (56投票s)
简要概述可扩展存储引擎技术。
引言
在本文中,我将尝试简要概述可扩展存储引擎技术。该技术已推出三年,算不上新技术。它被用于创建诸如 Active Directory 和 Exchange 2000 等产品,但广大开发人员对此并不了解。在业内人士之间,它被称为 JET Blue。
大约在 2005 年底,微软决定发布 JET Blue,并赋予它一个更具吸引力的商业名称——可扩展存储引擎。他们认为该技术已经足够成熟,可以惠及许多人。
在此,我将尽可能描述该技术的目的、优缺点以及潜在的应用范围。但在那之前,让我们先来看看一些典型的状态保存场景。
典型场景 - 应用程序如何保存其状态
许多应用程序在关闭时需要将其状态保存在数据仓库中。开发人员通常根据应用程序的需求选择数据仓库(除非有特殊的营销要求)。
例如,对于一个应用程序来说,仅保存用户设置可能就足够了。而对于第二个应用程序,保存其工作成果可能更为重要。对于第三个应用程序,保存一些中间信息可能至关重要。根据这些需求,程序员可以选择不同类型的数据仓库——文件、Windows 注册表、关系数据库管理系统 (RDBMS)、Active Directory 等,或者它们的组合。
数据量通常是选择数据仓库时的基本考量因素。除此之外,访问和/或搜索速度以及使用数据仓库的便利性也是重要的考量因素。
让我们更详细地考虑这些标准。假设我想创建一个全新且改进的记事本。记事本是一款桌面应用程序,在选择数据仓库时我不应该遇到麻烦——文件将保存在用户指定的路径下。此外,用户设置将保存在 Windows 注册表中。
接下来,假设我想创建一个可以超越 Google 的搜索系统。那么,我需要考虑在哪里存储收集到的 URL。作为后端存储,我确信我会选择一个久经考验的(也可能是最易于获取的)RDBMS。这个决定对于服务器应用程序来说是经典的,我不需要过多思考。
现在,让我们想象一下,我正计划与 The Bat 和 Eudora 这样的流行邮件客户端竞争,甚至可能是 Outlook Express。最终,我会面临一个问题——程序在哪里存储电子邮件。是分开存储:每个邮件一个文件?还是将它们全部保存在一个平面文件中?这两种方案都不是最佳选择。
如果我们选择“每个邮件一个文件”的方案,打开文件将耗费大量时间。因此,“全文搜索”以及排序和分组将花费很长时间。
另一方面,使用“所有邮件在一个文件”的方案,我将节省打开文件的时间,但在搜索每封邮件的开头时会浪费时间。虽然“全文搜索”的实现会更容易,因为每封邮件的开头都已经计算出来了,但排序和分组显然需要非平凡的算法。此外,在关键字段(发件人、收件人、主题)上的搜索速度将大打折扣。
如果我的邮件客户端不能像其他邮件客户端那样进行搜索、分组和排序,即使速度相同,我也几乎没有竞争的机会。看起来(使用文件作为数据仓库的)蛮力方法在这种情况下是行不通的。这使我倾向于使用小型数据库的方法。但要求是没有服务器,并且没有复杂的安装过程。换句话说,关系数据库对用户来说必须是不可见的。
在做出任何决定之前,让我们看一个小表格。表格反映了 Windows 注册表、平面文件、Active Directory 和 RDBMS 等数据仓库中最有趣的特征。
注册表 | 文件 | Active Directory | RDBMS | |
保存大量信息的能力 | 不提供 | 提供 | 提供 (1) | 提供 |
搜索速度 | - | 低功耗 | 高 | 高 |
数据仓库是外部服务 | 否 | 否 | 是 | 是 |
数据仓库应先安装 | 否 | 否 | 否 (2) | 是 |
写入数据仓库的权限 | 写入 Windows 注册表的权限 | 写入文件的权限 | 写入 Active Directory 的权限 | 写入数据库表(或表)的权限 |
数据仓库支持事务 | 否 | 否 | 否 | 是 |
使用复杂度 (3) | 低功耗 | 低功耗 | 中心 | 中心 |
- 目录能够存储大量信息。但是,微软不建议为此目的使用 Active Directory。微软建议使用 ADAM 作为替代。
- 当然,前提是应用程序部署在域环境中。
- 使用复杂度是一个主观特征。然而,许多开发人员似乎在处理 Windows 注册表或文件系统时比处理 Active Directory 或 RDBMS 感觉更自信。
以下是对表格中特征的一些说明:
- 数据仓库是外部服务 - 这一行描述了应用程序对外部服务的依赖(或独立)。如果应用程序的可用性严重依赖于数据仓库的可用性,这一点可能很重要。
- 数据仓库应先安装 - 这一行显示了应用程序部署阶段可能出现的潜在复杂性(或限制)。
- 写入数据仓库的权限 - 这一行指出了产品运营期间可能产生的数据仓库管理费用。
- 使用复杂度 - 从这一行可以看出,开发成本如何随着所使用数据仓库的变化而增加。
对于我们这个“并非微不足道”的案例,从表格中可以明显看出,使用 Windows 注册表和/或平面文件的主要问题在于搜索速度慢(或不存在——在 Windows 注册表的情况下)。在使用 Windows 注册表时,也无法存储大量数据。
Active Directory(如果我们忽略微软的建议)可以提供高速搜索,但需要相应的环境和权限。安装 RDBMS(或 ADAM)的要求对 RDBMS(或 ADAM)来说没有带来任何优势。此外,Active Directory、RDBMS 和 ADAM 在应用程序运行时可能无法访问。这对于某些应用程序来说是不可接受的。另外,需要注意的是,RDBMS 是列表中唯一支持事务的数据仓库。
不能说我们的“非平凡”案例过于特殊和理想化。例如,微软有许多产品,由于其架构原因,在不使用 RDBMS 的情况下使用了关系数据库的功能。以下是一些最知名的例子:
- DHCP 服务器
- WINS 服务器
- Exchange Server
- 当然,还有 Active Directory
对于类似的问题,微软采用了一个引擎,允许替换关系数据库。该引擎被称为 JET Blue。JET 是“Joint Engine Technology”的缩写。Blue 是该项目经理 T 恤的颜色(开玩笑 :))。实际上,我不知道“Blue”这个词在库名称中的含义,但它允许将其与微软 Office Access 使用的另一个名为 JET Red 的引擎区分开来。除了 JET 前缀,这两个引擎之间没有任何共同之处。JET Blue 的实现与 JET Red 的实现截然不同,并且在所有关键参数上都优于它。
经过这次简短的介绍,让我们深入探讨主题的细节。
可扩展存储引擎
如前所述,不久前,微软以“可扩展存储引擎”(ESE) 的名称发布了 JET Blue 接口。包含 ESE 头文件和 lib 文件的最早 SDK 版本是 Windows Server 2003 SP1 Platform SDK。处理此接口的函数实现在一个名为 (esent.dll) 的二进制文件中。该接口在 Windows 2000 及以上版本可用。
让我们看看制造商对 ESE 的说法。下面引述了 MSDN(经过少量编辑)的介绍:
可扩展存储引擎 (ESE) 是一种先进的索引顺序访问方法 (ISAM) 存储技术。ESE 使应用程序能够使用索引或顺序光标导航从表中存储和检索数据。它支持反范式模式,包括具有大量稀疏列、多值列以及稀疏和丰富索引的宽表。它通过事务数据更新和检索使应用程序能够享受一致的数据状态。提供崩溃恢复机制,即使在系统崩溃的情况下也能保持数据一致性。它通过写前日志和快照隔离模型提供 ACID(原子性、一致性、隔离性、持久性)事务。ESE 中的事务高度并发,因此 ESE 对服务器应用程序很有用。它缓存数据以最大限度地提高高性能数据访问。 [...]
ESE 适用于需要快速和/或轻量级结构化数据存储的应用程序,而原始文件访问或注册表无法满足应用程序的索引或数据大小要求。
它被用于那些数据量从不超过 1 兆字节的应用程序,并且在一些极端情况下,其数据库大小超过 1 TB,通常超过 50 GB。
本文档面向熟悉 C 和 C++ 以及诸如表、列、索引、恢复和事务等基本数据库概念的开发人员。 [...]
可扩展存储引擎是 Windows 的一个组件,于 Windows 2000 中引入。并非所有功能或 API 在所有 Windows 操作系统版本中都可用。
ESE 提供了一个用户模式存储引擎,它将数据管理在平面二进制文件中,这些文件可以通过 Windows API 访问。ESE 通过一个直接加载到应用程序进程中的 DLL 进行访问;数据库引擎本身不提供或需要远程访问方法。虽然 ESE 没有远程或进程间访问方法,但其使用的数据文件可以通过 Server Message Block (SMB) 通过 Windows API 进行远程提供,但不推荐这样做。
注意:Windows XP 64 位版本在确定 ESE 支持的功能集时,与 Windows Server 2003 相同。
这里有很多具体的技术术语。但总的来说,听起来不错。我们将尝试将这些信息分类。为此,我们将扩展我们的比较表,并添加 ESE。
注册表 | 文件 | ESE | Active Directory | RDBMS | |
保存大量信息的能力 | 不提供 | 提供 | 提供 | 提供 (1) | 提供 |
搜索速度 | - | 低功耗 | 高 | 高 | 高 |
数据仓库是外部服务 | 否 | 否 | 否 | 是 | 是 |
数据仓库应先安装 | 否 | 否 | 否 | 否 (2) | 是 |
写入数据仓库的权限 | 写入 Windows 注册表的权限 | 写入文件的权限 | 写入文件的权限 | 写入 Active Directory 的权限 | 写入数据库表(或表)的权限 |
数据仓库支持事务 | 否 | 否 | 是 | 否 | 是 |
使用复杂度 (3) | 低功耗 | 低功耗 | 高 | 中心 | 中心 |
- 请参阅上一表的第一个注释。
- 请参阅上一表的第二个注释。
- 请参阅上一表的第三个注释。
从邮件客户端的角度来看,JET Blue 是一个理想的方案。我可以存储大量数据,可以独立于外部服务,可以进行快速搜索和排序,并且可以支持事务。对于邮件客户端来说,除了一个好的后端,还需要什么呢?
因此,使用该库可以获得使用 RDBMS 的便利性。但是,有两点对开发人员很重要,需要注意:
- 重要的是不要产生错觉——ESE 不是 RDBMS。ESE 没有诸如外部键、触发器、存储过程、用户类型、访问权限区分等常见的 RDBMS 服务。
- 使用 ESE 会增加开发成本。由于 MSDN 中的现有文档比较简陋,学习新技术通常需要更高的成本。不幸的是,MSDN 中 ESE API 的使用示例缺失。
总体印象
在前一节中已经提到,所有 ESE 功能都在用户模式下实现,位于一个名为 (esent.dll) 的二进制文件中。该 DLL 直接加载到客户端应用程序的地址空间。这种轻量级以及引擎的强大功能,值得其设计者称赞。
所有函数的原型都包含在 (esent.h) 文件中。所有错误消息也在此处列出。如果您要在 Windows XP 上进行开发,函数声明可能会让您感到意外,但不会造成任何问题。问题在于,处理字符串的函数仅支持 Windows XP 的 (char
)(窄字符)。不提供 (wchar_t
) 函数。
这不会造成主要问题,因为唯一的“char
”函数是那些操作文件名和二进制文件位置的函数,以及操作表名和表中列名的函数。应用程序应使用 WideCharToMultibyte
将文件路径从 Unicode 转换为 ANSI(其中 CodePage 参数应设置为当前代码页 - CP_ACP)。此外,应用程序在命名表和列时应使用 char 字符。
在数据存储和读取过程中,使用 (void*
) 数组和数组大小(特别是在存储和读取 Unicode 字符串时),即存储和读取 Unicode 字符串不会造成问题。
看看引擎如何提供 Unicode 字符串索引很有意思。为了支持包含 Unicode 字符串的列中的索引,ESE 会对这些列中的 Unicode 字符串进行规范化。您不必担心“规范化”一词。“规范化” Unicode 字符串是计算对应给定字符串的键的操作。该键又是一个 (string
)。但是,与原始 (string
) 不同,它是有结构的,并且(通常)长度小于原始 (string
) 的长度。之后,生成的键可以用于排序和搜索,而不是原始 (string
)。
规范化是通过 LCMapString
函数完成的,该函数使用原始 (string
) 和区域设置标识符作为参数来计算键(确切术语是“排序键”)。该算法会导致一个有趣的副作用,迫使开发人员在创建 Unicode (string
) 列的索引时格外注意。我将在下面详细描述这个副作用。
事务机制是另一个有趣的特性。事务是通过快照技术实现的,与经典的 ANSI SQL 隔离模型不同。简而言之,快照技术的基本原理是:在事务开始时,数据库状态被冻结。在一个事务中所做的更改对另一个事务不可见。快照技术及其与经典 ANSI SQL 隔离模型的区别在“ANSI SQL 隔离级别批判”(H. Berenson, P. Bernstein, J. Gray, J. Melton, E. O'Neil, and P. O'Neil)一文中进行了描述。稍后我们将回到事务的讨论。
在此需要补充的是,数据库的最终大小可以达到 16 TB。从实际角度来看,这意味着数据库的大小仅受硬盘容量以及备份、恢复、碎片整理等各种服务的限制。
开始工作
让我们从简单的开始——创建数据库和表,例如。但在此之前,我们应该初始化 ESE 子系统。
初始化
在创建数据库之前(更确切地说,在我们开始处理 ESE 之前),我们应该初始化 ESE。初始化分两步进行:
- 第一步,我们创建一个 ESE 实例(使用
JetCreateInstance
/JetCreateInstance2
函数族)。 - 第二步,应初始化创建的实例(使用 JetInit/
JetInit2
/JetInit3
函数族)。
让我们详细考虑这些步骤。要创建 ESE 实例,可以使用 JetCreateInstance
或 JetCreateInstance2
函数。
JET_ERR JET_API JetCreateInstance(
JET_INSTANCE *pinstance,
const char *szInstanceName
);
JET_ERR JET_API JetCreateInstance2(
JET_INSTANCE *pinstance,
const char *szInstanceName,
const char *szDisplayName,
JET_GRBIT grbit
);
此函数接受一个指向 JET_INSTANCE
变量的指针,创建实例(从函数名可以看出),并返回一个指向它的指针。我们将省略其他参数。它们在 MSDN 中得到了非常仔细的描述,从 ESE 技术角度来看,它们没有提供任何重要信息。我们也将忽略这两个函数之间的差异(差异不重要,并在 MSDN 中有描述)。接下来,我们将继续这样做。我应该指出,库中包含许多以 2 或 3 结尾的函数。这些函数似乎是在库开发过程中创建的。名称中带有数字的函数扩展了没有数字或数字较小的基础函数的能力。进一步,为了简单起见,我们还将考虑具有较短签名的函数。
在调用 JetCreateInstance
后,调用 JetInit
将引擎置于就绪状态。此函数还接受一个指向 JET_INSTANCE
变量的指针。
JET_ERR JET_API JetInit(
JET_INSTANCE *pinstance
);
pinstance
变量应包含 JetCreateInstance
(JetCreateInstance2
)返回的值或 0(稍后将讨论此值)。
如果我们组合两个调用,初始化代码将如下所示:
try
{
JET_INSTANCE instance = JET_instanceNil;
JET_ERR err = ::JetCreateInstance(
&instance, // return value
"{0A9A6617-8AE9-4c5e-AF28-01D5D4820C23}"
// unique name of ESE instance
);
if(JET_errSuccess != err)
{
throw CError(err);
}
err = ::JetInit(
&instance // created ESE instance
);
if(JET_errSuccess != err)
{
throw CError(err);
}
}
catch(const CError& e)
{
::JetTerm(0);
}
为了减少示例中的代码量,我将使用 `CError` 类(我自己的,本文未描述)抛出的异常。当捕获到异常时,我们应该(当然)关闭所有打开的句柄(使用 JetCloseTable
、JetEndSession
、JetTerm
)。此外,我将跳过示例中的这些函数调用。
上面的代码示例将在 Windows XP 和 Windows 2003 操作系统上正常工作。但它在 Windows 2000 上将无法工作。我们将收到运行时错误。问题在于,此操作系统对应的 `esent.dll` 不包含 JetCreateInstance
和 JetCreateInstance2
函数。这意味着我们只能在 Windows 2000 操作系统上使用一个(“零”)ESE 引擎实例。这就是为什么我们应该将零传递给 `JetInit` 函数(Windows 2000 上也没有 `JetInit2` 和 `JetInit3`)作为 `pinstance` 参数的值。
因此,对于 Windows 2000,ESE 子系统的初始化将如下所示:
JET_ERR err = ::JetInit(
0 // zero is default ESE instance
);
if(JET_errSuccess != err)
{
throw CError(err);
}
看起来简单多了,不是吗? :)
需要注意的是,上面的示例在 Windows 2000 和 Windows XP(Windows 2003)上都能正常工作。通过将零作为实例指针传递给 JetInit
函数,我们告知 ESE 我们将在旧版模式(Windows 2000 兼容模式)下运行。在此模式下,ESE 每个进程只支持一个引擎实例。
初始化 ESE 后的下一步是创建会话。
创建会话
会话是执行所有数据库操作的上下文。我可以将其类比于 RDBMS——ESE 中的会话对应于 RDBMS 中的连接。
创建会话的代码很简单——会话通过调用 JetBeginSession
创建。
JET_ERR JET_API JetBeginSession(
JET_INSTANCE instance,
JET_SESID *psesid,
const char *szUserName,
const char *szPassword
);
该函数接受一个之前已创建并初始化的 JET_INSTANCE
变量(或 Windows 2000 的情况下的零)。会话标识符将作为结果返回。
JET_SESID sessionID = JET_sesidNil;
err = ::JetBeginSession(
0, // zero as ESE instance
&sessionID, // return value
0, // reserved
0 // reserved
);
if(JET_errSuccess != err)
{
throw CError(err);
}
由于每个会话都是执行所有数据库操作的上下文,因此每个会话都控制着事务边界。事务只能在一个会话的范围内开始和结束。应用程序可以创建多个到数据库的会话,从而可能提高其性能。
ESE 初始化和会话创建的相关工作现已完成。
创建数据库
使用 JetCreateDatabase
来(惊喜地)创建数据库。该函数接受会话标识符和数据库文件名(名称可以是完整名称或相对名称)。该函数返回数据库标识符。
JET_DBID dbID = JET_dbidNil;
err = ::JetCreateDatabase(
sessionID, // the session identifier
"test.db", // file name
0, // reserved
&dbID, // return value
0 // zero flag - just create the database
);
if(JET_errSuccess != err)
{
throw CError(err);
}
每个进程中的 ESE 实例(我相信您还记得 Windows 2000 每个进程只能容纳一个 ESE 实例)最多可以创建和使用七个数据库。
创建表
创建表的函数声明如下:
JET_ERR JET_API JetCreateTableColumnIndex(
JET_SESID sesid,
JET_DBID dbid,
JET_TABLECREATE *ptablecreate
);
所有必要信息都通过 JET_TABLECREATE
结构传递。结构声明(来自头文件)如下:
typedef struct tagJET_TABLECREATE
{
unsigned long cbStruct; // size of this structure (for future expansion)
char *szTableName; // name of table to create.
char *szTemplateTableName; // name of table from which to inherit base DDL
unsigned long ulPages; // initial pages to allocate for table.
unsigned long ulDensity; // table density.
JET_COLUMNCREATE *rgcolumncreate; // array of column creation info
unsigned long cColumns; // number of columns to create
JET_INDEXCREATE *rgindexcreate; // array of index creation info
unsigned long cIndexes; // number of indexes to create
JET_GRBIT grbit;
JET_TABLEID tableid; // returned tableid.
unsigned long cCreated; // count of objects created (columns+table+indexes).
} JET_TABLECREATE;
我们不检查该结构的所有字段。我们省略了 `cbStruct`、`szTableName`、`rgcolumncreate`、`cColumns`、`rgindexcreate`、`cIndexes` 字段,因为它们的目的从注释中就可以明显看出。此外,我们跳过 `szTemplateTableName`、`ulDensity` 和 `grbit`,因为它们在 MSDN 中有详细描述(可以轻松设置为 `null`)。`ulPages` 字段稍后讨论。
有两个返回值。第一个是已创建表的游标标识符(tableid)。*请不要被表 ID 的名称及其类型(JET_TABLEID
)混淆。实际上,根据名称和类型,它并不是表标识符,而是游标标识符。在表创建或打开后可以获得它。MSDN 在提及此变量时使用“光标”一词。*
第二个返回值是数据库中创建的对象数量(`cCreated`)。此数字包括创建的列、表和索引的数量。如果发生错误,`cCreated` 字段的值未定义。(我未能找到此字段可以使用的案例,因此它似乎无用。)
有关表列的信息通过 JET_COLUMNCREATE
结构传递。我在此列出头文件中的结构:
typedef struct tag_JET_COLUMNCREATE
{
unsigned long cbStruct; // size of this structure (for future expansion)
char *szColumnName; // column name
JET_COLTYP coltyp; // column type
unsigned long cbMax; // the maximum length of this column
// (only relevant for binary and text columns)
JET_GRBIT grbit; // column options
void *pvDefault; // default value (NULL if none)
unsigned long cbDefault; // length of default value
unsigned long cp; // code page (for text columns only)
JET_COLUMNID columnid; // returned column id
JET_ERR err; // returned error code
} JET_COLUMNCREATE;
该结构没有惊喜。这里列出了最重要的字段:
coltyp
- 列的类型。ESE 支持舒适开发所需的所有数据类型,即整数、浮点数、字符串、二进制数据(包括用于存储大型数据的两种数据类型,最大 2,147,483,647 字节——JET_coltypLongBinary
和JET_coltypLongText
)、日期和货币。完整列表可在JET_COLTYP
类型说明中找到。cbMax
- 最大长度(适用于字符串或二进制数据类型)。grbit
- 标志定义了列的特征。此标志的值超过十个。有一些必要(且不言自明)的值,如 `JET_bitColumnNotNULL`、`JET_bitColumnAutoincrement` 和 `JET_bitColumnMultiValued`。但是,也有一些特定值,如 `JET_bitColumnUserDefinedDefault`(此标志表示列的值是通过开发人员实现的 callback 函数提供的)。我已跳过所有这些标志值的解释(它们在 MSDN 中有详细描述),只有一个;我将在后面回到这个值。pvDefault
- 列的默认值。cp
- 将用于该列的代码页编号(更准确地说,是字符串列)。仅支持两个值——英文 (1252) 和 Unicode (1200)。此外,英文 (1252) 是默认值。所以,如果您传递零,将使用 1252 代码页。(对于非字符串类型,此值当然会被忽略。)
我想在 `grbit` 标志上多说几句——关于该标志的 `JET_bitColumnTagged` 值。此值用于标记一个保存其数据与表分开的列。列中只包含引用,而不是数据。数据存储在另一个表中。我很快就会解释它的含义。
ESE 在执行数据库文件操作时使用“页大小”值。页大小(以字节为单位)定义了以下方面:
- 首先,数据库文件只能以“页大小”字节为增量进行扩展。
- 其次,也是更重要的是,“页大小”设置了表中行可以具有的最大大小。
默认情况下,“页大小”为 4096 字节。可以使用 JetSetSystemParameter
函数,通过传递“system”参数 JET_paramDatabasePageSize
来更改此值。可接受的值为 2048、4096 和 8192。*ESE 有许多“system”参数,用于控制引擎行为的不同方面,例如:*
- 数据库位置
- 文件名
- 回调函数注册
- 日志记录等。
所有参数都使用(前面提到的) JetSetSystemParameter
函数设置。该函数将参数名称(标识符)和参数的新值——数字、指针或字符串(取决于参数类型)作为参数。
因此,为了保存超出“页大小”的数据,使用了 `JET_bitColumnTagged` 标志。通常,该标志应用于 `JET_coltypLongBinary` 和 `JET_coltypLongText` 数据类型。有时,使用此标志是强制性的。例如,对于多值列(`JET_bitColumnMultiValued` 标志),也应传递 `JET_bitColumnTagged` 标志。
现在我们来考虑返回值。与 JET_TABLECREATE
结构一样,JET_COLUMNCREATE
结构也有两个返回值。但在这种情况下,两个返回值都很有用。第一个返回值是刚刚创建的列的标识符(该标识符将用于更新表和从中选择)。第二个返回值是描述列未创建原因的错误代码(如果未创建)。
创建表所需的最后一个结构是 JET_INDEXCREATE
。该结构描述了表的主键和索引。
typedef struct tagJET_INDEXCREATE
{
unsigned long cbStruct; // size of this structure (for future expansion)
char *szIndexName; // index name
char *szKey; // index key
unsigned long cbKey; // length of key
JET_GRBIT grbit; // index options
unsigned long ulDensity; // index density
union
{
// lcid for the index (if JET_bitIndexUnicode NOT specified)
unsigned long lcid;
// pointer to JET_UNICODEINDEX struct
// (if JET_bitIndexUnicode specified)
JET_UNICODEINDEX *pidxunicode;
};
union
{
unsigned long cbVarSegMac;
// maximum length of variable length columns
// in index key (if JET_bitIndexTupleLimits specified)
#ifdef JET_VERSION_SERVER2003
// pointer to JET_TUPLELIMITS struct
// (if JET_bitIndexTupleLimits specified)
JET_TUPLELIMITS *ptuplelimits;
#endif // ! JET_VERSION_SERVER2003
};
// pointer to conditional column structure
JET_CONDITIONALCOLUMN *rgconditionalcolumn;
// number of conditional columns
unsigned long cConditionalColumn;
// returned error code
JET_ERR err;
} JET_INDEXCREATE;
该结构比前面的结构更有趣。以下是预期属性:
szIndexName
- 索引名称grbit
- 标志(该标志允许定义诸如索引是否为主键、索引是否唯一、是否可以使用 `NULL` 值进行索引等属性)ulDensity
- 索引密度
该结构包含一些有趣的字段:
szKey
- 指定索引标准的键:用于索引的字段列表以及这些字段的排序顺序。标准定义得相当简单优雅——键是一个字符串。该 `string` 以双 `null` 终止,并且本身由字符串组成(每个字符串也以 null 终止)。子字符串格式为:<排序><列>。因此,键“+FirstColumn\0-SecondColum\0
”创建一个以“FirstColumn
”升序排序和“SecondColumn
”降序排序的索引。cbKey
- 键的长度。lcid
- 在 Unicode 规范化期间使用的区域设置标识符。文档没有详细说明,但似乎该标识符传递给LCMapString
函数。ESE 仅在未设置 `JET_bitIndexUnicode` 标志时才使用此值。pidxunicode
- 指向JET_UNICODEINDEX
结构(如果设置了 `JET_bitIndexUnicode` 标志)。该结构允许更好地控制LCMapString
的行为。它允许除了 `lcid` 参数外,还将 `dwMapFlags` 传递给LCMapString
。cbVarSegMac
- 用于构建索引的索引键中每个可变长度列的字节数。如果指定零值,则每个索引列使用 255 字节(JET_cbPrimaryKeyMost
和JET_cbSecondaryKeyMost
常量)。只有当 `grbit` 中未指定 `JET_bitIndexTupleLimits` 时,才使用 `cbVarSegMac`。结构定义和注释来自 Microsoft Windows Server 2003 R2 平台 SDK 的 `esent.h` 文件。请注意 `cbVarSegMac` 的注释,它包含一个错误。应将“索引键中可变长度列的最大长度(如果指定了 `JET_bitIndexTupleLimits`)”替换为“索引键中可变长度列的最大长度(如果**未**指定 `JET_bitIndexTupleLimits`)”。ptuplelimits
- 指向JET_TUPLELIMITS
结构的指针。该结构允许为元组索引构建指定参数。元组索引使得能够使用子字符串在 `string` 中进行搜索(如果未使用元组索引,则只能搜索 `string` 的开头)。通过索引字符串的所有可能子字符串来实现此目标。元组索引有一些限制(由于元组索引的特性,这些限制是符合逻辑的)。元组索引不能是主键,元组索引不能是唯一索引,最后,元组索引只能包含一个列(该列当然是一个文本列)。JET_TUPLELIMITS
结构很简单,它包含要索引的子字符串的最小和最大长度,以及要索引的目标字符串的最大长度。如果指定了 `JET_bitIndexTupleLimits`,ESE 将使用此结构。rgconditionalcolumn
- 指向JET_CONDITIONALCOLUMN
结构的指针。该结构允许创建“条件”索引。条件索引仅包含满足条件索引标准的表中的条目(行)。例如,所有不为 `NULL` 的 `string`。目前仅支持两个条件(它们的名称不言自明):`JET_bitIndexColumnMustBeNull` 和 `JET_bitIndexColumnMustBeNonNull`。
每个表都应有一个主键。但是,在创建表时可以省略主键。如果缺少主键,ESE 会在不引起开发者注意的情况下创建它。JET_INDEXCREATE
结构像 JET_COLUMNCREATE
结构一样,有一个 `err` 字段用于在发生错误时返回错误代码。
总结一下,我们来看一段创建“TestTable
”的代码,该表有两个列(“PK
”和“Value
”)和两个键(主键 - “PK_index
”和普通索引“Value_index
”)。
JET_COLUMNCREATE columnCreate[2] = { 0 };
columnCreate[0].cbStruct = sizeof(JET_COLUMNCREATE);
columnCreate[0].szColumnName = "PK";
columnCreate[0].coltyp = JET_coltypLong;
columnCreate[0].grbit = JET_bitColumnAutoincrement;
columnCreate[0].err = JET_errSuccess;
columnCreate[1].cbStruct = sizeof(JET_COLUMNCREATE);
columnCreate[1].szColumnName = "Value";
columnCreate[1].coltyp = JET_coltypLongText;
columnCreate[1].cbMax = 1024;
columnCreate[1].grbit = JET_bitColumnTagged;
columnCreate[1].cp = 1200;
columnCreate[1].err = JET_errSuccess;
JET_INDEXCREATE indexCreate[2] = { 0 };
indexCreate[0].cbStruct = sizeof(JET_INDEXCREATE);
indexCreate[0].szIndexName = "PK_index";
indexCreate[0].szKey = "+PK\0";
indexCreate[0].cbKey =
static_cast< unsigned long >(::strlen(indexCreate[0].szKey) + 2);
indexCreate[0].grbit = JET_bitIndexPrimary;
indexCreate[0].err = JET_errSuccess;
indexCreate[1].cbStruct = sizeof(JET_INDEXCREATE);
indexCreate[1].szIndexName = "Value_index";
indexCreate[1].szKey = "+Value\0";
indexCreate[1].cbKey =
static_cast< unsigned long >(::strlen(indexCreate[1].szKey) + 2);
indexCreate[1].grbit = JET_bitIndexUnique;
indexCreate[1].err = JET_errSuccess;
JET_TABLECREATE tableCreate = { 0 };
tableCreate.cbStruct = sizeof(tableCreate);
tableCreate.szTableName = "TestTable";
tableCreate.rgcolumncreate = columnCreate;
tableCreate.cColumns =
sizeof(columnCreate) / sizeof(columnCreate[0]);
tableCreate.rgindexcreate = indexCreate;
tableCreate.cIndexes =
sizeof(indexCreate) / sizeof(indexCreate[0]);
tableCreate.tableid = JET_tableidNil;
err = ::JetCreateTableColumnIndex(sessionID, dbID, &tableCreate);
if(JET_errSuccess != err)
{
throw CError(err);
}
基本操作
添加、修改和删除
添加,就像修改表中的现有条目(行)一样,通过 JetPrepareUpdate
/JetSetColumns
/JetUpdate
函数三元组执行。在描述 JetSetColumns
之前,我们将查看开始和停止数据修改的函数:JetPrepareUpdate
和 JetUpdate
。
使用 JetPrepareUpdate
函数,用户指定操作的类型:
JET_ERR JET_API JetPrepareUpdate(
JET_SESID sesid,
JET_TABLEID tableid,
unsigned long prep
);
prep
定义了操作的类型。
可以执行三个基本操作:
JET_prepCancel
- 取消当前光标的任何先前操作JET_prepInsert
- 用于插入新条目(行)JET_prepReplace
- 用于编辑当前光标指向的条目(行)
有三个其他操作是基本操作的变体:
JET_prepInsertCopy
- 允许插入当前光标指向的条目(行)的副本JET_prepInsertCopyDeleteOriginal
- 允许插入当前光标指向的条目(行)的副本,并删除原始条目(行)。该标志用于修改主键。JET_prepReplaceNoLock
- 与 `JET_prepReplace` 相同,但不锁定当前条目(行)。
调用 `JetUpdate` 函数后,数据将进入表。
JET_ERR JET_API JetUpdate(
JET_SESID sesid,
JET_TABLEID tableid,
void *pvBookmark,
unsigned long cbBookmark,
unsigned long *pcbActual
);
该函数返回一个书签。书签是主键的规范化形式,以后可以使用 JetGotoBookmark
函数定位创建的条目(行)。可以将书签保存在另一个表中以组织表之间的引用(ESE 不支持外键)。
好的,现在是时候分析准备插入数据的函数了。
JET_ERR JET_API JetSetColumns(
JET_SESID sesid,
JET_TABLEID tableid,
JET_SETCOLUMN *psetcolumn,
unsigned long csetcolumn
);
所有必要信息都通过 JET_SETCOLUMN
结构传递。
typedef struct
{
JET_COLUMNID columnid;
const void *pvData;
unsigned long cbData;
JET_GRBIT grbit;
unsigned long ibLongValue;
unsigned long itagSequence;
JET_ERR err;
} JET_SETCOLUMN;
除了目的明确的字段外,例如:
columned
- 列标识符(在表创建期间获得;可以使用 `JetGetColumnInfo` 函数获得)pvData
- 指向缓冲区cbData
- 缓冲区大小grbit
- 标志(主要用于插入和编辑“长”数据类型和多值列)err
- 错误代码(如果发生错误)
有两个字段需要解释。第一个是 `ibLongValue`,它允许分块读取“long
”数据类型(`JET_coltypLongBinary` 和 `JET_coltypLongText`)。此字段包含要检索的列的第一个字节的偏移量。对于其他类型,它应为零。第二个字段 `itagSequence` 用于保存多值列的已编辑值的序号。在插入多值列时,该字段应为零。如果列是单值列,该字段也应为零。
让我们将所有这些放在一起,为我们在前面创建的表中插入条目(行)编写一个示例。
err = ::JetPrepareUpdate(sessionID, tableCreate.tableid, JET_prepInsert);
if(JET_errSuccess != err)
{
throw CError(err);
}
JET_SETCOLUMN setColumn = { 0 };
wchar_t szFirstRow[] = L"FirstInsertedRow";
setColumn.columnid = columnCreate[1].columnid;
setColumn.pvData = szFirstRow;
setColumn.cbData = sizeof(szFirstRow);
setColumn.err = JET_errSuccess;
err = ::JetSetColumns(sessionID, tableCreate.tableid, &setColumn, 1);
if(JET_errSuccess != err)
{
throw CError(err);
}
err = ::JetUpdate(sessionID, tableCreate.tableid, 0, 0, 0);
if(JET_errSuccess != err)
{
throw CError(err);
}
要删除数据,使用 JetDelete
函数。该函数定义非常简单。
JET_ERR JET_API JetDelete(
JET_SESID sesid,
JET_TABLEID tableid
);
该函数删除光标指向的条目(行)。
很有趣的是,上面的示例会失败。如果我们合并表创建示例和条目插入示例,编译结果并运行它,`JetSetColumns` 函数将返回 `JET_errNotInTransaction` 错误代码。这意味着——“操作必须在事务中进行”。
让我解释一下为什么 ESE 要求将 `JetSetColumns` 调用包装在事务中。原因是修改“长”数据类型(您还记得,“long
”值是与表分开存储的)。因此,(由于错误)没有事务,“long
”值可能与表状态不匹配。为了避免这种情况,ESE 要求在修改“long
”数据类型时使用事务。如果 `JET_coltypText` 是第一列的类型(但不是 `JET_coltypLongText`,如示例中所示),我们将避免使用事务。
既然谈到了事务,让我们详细讨论一下。
事务
事务的开始、结束和取消分别由 JetBeginTransaction
、JetCommitTransaction
和 JetRollback
函数表示。这些函数很容易理解(只需看看它们)。
JET_ERR JET_API JetBeginTransaction(
JET_SESID sesid
);
JET_ERR JET_API JetCommitTransaction(
JET_SESID sesid,
JET_GRBIT grbit
);
JET_ERR JET_API JetRollback(
JET_SESID sesid,
JET_GRBIT grbit
);
这些函数很简单。唯一有趣的参数 `grbit` 在 MSDN 中进行了描述(通常为零)。没有什么可补充的。因此,我们将讨论事务在 ESE 中的实现方式。
正如我在文章开头提到的,事务机制是通过快照技术实现的。可以将快照表示为数据库内容的“冻结”——当会话首次进入事务时,数据库被冻结。“解冻”在事务完成(JetCommitTransaction
)或取消(JetRollback
)时执行。如果事务已完成,那么在事务期间所做的所有更改都将插入数据库。
由于这种机制,事务不使用读锁,因为会话在事务中只能读取在事务开始时存在的那些值。然而,写锁是正常执行的。假设事务 A 修改了一些数据。ESE 会考虑这一点,并不允许事务 B 修改相同的数据。如果事务 B 尝试修改此数据,它将收到 `JET_errWriteConflict` 错误。如果发生此错误,事务 B 应完成(提交或回滚)。然后可以重试数据修改。
ESE 将快照保存为事务修改的页面不同版本集的集合。我们可以将此机制视为“写时复制”保护。事务 A 在修改数据之前操作数据库中的原始页面。当事务 A 开始修改页面上的数据时,ESE 会用副本替换页面,并将页面标记为禁止写入。如果事务 B 决定修改同一页面,它(事务 B)将收到 `JET_errWriteConflict` 错误。如果事务 A 已提交,ESE 会将修改后的页面复制到数据库中。否则(如果事务已取消),ESE 会丢弃修改后的页面。这非常简单且合乎逻辑。事务使用的页面数量可以通过 JET_paramMaxVerPages
“system”参数进行管理。
事务可以嵌套。这一点没有什么新的:对于每次嵌套调用 JetBeginTransaction
,都应该有一个嵌套的 JetCommitTransaction
或 JetRollback
调用。当最外层的 JetBeginTransaction
或 JetRollback
调用完成时,事务就完成了。有一个例外——可以调用带有 `JET_bitRollbackAll` 标志的 JetRollback
。这意味着所有内部事务都将被终止。
我想就事务的使用补充几点。
第一点是关于线程依赖性。默认情况下,在开始事务后,使用会话标识符的所有函数调用都应在调用 JetBeginTransaction
的同一线程中执行。似乎此限制并非必不可少。此外,可以使用 JetSetSessionContext
和 JetResetSessionContext
函数来避免此限制。
第二个特性更有趣。看起来,有事务的代码执行速度可能比没有事务的代码快。原因在于,如果会话中没有当前活动的事务,ESE 会将任何读写数据的函数调用包装在事务中。当然,这会影响应用程序的性能。因此,MSDN 建议在应用程序执行上述操作时使用事务。
那么,回到我们的例子。正确版本(此版本不会返回 `JET_errNotInTransaction`)如下所示:
err = ::JetBeginTransaction(sessionID);
if(JET_errSuccess != err)
{
throw CError(err);
}
err = ::JetPrepareUpdate(sessionID, tableCreate.tableid, JET_prepInsert);
if(JET_errSuccess != err)
{
::JetRollback(sessionID, 0);
throw CError(err);
}
JET_SETCOLUMN setColumn = { 0 };
wchar_t szFirstRow[] = L"FirstInsertedRow";
setColumn.columnid = columnCreate[1].columnid;
setColumn.pvData = szFirstRow;
setColumn.cbData = sizeof(szFirstRow);
setColumn.err = JET_errSuccess;
err = ::JetSetColumns(sessionID, tableCreate.tableid, &setColumn, 1);
if(JET_errSuccess != err)
{
::JetRollback(sessionID, 0);
throw CError(err);
}
err = ::JetUpdate(sessionID, tableCreate.tableid, 0, 0, 0);
if(JET_errSuccess != err)
{
::JetRollback(sessionID, 0);
throw CError(err);
}
err = ::JetCommitTransaction(sessionID, 0);
if(JET_errSuccess != err)
{
::JetRollback(sessionID, 0);
throw CError(err);
}
同样,我将向表中插入三个不同的值——“SecondInsertedRow
”、“ThirdInsertedRow
”和“FourthInsertedRow
”。我们将在下一节中使用它们。
数据读取
让我们看看如何从表中读取数据。我们将从最简单的情况开始——按顺序读取表中的所有条目(行)。要读取数据,我们应该将当前光标与表索引关联起来。完成此操作后,我们就可以使用当前光标遍历表。通过 JetSetCurrentIndex
函数执行关联:
JET_ERR JET_API JetSetCurrentIndex(
JET_SESID sesid,
JET_TABLEID tableid,
const char *szIndexName
);
其中 `szIndexName` 应等于“PK_index
”或“Value_index
”——我们之前创建的索引之一。
建立索引和光标之间的链接后,就可以使用 JetMove
函数进行导航:
JET_ERR JET_API JetMove(
JET_SESID sesid,
JET_TABLEID tableid,
long cRow,
JET_GRBIT grbit
);
并使用 JetRetrieveColumn
函数读取数据:
JET_ERR JET_API JetRetrieveColumn(
JET_SESID sesid,
JET_TABLEID tableid,
JET_COLUMNID columnid,
void *pvData,
unsigned long cbData,
unsigned long *pcbActual,
JET_GRBIT grbit,
JET_RETINFO *pretinfo
);
这两个函数(移动和读取)都很简单。我不会详细介绍它们。我必须指出,`JetMove` 可以将光标导航到相对(例如,下一条目)和绝对(第一条或最后一条)位置。`JetRetrieveColumn` 函数通过 `grbit` 标志控制读取的细微之处(这些细微之处过于具体,不便详细讨论)。此外,`grbit` 和 `pretinfo` 用于读取多值和“long
”数据类型。
让我们看看将所有内容组合在一起的结果。以下是使用“Value_index
”对表进行顺序读取的示例:
err = ::JetSetCurrentIndex(sessionID, tableCreate.tableid, "Value_index");
if(JET_errSuccess != err)
{
throw CError(err);
}
for(err = ::JetMove(sessionID, tableCreate.tableid, JET_MoveFirst, 0);
JET_errSuccess == err;
err = ::JetMove(sessionID, tableCreate.tableid, JET_MoveNext, 0))
{
unsigned long nPK = 0;
unsigned long nReadBytes = 0;
err = ::JetRetrieveColumn(sessionID,
tableCreate.tableid,
columnCreate[0].columnid,
&nPK,
sizeof(nPK),
&nReadBytes,
0,
0);
assert(nReadBytes == sizeof(nPK));
std::wcout << nPK;
if(JET_errSuccess != err)
break;
wchar_t buffer[1024] = { 0 };
err = ::JetRetrieveColumn(sessionID,
tableCreate.tableid,
columnCreate[1].columnid,
buffer,
sizeof(buffer),
&nReadBytes,
0,
0);
assert(nReadBytes < sizeof(buffer));
std::wcout << L'\t' << buffer << std::endl;
}
if(JET_errNoCurrentRecord != err)
{
throw CError(err);
}
如果我们分析程序编译和执行时的控制台输出,我们可以确保值是按照“Value_index
”的递增顺序读取的。
1 FirstInsertedRow
4 FourthInsertedRow
2 SecondInsertedRow
3 ThirdInsertedRow
当然,仅仅逐行读取并不是我们可能想要做的最有用的一件事。我认为搜索使用得更频繁。搜索由 JetSetCurrentIndex
、JetMakeKey
、JetSeek
和 JetSetIndexRange
函数支持。
我们已经见过第一个函数。`JetSetCurrentIndex` 函数允许指定当前索引的名称。让我们考虑其他三个函数。
`JetMakeKey` 函数设置一个用于数据搜索的键(标准)。该键与当前索引和当前光标相关联(这意味着 `JetMakeKey` 函数应在 `JetSetCurrentIndex` 函数调用之后调用)。使用此函数可以为构成索引的每个列指定值。
JET_ERR JET_API JetMakeKey(
JET_SESID sesid,
JET_TABLEID tableid,
const void *pvData,
unsigned long cbData,
JET_GRBIT grbit
);
参数足够简单:
pvData
缓冲区包含索引当前列的值。值数据类型应与列数据类型完全匹配——ESE 不执行任何类型转换。- 缓冲区长度通过 `cbData` 参数传递。
grbit
标志使得创建新键或继续创建当前键成为可能。此外,该标志允许指定搜索标准。文档关于此标志的值相当模糊(可能不正确)。实验表明,`JET_bitStrLimit`、`JET_bitSubStrLimit`、`JET_bitPartialColumnStartLimit` 和 `JET_bitPartialColumnEndLimit` 标志会影响JetSetIndexRange
函数的行为(我们稍后会看到)。
`JetSeek` 函数将当前光标定位到满足搜索标准的条目(如果存在)。
JET_ERR JET_API JetSeek(
JET_SESID sesid,
JET_TABLEID tableid,
JET_GRBIT grbit
);
grbit
参数指示定位发生的方式。有几种变体;我将描述更常用的变体:
JET_bitSeekEQ
允许将光标定位在“正好匹配搜索键的索引条目”(MSDN 引用)。换句话说,当使用主键进行搜索时,此值最合适。- 除了 `JET_bitSeekEQ` 值之外,还可以使用 `JET_bitSeekGE`、`JET_bitSeekGT`、`JET_bitSeekLE` 和 `JET_bitSeekLT` 值(名称不言自明)。类似地,定位发生在最接近索引开头且满足标准的条目(大于或等于、大于、小于或等于、小于)。
- `JET_bitCheckUniqueness` 标志允许验证是否只有一个条目满足搜索键。
JET_bitSetIndexRange
- 我们稍后将讨论此标志。
如果可能根据搜索键定位到条目,`JetSeek` 函数返回 `JET_errSuccess`。如果没有找到条目,则函数返回 `JET_errNoCurrentRecord`。
好的,让我们看看如何定位到“Value_index
”的条目,从“Fo
”开始(即,此索引的第二条目)。
wchar_t bufferSearchCriteria[] = L"Fo";
err = ::JetMakeKey(sessionID,
tableCreate.tableid,
bufferSearchCriteria,
sizeof(bufferSearchCriteria),
JET_bitNewKey);
if(JET_errSuccess != err)
{
throw CError(err);
}
for(err = ::JetSeek(sessionID, tableCreate.tableid, JET_bitSeekGE);
!(err < 0);
err = ::JetMove(sessionID, tableCreate.tableid, JET_MoveNext, 0))
{
unsigned long nPK = 0;
unsigned long nReadBytes = 0;
err = ::JetRetrieveColumn(sessionID,
tableCreate.tableid,
columnCreate[0].columnid,
&nPK,
sizeof(nPK),
&nReadBytes,
0,
0);
assert(nReadBytes == sizeof(nPK));
std::wcout << nPK;
if(JET_errSuccess != err)
break;
wchar_t buffer[1024] = { 0 };
err = ::JetRetrieveColumn(sessionID,
tableCreate.tableid,
columnCreate[1].columnid,
buffer,
sizeof(buffer),
&nReadBytes,
0,
0);
assert(nReadBytes < sizeof(buffer));
std::wcout << L'\t' << buffer << std::endl;
}
if(JET_errNoCurrentRecord != err)
{
throw CError(err);
}
示例将按照“Value_index
”的递增顺序打印条目,从找到的第一个开始。
4 FourthInsertedRow
2 SecondInsertedRow
3 ThirdInsertedRow
显然,我们限制了样本,正如我们计划的那样——找到的第一个条目是startswith“Fo
”的条目。
要找到仅以“Fo
”开头的条目,需要另一个函数调用。那么,是时候使用 JetSetIndexRange
函数了。
JET_ERR JET_API JetSetIndexRange(
JET_SESID sesid,
JET_TABLEID tableid,
JET_GRBIT grbit
);
此函数形成一个临时索引值范围,其中范围的一个边界是当前索引的当前位置。第二个边界是满足搜索条件的最后一个值。范围的形成方式取决于 `grbit` 标志。MSDN 中描述了四种可用值。为了避免直接引用 MSDN,我只描述其中两种:
- `JET_bitRangeInclusive` 标志指定边界是否应包含在范围内。
- 当搜索条件(由
JetMakeKey
函数创建)表示查找最接近当前索引末尾的条目时,应使用 `JET_bitRangeUpperLimit` 标志。这正是我们限制上一个示例时需要做的。
另外两个允许删除先前形成的索引并仅测试指定范围是否存在。它们在 MSDN 中有详细描述。
一旦形成范围,就可以使用 `JetMove` 函数(带有 `JET_MoveNext` 或正值作为 `cRow` 参数)遍历该范围(如果为 `JetSetIndexRange` 调用使用了 `JET_bitRangeUpperLimit` 参数;如果不是 `true`,则应反向迭代范围;在这种情况下,应将 `JET_MovePrevious` 或负值作为 `cRow` 参数传递)。使用任何其他参数调用 `JetMove` 将导致当前光标离开临时索引值范围。
err = ::JetMakeKey(sessionID,
tableCreate.tableid,
bufferSearchCriteria,
sizeof(bufferSearchCriteria),
JET_bitNewKey | JET_bitPartialColumnStartLimit);
if(JET_errSuccess != err)
{
throw CError(err);
}
// set the current cursor to the "FourthInsertedRow" entry
err = ::JetSeek(sessionID, tableCreate.tableid, JET_bitSeekGE);
if(err < 0)
{
throw CError(err);
}
err = ::JetMakeKey(sessionID,
tableCreate.tableid,
bufferSearchCriteria,
sizeof(bufferSearchCriteria),
JET_bitNewKey | JET_bitPartialColumnEndLimit);
if(JET_errSuccess != err)
{
throw CError(err);
}
// form the temporary range
for(err = ::JetSetIndexRange(sessionID,
tableCreate.tableid,
JET_bitRangeInclusive | JET_bitRangeUpperLimit);
!(err < 0);
::JetMove(sessionID, tableCreate.tableid, JET_MoveNext, 0))
{
unsigned long nPK = 0;
unsigned long nReadBytes = 0;
err = ::JetRetrieveColumn(sessionID,
tableCreate.tableid,
columnCreate[0].columnid,
&nPK,
sizeof(nPK),
&nReadBytes,
0,
0);
if(JET_errSuccess != err)
break;
assert(nReadBytes == sizeof(nPK));
std::wcout << nPK;
wchar_t buffer[1024] = { 0 };
err = ::JetRetrieveColumn(sessionID,
tableCreate.tableid,
columnCreate[1].columnid,
buffer,
sizeof(buffer),
&nReadBytes,
0,
0);
assert(nReadBytes < sizeof(buffer));
std::wcout << L'\t' << buffer << std::endl;
}
我想请您注意以下几点:
- 在调用 `JetSetIndexRange` 之前,必须反复调用 `JetMakeKey` 函数。(第一次调用已在 `JetSeek` 调用之前完成)。原因是 `JetSeek`(`JetSetIndexRange` 也一样)会删除由前一次 `JetMakeKey` 调用创建的键。
- 第二次 `JetMakeKey` 调用使用 `bitPartialColumnEndLimit` 参数。此值定义了形成范围的算法。文档解释说,“使用此选项构建用于查找最接近索引末尾的索引条目的通配符搜索键”。凭直觉,很清楚这个选项是如何工作的——在 `JetSeek` 返回控制后,光标应定位在距离索引开头最远的位置。当我们调用 `JetSetIndexRange` 时,我们也观察到相同的行为——范围的第二个边界设置在离当前索引结尾更近的位置。
- 尽管 MSDN 说 `JetSetIndexRange` 的临时限制和范围可以通过连续调用 `JetMove` 函数来迭代,但这是不正确的。范围限制的是 `JetRetrieveColumn` 调用,而不是 `JetMove` 调用。在上面的示例中,循环在 `JetRetrieveColumn` 调用之后中断,而不是在 `JetMove` 之后(正如我们可能假设的那样)。
上面的示例将显示此行:
4 FourthInsertedRow
如果您计划使用全文搜索,您将需要特殊类型的索引——元组索引(上面已描述)。
现在我们回到 `JetSeek` 函数的 `JET_bitSetIndexRange` 标志(正如承诺的那样)。当我们使用 `JET_bitSetIndexRange` 标志调用 `JetSeek` 时,可以将其视为另一种搜索方式。但是,它与使用 `JetSetIndexRange` 函数的方式有所不同。
- 仅执行严格搜索。这意味着 `JET_bitSetIndexRange` 标志只能与 `JET_bitSeekEQ` 标志结合使用。尝试在没有 `JET_bitSeekEQ` 的情况下使用 `JET_bitSetIndexRange` 将失败。在我使用的 ESE 版本中,`JetSeek` 返回一个混淆的返回码 313(正返回码表示警告;不幸的是,esent.h 中没有这样的警告,所以我担心我们永远不会知道这个警告的含义)。后续的 `JetMove` 调用工作不正确。
- 我们不再需要预先调用 `JetSeek` 来将光标定位到样本的开头。`JetSeek` 调用(带 `JET_bitSetIndexRange` 参数)会立即形成范围。
- 应在不指定 `JET_bitStrLimit`、`JET_bitSubStrLimit`、`JET_bitPartialColumnStartLimit` 和 `JET_bitPartialColumnEndLimit` 参数的情况下执行 `JetMakeKey` 调用。这足够清楚了。由于搜索是精确匹配的结果,因此没有必要指定范围边界。
如果我们把所有代码片段组合起来,编译结果并运行它,我们将在创建的应用程序的工作目录中找到几个文件。这些文件是什么以及它们的用途,我们将在下一节中了解。
数据库由什么组成
ESE 使用一些文件进行服务。如果您在我们示例运行的目录中执行 `dir` 命令,您会找到以下文件:
01.07.2006 20:27 8 192 edb.chk
01.07.2006 20:27 5 242 880 edb.log
01.07.2006 20:26 5 242 880 res1.log
01.07.2006 20:26 5 242 880 res2.log
01.07.2006 20:27 1 056 768 test.db
数据库文件
唯一我们可以猜测其用途的文件是 `test.db` 文件——我们将此名称传递给了 JetCreateDatabase
函数。
该文件保存:
- 描述表的模式
- 表索引
- 表中的数据
文件大小为 1MB。随着新信息的插入,它会增长。可以使用 JET_paramDbExtensionSize
“system”参数(使用 JetSetSystemParameter
函数设置参数)来指定增长值。
事务日志文件
我们要讨论的下一个文件是事务日志文件。在我们的例子中,它名为 `edb.log`(可以使用 JET_paramBaseName
“system”参数更改名称)。
事务日志文件包含“对数据库文件的操作”(MSDN 引用)。该文件包含“足够的信息,可以在进程意外终止或系统关机后将数据库恢复到逻辑上一致的状态”(引用)。这意味着,如果应用程序或操作系统、电源等发生故障,事务文件将保留需要重放的操作列表,以便将数据库恢复到一致状态。此过程称为“软恢复”。ESE 在调用 JetInit
/JetInit2
/JetInit3
时执行此过程。
我们看到一个文件,但 MSDN 提到了多个文件。事实是,可能在流式传输时增长多个文件。让我们更仔细地看看这个过程。
当前事务被写入当前事务日志文件——`edb.log`。当当前事务日志达到 5MB 时,ESE 会将其重命名为 `edb000001.log`,并创建一个新的事务日志文件——再次命名为 `edb.log`。当此日志文件填满时,ESE 会将其重命名为 `edb000002.log`,依此类推。每个事务文件都有固定大小(默认 5MB,可以使用 JET_paramLogFileSize
参数更改此值)。
根据此算法,无法确定 `edb00000*.log` 文件中的哪个文件包含实际操作数据(它们对于在发生故障时恢复数据库是必需的)。或者更确切地说,很难说哪个文件不包含实际操作数据。这就是为什么我们不应该移动、删除、重命名或操作这些文件中的任何一个。
在以下情况下可以安全地删除事务文件:
- 执行完全数据库备份时(使用以下函数之一——
JetBackup
、JetTruncateLog
、JetTruncateLogInstance
)。 - 启用循环日志记录时。如果此选项已打开(
JET_paramCircularLog
“system”参数),不必要的日志文件会自动删除。
保留的事务日志文件
`res1.log` 和 `res2.log` 文件也是事务日志文件,但它们不是普通的日志文件,而是保留的。在引擎初始化期间,ESE 创建这些文件以确保“干净关机”的实现。
“干净关机”是什么意思?让我来描述一下。应用程序可能在磁盘空间不足的条件下启动。在这种情况下,ESE 无法创建新的事务日志文件。它唯一能做的就是分离数据库并关闭。但是,要以“干净”的方式执行此操作,ESE 可能需要额外的磁盘空间(例如,为当前操作写入回滚)。在这种情况下将使用事务日志文件。
检查点文件
`edb.chk` 文件是检查点文件。让我们检查一下这个文件的目的。
ESE 中有一个有趣的特性——对数据库的任何操作都会写入事务日志文件并在内存中持久化。(这里是另一个有趣的特性——对数据库的操作可能不像写入事务日志那样有序;根据 MSDN 的说法,这会提高效率)。
因此,事务日志可能包含已存在于数据库文件中的操作,也可能包含尚未进入数据库文件中的操作。检查点——它是一个标记事务日志文件确切位置的时间戳。此位置之前的所有信息都已存在于数据库文件中。此点之后的信息是否在数据库中是未知的。
edb.chk 文件(与事务日志文件一起)在“软恢复”过程中使用。
检查点文件的路径可以通过“系统”参数 JET_paramSystemPath
设置。
Unicode 字符串的索引
研究 ESE 如何处理基于 Unicode 字符串的索引(简称 Unicode 索引)很有趣。如前所述,如果 Unicode 字符串被插入、删除或从表中读取,是没有问题的。那么,与 Unicode 索引相关的有什么问题吗?
然而,在回答之前,我将提及 ESE 如何维护二级索引。ESE 将它们保存在单独的结构中,并允许在表创建后创建和删除二级索引。这对于主索引不成立。主索引在表创建期间创建,无法删除。
好的,现在我们可以继续了。我提醒大家,ESE 使用 LCMapString
来构建 Unicode 索引。虽然有一个副作用——此函数的结果可能因 Windows 操作系统版本不同而异。由于 ESE 使用 LCMapString
来创建用于排序的键,这意味着为 Windows 2000 操作系统创建的键可能与为 Windows XP 或 Windows 2003 创建的键不同。这也意味着在 Windows 2000 上创建的 Unicode 索引在 Windows XP 上可能无法正常工作。在“错误”操作系统上使用索引最可能的结果是无法通过该索引进行排序以及向表中插入错误的数据。
这对许多应用程序来说不是一个大问题。尽管如此,情况比乍一看要糟糕。LCMapString
即使在同一版本的 Windows 操作系统上,也可能返回不同的结果。这是因为函数行为可能在常规更新或安装了服务包后发生更改。
这是否意味着我们应该拒绝使用 Unicode 索引?简短的回答是:不,我们不应该。
完整的回答是:
- 我们不应将 Unicode 字符串用作主键。
- 如果使用二级 Unicode 索引,则必须在附加数据库之前执行特殊操作。
让我们从第二点开始。开发人员可以要求 ESE 验证操作系统使用的 NLS 版本(国家语言支持库——该库负责 LCMapString
)。如果已请求,ESE 在执行 JetAttachDatabase
时会检查 NLS 版本。如果当前 NLS 版本比用于创建 Unicode 索引的 NLS 版本更新,JetAttachDatabase
将返回错误。如果函数返回错误,则有必要使用新版本的 NLS 库重新创建 Unicode 索引。
让我们详细讨论这些步骤。默认情况下,JetAttachDatabase
不验证 NLS 版本。为了强制执行此检查,开发人员应该在调用 JetInit
之前将“系统”参数 JET_paramEnableIndexChecking
设置为 true。如果参数已正确设置,并且 JetAttachDatabase
发现过时的索引,它将返回以下任一结果:
第一个错误代码告诉我们应该重新创建二级 Unicode 索引。最后一个则告知我们主键已失效,无法重新创建。正是由于这个错误代码,才限制了将 Unicode 字符串用作主键。
但是,二级索引可以轻松地重新创建——这是一个简单的操作。首先,应该使用 JetDeleteIndex
函数删除该索引,然后使用 JetCreateIndex2
创建它。索引删除任务可以委托给 ESE。要实现此目标,有必要在调用 JetAttachDatabase
时传递 JET_bitDbDeleteCorruptIndexes
标志。损坏的索引将被删除,并且该函数将返回 JET_wrnCorruptIndexDeleted
值。我们只需重新创建索引。
MSDN 描述了另一种处理过时的 Unicode 索引的方法。然而,这种描述不小心且不一致。文档断言,如果开发人员在调用 JetInit
之前将 JET_paramEnableIndexCleanup
参数设置为 true,则可以自动删除损坏的索引。
在阅读“数据库引擎可能会在 JetInit
中自动清理 Unicode 键列上的索引”这句话后,问题出现了。不清楚这句话是否意味着引擎可能不会清理索引。下一段澄清了情况。它告知我们“增量清理”并非总是可能(不幸的是,上下文不清楚“增量清理”的含义)。如果无法清理,“索引将按照 JET_paramEnableIndexChecking
的规定进行处理”。这是否意味着如果无法清理,我们就应该做在使用 JET_paramEnableIndexChecking
时所做的事情?如果是,为什么我们要使用 JET_paramEnableIndexCleanup
参数?只有问题,没有答案。
esent.h 中的注释阐明了 JET_paramEnableIndexCleanup
参数的用途。注释中写道——“如果设置了 JET_paramEnableIndexCleanup
,将使用内部修复表来修复索引条目。这可能无法修复所有索引损坏,但对应用程序是透明的。”我们只能猜测这意味着什么。我的假设是,JET_paramEnableIndexCleanup
标志是尝试“实时”修复 NLS 问题,并且对应用程序是透明的。这种尝试似乎并不成功(“这可能无法修复所有索引损坏”),但我们知道存在解决方案(请参阅上面的两段)。
结论
总而言之,可以说微软为我们提供了一个非常成功的库。该库的主要优点是:
- 高效的引擎,支持海量数据。
- 库的轻量级,可用作进程内服务。
- 支持不同版本的 Windows(从 Windows 2000 开始)。
- 完整的崩溃和错误恢复机制,使应用程序在意外进程终止或系统关机后能够依赖数据一致性。
- 该库的可靠性在 Active Directory 和 Exchange 2000 等产品中得到了体现。
- 可以在 C# 中使用该库——http://managedesent.codeplex.com/ 项目提供了互操作二进制文件以操作 ESE NT(感谢 Thomas Krojer 提供此信息)。
作为缺点,我应该指出:
- 相比使用常规 RDBMS,开发成本可能更高。
- 缺乏“真正的”RDBMS 服务——触发器、存储过程、外键、安全性等。
客观地说,这项技术并非独一无二。有轻量级引擎可以作为进程内服务运行。例如——Firebird 和 SQLLite。虽然我无法评价前者,但我知道至少有一个成功部署后者的例子(即 SQLLite)在商业项目中。对上述引擎的比较当然值得单独考虑。
看来,首先,ESE 将引起需要可靠存储但因某些原因无法使用常规 RDBMS 的服务器应用程序创建者的兴趣。尽管如此,这项技术有可能对桌面应用程序开发者也具有吸引力,并对他们有所帮助。
参考文献
- MSDN:“Extensible Storage Engine” - http://msdn2.microsoft.com/en-us/library/ms684493.aspx
- Microsoft TechNet:“Extensible Storage Engine Architecture” - http://www.microsoft.com/technet/prodtechnol/exchange/guides/E2k3TechRef/764d8347-a99b-408a-a774-f1263797c3b0.mspx
- H. Berenson, P. Bernstein, J. Gray, J. Melton, E. O'Neil, and P. O'Neil. A critique of ANSI SQL isolation levels.