本文要点
- Reladomo 是 Goldman Sachs 开发的一个企业级 Java ORM,在 2016 年开源发布。
- 性能是 Reladomo 的关键交付。通过提供对 IO 最小化、组合并扩展的功能,赋予了开发人员优化自身应用性能的工具。
- Reladomo 的定制内存有效地利用了内存,实现了 IO 的降低,并提升了性能。
- Reladomo 的企业特性集合使得它有别于传统的 ORM。分片和支持时态对象是企业特性中的亮点。
- 可测试性并非是后添加到 Reladomo 中的。Reladomo 所提供的测试资源非常适合于编写高质量的测试,将会改进应用代码库的长期生存力。
在本文第一部分中,我们介绍了 Reladomo 的可用性和可编程性等特性,并给出了一些指导开发的核心理念。在第二部分和最后一部分中,我们将介绍 Reladomo 的性能、可测试性及部分聚焦于企业应用的特性。
性能
高性能解决方案是大规模可扩展企业应用的基石。从框架的角度看,性能包括两个方面:
- 框架代码本身应该是良好优化的。
- 框架所暴露的模式应允许使用高性能代码实现应用代码。
具体到数据库的 ACID 交互,最关键的关注点可归结为正确的 IO 操作。我们在 Reladomo 中还发现,相对于带宽而言,延迟的问题更大,因此我们围绕延迟开展优化。简而言之,“正确的 IO 操作”意味着编写可最小化、可组合(即批处理)和 IO 可扩展(即多线程)的代码。
最小化 IO
在先前给出的本文第一部分中,我们已经介绍了 deepFetch 功能和 Reladomo 中的高级关系,这些功能显著地降低了对象图上的 IO。在读路径上,Reladomo 的完全定制缓存对降低 IO 会有显著的效果,我们随后将做详细介绍。在写路径上,对同一对象的多重写将会自动地组合到同一工作单元中,实现对数据库调用的最小化。
组合 IO
下面两个特性使得应用在进行查询时适当地组合 IO:
- Reladomo 支持将临时对象映射到临时表;
- Reladomo 支持查询中的元组集。
回到我们分类账目例子中的 Balance 对象。如果我们想要从一对 Account/Product 的列表中检索 Balance,可以如下编写代码:
MithraArrayTupleTupleSet tupleSet = new MithraArrayTupleTupleSet(); tupleSet.add(1234, 777); // 在查询中添加 Account 和 Product 信息。 tupleSet.add(5678, 200); tupleSet.add(1111, 250); TupleAttribute acctAndProd = BalanceFinder.acctId().tupleWith(BalanceFinder.productId()); Operation op = BalanceFinder.businessDate().eq(today); op = op.and(BalanceFinder.desk().eq("A")); op = op.and(acctAndProd.in(tupleSet)); BalanceList balances = BalanceFinder.findMany(op);
对于小规模集合,Reladomo 会将上面的操作转译为一个“or-and”语句。而对于大规模集合,则需要在后台使用到临时表,并对 Balance 表做连接运算。
在写路径上,包括自动批处理在内的批量操作工具将有助于增强写操作。Reladomo 对事务中的写操作重新排序,在无需对正确性做出妥协的情况下最大化批处理。Reladomo 也会选取一个适合于工作情况和数据库规模的批处理策略。例如,Reladomo 可以在四种不同的插入策略(即 bulk、union、multi-value 和 jdbc-batch)中做出选取。
分布 IO
Reladomo 主要采用两种有助于扩展 IO 的模型:
- 分片(Sharding)。正如下面将介绍的,分片允许更多的并发写操作,因为它可以使用更多的硬件,并且无需过多地操心锁。
- Reladomo 的对象身份合约(即给定主键在整个 JVM 中只具有一个持久对象)简化了多线程代码的推理。编写多线程代码可能会对 IO 产生巨大的影响。很多 Reladomo 中的基础 API 都暴露了内建的多线程,例如 MithraList.setNumberOfParallelThreads,以及 MatcherThread 和 MultiThreadedBatchProcessor 等。
Reladomo 的多线程加载器就是这样一种集成了所有概念的通用工具。它覆盖了一个简单但是高度循环并可重用的用例,即对于给定的输入的大型数据集(例如一个文件),什么是最有效地在数据库中插入、更新和删除相应数据行的方法?当以批处理方式将数据从一个系统拷贝到另一个系统时,该用例是非常典型的。使用多线程加载器的情况下,源和目标将被异步读取、比较并有效地回写。其中所涉及的组件包括:完成比较功能的 MatcherThread、读取源和 Sink 的 InputThread 和 DbLoadThread,以及完成写操作智能批处理的 SingleQueueExecutor。大量地对读、比较和写操作使用多线程,这就是我们扩展 IO 的方法。DbLoadThread 使用 Reladomo 提供的 forEachWithCursor 方法构建数据库的数据流,使得读操作最小化。SingleQueueExecutor 对 IO 做智能组合,降低了死锁。只需要几行代码,就能让多线程加载器的实例跑起来。如果你具有这个用例,那么尝试一下!
要使用上面所提供的功能,关键在于应用设计。在我们给出的分类账目例子中,如下设计将会显著地提升吞吐量:
- 分片,用于扩展 IO。
- 每个分片中,使用同一工作单元中处理多个交易,实现提交和查询的最小化,并将写操作组合在一起。
- 例如,为了查找传入交易中的产品信息,我们使用了大量的交易信息,实现为单一的数据库查询。
- 余额计算以批量方式写数据库。
- 在多个线程中实现对多个分片的用户界面查询。
Reladomo 的缓存
Reladomo 提供了一个定制缓存,该缓存比任何通用缓存都更好地匹配了 ORM 的需求。Reladomo 缓存并非 Map 结构,而是一系列的无键索引,其中每个索引都是一个可搜索的集合或多重集。构成特定索引标识的属性是任意的。缓存总是具有一个主键索引,其它索引是根据应用的定义或是对象间的关系而添加。
为允许缓存覆盖整个 JVM,Reladomo 保证具有特定主键的对象在 JVM 中只存在一次。这使得该缓存比基于会话的缓存更为有用。此外,也使得该缓存比二级序列化缓存更加高效,因为无需对同一对象做二次存储,也无需做序列化和反序列化。不同于其它的 ORM 缓存,Reladomo 缓存中的对象对应用是可见的,并可被应用所使用。
缓存也完全可感知 Reladomo 的事务上下文。事务上的更改操作(即 insert/update/delete 操作)在事务内部是可见的,但只有在更改提交后才对外部可见。
缓存可配置为按需填充(基于被触发的查询),或是在启动时完全填充。完全填充的缓存适用于小型静态对象(例如,“国家”或“货币”)。在一些适当的场景下,完全填充的缓存也适用于大型的数据集。
缓存结构也可完全定制,用于临时对象的存储(参见下文)。临时对象需要一种完全不同的内存中存储,因为业务主键并不能唯一地标识一行。Reladomo 的 SemiUniqueDatedIndex 是一个单一数据结构,对同一数据以两种不同的方式做哈希。
此处对缓存做了更专业的讨论。
缓存通知
在企业场景中,一个特定的数据集不太可能只被一个 JVM 访问或更改,这对 ORM 缓存提出了严重的问题。为解决该问题,Reladomo 的缓存支持缓存间的通知机制。通知由一系列轻量级的缓存过期消息组成,通过广播网络发送。Reladomo 创造性地给出了一种广播网络的 TCP 实现,适合于数百个规模的 JVM。该 TCP 实现为一个基本的“轴辐式”(Hub-Spoke)模型,并出于容错的考虑而加入了双重 Hub。在该实现中也非常易于插入其它的广播实现,只需要实现一系列的小型接口。对缓存通知机制的更多介绍,参见此处。
被复制的堆外缓存
根据应用访问模式的不同,一些问题需使用内存中架构才能很好地解决。如果数据是存活于数据库中的(并且可能是分片的和双时态的),内存中缓存的扩张并保持最新是非常困难的,其中的原因包括:
- 要保持数据与数据库同步,需要一种避免将缓存置于不确定状态过长时间的策略。
- Java 的垃圾回收将在堆的规模达 100GB 时启动工作,应用在计算的过程中制造了垃圾。
- 如果对象是在堆外区域之内或之外被序列化,那么堆外缓存结构将可能会导致更多的垃圾问题。
- 堆外缓存结构难以维护对象间的关系。
- 当单一节点无法满足计算需求的增长时,缓存最好应在多个节点上可用。
Reladomo 的堆外缓存解决了如下的问题:
- 为保持缓存与数据库及复制的同步状态,Reladomo 使用了审计时态维度。对于随新数据到来而不断推进的里程碑,Reladomo 总是可保证应用代码所看到的是一致的数据。
- 缓存结构允许不做反序列化即可访问所有的数据,这对于解决那些满足整体中大部分问题的应用(例如聚合)是非常重要的,也有助于保持垃圾回收的最小化。
- 堆外缓存对象的 API 与堆内对象的完全一样,因此应用代码不需要根据缓存模式做更改。
- 同样的索引结构也适用于堆内缓存,索引数据同样采用堆外保存。
- 数百 GB 的缓存将能提供非常好的性能。
- 缓存复制中采用了一种高效的算法,可轻易实现十个备份服务于同一个主机(Master)。
- 正如堆内对象一样,对象间的关系是动态解析的,这使得堆外缓存具有一种简单且规范化的结构。
- 字符串是特殊处理的。在此幻灯片中提供了更多的技术细节。
企业级特性
在企业场景中,相比于传统 ORM 可以提供的,需要对代码和数据做更宽泛的考虑。企业级 ORM 需要适合对象的全生命周期,从生成到停用。
回到有价证券分类账目这一教科书例子,它具有如下的需求:
- 对收入交易的高速交易处理。
- 基于收入数据(交易、价格等)的余额计算。
- 繁重的用户交互,其中包括一些基于时间的查询,例如:
- 在过去的一天、一周或一月中,余额是如何改变的?
- 在过去的一个月中,这一系列交易是如何影响某个账户的?
- 在上一个季度中,特定收入产品的利润和损失情况如何?
- 用户(或上游数据)在不损失再现性的情况下,对过去事件的更正能力。
- 清空不再需要报告的数据。
要在统一的代码库中实现如上需求,Reladomo 提供了一系列很广泛的能力。
分片:水平可扩展的 ACID
ACID,即原子性(Atomic)、一致性(Consistent)、隔离性(Isolated)和持久性(Durable),是 Reladomo 存储的基本假设之一。扩展 ACID 需要进行审慎的设计。为此,Reladomo 提供了内建的分片特性。对分片的识别也保存在对象中,一并维护在内存中,而不是持久性存储上,并成为对象完整身份的组成部分。这简化了传统主键的构建,不必担心全局唯一性。例如,可以赋予分片 A 中的一个交易以一个简单数字标识“17”,这样以此为标识的分片就可以被其它交易所用的分片重用。
分片是作为 Reladomo API 的头等部分处理的。这意味着,与分片对象交互不需要进行配置转换或是代码隔离。一个查询可以跨越多个分片,这是 Finder API 天然提供的功能,将分片属性与其他属性等同对待。例如:
Operation op = BalanceFinder.businessDate().eq(today); op = op.and(BalanceFinder.desk().in(newSetWith("A", "DR", "T", "ZZ"))); op = op.and(moreOperations()); BalanceList balances = BalanceFinder.findMany(op); balances.setNumberOfParallelThreads(3);
上面代码会使用多个线程访问所有列出的分片(如果是在事务之外)。
对于分类账目的例子,我们可以通过采用一种分片设计使用该功能。Reladomo 将这种分片策略的决策权交给了应用。通常会使用下面两个策略之一:
- 基于(全局)哈希的随机分片;
- 向业务看齐的分片,基于一些业务上的关注。
分类账目交易处理流水线如下图所示:
(点击放大图像)
交易来自于各种上游的源。它们被路由到适当的分片,并在分片中做处理(通常是以FIFO 的方式)。可以将不同分片上的操作安排为完全独立的。该架构可以很好地扩展到上百个分片,很容易实现对上百万交易的处理。
双时态
以正确的时态顺序重新生成前期报告及属性活动,这是包括在分类账目的核心需求之中的。对于“可重新生成”,我们指的是获取与过去已完成的查询完全相同结果的能力。对于“正确的时态次序”,我们指的是可以使用新的信息修复对过去时刻的视图,而不会影响到过去已完成查询的结果。简单以交易为例,如果我们推迟了一天才访问分类账目。那么首要的是,由此新信息而采取的任何行动不能更改过去查询的结果。如果用户提问:“我们昨天下午五点发送了一个昨日交易活动的报告。它看上去如何?”分类账目系统必须能正确地回答该问题。其次,分类账目必须可以将该交易置于到昨日的事件流中。当一个用户提问:“根据我们现在所知的所有事情,昨日的交易活动情况如何?”分类账目系统必须可以在这些数据中展示新的交易。这一问题具有两个二维度。一个时间维度是完全可重生性,这在文献中被称为“交易时间”,我们在Reladomo 中称其为“processingDate”。另一个时间维度用于表示业务视角的事件,而考虑实时发生的情况。这在文献中被称为“有效期”,我们在Reladomo 中成为“businessDate”。处理日期完全由Reladomo 处理,它总是对应于挂钟时间(Wall-clock Time)。业务数据是完全灵活的,应用必须决定如何处理它。
从API 角度看,时态问题通过包括我们将在下面介绍的四个域,存在于Reladomo 实现的每个方面上。时间维度是查询API 的一部分:
Timestamp today = toTimestamp("2017-05-03"); Timestamp lastEvening = toTimestamp("2017-05-03 18:30"); Operation op = BalanceFinder.businessDate().eq(today); op = op.and(BalanceFinder.processingDate().eq(lastEvening)); op = op.and(BalanceFinder.desk().eq("A")); op = op.and(moreOperations()); BalanceList balances = BalanceFinder.findMany(op);
这将对特定业务日期和处理日期检索账户信息。对象域 API 包括 getBusinessDate() 和 getProcessingDate()。换句话说,每个在应用中使用的对象被定位成二维时态空间中的某一点。值在被检索时或构建时,就标记在对象上。
Reladomo 框架的一个亮点是定义对象间时态关系的功能。如果我们要检索一个账户对象,并询问其账号或产品,这些对象对应于时态空间中的同一个点。整个可浏览的对象图表示了一个一致的数据时态视图。这一概念同样也在查询上执行,即一旦一个关系在查询中被浏览,时态信息会被应用到查询上。例如:
Timestamp thirdQuarter = toTimestamp("2016-09-30"); Timestamp lastYear = toTimestamp("2016-12-01 20:00"); Operation op = BalanceFinder.businessDate().eq(thirdQuarter); op = op.and(BalanceFinder.processingDate().eq(lastYear)); op = op.and(BalanceFinder.desk().eq("A")); op = op.and(BalanceFinder.product().productName().startsWith("S")); BalanceList balances = BalanceFinder.findMany(op); In the above query, productName corresponds to what it was last year.
写入双时态对象需要一个更宽泛的 API 集合,隐藏了大量的更为复杂的实现。API 覆盖了如下方面:
- 在 2D 时间上查询一个点。
- 以一致时态方式浏览对象图。
- 对历史的查询。
- 在特定的业务时间点上插入并终止。
- 在特定业务时间点上做修改,视情况可一直持续到另一时间点。
- 在特定业务时间点上的数值域增量计算,视情况可持续到另一个时间点。
API 的开发需求来自于已使用双时态存储的真实世界应用。对 API 的更全面介绍,参见“ Reladomo Kata ”中的双时态章节内容。下面让我们看一个例子。如果我们基于交易活动存储一个账户的余额量,在两次交易后,余额在数据库中如下图所示:
(点击放大图像)
如果我们反过来添加一个交易,使用下面的代码就能更新余额:
Timestamp oldTradeDate = toTimestamp("2005-01-12"); Operation op = BalanceFinder.businessDate().eq(oldTradeDate); op = op.and(BalanceFinder.desk().eq("A")); op = op.and(BalanceFinder.balanceId().eq(1234)); Balance balance = BalanceFinder.findOne(op); balance.incrementValue(40);
下图显示了上面代码对数据库中余额数据的作用:
(点击放大图像)
Reladomo 从一行代码 incrementValue 执行了两次 Insert 和两次 Update 操作。控制对双时态数据修改的规则是相当地复杂的。如果寄希望于每位开发人员能在传统的 ORM 上实现它们,那么就会导致软件缺陷和潜在的数据丢失。这种后台机制的实现就是 Reladomo 双时态 API 的价值定位。
特性列举
Reladomo 还提供了一系列较细微的特性,使得在企业场景中的工作更平滑。其中一些为:
- 时区支持:对域做标记,自动转换为 UTC 或数据库的时区。
- 临时对象:在 Java 代码中使用,等价于临时表。
- 可变主键:对于组合主键,将其中部分键值可选地标记为可变的。
- 元数据工具:使用给出的结构和运行时元数据去编写更通用的代码。一些 Reladomo 的内建工具正是这样做的。
- 三层部署:是需要限制全部连接为数据库,还是缓存一些数据?设置一个三层配置,并将其中的一个 JVM 用做为其它 JVM 的 Hub。
- 清空和归档 API,允许对不再需要的数据做过期或是移动处理。
开箱即用的可测试性
一名好的开发人员应该知道,每个要运行于生产环境的应用应该具备经良好测试的代码。对于那些具有高度监管审查、信誉风险或安全问题的企业(例如金融和卫生保健),其真实性存疑。
要测试与持久数据存储交互的代码,存在着如下的问题:
- 测试数据的设置和清除可能会非常繁琐。
- 有可能对特定的测试设置了不完全的对象图,当生产代码开始使用对象图的其它部分时,这很容易引发测试失败。
- 如果模拟与数据存储交互的测试是手工编写的,可能会存在问题。
- 即使是一个实现生产代码在读取后执行写入的简单测试,也将需要一些类型的模拟状态管理。
- 在开发人员看不到生成的 SQL,或是不容易推断 IO 的情况下。
- 在传统的场景中,测试基于真实的数据库运行,这对于大型团队而言是难以管理的,并为每个开发人员添加了额外的负担。
Reladomo 内建的测试框架通过提供一个对完全采样实例化一个内存中数据库的测试资源,解决了上述问题,参见“ Reladomo Test Resource ”一文。其中,数据库使用了一系列易于管理的文本文件填充;无需编码难以读取和修改的 insert 语句;所有交互不用物理资源就可以被测试,即测试可以运行在没有网络连接的机器上;开发人员可以轻易地检查所生成的 SQL(不存在标识变量的问号!)并对 IO 做推理,例如,为定位缺失的 deepFetch 或对写操作优化;完全支持混合了所有操作(即写操作和读操作)的完整生命周期测试。
在首次发布以来,Reladomo 的测试框架就作为一部分存在于代码中。Reladomo 本身也大量使用这些测试。
对遗留代码的提升是内建测试框架的一个最好用例。对未经良好测试的已有代码引入测试,并使用 Reladomo 重新实现,实现了对遗留代码的高置信度替换。
结论
Reladomo 的可测试性、性能和企业级特性使得编写用于 ACID 数据库交互的面向对象代码成为现实。Reladomo 填充了传统 ORM 特性在企业空间中的空白。Reladomo 通过提供 API 以编写更好更紧致的代码,并无缝地测试这些代码,使得开发人员可以将他们的时间用于他们应用和业务逻辑的高层设计特性。
如果你有兴趣更深入地了解 Reladomo,可以尝试 Kata 练习,它是我们给出的一套 Reladomo 教程,其中附带地使用了 Reladomo 测试框架,因此你不需要安装一个数据库才能开始尝试。欢迎访问我们的 Github 项目,并查看文档
作者简介
Mohammad Rezaei是 Reladomo 的主架构师,任 Goldman Sachs 公司平台业务部门的技术专家(Technology Fellow)。他具有在多种环境中编写高性能 Java 代码的丰富经验,从分区、并发交易系统,乃至采用无锁算法实现最大吞吐量的大内存系统。Mohammad 具有宾夕法尼亚大学的计算机科学本科学位,并在康奈尔大学获得物理学博士学位。
查看英文原文: Introducing Reladomo - Enterprise Open Source Java ORM, Batteries Included! (Part 2)
评论