窥镜下的OLTP以及我们的发现(二)

作者: Stavros Harizopoulos, Daniel J. Abadi, Samuel Madden, Michael Stonebraker

——接上篇——

3.Shore

Shore (可扩展异构对象库)由美国威斯康星大学在 20 世纪 90 年代早期开发出来,旨在成为一个类型化的持久对象系统,它借鉴了文件系统和面向对象的数据库技术 [CDF+94] 。它具有分层架构,允许用户从多个组件中为其应用程序选择适当的支持层级。这些层(类型系统、 Unix 兼容性、语言异构性)是紧接着 Shore Storage Manager (SSM) 提供的。存储管理器提供了在所有现代 DBMS 中都能找到的功能:完整的并发控制和恢复( ACID 事务属性),具有两阶段封锁和预写日志记录,以及 B 树的可靠实现。它的基础设计源自 Gray 和 Reuter 关于事务处理的著作 [GR93] 所描述的想法,具有很多直接来自 ARIES 论文 [MHL+92, Moh89, ML89] 的算法。

人们对该项目的支持结束于 20 世纪 90 年代晚期,但对 Shore Storage Manager 的支持继续存在;截至 2007 年, SSM 5.0 版本可以用于运行在 Intel x86 处理器上的 Linux 。在整个白皮书中,我们使用 “Shore” 来指代 Shore Storage Manager 。 Shore 的相关信息和源代码可以在线查阅 1 。在本部分的剩余内容中,我们将讨论 Shore 的关键组件、代码结构、影响端到端性能的特性,以及我们对它进行的一系列修改和这些修改对代码行的影响。

3.1 Shore 的架构

我们没有在此描述 Shore 的部分功能,因为它们与本白皮书无关。这些功能包括磁盘容量管理(我们在主存储器中预载整个数据库)、恢复(我们没有检查应用程序崩溃的问题)、分布式事务和 B 树之外的访问方法(例如 R 树)。剩下的功能可以粗略地以如图 2 所示的组件来表示。

Shore 被当作一种库提供给用户;用户代码(对我们而言, TPC-C 基准的实现)与库相链接,并且必须使用 Shore 也使用的线程库。每个事务在一个 Shore 线程中运行,访问本地用户空间变量和 Shore 提供的数据结构和方法。与 OLTP 相关的方法用于创建和填充数据库文件,把它加载到缓冲池,开始、提交或中止事务,以及执行记录级的操作,例如锁存、更新、创建和删除,以及与一级和二级 B 树索引相关的操作。

在事务体内部(周围是 begin 和 commit 语句),应用程序设计员使用 Shore 的方法来访问存储结构:文件和索引,以及用于查找二者的目录。所有的存储结构均使用分页槽来存储信息。 Shore 的方法在事务管理器下运行,而事务管理器与所有其他组件紧密交互。访问存储结构涉及到对日志管理器、锁管理器和缓冲池管理器的调用。这些调用总是通过一个并发控制层进 行,该并发控制层负责监督对各种资源的共享和互斥访问。这并不是一个单独的模块;相反,在整个代码中,对共享结构的所有访问都是通过获取一个锁存器来实现的。锁存器类似于数据库锁(因为它们可以被共享或独占),但它们是轻量级的,并且没有死锁检测机制。应用程序设计员需要确保锁存不会导致死锁。
图 2. Shore 的基本组件(详细描述见正文)。
图 2. Shore 的基本组件(详细描述见正文)

接下来,我们将讨论线程架构,并进一步详细论述封锁、日志记录和缓冲池管理。

线程支持。 Shore 提供自有的用户级、非抢占式线程包,这个线程包源自 NewThreads (最初由华盛顿大学开发),提供一个可移植的 OS 接口 API 。线程包的选择影响了 Shore 的代码设计和行为。由于线程是用户级的,应用程序作为一个单一进程来运行,多路复用 Shore 的所有线程。 Shore 通过大量生成负责 I/O 设备的独立进程(所有进程通过共享内存进行通信)来避免 I/O 的阻塞。然而,应用程序并不能直接利用多核(或者 SMP )系统,除非它们被构建为分布式应用程序的一部分;但是,在简单的非用户级线程就足以满足需求的情况下,这将为多核 CPU 增加不必要的开销。

扫描二维码关注公众号,回复: 5488528 查看本文章

因此,本白皮书所报告的结果均为使用单线程操作的情况。对于每个事务,使用多线程操作的系统将会消耗更大数量的指令和 CPU 周期(因为除了事务代码,还需要被执行线程代码)。由于本白皮书的主要目的是研究各种数据库系统组件的 CPU 指令的开销, Shore 缺乏完整的多线程实现只会影响我们的结果,毕竟我们是从 CPU 指令总数的较低起点开始删除系统组件的。

表 1 : OLTP 的潜在优化集合
表1:OLTP的潜在优化集合
封锁和日志记录 。 Shore 执行标准的两阶段封锁,其事务具有标准的 ACID 属性。它支持分层封锁,默认锁管理器沿着层次结构逐步升级(记录、页面、存储器、卷)。每个事务都保存着它所持有的锁的列表,以便当事务进入准备状态时锁可以被记录下来,并在结束事务时被释放。 Shore 还执行预写日志记录 (WAL) ,这需要日志管理器和缓冲区管理器之间的紧密交互。从缓冲池清除页面之前,可能需要清除相应的日志记录。这也需要日志管理器和事务管理器之间的紧密交互。这三个管理器都能理解日志序列号 (LSN) ,后者用于识别和定位日志中的日志记录、时间戳页面,识别事务执行的最后一个更新,并找到事务写入的最后一个日志记录。每个页面都有影响该页面的最新更新的 LSN 。页面无法被写入磁盘,除非带有该页面的 LSN 的日志记录已被写入稳定的存储器。

缓冲区管理器。 缓冲区管理器是所有其他模块(日志管理器除外)读取和写入页面的工具。页面的读取是通过向缓冲区管理器发出修复方法调用来实现的。对于驻留在主存储器的数据库,页面始终在缓冲池中(对于非主存数据库,如果所请求的页面不在缓冲池中,线程将放弃 CPU 并等待负责 I/O 的进程以便将页面放入缓冲池)。修复方法更新页面 ID 与缓冲帧和使用情况统计信息之间的映射。为了确保一致性,使用一个锁存器来控制对修复方法的使用。读取记录(通过索引查找发现记录 ID 之后)涉及到:

  1.  封锁记录(和页面,按照分层封锁);
    
  2.  修复缓冲池中的页面;
    
  3.  计算记录标签页面内的偏移量。
    

页面的读取是通过发出固定 / 取消固定方法调用来实现的。记录的更新伴随着复制来自缓冲池的记录的一部分或全部到用户的地址空间,并在那里执行更新和传送新数据给存储管理器。

关于 Shore 架构的更多详细信息可以在这个项目的网站上找到。我们在下文中讨论了我们对 Shore 的修改,同时描述了它的一些其他机制和功能。

3.2 删除 Shore 的组件

表 1 总结了现代 OLTP 系统的属性和特征(左栏),而现代 OLTP 系统允许我们删除来自 DBMS 的某些功能(右栏)。我们使用这些优化作为修改 Shore 的指南。由于 Shore 中的所有管理器紧密集成在一起,我们无法干净地分离所有组件,因此,我们可以按任意顺序删除它们。另外一个好处是,我们能够按照代码结构所述的顺序来删除功能,从而带来了任何潜在情况下的灵活性。该顺序以下:

  1.  删除日志记录。
    
  2.  删除封锁或锁存。
    
  3.  删除锁存或封锁。
    
  4.  删除与缓冲区管理器相关的代码。
    

此外,我们发现以下优化可以在任何时间点进行:

       简化和硬编码 B 树键值评估逻辑,就像目前在大多数商业系统中所做的那样。

       加速目录查找。

       增加页面大小以避免频繁的分配(包含在上述步骤 4 中)。

       删除事务会话(开始、提交、各种检查)。

我们执行上述操作的方法以下所述。一般而言,如要从系统删除某个组件,我们可以添加一些 if 语句来避免执行属于该组件的代码,或者如果我们发现这些 if 语句增加了大量的开销,我们重写整个方法来避免完全调用该组件。

**删除日志记录 。**删除日志记录分为三个步骤。第一个步骤是避免产生 I/O 请求,以及执行这些请求的时间(在下文的图 7 中,我们将此修改标为 “ 磁盘日志 ” )。我们实现这个步骤的方法是允许组提交然后增加日志缓冲区的大小,以便在我们的实验期间它不会被清除到磁盘。然后,我们注释掉用来准备和写入日志记录(在图 7 中被标记为 “ 主日志 ” )的所有功能。最后一个步骤是在整个代码中添加 if 语句以避免处理日志序列号(在图 7 中被标记为 “LSN” )。

删除封锁(可与删除锁存互换)。 在我们的实验中,我们发现,我们可以安全地互换删除封锁和删除锁存的顺序(当日志记录被删除之后)。由于锁存也是在封锁内执行的,删除其中一个也会减少另一个的开销。为了删除封锁,我们首先更改了所有锁管理器方法来立即返回,就好像锁请求是成功的,并且对锁的所有检查都是令人满意的。然后,我们修改了与固定记录相关的方法,从而在目录中查找它们,并通过 B 树索引访问它们。在每一种情况下,我们都消除了与未授予的锁请求相关的代码路径。

删除锁存(可与删除封锁互换)。 删除锁存类似于删除封锁;我们首先修改了所有将被立即满足的互斥请求。然后我们在整个代码中添加了 if 语句来避免针对锁存器的请求。由于 B 树方法中的锁存器代码的紧密集成,添加 if 语句会显著增加开销,为此,我们不得不使用没有使用锁存器的方法来替换 B 树方法。

删除缓冲区管理器调用。 我们进行的最广泛的修改是删除缓冲区管理器方法,这是在我们知道日志记录、封锁和销存已经被禁用后进行的。为了创建新记录,我们放弃了 Shore 的页面分配机制,转而使用标准的内存分配库。我们为每个新记录(不再驻留在页面中的记录)调用内存分配并使用指针来方便未来的访问。内存分配可以更高效地进行,特别是在人们事先知道所分配的对象的大小时。然而,主存分配的进一步优化是与我们正在研究的开销有关的渐进式改进,我们将在未来的工作中研究它。我们无法完全删除缓冲帧的页面界面,因为它的删除会使大部分的剩余 Shore 代码无效。相反,我们加速了页面和缓冲帧之间的映射,从而最大程度地减少开销。类似地,固定和更新记录仍将通过一个缓冲区管理器层,尽管这个层非常薄(我们在图 7 中将这组优化标记为 “ 页面访问 ” )。

其他优化。 我们进行的优化有四个可以在删除上述组件的过程中随时调用。这些优化如下所述。 (1) 通过手工编码的节点搜索来加速 B 树,以便针对键值是未压缩整数的常见情况进行优化(在图 5 -8 中被标记为 “B 树键值 ” )。 (2) 通过针对所有事务使用一个单一缓存来加速目录查找(在图 7 中被标记为 “ 目录查找 ” )。 (3) 增加页面的大小,从默认的 8KB 增加到 Shore 允许的最大值 32KB (在图 7 中被标记为 “ 小页面 ” )。尽管较大的页面不适合基于磁盘的 OLTP ,但它们可以通过减少 B 树的层级数量(归因于较大的节点)来在主存数据库中提供帮助,并减少针对新创建的记录的页面分配。另一种方法是按照 [RR99] 的提议,将 B 树节点的大小减小到缓存行的大小,但这需要删除 B 树节 点和 Shore 页面之间的关联,或者将 Shore 页面减小到 1KB 以下(这是 Shore 所不允许的)。 (4) 通过将事务合并到单个会话中(在图 7 中被标记为 “X 事务 ” ),消除针对每个事务进行设置和终止会话的开销,以及相关的事务运行监视。

我们对 Shore 进行的全部修改 / 优化,以及基准测试套件和关于如何进行实验的文档,均可在网上找到。
图 3. TPC-C 模式。
图 3. TPC-C 模式。

  1. 性能研究

本部分的组织结构如下所述。在第 4.1 节,我们将描述我们使用过的 TPC-C 基准的变体。在第 4.2 节,我们将提供关于硬件平台、实验设置和我们用来收集性能参数的工具的详细信息。在第 4.3 节,我们将展示一系列结果,详细说明 Shore 在我们渐进式地应用优化和删除组件时的性能表现。

4.1 OLTP 工作负载

我们的基准源自 TPC-C [TPCC] ,它模拟了一个拥有很多仓库和相关销售区域的零部件批发供应商。 TPC-C 旨在代表任何必须管理、销售或分销产品或服务的行业。它可以随着供应商扩张和新仓库的创建而扩展。扩展要求是,每个仓库必须供应 10 个销售区域,然后每个销售区域必须服务于 3000 个客户。带有扩展要求(作为仓库 (W) 数量的函数)的数据库模式如图 3 所示。每个仓库的数据库大小约为 100 MB (我们的实验使用了五个仓库,总共 500 MB )。

TPC-C 涉及到五个不同类型和复杂程度的并发事务。这些事务包括输入订单(新订单事务)、记录付款(支付)、发货、订单状态查询和库存状态监控。 TPC-C 还规定,前两个事务的执行时间约占 90% 。就本白皮书而言,为了更好地了解我们的干预所生产的效果,我们的实验只涉及前两个事务。它们的代码结果(调用 Shore )如图 4 所示。为了实现实验的可重复性,我们对原始规格进行了以下微调:

新订单。 每个新订单事务下达一个含有 5~15 款产品的订单,其中,所有订单的 90% 完全由客户 “ 本地 ” 仓库的库存来供货(剩余的 10% 需要使用属于异地仓库的库存),并且所提供的产品中有 1% 是无效的产品(不在 B 树中)。为了避免结果的差异,我们将产品数量设定为 10 ,并且始终由一个本地仓库来供货。这两个变化并不影响吞吐量。表 4 中的代码展示了第 2.5 节所提到的两阶段优化,这使得我们可以避免中止一个事务;我们在开始阶段读取所有的产品,并且如果我们发现一个无效的商品,我们将在不重新修改数据库的情况中止。
图 4. 新订单和支付事务的 Shore 方法调用。
支付。 这是一个轻量级事务;它更新客户的余额和仓库 / 销售区域字段,并生成一个历史记录。同样,这里有一个本地和异地仓库的选择问题,而我们始终选择本地仓库。另一个随机设置的输入是按名称还是按 ID 查找客户,而我们始终使用 ID 。

4.2 设置和测量方法

所有的实验均在一个单核 3.2GHz 奔腾 4 处理器( 1MB L2 缓存,超线程已禁用, 1GB RAM ,运行 Linux 2.6 )上进行。我们使用 GCC 3.4.4 版本和 O2 优化进行编译。我们使用标准的 Linux 工具 iostat 来监视磁盘活动,并在主存驻留实验中证实没有磁盘流量生成。在所有实验中,我们预载整个数据库到主存。然后我们运行了大量的事务 (40,000) 。吞吐量是通过将挂钟时间除以已完成事务的数量来直接度量的。

对于详细的指令和周期计数,我们通过调用 PAPI 库 [MBD + 99] (http://icl.cs.utk.edu/papi/) 来测试基准应用程序代码,其中 PAPI 库提供对 CPU 性能计数器的访问。由于我们在每次调用 Shore 之后调用 PAPI ,我们在报告最终的数字时不得不补偿 PAPI 调用的开销。这些调用在我们的机器中拥有 535-537 个指令,并占用了 1350 ~ 1500 个周期。我们针对所有 40,000 个事务测量了每一次 Shore 调用,并报告了平均数字。

本白皮书中的大多数图表均基于 CPU 指令数(通过 CPU 性能计数器测量),而不是挂钟时间。原因在于,指令数代表总运行时代码路径长度,并且它们是确定的。由于不同的微架构行为(缓存未命中、 TLB 未命中等),不同组件之间的相等指令数当然会导致不同的挂钟执行时间( CPU 周期)。在第 4.3.4 节中,我们将指令数与 CPU 周期进行了比较,并举例说明了具有高微架构效率的组件,这种高微架构效率可归因于诸如 L2 缓存未命中和良好的指令级并行性等问题。
然而,周期数很容易受到各种参数的影响,从 CPU 特性(比如缓存大小 / 关联性、分支预测器、 TLB 操作)到运行时变量(例如并发进程),不一而足。因此,它应被视为相对时间分解的参考。在本白皮书中,我们没有展开 CPU 缓存性能的问题,因为我们的重点是识别出被删除之后可以针对某些类别的 OLTP 工作负载将性能提升高达两个数量级的 DBMS 组件。关于数据库工作负载的微架构行为的更多信息可以在其他地方找到 [Ail04] 。

接下来,我们开始展示我们的结果。

——更多待续——

猜你喜欢

转载自blog.csdn.net/weixin_43965756/article/details/88388899