为什么Uber Engineering从Postgres切换到MySQL

2021-02-27 18:34:17

Uber的早期架构包括一个用Python编写的整体式后端应用程序,该应用程序使用Postgres进行数据持久化。自那时以来,Uber的架构已发生了巨大变化,已成为微服务和新数据平台的模型。具体来说,在以前使用Postgres的许多情况下,我们现在使用Schemaless,这是一种基于MySQL的新型数据库分片层。在本文中,我们将探讨Postgres所发现的一些缺点,并解释在MySQL之上构建Schemaless和其他后端服务的决定。

我们将通过分析Postgres在磁盘上的表和索引数据的表示法来研究所有这些限制,尤其是与MySQL用其InnoDB存储引擎表示相同数据的方式进行比较时。请注意,我们在此处进行的分析主要是基于我们对较旧的Postgres 9.2版本系列的经验。据我们所知,我们在本文中讨论的内部体系结构在较新的Postgres发行版中并未发生显着变化,并且至少自Postgres 8.3发行版以来,9.2中的磁盘上表示形式的基本设计没有发生显着变化(现在已接近10个)。岁)。

实施多版本并发控制(MVCC)机制,以便不同的连接对其使用的数据具有事务性视图

考虑所有这些功能如何协同工作是设计数据库如何表示磁盘上数据的重要部分。

Postgres的核心设计方面之一是不可变的行数据。这些不变的行在Postgres中被称为“元组”。这些元组由Postgres所谓的ctid唯一标识。 ctid从概念上表示元组的磁盘上位置(即物理磁盘偏移)。多个ctid可以潜在地描述单个行(例如,当出于MVCC目的而存在该行的多个版本时,或者当自动真空处理尚未回收该行的旧版本时)。有组织的元组的集合构成一个表。表本身具有索引,这些索引被组织为将索引字段映射到ctid有效负载的数据结构(通常是B树)。

通常,这些ctid对用户是透明的,但是了解它们的工作原理有助于您了解Postgres表的磁盘结构。要查看行的当前ctid,可以在WHERE子句的列列表中添加“ ctid”:

uber @ [local] uber => SELECT ctid,*从my_table LIMIT 1开始; -[记录1] -------- ++ ------------------------------ ctid | (0,1) ...这里的其他领域...

为了说明布局的详细信息,让我们考虑一个简单的用户表的示例。对于每个用户,我们都有一个自动递增的用户ID主键,用户的名字和姓氏以及用户的出生年份。我们还在用户的全名(名字和姓氏)上定义了复合二级索引,并在用户的出生年份定义了另一个二级索引。创建这样的表的DDL可能是这样的:

CREATE TABLE用户( ID SERIAL, 第一个TEXT, 最后一个TEXT, birth_year INTEGER, 主键(id) ); 创建索引ix_users_first_last ON用户(第一个,最后一个); CREATE INDEX ix_users_birth_year开用户(birth_year);

请注意此定义中的三个索引:主键索引加上我们定义的两个辅助索引。

对于本文中的示例,我们将从表中的以下数据开始,该数据由一些有影响力的历史数学家组成:

如前所述,这些行中的每一个都隐式具有唯一的,不透明的ctid。因此,我们可以这样考虑表的内部表示形式:

B树在id字段上定义,并且B树中的每个节点都保存ctid值。请注意,在这种情况下,由于使用了自动递增的ID,因此B树中字段的顺序恰好与表中的顺序相同,但不一定是这种情况。

二级索引看起来很相似。主要区别在于字段的存储顺序不同,因为B树必须按字典顺序组织。 (first,last)索引以名字开头,朝向字母表的顶部:

如您所见,在这两种情况下,二级索引中的ctid字段在字典上都没有增加,这与自动递增主键的情况不同。

假设我们需要更新该表中的一条记录。举例来说,假设我们要更新出生年份字段,以估算赫卡里兹米(Khwārizmī)的出生年份770 CE。如前所述,行元组是不可变的。因此,为了更新记录,我们向表中添加了一个新的元组。这个新的元组具有一个新的不透明ctid,我们将其称为I。 Postgres需要能够将I处的新活动元组与D处的旧元组区分开。在内部,Postgres在每个元组中存储一个版本字段和一个指向前一个元组的指针(如果有的话)。因此,表的新结构如下所示:

只要存在al-Khwārizmī行的两个版本,索引就必须同时包含两个行的条目。为简便起见,我们省略了主键索引,而在此处仅显示了二级索引,如下所示:

我们用红色表示旧版本,用绿色表示新行。在幕后,Postgres使用另一个保存行版本的字段来确定哪个元组是最新的。通过此添加的字段,数据库可以确定哪个行元组可服务于可能不允许查看最新行版本的事务。

当我们在表中插入新行时,如果启用了流复制,则Postgres需要对其进行复制。为了崩溃恢复,数据库已经维护了一个预写日志(WAL),并使用它来实现两阶段提交。即使未启用流复制,数据库也必须维护此WAL,因为WAL允许ACID的原子性和持久性。

我们可以通过考虑如果数据库意外崩溃(例如突然断电期间)会发生什么情况来了解WAL。 WAL代表数据库计划对表和索引的磁盘上内容进行的更改的分类帐。当Postgres守护程序首次启动时,该过程会将此分类帐中的数据与磁盘上的实际数据进行比较。如果分类帐中包含未反映在磁盘上的数据,则数据库会更正任何元组或索引数据以反映WAL指示的数据。然后,它回滚出现在WAL中但来自部分应用的事务的任何数据(这意味着该事务从未提交)。

Postgres通过将主数据库上的WAL发送到副本来实现流复制。每个副本数据库都有效地像在崩溃恢复中一样,不断地应用WAL更新,就像崩溃后启动一样。流复制和实际崩溃恢复之间的唯一区别是,处于“热备用”模式的副本在应用流WAL时会提供读取查询,而实际上处于崩溃恢复模式的Postgres数据库通常会拒绝提供任何查询,直到数据库实例完成崩溃恢复过程。

因为WAL实际上是为崩溃恢复目的而设计的,所以它包含有关磁盘更新的低级信息。 WAL的内容在行元组及其磁盘偏移量(即行ctids)的实际磁盘上表示形式上。如果在副本完全被追上时暂停Postgres主副本,则副本上的实际磁盘内容与主字节上的内容完全匹配。因此,如果rsync之类的副本与主副本过期,则rsync之类的工具可以修复该副本。

Postgres设计的第一个问题在其他情况下称为写入放大。通常,写放大是指将数据写到SSD磁盘时遇到的问题:小的逻辑更新(例如,写入几个字节)在转换到物理层时会变得更大,更昂贵。在Postgres中也会出现同样的问题。在前面的示例中,当我们对哈瓦里兹米(Khwārizmī)的出生年份进行了较小的逻辑更新时,我们必须发布至少四个物理更新:

实际上,这四个更新仅反映对主表空间的写操作。这些写操作中的每一个也需要反映在WAL中,因此磁盘上的写操作总数甚至更大。

这里值得注意的是更新2和3。当我们更新al-Khwārizmī的出生年份时,我们实际上没有更改他的主键,也没有更改他的名字和姓氏。但是,仍然必须通过在数据库中为行记录创建新的行元组来更新这些索引。对于具有大量二级索引的表,这些多余的步骤可能会导致极大的效率低下。例如,如果我们在一个表上定义了十二个索引,则必须仅将一个索引覆盖的字段更新传播到所有12个索引中,以反映新行的ctid。

由于复制发生在磁盘更改级别,因此该写放大问题自然也转化为复制层。数据库没有复制一个小的逻辑记录,如“将ctid D的出生年份更改为现在的770”,而是为我们刚才描述的所有四次写写了WAL条目,并且所有这四个WAL条目都传播了过来。网络。因此,写放大问题也转化为复制放大问题,并且Postgres复制数据流很快变得非常冗长,可能占用大量带宽。

如果Postgres复制仅发生在单个数据中心内,则复制带宽可能不是问题。现代网络设备和交换机可以处理大量带宽,许多托管服务提供商提供免费或廉价的内部数据中心带宽。但是,当必须在数据中心之间进行复制时,问题可能会迅速升级。例如,Uber最初在西海岸的托管空间中使用物理服务器。为了灾难恢复,我们在第二个东海岸托管空间中添加了服务器。在这种设计中,我们在西部数据中心有一个主Postgres实例(加上副本),在东部有一个副本集。

级联复制将数据中心间的带宽要求限制为仅在主副本和单个副本之间所需的复制数量,即使第二个数据中心中有很多副本也是如此。但是,Postgres复制协议的详细信息仍然可能导致使用大量索引的数据库的数据量巨大。购买非常高带宽的越野链接非常昂贵,即使在钱不成问题的情况下,也根本不可能获得具有与本地互连相同带宽的越野网络链接。这个带宽问题也给我们的WAL归档带来了麻烦。除了将所有WAL更新从西海岸发送到东海岸之外,我们还将所有WAL都存档到文件存储Web服务中,以确保在发生灾难时我们可以恢复数据,并确保存档的WAL可以启动。数据库快照中的新副本。在早期的高峰流量期间,存储Web服务的带宽根本不够快,无法跟上WAL写入速率。

在例行升级master数据库以增加数据库容量的过程中,我们遇到了Postgres 9.2错误。副本跟随时间轴开关不正确,导致其中一些错误地应用了一些WAL记录。由于存在此错误,某些本应由版本控制机制标记为无效的记录实际上并未被标记为无效。

该查询将返回两条记录:原始的al-Khwārizmī行与780 CE出生年份,以及新的al-Khwārizmī行与770 CE出生年份。如果我们将ctid添加到WHERE列表中,那么对于两个返回的记录,我们将看到不同的ctid值,就像人们期望的是两个不同的行元组一样。

由于几个原因,这个问题非常棘手。首先,我们无法轻易得知此问题影响了多少行。从数据库返回的重复结果在许多情况下导致应用程序逻辑失败。我们最终添加了防御性编程语句,以检测已知有此问题的表的情况。因为该错误影响了所有服务器,所以在不同的副本实例上损坏的行是不同的,这意味着在一个副本上,行X可能是坏的,行Y可能是好的,但是在另一副本上,行X可能是好的,行Y可能是好的坏的。实际上,我们不确定数据损坏的副本数量以及问题是否影响了主服务器。

据我们所知,该问题仅出现在每个数据库的几行上,但我们非常担心,由于复制发生在物理级别,因此我们最终可能会完全破坏我们的数据库索引。 B树的一个重要方面是必须定期重新平衡它们,并且当子树移动到新的磁盘位置时,这些重新平衡操作可以完全改变树的结构。如果移动了错误的数据,则可能导致树的大部分变为完全无效。

最后,我们能够找到实际的错误并使用它来确定新提升的master没有任何损坏的行。我们通过从主服务器的新快照重新同步所有副本(这是一个费力的过程)来修复副本上的损坏问题。我们只有足够的容量来一次从负载平衡池中取出几个副本。

我们遇到的错误仅影响了Postgres 9.2的某些版本,并且已经修复了很长时间。但是,我们仍然发现,此类错误根本不会发生。可能会随时发布具有这种错误的新版本的Postgres,并且由于复制的工作方式,此问题有可能传播到复制层次结构中的所有数据库中。

Postgres没有真正的副本MVCC支持。副本应用WAL更新的事实导致它们在任何给定时间点都具有与主数据库相同的磁盘数据副本。这种设计给Uber带来了问题。

Postgres需要维护MVCC的旧行版本的副本。如果流复制副本具有打开的事务,则如果数据库更新影响事务保持打开的行,则会阻止对数据库的更新。在这种情况下,Postgres暂停WAL应用程序线程,直到事务结束。如果事务处理要花费很长时间,则这是有问题的,因为副本可能严重滞后于主服务器。因此,Postgres在这种情况下应用超时:如果事务在指定的时间内阻止了WAL应用程序,则Postgres将终止该事务。

这种设计意味着副本通常会比主副本落后几秒钟,因此很容易编写导致交易终止的代码。对于编写模糊事务开始和结束位置的代码的应用程序开发人员来说,此问题可能并不明显。例如,假设开发人员有一些代码必须通过电子邮件将收据发送给用户。根据编写方式的不同,代码可能会隐式地保持数据库事务处于打开状态,直到电子邮件完成发送为止。尽管在执行不相关的阻塞I / O时让代码保持开放的数据库事务总是很糟糕的形式,但现实是大多数工程师不是数据库专家,并且可能并不总是了解此问题,尤其是在使用掩盖了底层细节的ORM时像公开交易。

由于复制记录在物理级别上起作用,因此无法在Postgres的不同常规可用性版本之间复制数据。运行Postgres 9.3的主数据库不能复制到运行Postgres 9.2的副本,运行9.2的主数据库也不能复制到运行Postgres 9.3的副本。

在主数据库上运行一个名为pg_upgrade的命令,该命令将就地更新主数据库数据。对于大型数据库,这可能很容易花费多个小时,并且在此过程发生时,无法从主服务器提供任何流量。

创建主服务器的新快照。此步骤完全复制了主数据库中的所有数据,因此大型数据库也要花费许多时间。

将每个副本带回到复制层次结构中。等待副本完全恢复到副本还原时由主服务器应用的所有更新。

我们从Postgres 9.1开始,并成功完成了升级过程,以迁移到Postgres 9.2。但是,该过程花费了很多小时,因此我们无力承担再次执行该过程的费用。到Postgres 9.3发布时,Uber的增长大大增加了我们的数据集,因此升级本来就更长。因此,即使当前的Postgres GA版本为9.5,我们的传统Postgres实例也可以运行Postgres 9.2。

如果您运行的是Postgres 9.4或更高版本,则可以使用pgologic之类的东西,它为Postgres实现了一个逻辑复制层。使用pgologic,您可以在不同的Postgres版本之间复制数据,这意味着可以将9.4升级到9.5,而不会造成大量停机。该功能仍然存在问题,因为它尚未集成到Postgres主线树中,并且对于在较旧的Postgres版本上运行的用户而言,pgologic仍然不是一种选择。

除了说明Postgres的一些局限性之外,我们还说明了为什么MySQL是更新的Uber Engineering存储项目(例如Schemaless)的重要工具。在许多情况下,我们发现MySQL更适合我们的使用。为了理解这些差异,我们研究了MySQL的体系结构及其与Postgres的对比。我们专门分析MySQL如何与InnoDB存储引擎一起使用。我们不仅在Uber使用InnoDB;它可能是最受欢迎的MySQL存储引擎。

与Postgres一样,InnoDB支持MVCC和可变数据等高级功能。关于InnoDB磁盘格式的详尽讨论不在本文讨论范围之内。相反,我们将重点介绍其与Postgres的核心区别。

最重要的架构差异是,尽管Postgres将索引记录直接映射到磁盘上的位置,但InnoDB维护二级结构。 InnoDB二级索引记录拥有一个指向主键值的指针,而不是持有一个指向磁盘上行位置的指针(就像ctid在Postgres中一样)。因此,MySQL中的辅助索引将索引键与主键相关联:

为了对(第一个,最后一个)索引执行索引查找,我们实际上需要执行两次查找。第一次查找将搜索表并找到记录的主键。找到主键后,第二次查找将搜索主键索引,以找到该行的磁盘位置。

这种设计意味着在执行辅助键查找时,InnoDB相对于Postgres略有不利,因为与IngreDB相比,必须使用InnoDB搜索两个索引。但是,由于数据已标准化,所以行更新仅需要更新由行更新实际更改的索引记录。另外,InnoDB通常会进行行更新。如果出于MVCC的目的,旧事务需要引用一行,则MySQL将旧行复制到称为回滚段的特殊区域中。

让我们来看看更新al-Khwārizmī的出生年月会发生什么。如果有空间,则ID为4的行中的出生年份字段将被适当地更新(实际上,此更新总是在适当位置进行,因为出生年份是一个占用固定空间量的整数)。出生年份指数也已更新,以反映新日期。将旧行数据复制到回滚段

......