Realm 核心数据库引擎探秘

jopen 6年前

 

Realm 大部分代码都是开源的,但是其强大功能取决于隐藏在平台内部的一个核心数据库引擎,这个引擎完全由 C++ 编写而成。在这次讲座当中,来自 Realm 的工程师 JP Simard 将带领大家一探 Realm 的核心!JP 将阐述 Realm 设计背后的准则,包括 Realm 是如何保证快速高效运行的,以及为什么我们要自己撰写数据库引擎,而不是像包括 Core Data 在内的移动端数据库解决方案那样,采用 SQLite 作为内部核心。在这个讲座中,你还能够了解到如何创建一个高效的模型层,以及相比其他数据库解决方案来说 Realm 的优势何在。

See the discussion on Hacker News .

Transcription below provided by Realm: a replacement for SQLite & Core Data that makes building iOS apps a joy. Check out the docs!

Sign up to be notified of new videos — we won’t email you for any other reason, ever.

About the Speaker: JP Simard

JP 负责Realm 数据库 Objective-C 与 Swift 版本的构建,是 jazzy (苹果忘记发布的文档工具) 的创始人,他十分喜欢开发关于 Swift 的工具。

</div>

简介(0:00)

自从一年前我们发布 Realm 以来,我们得到了很多询问关于 Realm 工作原理的问题。我们已经很详细地阐述过使用 Realm 的方法以及 Realm 的优势何在,但是我们仍未向大家分享我们核心数据库引擎背后的复杂性(complexity)和强大能力。

我开始思考如何向大家分享为什么我们决定从头自己搭建 Realm 引擎,而不是在诸如 SQLite 这样的成熟、稳定的数据库核心的基础上构建 ORM。让我们来详细看一下是什么让 Realm 工作的,也就是目前我们正使用的 C++ 核心引擎。

对象就是一切(1:33)

Realm 最核心的理念就是对象驱动,这是 Realm 的核心原则。这个原则是我们从头自己搭建引擎,而不是采用现有的关系模型的原因之一。如果你看一下现有的解决方案,你会发现它们大多是 ORM 结构。通常情况下,人们下意识会使用面向对象模型,实际上也就是对底层发生的操作进行抽象。而 ORM 通常都是记录、带有外键的表以及主键的集合。一旦你开始建立关系,抽象步骤就会变得难上加难,因为你需要花费更多的操作来遍历这些关系。

让我们来直面这个问题,这也是大家构建应用的时候都会碰到的问题,这个问题从智能手机出现以来就困扰着大多数开发者。不幸的是,现有的数据库并没有考虑到这个功能。这带来的结果就是数据库存在着大量冗余复杂的映射层,如果要避免的话,就可以带来极大的优化。不仅优化了性能,而且实现方式还足够简单。

Realm 是什么?(3:17)

Realm 本质上是一个嵌入式数据库,但是它也是看待数据的另一种方式。它用另一种角度来重新看待你移动应用中的模型和业务逻辑。我们所做的就是尝试减少数据库读写的开销。我们尽可能让其运行得足够快,因此我们一直在调整性能数据。

我们同样尽可能添加了诸如零拷贝(zero-copy)之类的操作,这也是为什么我们有底气保证 Realm 能够替代你现在所使用的对象访问器。我们减少了从数据库读出数据的步骤,你只需要读出来,放到一个实例变量当中,然后就结束了。相反,你可以直接访问数据库。这样一来,你就不会复制任何东西,并且如果没有必要你也无需对数据进行反序列化(deserialize)。

Realm 目前正被很多人使用,就和其他优秀数据库一样,它兼容 ACID,并且是跨平台的。

Realm 长什么样?(4:17)

Swift

 let company = Company() // Realm 对象单例  company.name = "Realm" // 诸如此类...    let realm = Realm() // 默认的 Realm 数据库  realm.write { // 写操作事务    realm.add(company) // 持久化 Realm 对象  }    // 检索  let companies = realm.objects(Company) // 类型安全  companies[0].name // => Realm (泛型)  // 检索所有全职的名为"Jack"的人 (懒加载、可链)  let ftJacks = realm.objects(Employee).filter("name = 'Jack'")                  .filter("fullTime = true")

Objective-C

 // Realm 对象单例  Company *company = [[Company alloc] init];  company.name = @"Realm"; // 诸如此类...    // 写操作事务  RLMRealm *realm = [RLMRealm defaultRealm];  [realm transactionWithBlock:^{    [realm addObject:company];  }];  RLMResults *companies = [Company allObjects];  // 检索所有全职的名为"Jack"的人 (懒加载、可链)  RLMResults *ftJacks = [[Employee objectsWhere:@"name = 'Jack'"]                   objectsWhere:@"fullTime == YES"];

Java
 Realm realm = Realm.getInstance(this.getContext()); // 默认的 Realm 数据库  realm.beginTransaction(); // 事务  Company company = realm.createObject(Company.class); // 持久化  dog.setName("Realm"); // 诸如此类...  realm.commitTransaction();    // 检索  Company company = realm.where(Company.class).findFirst();  company.getName; // => Realm  // 检索所有全职的名为"Jack"的人 (懒加载、可链)  RealmResults<Employee> ftJacks = realm.where(Employee.class)                                        .equalTo("name", "Jack")                                        .equalTo("fullTime", true)                                        .findAll();

你可以通过 Realm 来创建对象,就和 Swift 或者 Objective-C 一样,然后设置其属性,但是 Realm 的对象带有数据库连接的概念。一旦你实例化 Realm 数据库后,你就成功地连接到数据库了。连接数据库并不如你所想的那样复杂。在 Realm 中,为了减少你可能会存储在内存中的数据量,我们会将内存进行映射。只要你向 Realm 中添加了company对象,这个对象就会变成访问器(accessor)。一旦你从中读取属性,你就不再是访问内存变量了,而是直接访问数据库中的数据,这样就可以免除一堆的内存拷贝以及至少四个到五个的读取步骤。

接下来是检索。在 Swift 中,我们使用泛型(和 Xcode 7 中的 Objective-C 很像),我们允许你像范围属性那样访问检索列表和检索结果。零拷贝和懒加载在你执行条件检索的时候得到了充分的展现,比如说打算检索一个公司中所有全职的名为”Jack”的人。即使我们在得到所有公司信息之前执行了同样的检索操作,我们仍不会从硬盘中读取任何的固原信息。相反,我们会编译这个检索对象。即时我们在此之后添加了条件,我们仍不会重复执行检索操作,我们简单搭建了一个结果结构的属性图。即使在这个检索外访问第一条检索结果,我们也不会从所有对象中读取所有的属性,因为这是懒加载的。这种行为允许我们能够得到不错的性能指标。

然而,这确实付出了许多代价。很多时候我们经常听到这样的话:“如果我能够在 Realm 中使用 Swift 结构体就好了。”没错,这的确会非常赞,但是根据目前 Swift 语言的设计方式,你就必须将整个结构体全部拷贝到内存当中,这也正是我们希望避免的。所以尽管你可以自己分离所有的对象,将它们放到内存中,然后从数据库中完全分离实际臃肿但是感觉轻巧的对象,这和我们致力于引导你如何构建应用的方向大相径庭。我们希望能够帮助你 使用 工具而不是 工具使用。

对于 Objective-C 来说,设计是非常相似的。对于 Java 来说,尽管有特定的检索函数,但是整体上设计是也是非常相似的。对于 Cocoa 框架,我们使用NSPredicate来检索。

为什么要从新构建 Realm 数据库?(7:37)

我们为什么决定设计自己的数据库引擎而不是使用已有的引擎呢?为什么要在已经有15年历史、有良好稳定性、鲁棒性以及久经考验的引擎的基础上独辟蹊径呢?

一部分原因我已经说过,就是避免 ORM 架构以及其带来的抽象方式。通过尽可能细致的分割和组合,我们能够减少复杂性。另一个原因就是市面上对商用数据库已经有了大量的研究和开发。

在上面的视频中有一幅图表,描绘出了自20世纪90年代后期以来数据库所进行的创新。查看视频中的幻灯片。

注意图表顶部的一系列活动,它代表着服务端数据库。每一个热点都意味着一个新的数据库出现。这里出现了很多创新,尤其是2007年智能手机出现的时候。你可能会认为对于服务器端数据库来说也一样,但是事实并非如此。

这是图标的下半部分,这就是移动端数据库。现在我们有 SQLite,这是2000左右出现的……然后之后有记载的就没有了。我们有许多基于 SQLite 构建的封装数据库:Core Data、ORMLite、greenDAO等等。许多优秀的产品都基于这个底层核心技术构建,但是没有一个产品在核心层替换掉数据库引擎。我们着眼于此,发现有大量能够给移动端带来的新技术,这些新技术都有十分强大的优点,但是限制是你只能在服务器端实现。

服务器端数据库为了满足服务器需求做出了许多妥协,比如说大规模分布(massively distribute)、多实例间共享,以及 Internet 低延迟访问等等。在我们为移动端设计的时候,就可以将这些限制扔掉,专注于提供最好的本地体验就可以了。

MVCC: 多版本并发控制(10:41)

我们的目标就是给移动端应用上这些新技术。其中一个就是 MVCC: 多版本并发控制, Multiversion Concurrency Control 。这和源代码管理算法(例如 Git)的设计是一样的。你可以将 Realm 内部的模型看作是 Git,因为它还包括了分支以及原子性提交(atomic commit)的概念。这意味着你无需完全复制所有的数据就可以在多个分支上工作。你可以执行一个语义化的写时拷贝类型(copy-on-wirte type of semantic)而无需但系另一个线程上的写操作会对其产生影响。事务当中将会完全摒除外界的影响,只需要很小的一点开销就可以实现这一点。

当事务开始的时候,你可以将所有的事务都视为数据库的一份快照。这就是为什么你能够在上百个线程中做大量的操作并同时访问数据库,却不会发生崩溃的原因。

另一个就是执行写操作事务无需阻塞读操作,并且也无需执行太多的簿记(bookkeeping)操作。你可以暂停写事务,然后在读事务中继续读取数据,因此即使有写操作事务正在运行,你也可以有多个读操作事务。在某种意义上,它是数据的一个常量,也就是一个快照。目前在构建级别上 Realm 的工作方式允许你在单独事务中修改信息。然而,借助底层方案,我们可以阻止发生在事务中的任何修改操作,然后你就可以免费得到数据的不可变性。基本上,通过我们的核心你得到的好处十分多,无需考虑诸如大多数 ORM 数据库还需要解决的底层数据库存储的问题。

本地链接(13:18)

链接无处不在,文件系统的核心就在于链接。在 Realm 中,在多链接中,也就是建立了关系的对象中执行检索是依靠“B-树”来进行的,因此检索的速度十分快速。你无需在 ORM 之间建立关系的双重抽象(dual abstraction),只需在文件系统层级的文件转换中,建立直接的对象链接即可。对于检索来说也是一样的,比如说检索整数列、关系、一对多关系甚至多对多关系都可以。

这正是对象图遍历的强大之处,因此当我们在以面向对象的方式设计移动应用的时候,我们通常就会采用这种方式。大家跟随我们的做法就没有任何问题。

我们在内核层级进行了一系列的优化,比如说在文件转换层中对本地链接进行了优化。这也正是为什么我们不能给现有的数据库引擎添加补丁以支持这种功能。有很多本质的修改是无法轻易地作为额外功能添加到现有的数据库引擎当中的。

String & Int 优化(14:18)

另一点就是我们对这些数据进行了优化。我们可以执行诸如转换之类的操作,比如说,你有一个供用户选择国家的下拉列表,然后你想要展示国家的名称。在我们的这个例子中,我们有丹麦、美国、加拿大、澳大利亚等等。你可能会有一个国家名称的巨型列表,里面包含了上百个国家,但是如果你的数据库中只会有几千个实体的话,那么你就会遇到字符串的大量重复。我们所能做的就是遍历你得字符串,然后将其转变为枚举,因此它们现在就像 Objective-C 中的标记指针(tagged pointer)哪样,提供快速查找的功能。

整数尽可能被包装成 int 类型以减少空间占用,这也就是为什么在 Realm 中,在模型中指定不同长度的整数都是没有任何问题的。Realm 会尽可能以最优化的方式在内部将整数存储为 int 型,因为其实现方式因此这种做法是几乎没有任何性能开销的。

崩溃保护(16:00)

崩溃保护(Crash Safety)是一个非常重要的内容。如果你的数据需求很小,比如说仅仅只是序列化一个二进制属性列表、JSON 文件或者其他类似的数据,但是有一个很严重的问题就是,当你在执行写操作的时候如果手机恰好中途没电关机了,再次打开你就会发现文件被完全损坏了。

Realm 重新思考了数据存储的方式,因此它可以很好的规避某些操作系统的 BUG 以及某些未预见的崩溃。在这种情况下能够切实保护你的用户数据。正如我所说,Realm 和大型“B-树”的结构很类似,并且在任何时候你的提交操作都是第一优先级的(和 Git 的HEAD提交类似)。当你在执行修改的时候,写时拷贝动作就会启动,这意味着你建立了一个“B-树”分支并且不会修改原有分支的数据,因此如果某些错误发生,原始数据仍不会被破坏。值得庆幸的是,由于良好的架构设计,顶层指针始终会指向未发生崩溃的树结构,你的写入操作是发生在别的地方。当最后你决定提交修改的时候,一旦我们确认数据能够安全地同步到硬盘当中,我们就会移动指针到这个新的正式版本上来。这就意味着在最坏的情况下,你只会丢失当前正在进行的修改,而不会丢失所有数据。

零拷贝(18:06)

知道大多数 ORM 数据库是怎么处理数据展示的么?你的数据大多数时间都静静地呆在硬盘当中。当你访问NSManagedObject对象中的某个属性的时候,Core Data 会将这个请求转换为一组 SQL 语句,如果还未连接数据库的话则创建一个数据库连接,然后将这个 SQL 语句发送给硬盘,执行检索,从匹配检索的结果中读取所有的数据,然后将它们放到内存当中(也就是内存分配)。然而,这时候你需要对其格式进行反序列化 (deserialize),因为硬盘上存储的格式不能直接在内存中使用,这意味着你需要调整位,以便 CPU 能够对其进行处理。因此,你就必须将其转换为语言层级(比如说你要读取一个字符串,即使你只需要其中一个属性但是你仍必须加载实体的全部内容,加载完毕之后还需要将其转换为字符串属性类型)。最后,将这个对象返回给初始化请求器。这个时候仍有 很多 步骤需要做。

那么 Realm 是怎么做的呢?Realm 跳过了整个拷贝流程。首先,文件始终是内存映射的,无论文件是或否在内存当中,你都能够访问文件的任何内容。关于核心文件格式的重要一点就是,我们确保硬盘上的文件格式都是内存可读的,这样就无需执行任何反序列化操作了。看见没有,我们跳过了一整个步骤。你所需做的就是计算在文件中的的偏移量,以便能够在内存映射的内存中读取数据,通过读取偏移量以及数据长度之间的数据,接下来就可以通过属性访问获取到原始值了。我们跳过了大部分步骤,事情更加高效。

真实的懒加载(20:33)

根据硬盘和固态硬盘的构建方式,只读取一位数据是完全不可能实现的。因此如果你只打算读取某个对象的布尔属性,那么你就要加载一个硬盘页大小的数据。你不能读取比此更小的数据,因为硬盘访问不会给你机会那么做的。大多数数据库趋向于在水平层级存储数据,这也就是为什么你从 SQLite 读取一个属性的时候,你就必须要加载整行的数据。它在文件中是连续存储的。

不同的是,我们尽可能让 Realm 在垂直层级连续存储属性,你也可以看作是按列存储。这意味着如果你有一系列邮件对象并且打算把它们标记为“未读”的话,我们不会为其执行一个特殊的操作,而是尝试优化原始数据,对它们进行遍历然后将它们的属性设置为“不可读”。基于懒加载的工作机制,这项操作将十分高效。当然,这个操作仍然还是会有性能浪费,因为我们必须创建一个语言层级的访问,虽然有很多方法可以解决这个问题,但是我们仍试图去优化原始数据。在大多数情况下,你真的无需去对数据进行国度优化,它避免了磁盘往返读取以及读取未使用过的属性。

内部加密(22:10)

现有的数据库解决方案很难做到的一点就是内部加密。虽然你可以使用 SQLCipher,但是它只是挂接到底层引擎,并且完全重做了很多引擎本身所做的事情。借助 Realm,我们可以轻松地进行加密,因为我们可以轻松地决定数据库内核所应该做的事情。内部加密和通常在 Linux 当中做的加密哪样很类似。因为我们对整个文件建立了内存映射,因此我们可以对这部分内存进行保护。如果任何人打算读取这个加密的模块,我们就会抛出一个文件系统警告“有人正视图访问加密数据。只有解密此模块才能够让用户读取。”通过非常安全的技术我们有一个很高效的方式来实现加密。加密并不是在产品表面进行的一层封装,而是在内部就构建好的一项功能。

支持多进程访问(23:30)

随着 iOS 应用扩展的发布,支持多个不同进程间进行并发访问变得刻不容缓。试想你有一个键盘,这个键盘要访问一个字典,你需要能够执行检索并且当随着键盘一起出现的应用的同时向字典中写入数据。

因此你需要多进程访问。MVCC让事情变得更简单,因为它拥有结尾附加(append-only)、写时拷贝技术。你的写入事务会一直在新的、单独的状态中进行。通过数据库底层的构建方式,我们就能够轻易地执行并发操作,我们在顶层所需要做的就是在事件发生的时候通知其他进程即可。我们为此使用命名管道(named pipe)来执行,开销大大降低。

空值(24:29)

有一个核心数据库支持但是我们还没有完全发布的一点就是“空值”。很久以前我们就有这样一个 PR 了,我们一直在尽力让所有的操作都能够正常运行,以确保我们不会意外地破坏任何数据,不过我们的测试还没有结束。另一个导致这个功能开发持续这么长的原因就是:我们必须确保不会完全毁灭你的工程,因为我们正试图给大家带来一个全新的功能。

class Conference: Object { dynamic var name: String? = nil }

这将是空值理想中的样子。Swift 可选值就是一个很好的例子。在文件系统层级上支持可选值也是非常必要的。目前文件编码和核心数据库都支持空值了,随着我们已有的全部检索和类型的陆续支持,这项功能很快就可以推出。如果你对此感兴趣的话可以查看我们的 PR。

妥协(25:24)

开发一个全新的数据库引擎并不是没有妥协。我一直在描述 Realm 的优势,但是我们也为我们决定开发全新数据库的决定付出了不少的代价。

  • 漫长的开发周期(25:54)—— 如果我们决定在 SQLite 的基础上开发的话,我们可能早就已经提供了大量的新特性,通过在编译级别进行操作可以大量的减少我们的工作。但是我们就会止步于此。这就是区别所在。借助完全由我们控制的数据库核心,我们可以为大家带来更多的新特性,如果采用 SQLite 之类的成熟产品的话可能就会十分的困难。不过……这意味着大家必须要耐心等待!

  • 还未到1.0(26:36)— 我们的 API 一直都在变化。如果你在使用 Swift 的话,你可能早已习惯,每次新的 Xcode 版本推出你都要重写你的应用。但是这是一个缺点,这意味着你会有一些不向后兼容的更改。自一年前 Realm 推出以来我们已经弃用了不少的 API。不过大多数的通常都是 API 命名之类的级别,我尽量向大家保证,永远不会出现在内部功能上的大规模变化——不过这也不能百分百保证。

    文件格式也可能会发生改变。事实上,它 发生一些变化以能够支持空值。我们必须确保你没有运行某些在未来10年不会改变文件格式的软件。这仍是一件有待考虑的事情。

  • 功能还是有些少(27:26)— 我们已经在努力达成 Core Data 所拥有的功能,不过还有诸如替代“细粒度通知”的功能还没有实现。不过这正是我们正在努力的方向。

还有更多(27:40)

最近我们推出了 KVO:现在你可以为每个对象建立更详细的通知机制。这是Realm Cocoa 0.95 的一部分。当你的 Realm 文件发生修改或者你正在执行请求的时候,你都可以随时查看相应的变化。空值目前也快要完美收工了。此外还有线程间的切换。这些都是我们正在积极努力改善的东西。

资源及链接(28:25)

问答时刻(28:45)

Q: 我知道数据库实现了MVCC,那么过期的数据是否会消失,还是会一直遗留下来呢?

JP:这些数据可能会被垃圾收集清除掉,基于当前指针所指向的树,我们可以知道哪些结点是未使用的,然后就可以将它们清除掉。有时你可以强制复制刚遍历完可用结点的 Realm 文件,然后将数据写入到一个新的文件当中。这时你可以释放掉相当大的空间。

Q: 所以这个功能必须自己完成了?

JP:核心系统会自行完成,但是如果你想立刻强制执行,那么你随时都可以调用此拷贝函数。

Q: 那么云同步功能呢?有没有什么相关消息?

JP:敬请期待!

Q: 当 Realm 抵达 1.0 版本的时候,你如何平衡不同平台和语言之间的功能的稳定性,以及新老版本的平稳过渡呢?我想肯定有一个平衡点。

JP:关于功能的广度和深度总是有一个平衡点存在的。真的,我刚才说的为移动端优化这一点是我们功能设计的前提所在。这就是为什么我们首先专注于主要的移动平台,并且在此基础上得到很棒的功能。我们并不会止步于此。我们是如何平衡的呢?好吧,你首先应当关注最大的平台,然后再慢慢往其他平台发展。这就是我们的诀窍所在。

See the discussion on Hacker News .

Sign up to be notified of new videos — we won’t email you for any other reason, ever.