在 20 世纪 90 年代初期,当面向对象语言成为软件开发的主流时,开发者们看到了创建软件程序的新方法和更佳方法,生产力显著提升。尽管这种新的、高效的对象编程范式受到了越来越多的组织的赞扬和接受,但关系数据库管理系统仍然是管理企业数据的首选技术。因此,ORM(对象关系映射)应运而生,这是出于必然,而将对象环境的持久状态保存在关系数据库中的复杂挑战随后被称为对象关系阻抗失配。
复杂的问题有时需要复杂的解决方案,ORM 软件也不例外。它必然是错综复杂的,具有多个方面和组件,以处理从查询生成和数据库写入优化,到虚拟机中对象身份管理的一切事务。普通开发者仅仅试图让应用程序工作,可能会选择忽略 ORM 子系统及其配置的某些复杂性。理解诸如缓存之类的基本功能(可能被视为仅仅是一种优化)对于有意的和正确的应用程序设计至关重要,不应被忽视。
缓存通常被认为是性能优化的关键。几乎每个计算领域的研究都表明,缓存可以提高性能和吞吐量,1 并且很少有这样的说法会引起哪怕一丝的争论。然而,开发者未能理解 ORM 产品的缓存方法,可能会导致异常的应用程序行为、意外的结果或彻底的错误。用户论坛上充斥着开发者因未能理解而遭受后果的证据。
缓存可能是 ORM 实现中最先进的技术组件之一,因此代表了任何使用该实现的应用程序的关键平衡点。未能将其视为潜在的支点可能会导致应用程序在性能不佳和语义错误的一侧摇摇欲坠或坠落。因此,在本文中,我们讨论与 ORM 系统中的缓存相关的主题,并揭示实现必须关注以及应用程序开发者应该了解的一些细节。
首先,开发者必须认识到对象的本质以及它们在面向对象语言中的使用方式。在实践中,对象很少孤立于其他对象而存在。应用程序对对象的引用实际上是对整个对象图的间接引用,而不是对单个孤立对象的引用。这种认识的后果是深远的,构成了 ORM 中与缓存相关的许多困难的基础。
当执行读取操作时,运行时必须考虑到该过程也可能导致读取所请求对象引用的对象。当然,这个序列可能会递归地继续下去,导致从数据库中读取大量的对象,每个对象都是根据需要和按顺序单独请求的(这种现象被称为波纹加载2)。开发者可以通过多种后备措施来防止这种情况发生,例如静态或动态地声明是否应该遍历和加载特定的关系。还有其他方法可以避免多次连续访问数据库,但对这些方法的讨论超出了本文的范围。
对象图,根据定义,意味着可能存在通向同一对象的多个路径。在某些情况下,这些多重关系可能来自单个对象,但在大多数情况下,它们来自不同的对象。在加载对象图的过程中,这些关系必须最终指向同一个相同的对象,而不是两个恰好具有相同状态的不同内存印记。未能维护对象身份将导致对象的持久状态在多个实例中被复制,每个实例都包含实体状态的某个时间点视图。这将不可避免地导致状态不一致和不正确的程序行为。
在图中维护对象的身份意味着加载器必须跟踪每个对象及其身份。解决方案的性质与缓存已经要做的工作完美契合,因此将这项任务委派给缓存也就不足为奇了。
应用程序在其执行期间管理不同的可见性范围。对于单用户范围,隔离缓存是合适的,但对于全局上下文,共享缓存(有时称为 L2(二级)缓存)提供了为所有请求者提供相同状态的缓存级别。这些缓存中的每一个都对其用途是唯一的,并且其功能或性能可能与其他缓存略有不同。两个缓存之间甚至可能存在状态重复,尤其是在考虑到隔离要求的情况下。
事务在任何系统(包括缓存)中都起着重要作用。事实上,事务缓存是专门为事务而设计的,其内容严格来说是事务对象。与事务关联意味着缓存导出其对象的正确隔离性和一致性(“正确”的隔离性在后面的章节中更详细地描述)。关于事务类型的假设尤其相关,因为它们之间存在差异。有些是线程绑定的,而另一些则允许多线程;有些与单个数据库连接绑定,而另一些则可能访问多个资源。
事务缓存中对象的存在,根据定义,意味着它是事务性的。两者之间存在当且仅当的关系,这样,当一个事务对象被修改时,其修改后的状态必须反映在事务缓存中。此外,事务缓存的状态代表了从 ORM 角度来看事务的总更改摘要,并且必然遵循事务的生命周期。如果事务回滚,则更改将被丢弃;但如果事务提交,则缓存中包含的逻辑更改摘要将被转换为 SQL 并提交到数据库。
在大多数应用程序中,绝大多数操作都是读取操作。当没有计划在事务内外修改对象状态时,即使只是暂时被认为是只读的,全局共享缓存也是获取只读状态的最有效机制。
全局共享缓存可以被同一进程空间中的所有客户端访问,无论它们是否处于事务上下文中。某些实现甚至允许只读操作在事务内发生,返回非事务性的共享状态而不是事务性状态。这样做的动机通常是为了性能,因为使对象成为事务性的需要付出一定的成本。然而,不遵守规则的后果可能是严重的。如果调用者修改了一个先前被认为是只读的对象,那么它的更改将不会反映在数据库中,并且缓存的对象将最终处于损坏和不一致的状态,其中包含事务外部未提交的更新。
即使事务缓存的生命周期不会超过事务,保留这些事务缓存中包含的更改也效率更高。对象更改的总和需要反映在共享缓存中,以便使其保持最新。如果未完成此合并步骤,则共享缓存中的对象版本将是过时的或陈旧的,从而需要从数据库执行额外的刷新操作。因此,在更新的情况下,更新数据的方向是从事务缓存到共享缓存。
从读取的角度来看,它的工作方式相反。当一个对象变成事务性对象时,通常可以从共享缓存中获得该对象最近已知的状态;因此,事务缓存充当共享缓存的消费者,以便节省一次数据库访问。图 1 是缓存之间交换的示意图。请注意,如果共享缓存无法提供对象,则必须发出数据库查询以获取它,然后可以将结果对象在共享缓存中提供。
通用缓存可以容纳一种或多种类型的缓存结构,ORM 缓存也是如此。尽管我们一直将 ORM 缓存称为对象缓存,但实际上实现方式相当多样,并且在存储、访问和更新其中包含的数据的方式上有所不同。这些区别,以及与每种区别相关的各种配置,可能会对性能产生不同的影响。
在面向对象环境中,缓存内容的选择往往倾向于最直观的格式——即域对象本身。域对象最终无论如何都会返回给用户,这一认识进一步支持了这一点,并且以中间形式缓存可能会在每次必须构造对象时引入额外的开销。
事务缓存往往是专有的对象缓存。以其原生域形式存储对象是事务内操作发挥作用的最有效方式,从而允许简单的关系遍历。
缓存域对象的成本是,对象必须在从数据库读取时构建并预加载对象状态。当缓存对象时,通常没有其他类型的缓存,因此检索到的数据必须作为对象聚合的一部分存储。当然,它也以两种方式工作,因为刷新或返回只读数据的好处变得更加明显,因为对象是预先构建的,从而避免了重建的成本。
如果对象缓存位于缓存频谱的一端,那么数据缓存则位于另一端。在数据级别进行缓存意味着,每个对象的原始组成状态都单独存储在缓存中,而没有封装对象。简单的数据片段易于操作和存储,几乎或没有因对象管理和关系而产生的累积成本。
以其原始或基本形式缓存状态的另一个主要优点是,它更接近于正在传输到和从基本数据库连接层传输的数据类型。这为交换提供了更简单的接口,并使缓存更易于插拔。
缓存数据的性能成本是,每个成功的请求都需要至少一个——通常更多——对象构造。然后,新构造的对象从缓存的数据中水合,并返回给 ORM 管理器。
ORM 缓存的主要动机是通过本地化数据访问来提高性能,以此作为进行数据库往返以检索数据的替代方案。初始操作始终是执行查找或查询调用以获取实体或结果对象集;因此,缓存和请求对象的查询紧密相连。
ORM 产品被认为对其与之通信的数据库相当熟悉。然而,ORM 系统本身不是数据库,通常不希望在内存中执行查询,尽管有些系统确实支持该功能的一个子集(有时称为内存查询)。如果查询条件基于一个或多个主键值,或者缓存实体存储在其上的键,则可以通过内存缓存满足查询。这是最佳的查询处理方案,因为它避免了进行数据库往返。
如果搜索条件依赖于非键字段,则通常必须针对数据库执行查询以获取结果标识符集。然后,该集合可用于从缓存中获取实体集。
此时可以更清楚地评估权衡。从数据库获得的数据可以是完整的实体数据,也可以仅仅是标识符。一方面,如果给定标识符的实体碰巧不在缓存中,则必须进行额外的数据库访问才能获得丢失的实体数据。另一方面,如果实体数据是从数据库中提取的,并且实体实际上驻留在缓存中,那么检索到的数据的携带成本显然被浪费了。事实证明,即使实体被缓存,其内容也可能自加载以来变得陈旧。在这种情况下,返回的数据可用于使用来自数据库的新鲜数据刷新缓存的副本。
从数据库检索实体状态还有一个额外的缓解因素:如果记录不大,那么一旦计算出记录位置的所有数据库开销,获取整个记录的成本仅比仅检索标识符的成本略高。
Java 和其他面向对象语言的开发者非常熟悉垃圾回收器在虚拟机中的工作方式。不再被活动对象(那些与活动执行上下文关联的对象)引用的对象成为垃圾,它们占用的内存被回收以供重用。
共享缓存的主要职责之一是保留不再被活动对象引用的状态,从而防止其被垃圾回收。在其他情况下,缓存应配置为释放不再需要的对象。理想情况下,缓存会确切地知道何时不再需要对象,或者是否会在近期访问该对象并应将其保留。不幸的是,不能期望缓存预测未来,并且用户有责任根据用户对应用程序访问模式的了解来配置缓存如何引用对象。自适应策略确实存在,其中缓存尝试变得“智能”并根据先前观察到的访问模式来调整缓存策略,但这些策略超出了本文的范围。
缓存引用其缓存状态的方式通常是高度可配置的。参数基于软引用和弱引用的传统内存管理概念。(我们正在讨论传统的 ORM,而不是必须对发生的实例数量和垃圾回收周期施加严格控制的实时系统。)回想一下,弱引用是指向垃圾回收器可能会回收的对象,如果没有任何其他常规或硬引用指向它们。软引用是指向可以回收的对象,如果虚拟机真的需要更多堆空间(并且没有对对象的硬引用)。在同一缓存中组合两种引用类型并将引用从一种类型迁移到另一种类型可以提供动态平衡,该平衡适应应用程序和虚拟机的需求,但优先考虑应用程序。
缓存驱逐策略也各不相同,选项包括生存时间设置(导致对象在特定时间段后被驱逐)、在特定日期或一天中的特定时间触发驱逐的计划,以及跟踪对象上次访问时间并在访问之间的时间过长时驱逐对象的新鲜度保证。
图 2 显示了一个带有计划驱逐策略的示例缓存引用配置。在此示例中,L2 缓存的一部分保留用于软引用对象,其余部分用于弱引用。最常访问的弱引用对象将被升级并软引用。
应用程序的需求决定了软组件的大小。适当的平衡将使经常使用但不总是硬引用的对象保留在缓存的软部分中,而不会为未引用的对象分配过多的空间。权衡是缓存永远不会导致 VM 内存不足,但如果您最终在边缘花费太多时间,则缓存引用可能会被重复丢弃。
通过驱逐策略,在图 2 的示例中,特定域类的所有实例都计划在每天凌晨 3 点被驱逐。这将允许隔夜批量更新过程的结果在第二天可见,而与缓存内容和使用情况无关。
扩展成功的基于 ORM 的应用程序可能比其初始开发困难得多,因为通常应用程序在先验上没有架构为适应未来的扩展。仅仅采购整个服务器集群并在其上运行,就可以按原样扩展在单个服务器上运行的正常 ORM 应用程序,这通常是一个神话。在典型的 ORM 应用程序中,缓存可能是应用程序性能良好的重要原因。当其他进程可能更新底层数据库时,必须考虑各个进程缓存,并且必须考虑组合集群缓存的整体健康状况。
问题在于,每增加一台操作相同数据集的新服务器,陈旧数据综合征的可能性就会急剧增加。每个导致主数据源(数据库)中数据突变的操作也会产生这样的后果,即集群中该实体的每个缓存版本(除了在进行更新的服务器中缓存的条目之外)都会失效。此外,每个缓存都需要知道,或者至少有能力弄清楚,其该实体的缓存条目已陈旧。
可以使用许多策略来解决集群缓存问题,但大多数可以归类或包含在以下三种策略之一中:
特定应用程序的最佳选择将取决于应用程序本身,以及其环境支持给定策略的能力。如果写入次数足够低,则所有策略的性能显然都比写入次数高时更好,因为数据突变是缓存不一致的根源,也是导致流量以使缓存一致的原因。
在不断增长的网络面前,第一种和第三种方法似乎更网络密集,因为向网络添加 n 个实例(或节点)将导致每次对象更改时发送 n 条额外消息(由发起节点或通知程序发送)。即使第二种方法不向其他节点发送任何消息,它也可能必须频繁地与数据库检查。它永远不知道对象是否已更改,因此即使只是进行读取,它也必须询问真相的来源数据库,以确保它具有最新的状态。如果每个节点都遵循相同的过程,那么流量最终可能会高于其他两种方法,从而造成数据库瓶颈,并且基本上在没有任何缓存的情况下执行。
可能是某些对象是不可变的,或者某些对象对陈旧数据的容忍度高于其他对象,因此只需查询一小部分选定的对象或以特定的频率查询数据库即可。这可能会使第二种方法更可接受。也可能只有一小部分对象被修改过,或者环境不允许从外部数据库监视进程到 ORM 系统建立连接;因此,第一种方法将非常适合该任务。
甚至需要考虑缓存的事务隔离性这一概念对某些人来说是陌生的。假设是缓存会工作,并且隔离性是正确的。这种思维方式的错误在于,管理、加载、合并、驱逐和咨询缓存的策略与使用缓存的产品一样多,并且这些因素中的每一个都可能对事务隔离性产生影响。
通常期望的隔离级别,无论是无知还是经验,从缓存中通常是 READ_COMMITTED。在低端,这是合理的,因为一般来说,没有人期望获得包含来自另一个事务的未提交更改的查询结果。因此,大多数严肃的产品在事务成功提交之前,不会将其事务缓存的内容合并到其共享缓存中。
在频谱的较高端,缓存中固有的隔离性存在一些差异。READ_COMMITTED 和 REPEATABLE_READ 之间的区别定义明确,但成本不受限制。一个供应商可能会决定缓存安全性至关重要,并且只能通过给定的隔离级别来实现,通过粗粒度锁门控所有缓存访问,或者急切地获取锁。这种善意的观点的问题在于,诸如序列化所有缓存访问之类的做法会带来不小的成本。许多应用程序没有跨其域模型传播的数据依赖性,因此实际上没有如此严格的隔离要求,但仍然被迫付出性能代价。
READ_COMMITTED 隔离对于大多数应用程序来说可能足够且令人满意,但那些确实需要更严格隔离的应用程序应该被允许在必要时原子地执行大规模复合缓存操作。区别在于它应该是一种缓存选项,而不是根深蒂固在实现中的特性。
我们刚刚从用户的角度讨论了事务隔离要求,但只是挥手示意,说明这种隔离要求可能以不同的方式实现。在本节中,我们将描述一些用于处理应用程序之间隔离的常用策略。
保证数据一致性和完全隔离的久经考验的方法之一是隐式读取时复制,它在工作单元中读取对象后立即创建对象的本地事务缓存副本。这当然保证了无论发生什么,在更改被写入持久存储或合并到共享缓存之前,没有其他应用程序会看到对该对象的更改。以下是传统隐式读取时复制场景中的示例事件序列:
在事务完成其提交阶段和更改被合并到共享缓存之间存在一个窗口。这意味着在另一应用程序可以从共享缓存中获取对象的陈旧副本的某个非零时间量内,即使它已在数据库中更新。
然而,这个窗口实际上无关紧要,因为如果第二个应用程序仅读取对象,那么它不妨在第一个应用程序提交其事务之前读取它并获得相同的陈旧数据。它碰巧在该时间窗口内读取它这一事实无关紧要。但是,如果它要有一个对象的陈旧副本并在其上执行写入操作,如果第一个应用程序的更改最终因第二个应用程序写入的对象的初始状态陈旧而被覆盖或丢失,那将是不好的。此问题的解决方案在于对实体进行乐观锁定,以确保没有更改被覆盖或丢失。3
由于实现会自动执行复制,而无需用户执行任何操作,因此隐式读取时复制的优点之一是,当用户决定更新对象时,无需采取任何特殊操作。用户可以依赖它一直在使用的副本来读取并对该副本执行写入操作。任何和所有更新都将在适当的时间发送到数据库。
急切地读取时复制的一个明显的缺点是事务缓存中对象的累积。缓存不一定区分读取的对象和已更新或变为事务性的对象,因为即将进行更新。启动事务并进行大量读取但仅进行少量写入的应用程序将看到其事务缓存增长到包含所有对象,而不仅仅是那些包含更改并且需要写出的对象。
隐式写入时复制
管理事务缓存空间的更有效方法是在将对象读取到事务中时不复制对象,而仅在用户修改对象时复制对象。这种隐式写入时复制方法将事务限制为仅包含已更改的对象,并且不会使事务容易受到实例计数激增和管理成本的影响。典型的隐式写入时复制序列是:
这似乎是管理事务数据的优雅方法,因为只有脏对象最终被复制,并且这将再次由实现自动执行。仅用于读取而变为事务性的对象最终实际上根本不是事务性的,并且不占用事务空间。
当必须执行复制时,或者具体来说当用户更改对象并且实现识别出必须创建副本时,此策略的缺点就显现出来了。该困难的延伸是,对象更改不能直接对共享对象进行,而必须存储在可修改的副本中。这给实现带来了负担,即在事务内访问时必须返回对象的更改状态,同时将这些更改与其他可能正在访问共享对象的线程隔离。
克服此挑战的最常用技术是在加载时编织类,以便实现可以将一些专有处理代码插入到创建的每个实例中。对于某些应用程序来说,这可能是或可能不是可接受的,但对于大多数使用面向方面编程以及利用执行某种字节码编织的能力的众多工具和库的现代系统来说,这最终不会成为繁重的限制。然而,仍然存在一些障碍可能会阻止某些应用程序使用依赖于这些类型技术的产品。
在面对对对象的事务性写入时提供隔离性的第三种策略是对先前写入时复制的改进,但平衡了编织对象类的需求。这种方法称为显式写入前复制,正是因为它要求用户调用 ORM 事务管理器以创建给定对象的副本。它受益于隐式写入时复制提供的优势,即只有被修改或显式复制的对象才变为事务性的。显式写入前复制的示例序列将是:
此示例清楚地表明,缺点是用户必须预先知道他们计划修改对象,或者如果在事务的某个时刻他们发现需要修改特定对象,则他们必须调用以创建副本并从该对象的新版本/实例开始。事实上,它甚至可能自上次读取以来发生了更改,因此获取副本的时间可能很重要。
我们可以通过将软引用和弱引用技术(在前面共享缓存的上下文中描述)与此处显示的复制策略结合使用来减少事务缓存占用空间。如果我们从弱引用对象开始,那么随着它们变得未使用或未引用,它们会逐渐被垃圾回收,并在较长时间运行的事务过程中离开缓存。
然而,事务缓存中弱对象的问题是,我们冒着更改一个对象然后转移到另一个对象的风险,而将我们已经更改的对象留在后面。如果它在缓存中被弱引用,那么该对象可能会从缓存中掉出来,并且更改永远不会写入后备数据库存储。为了防止这种情况发生,缓存在进行更改时可以收紧或加强从弱引用到硬引用的引用。这将导致包含更改的所有对象在事务期间被保留,因此不会丢失任何更改,但允许未更改的已取消引用的对象掉出来。
任何系统中的缓存都可以像创建者希望的那样简单或复杂,但 ORM 工具的本质使其比平均缓存层更复杂一些。
尽管存在影响 ORM 运行时性能和语义的大量缓存细节,但目标不需要是了解和理解实现的每个细节和细微差别。基本熟悉主要问题和一些可能的实现选择,可以为防止可能导致性能下降或错误的配置错误提供第一道防线。
这里讨论的一些实现策略侧重于优化和性能,而另一些则更注重易用性。一些 ORM 产品将自身锁定在一种特定的方案中,而另一些则提供多种方案并提供选择,为经验丰富的用户提供了从可能最适合其应用程序的性能选项中获益的机会。您对这些选项的理解越深入,就越有资格构建成功的、高性能的基于 ORM 的应用程序。
MIKE KEITH 在分布式系统和对象持久性方面拥有超过 15 年的教学、研究和实践经验。他曾在多个行业规范专家组任职,并且是 JPA(Java 持久性 API)1.0 版本的共同规范负责人。他拥有卡尔顿大学计算机科学硕士学位,并在那里担任过讲师。他曾在全球众多会议上发表演讲,为行业杂志和期刊撰写了多篇论文和文章,并且是《Pro EJB 3:Java 持久性 API》(Apress,2006)的合著者。他居住在加拿大渥太华,并在 Oracle 担任持久性和服务器架构师。
RANDY STAFFORD 拥有 20 年的开发人员、分析师、架构师、经理、顾问和作者经验。他目前在 Oracle 的中间件开发部门工作,在全球范围内参与概念验证项目、架构审查以及与各种客户组织的生产危机,专注于网格、SOA、性能、HA 和 JEE/ORM 工作。他是 Martin Fowler 的《企业应用架构模式》(Addison-Wesley,2002 年)和 Floyd Marinescu 的《EJB 设计模式》(Wiley,2002 年)的贡献者。他与妻子和家人居住在科罗拉多州丹佛市。
最初发表于 Queue 杂志第 6 卷,第 3 期—
在 数字图书馆 中评论本文
Qian Li,Peter Kraft - 事务和无服务器天生一对
数据库支持的应用程序是无服务器计算令人兴奋的新领域。通过紧密集成应用程序执行和数据管理,事务性无服务器平台实现了许多在现有无服务器平台或基于服务器的部署中不可能实现的新功能。
Pat Helland - 任何其他名称的身份
新兴的系统和协议既收紧又放松了我们对身份的概念,这很好!它们使事情更容易完成。REST、IoT、大数据和机器学习都围绕着身份的概念,这些概念被有意地保持灵活,有时甚至是模糊的。身份的概念是我们分布式系统基本机制的基础,包括互换性、幂等性和不变性。
Raymond Blum,Betsy Beyer - 实现数字永恒
今天的信息时代正在为世界所依赖的数据创造新的用途和新的管理方式。世界正在从熟悉的物理人工制品转向更接近信息本质的新型表示方式。我们需要流程来确保知识的完整性和可访问性,以保证历史将被知晓和真实。
Graham Cormode - 数据草图
您是否曾因源源不断的信息而感到不知所措?似乎大量的新电子邮件和短信需要持续关注,还有电话要接,文章要读,以及敲门声要回应。将这些碎片拼凑在一起以跟踪重要内容可能是一个真正的挑战。为了应对这一挑战,流数据处理模型越来越受欢迎。其目的不再是捕获、存储和索引每一个细微的事件,而是快速处理每一次观察,以便创建当前状态的摘要。