持久化编程

我们这样做对吗?

Archie L. Cobbs

大多数软件应用程序都需要某种形式的持久化编程——但它到底是什么?更重要的是,我们这样做对吗?

几年前,我的团队正在为一个增强型911 (E911) 紧急呼叫中心进行一个商业Java开发项目。我们试图使用传统的Java over SQL数据库模型来满足这个项目的数据存储需求时感到沮丧。在对项目的特定需求(和非需求)进行一些反思之后,我们深吸一口气,决定从头开始创建我们自己的自定义持久化层。这最终变成了很多工作,但也给了我们一个重新思考Java中持久化编程的机会。

在此过程中,我们发现了一些新的,可能更好的做事方式。总而言之,通过将“数据库”简化到其核心功能,并在Java中重新实现所有其他功能,我们发现管理持久性变得更加自然和强大。尽管这是一个Java项目,但所吸取的教训并非Java独有。

什么是持久化编程?

首先,持久化编程到底是什么?

一个简单的定义是,你的程序将数据存储在运行程序自身上下文之外。简单地说 int foo = 42 不符合条件,但将数据保存到文件或将数据写入数据库则符合条件。如果数据可以被你的程序的完全不同的调用或完全不同的程序读取,则数据是持久的。

按照这个定义,当你创建一个XML(可扩展标记语言)文档并通过网络发送它时,你就是在进行持久化编程。在某种意义上,XML文档是一个发送给接收器的小数据库,接收器然后打开并从中读取。事实上,我们项目的一个见解是,网络通信和数据库存储涉及几个共同的问题,而这些问题可以使用通用工具来解决(稍后会详细介绍)。

尽管大多数软件都需要某种形式的持久化编程,但编程语言通常对此提供有限的支持(例如,基本数据类型的序列化)。在Java编程语言中,访问一个 int 是理想化的:它是原子的、瞬时的、永不失败的,并且永远不需要模式迁移。但是,如果你想访问一个跨程序调用持久存在的 int ,你将面临许多新问题,而语言本身没有提供任何指导。然后,你的工作是组装(或自制)所需的附加组件。

从语言设计的角度来看,这可能是正确的选择,但这导致持久化编程以一种不自然的方式发展——从外向内,可以这么说。首先,创建了数据库;然后设计了诸如SQL之类的查询语言来与它们通信(最初供人类使用);最后,编写了库以允许程序通过网络发送SQL查询并检索结果。

程序员们需要弄清楚如何在编程语言的纯粹理想化世界和SQL表、查询设计、性能、事务、网络故障等的实际世界之间架起桥梁。最终结果是,程序员最终迎合了数据库技术的需求和要求,而不是相反。

这可以在JPA(Java持久性API)的许多问题中看到,JPA是当前Java中持久化编程最先进的工具。(2019年,JPA更名为Jakarta Persistence:https://en.wikipedia.org/wiki/Jakarta_Persistence。)JPA竭尽全力使从SQL数据库查询和检索数据尽可能无痛,但结果远非优雅或直观。

JPA的一些问题将在后面提到,但需要明确的是,这些问题不是JPA的错。它们本质上是试图在Java等高级面向对象编程语言的抽象和理想化世界与SQL数据库的截然不同的世界之间架起桥梁时固有的。这通常被称为“对象关系阻抗失配”。作为这种失配的一个例子,请考虑JPA定义了100多个Java枚举和注解类来涵盖两个领域之间所有不同的映射方式。

这引发了一个基本问题:我们这样做对吗?为了回答这个问题,让我们想象一下,如果我们能够重新开始并设计持久化编程,首先满足程序员的需求,它会是什么样子。

 

什么是数据库?

持久化数据的需求不会消失,所以让我们考虑一下使用数据库的本质是什么。首先,必须有一种方法将编程语言数据值编码为原始比特。理想情况下,这些编码应该按照与编程语言中对应值相同的顺序排序,这允许数据库尊重该排序。

一旦这些比特被放入数据库,就必须有一种方法来检索它们。由于你经常希望以与放入顺序不同的顺序检索它们,因此数据必须以某种方式被键控。然后你可以给数据库一个任意的键,并取回相应的值。你可能还希望数据库保持键的排序,这样你就可以有效地迭代它们,并通过上限或下限进行查询。对于通用用途,排序能力是预期的,所以我们将假设这一点。

显然,键查找需要高效,这意味着需要某种查找优化的数据结构。实际上,几乎所有数据库都使用两种数据结构之一来存储数据:哈希表(用于未排序数据)或平衡树(用于排序数据)。

最后,任何重要的数据库都必须允许并发访问,这意味着它必须定义事务的概念以及原子性、隔离性、一致性、持久性等的相应语义。(有关一致性级别的详尽讨论,请参见https://jepsen.io/consistency。)这里有各种各样有趣的模型,所以我们不会做任何具体的假设。

现代SQL数据库提供了所有这些,以及一堆“其他东西”。但是,如果你从头开始,那么事务性的、排序的二进制键/值存储是一个合理的最低共同标准。这个定义的简单性也使其成为定义API的好地方,允许你轻松移植现有数据库并添加新的数据库。

数据库通常提供的其他一切——模式、索引、外键约束、命令行界面等——都可以被认为是“其他东西”,因为没有任何东西要求它们必须由数据库本身来实现。从数据库的定义中省略它们,为以更好地服务程序员的新方式重新实现这些功能留下了空间。

 

将持久性引入语言

既然我们已经了解了数据库在底层提供了什么,让我们跳到顶层,看看从编程语言层面来看,持久化编程可能是什么样子。理解我们真正想要什么的一种方法是重新审视我们对JPA的挫败感,并针对每一个挫败感,问:有没有更好的方法来做事情?

 

基本类型

在JPA中,第一个挫败感是Java基本类型与它们对应的SQL类型不匹配。例如,浮点 NaN 值通常完全不受支持,SQL的 DATETIMEjava.util.Date 不同,并且SQL数据库有时会默默地截断或用空格填充字符串值。简而言之,如果你不小心,你将无法可靠地读回你写入的内容。

此外,支持的类型集是有限且固定的。例如,唯一支持的数组类型是 byte[]。如果你需要 COMPLEXLATLONG 类型,但你的数据库本身不支持它,你就倒霉了。

相反,我们希望精确支持任何原始类型、包装类型或数组类型,以及诸如 StringDate 等常见类型。同样重要的是,应该可以通过简单地提供其位编码来包含任何新的Java类型,并且这些自定义类型应该是第一等的,因为它们与内置类型一样可排序、可索引等。

 

类型、类和接口

JPA必须将Java类层次结构映射到矩形表,从而导致未使用的列或额外的连接。属性可能只能从超类继承,而不能从接口继承(即,JPA属性必须是具体的),并且JPA与Java泛型的某些用法不兼容。相反,我们希望高效地存储类层次结构,并完全兼容Java的接口继承和泛型。

 

变更通知

JPA通过 @PrePersist@PreRemove@PreUpdate 等支持基本的实体生命周期通知。这些通知以每个对象为粒度应用(即,多个单独的属性更改将合并为一个通知),但是,通知是在缓存刷新时异步生成的,而不是在它们发生时立即生成。

相反,我们希望对对象生命周期和单个属性更改事件进行精确的、同步的通知,以及检测通过任意数量的引用可访问的对象中的非本地更改的能力(理想情况下,无论是向前还是向后)。例如,具有 parent 属性的树中的节点可能希望在任何子节点(或孙子节点)的 color 属性更改时收到通知,反之亦然。

除了它们本身很有用之外,具有按属性粒度的通知,这些通知可以是同步的和非本地的,是其他有用的新功能的一项关键使能技术。

 

索引

当你定义一个带有 IDUSERNAME 列的SQL表 USER 时,数据库会创建一个内部平衡树,其中 ID 键和 USERNAME 值。如果你随后索引 USERNAME 列,数据库会在底层创建一个辅助平衡树,其中 USERNAME 键和 ID 值。当主树中的任何 USERNAME 被添加、修改或删除时,数据库会自动更新辅助树,从而保持两棵树的一致性。

这是一个传统的索引,JPA支持它。然而,更一般地思考,索引可以是以下任何组合:(a)完全从主数据派生的辅助数据结构;(b)通知主数据更改的变更通知;以及(c)在收到通知时更新辅助数据结构的更新算法。为什么你不应该能够仅通过定义a、b和c来创建任何类型的索引呢?

假设你有一个房屋价值表,并且你经常想要访问房屋价值的中位数。这不是你可以在SQL中索引(甚至直接查询)的东西。但是,如果数据库可以在任何房屋价值被添加、删除或更改时通知你,那么一个简单的辅助数据结构和更新算法就可以完成这幅图景:在房屋价值上创建一个传统索引,以便你可以快速找到相邻的值,然后在新的辅助数据结构中存储当前中位数、较低值的数量和较高值的数量。

这个例子说明了为什么更改更新必须是同步的并且是按属性而不是按对象的。对非本地更改通知的支持也很重要,因为有时你想索引的信息不限于单个对象。假设你希望树中的节点索引它们有多少个“蓝色”子节点。节点可以将该数字存储在一个私有字段 numBlueChildNodes 中,并在子节点的 parentcolor 属性上注册更改通知,以保持该字段的最新状态。

当然,任何这样的索引都可以由程序员手动实现,需要更多的工作,但是当数据库提供更改通知时,它会更简洁且更健壮。毕竟,数据库处于完美的位置来做到这一点,因为它看到了对每个数据值的每次更改。总之,我们希望数据库提供工具,使其易于实现任意自定义索引。同步的、非本地的、基于对象和属性的更改通知就是这样一种工具。

 

验证和不变性

对于任何管理数据的软件来说,关键是验证——即,维护和验证所需的不变性(又名验证约束)。当事务开始时,你的代码假设不变性成立,你的目标是做任何需要做的事情,即使可能暂时违反某些不变性,同时最终确保在事务提交时重新建立不变性。这与Java synchronized 代码块中应用的原则相同。

有效地强制执行不变性需要:(a)可以检查约束的代码;以及(b)当由于关联数据发生更改而需要重新检查约束时触发的通知。同样,精确的更改通知是关键要素。

在JPA中,数据库本身提供了一些验证约束,例如外键完整性和列唯一性。在Java层面,JSR 303(Java规范请求)验证提供了额外的按对象验证。这两种实现都不完美:JPA可能会触发由缓存刷新顺序引起的幻象外键违规(例如,当持久化两个间接相互引用的新对象时);并且两种类型的约束都应用于缓存刷新,而不是事务提交,这意味着它们可能发生在事务中间,在不变性重新建立之前。

相反,验证检查应该推迟到事务提交时,除非之前明确请求。手动将对象排队进行验证的能力很重要,因为这使得实现任意自定义(和精确的)验证约束变得容易:只需在涉及的字段上注册更改通知,然后在收到通知时排队进行验证。

通过非本地更改通知,验证约束可以跨越多个对象。例如,想象一下实现一个约束,即没有两个子节点可以同时为蓝色。你将在子节点 parentcolor 属性上注册更改通知,并在收到通知时,将父节点排队进行验证。

在实践中,索引和验证通常协同工作。在前面的示例中,你可以使用私有 numBlueChildNodes 属性索引蓝色子节点的数量,然后在 numBlueChildNodes 更改时简单地排队进行验证。

 

你如何查询?

以下SQL查询是否高效? SELECT * FROM User WHERE lastName = 'Smith'

这是一个陷阱问题,因为答案取决于 lastName 是否被索引,而这无法通过查看查询来确定。这违反了软件应该可以通过查看来理解的基本原则。

在JPA中,程序员需要学习和使用一种新的查询语言,但即使他们这样做了,也缺乏性能透明度。(JPA有三种查询方式:SQL、JPQL和Criteria。)要了解你的查询是否高效,你的技能组合必须包含计算机程序员和数据库管理员的结合。

在理想的世界中,低效的查询不应该像这样隐藏起来。如果你要遍历数据库中的每个 User ,那么在查看代码时应该很明显。

更好的是,如果查询是用普通的Java编写的,使用现有的概念,如 SetListMap 。然后,查询的效率将始终是显而易见的,或者至少是可见的。数据库提供的排序键/值对可以在Java中建模为 NavigableMap ,提取的数据可以用 Stream 表示。索引属性只是一个从属性值到具有该属性值的对象集(即,键前缀)的 Map 。事实上,Java语言已经拥有你查询所需的所有工具,使用程序员已经理解的现有概念。

 

模式和迁移

代码会随着时间的推移而演变,这意味着数据库模式也会如此。因此,有时必须通过模式迁移来更新数据库结构。这些迁移有两个方面:对实际数据格式或布局的结构性更改;以及作为数据相应“修复”的语义更改。

例如,如果你用 fullName 替换 lastNamefirstName 列,结构性更改是添加和删除列的 ALTER TABLE 操作,而语义更改是将每行的新 fullName 列初始化为 firstNamelastName 的串联。请注意,执行语义更改需要访问旧的( lastName, firstName )和新的( fullName )列。

JPA没有提供模式迁移的工具,也没有验证正在使用的模式是否正确。存在用于跟踪模式迁移的辅助库,但它们需要“停止世界”操作,例如 ALTER TABLE ,这与滚动(零停机时间)更新不兼容,在滚动更新中,数据库中可以同时存在多个模式。此外,这些工具通常需要手动编写结构性和语义性更改,并且是用SQL而不是Java编写。

我们希望从几个方面改进这一点:首先,数据库应该能够一次支持多个模式,允许滚动模式迁移,在滚动模式迁移中,对象可以随着时间的推移升级(例如,按需),这样你永远不需要“停止世界”操作。其次,数据库中正在使用的模式应该在数据库本身中自动跟踪,然后在每个事务开始时根据代码期望的模式进行验证(显然,此检查应该是高效的)。第三,没有理由结构性更改不能完全自动化,这将消除由不一致的迁移引起的错误。最后,语义性更改将比用SQL更自然地用Java编写。

 

离线数据

JPA对于离线数据有一个有点笨拙的模型,即从事务中读取但在该事务关闭后使用的数据。例如,触摸在事务期间未加载的集合会抛出异常,但在事务期间触摸集合会加载整个集合,这可能会变得笨拙。在任何情况下,并不总是清楚哪些离线数据可用,因为JPA将离线数据与其在线缓存混淆:离线数据是事务关闭时在线缓存中碰巧存在的数据。此外,一旦事务关闭,你将失去查询离线数据的能力,即使你可以查询,查询也会很慢,因为没有保留相关的索引信息。

JPA包括对提取连接和加载图的支持,但这些只能部分解决问题,因为你并不总是知道接下来需要什么数据,直到你首先看到一些数据。核心问题是SQL通常不够表达力或不够精确来定义你需要的确切数据,即使你碰巧提前知道它,也不会遗漏一些数据或导致“连接爆炸”。

如果有一种更精确的方法来定义在事务关闭后要复制和保留为离线数据的数据,那就太好了。此外,你应该能够以所有通常的方式查询离线数据,包括索引查询。这意味着辅助索引数据也应保留。简而言之,应该可以精确地定义整个数据库的子集,查询它并将其拉入内存,然后在原始事务关闭后,像对待普通的内存迷你数据库一样对待它。

对于高效支持快照的数据库设计(例如,日志结构数据库),此过程甚至可以是零复制操作,其中内存迷你数据库实际上只是快照的只读、内存映射视图。

 

网络通信

如前所述,网络通信和持久化编程有许多共同的问题。假设数据库只是一个排序的键/值存储,那么网络通信可以重新定义为一种简单的持久化编程形式:首先,创建并初始化一个内存数据库,包括通常的模式跟踪信息;其次,用要传输的Java对象填充该数据库;最后,序列化数据库(只是一堆键/值对)并通过网络发送它们。在接收端,像往常一样打开并读取数据库。由于数据库还包含模式信息,因此任何需要的模式迁移都会自动发生。

因此,通常在网络编程中重新出现的许多数据库问题都得到了自动解决:如何序列化任意对象图,定义和记录数据格式(即,模式),当连接两端运行不同版本的代码时(例如,在滚动升级期间)自动迁移,以及在接收端有效地查询数据。

 

将代码和数据结合在一起

我们开发了Permazen,一个开源项目(https://github.com/permazen/permazen),以研究和原型化本文中描述的概念,现在在我们的商业解决方案中使用它。所有这些想法都以某种形式实现和部署,使用我们新的持久化层进行编程是一种真正令人耳目一新的体验。它还使我们能够实现一些原本困难或不可能实现的所需自定义功能——例如,基于Raft共识算法(https://raft.github.io/)的集群键/值数据库,并支持“独立”模式。该项目证明,这些想法实际上是可行的,并且可能值得进一步探索。

然而,一个关键的注意事项使这个项目得以成功:每个节点都需要保留数据库的完整、最新的本地副本(以防需要恢复到独立模式)。因此,每个事务中的单个数据库访问都是低延迟的,因为大多数情况下,它们不需要网络流量。这对于这里提到的许多新功能至关重要,例如非本地更改通知和自定义索引,这些功能需要在每个事务中频繁但低量地访问数据。

换句话说,使用SQL通过网络连接进行持久化编程的常用方法有点像通过对讲机与一位说拉丁语的中介进行杂货店购物。如果你可以摆脱中介并亲自去那里,体验可能会更加高效。换句话说,距离本身(即,延迟)是持久化编程创新的障碍。有时距离是不可避免的,但在你可以让代码低延迟访问数据的情况下,新的可能性就会出现。在数据库和应用程序被网络分隔开的情况下,这主张将代码发送到数据,而不是将数据发送到代码。

从历史上看,持久化编程一直是从数据库方面驱动的,这限制了程序员的选择。将数据库重新定义为仅仅是一个排序的键/值存储为编程语言方面的创新创造了更多的空间。我们的经验表明,至少在某些情况下,这允许重新构想持久化编程,使其更自然,更少令人沮丧,这样我们就可以花更少的时间思考:我们这样做对吗?

 

Archie L. Cobbs是一位软件开发人员和企业家,他的整个职业生涯都在软件创业公司工作。他还创建并贡献了许多开源项目。他一直在寻求高度主观且永不制造冲突的神奇组合。他于1995年获得加州大学伯克利分校计算机科学博士学位。

 

版权所有 © 2022,所有者/作者持有。出版权已授权给。

acmqueue

最初发表于《Queue》第20卷,第1期——
数字图书馆中评论本文





更多相关文章

Ethan Miller, Achilles Benetopoulos, George Neville-Neil, Pankaj Mehra, Daniel Bittman - 远内存中的指针
有效地利用新兴的远内存技术需要考虑在父进程上下文之外操作丰富连接的数据。正在开发中的操作系统技术通过公开内存对象和全局不变指针等抽象来提供帮助,这些抽象可以由设备和新实例化的计算遍历。这些想法将允许在未来具有分离内存节点的异构分布式系统上运行的应用程序利用近内存处理以获得更高的性能,并独立扩展其内存和计算资源以降低成本。


Simson Garfinkel, Jon Stewart - 磨砺你的工具
本文介绍了我们在最初发布十年后更新高性能数字取证工具BE (bulk_extractor) 的经验。在2018年至2022年期间,我们将程序从C++98更新到C++17。我们还进行了完整的代码重构并采用了单元测试框架。必须经常更新DF工具,以跟上其使用方式的变化。对bulk_extractor工具的更新描述可以作为可以而且应该做什么的示例。


Pat Helland - 自主计算
自主计算是一种商业工作模式,它使用协作来连接封地及其使者。这种模式基于纸质表格,已经使用了几个世纪。在这里,我们解释封地、协作和使者。我们研究了使者如何在自主边界之外工作,并且在保持局外人身份的同时也很方便。我们还研究了如何在不同的封地之间启动工作、长时间运行并最终完成。


Torsten Ullrich - 真实世界的字符串比较
在许多语言中,字符串比较是初学者的一个陷阱。对于任何Unicode字符串作为输入,即使对于高级用户,比较也经常引起问题。Unicode中不同字符的语义等价性要求在比较字符串之前对其进行规范化。本文展示了如何正确处理Unicode序列。对两个字符串进行相等性比较通常会引发关于按值比较、对象引用比较、严格相等和宽松相等之间差异的问题。最重要的方面是语义等价性。





© 保留所有权利。

© . All rights reserved.