Uber的早期架构包括一个用Python编写的独立后端应用程序,它使用Postgres实现数据持久化。从那以后,Uber的架构发生了巨大的变化,变成了微服务和新数据平台的模式。具体来说,在我们以前使用Postgres的许多情况下,我们现在使用Schemaless,这是一个构建在MySQL之上的新数据库分片层。在本文中,我们将探索Postgres的一些缺陷,并解释在MySQL之上构建Schemaless和其他后端服务的缘由。

Postgres的体系结构

我们遇到了许多Postgres限制:

写入效率低下的架构

数据复制效率低下

表损坏的问题

复制品MVCC支持不佳

难以升级到新版本

我们将通过分析Postgres对磁盘上的表和索引数据的表示来了解所有这些限制,特别是与MySQL使用InnoDB存储引擎表示相同数据的方式相比。请注意,我们在这里提出的分析主要基于我们对Postgres 9.2发布系列的一些经验。据我们所知,我们在本文中讨论的内部体系结构在新的Postgres版本中没有发生显著的变化,9.2中磁盘上表示的基本设计自至少Postgres 8.3版本(现在已经有将近10年的历史了)以来也没有发生显著的变化。

磁盘格式

关系数据库必须执行几个关键任务:

提供插入/更新/删除功能

提供进行架构更改的功能

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

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

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

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

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

注意这个定义中的三个索引:主键索引加上我们定义的两个二级索引。

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

如前所述,每一行隐含地具有唯一的、不透明的ctid。因此,我们可以像这样考虑表的内部表示:

将id映射到ctids的主键索引定义如下:

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

二级索引看起来相似; 主要区别在于字段的顺序存储不同,因为B树必须按字典顺序组织。(第一个,最后一个)索引以字母顶部的名字开头:

同样,birth_year 索引按升序聚类,如下所示:

如您所见,在这两种情况下,相应二级索引中的ctid 字段不会按字典顺序增加,这与自动递增主键的情况不同。

假设我们需要更新此表中的记录。例如,假设我们正在更新出生年份字段,以换取al-Khwārizmī出生年份的另一个估计值,即公元770年。正如我们前面提到的,行元组是不可变的。因此,要更新记录,我们向表中添加一个新的元组。这种新的元组有一个新的不透明CTID ,我们称之为I 。Postgres需要能够区分I处的新的活动元组和D处的旧元组。在内部,Postgres在每个元组内存储一个版本字段和指向前一个元组的指针(如果有的话)。因此,表的新结构如下所示:

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

我们用红色表示旧版本,用绿色表示新行版本。在底层,Postgres使用另一个保存行版本的字段来确定哪个元组是最新的。此添加的字段允许数据库决定将哪个行元组提供给可能不允许查看最新行版本的事务。

当我们在表中插入一行新数据时,Postgres 需要在启用流复制时将数据复制到从节点。出于故障恢复考虑,数据库已经维护了一个write-ahead(WAL)并使用它来实现两阶段提交。即使未启用流复制,数据库也必须维护此WAL,因为WAL使得数据库支持ACID的原子性和持久性。

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

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

由于WAL实际上是为崩溃恢复目的而设计的,因此它包含有关磁盘更新的低级信息。WAL的内容处于行元组及其磁盘偏移(即行ctids )的级别。如果在完全捕获副本时暂停Postgres主副本,则副本上的实际磁盘内容与字节的主字节上的内容完全匹配。因此,像rsync这样的工具可以修复损坏的副本,如果这个副本与主副本的日期不符。

Postgres的设计导致了Uber数据的低效和困难。

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

在表空间中写入新行的 tuple

为新的 tuple 更新主键索引

为新的 tuple 更新姓名索引 (first, last)

更新 birth_year 索引,为新的 tuple 添加一条记录

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

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

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

级联复制将数据中心间带宽要求限制为仅在主服务器和单个副本之间所需的复制量,即使第二个数据中心中有许多副本也是如此。但是,Postgres复制协议的冗长仍然会导致使用大量索引的数据库产生大量数据。购买超高的带宽跨国链路是昂贵的,即使在资金不是问题的情况下,也不可能获得与本地互连相同带宽的跨国网络链路。这个带宽问题也给我们带来WAL存档问题。除了将所有WAL更新从西海岸发送到东海岸之外,我们还将所有WAL归档到文件存储Web服务,这些都可以进一步保证我们可以在发生灾难时恢复数据,以便归档的WAL可以从数据库快照中创建新的副本。在早期的高峰流量期间,我们对存储Web服务的带宽速度不够快,无法跟上WAL写入它的速度。

在常规主数据库升级期间,为了增加数据库容量,我们遇到了Postgres 9.2错误。副本跟随时间轴 错误地切换,导致其中一些副本错误地应用了WAL记录。由于此错误,一些应该被版本控制机制标记为非活动的记录实际上并没有被标记为非活动状态。

以下查询说明了此错误将如何影响我们的用户表示例:

SELECT * FROM users WHERE id = 4;

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

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

最后,我们能够追踪实际的bug,并使用它来确定新升级的master没有任何损坏的行。我们通过从主服务器中重新同步所有这些问题来修复复制数据的损坏问题,这是一个费力的过程: 我们一次只能从负载平衡池中取出几个副本。

我们遇到的bug只影响了Postgres 9.2的某些版本,并且已经修复了很长时间。然而,我们仍然发现这类的错误可能会发生。一个新的版本的Postgres可能在任何时候发布,并且由于复制的工作方式,此问题有可能扩散到复制层次结构中的所有数据库中。

Postgres 没有真正的从库 MVCC 支持。在从库任何时刻应用 WAL 更新,都会导致他们与主库物理结构完全一致。这样的设计也给 Uber 带来了一个问题。

为了支持 MVCC,Postgres 需要保留行的旧版本。如果流复制的从库正在执行一个事务,所有的更新操作将会在事务期间被阻塞。在这种情况下,Postgres 将会暂停 WAL 的线程,直到该事务结束。但如果该事务需要消耗相当长的时间,将会产生潜在的问题,Postgres 在这种情况下设定了超时:如果一个事务阻塞了 WAL 进程一段时间,Postgres 将会 终止这个事务。

这样的设计意味着从库会定期的滞后于主库,而且也很容易写出代码,导致事务被 kill。这个问题可能不会很明显被发现。例如,假设一个开发人员有一个收据通过电子邮件发送给用户一些代码。这取决于它是如何写的,代码可能隐含有一个的保持打开,直到邮件发送完毕后,再关闭的一个数据库事务。虽然它总是不好的形式,让你的代码举行公开的数据库事务,同时执行无关的阻塞 I / O,但现实情况是,大多数工程师都不是数据库专家,可能并不总是理解这个问题,特别是使用掩盖了低级别的细节的 ORM 的事务。

由于复制记录在物理层面上工作,因此无法在不同的Postgres可用性版本之间复制数据。运行Postgres 9.3的主数据库无法复制到运行Postgres 9.2的从库,运行9.2的主数据库也无法复制到运行Postgres 9.3的副本。

我们按照以下步骤从一个Postgres GA版本升级到另一个版本:

关闭master数据库。

在主库上运行名为pg_upgrade的命令,该命令会更新主数据。对于大型数据库而言,这很容易需要几个小时的时间,执行期间不能够提供任何访问服务。

再次启动主库。

创建主库的新快照。此步骤完全复制主数据库中的所有数据,因此对于大型数据库也需要数小时。

清除所有从库并将新快照从主库还原到从库。

将每个从库带回复制层次结构。等待从库追上主库的最新的更新数据。

我们使用上述方法将 Postgres 9.1 成功升级到 Postgres 9.2。然而,这个过程花了太多时间,我们不能接受这个过程再来一次。当 Postgres 9.3 问世时,优步的增长导致我们的数据大幅增长,因此升级时间甚至更加漫长。出于这个原因,我们的 Postgres 的实例一直运行 Postgres 9.2 到今天,尽管当前的 Postgres GA 版本是 9.5。

如果你正在运行 Postgres 9.4 或更高版本,你可以使用类似 pglogical,它实现了 Postgres 的一个逻辑复制层。使用 pglogical,你可以在不同的 Postgres 版本之间复制数据,这意味着可以进行9.4到9.5之类的升级,而不会导致严重的停机时间。但这个工具的能力依然存疑,因为它没有集成到 Postgres 主干,另外对于老版本的用户,pglogical 仍然不能支持。

MySQL的体系结构

为了更进一步解释的 Postgres 的局限性,我们了解为什么 MySQL 是 Uber 新存储工程 Schemaless 的底层存储 。在许多情况下,我们发现 MySQL 更有利于我们的使用场景。为了了解这些差异,我们考察了 MySQL 的架构,并与 Postgres 进行对比。我们特别分析 MySQL 和 InnoDB 存储引擎如何一同工作。Innodb 不仅在 Uber 大量使用,它也是世界上使用最广泛的 MySQL 存储引擎。

与 Postgres 一样,InnoDB 支持如 MVCC 和可变数据这样的高级特性。详细讨论 InnoDB 的磁盘数据格式超出了本文的范围;在这里,我们将重点放在从 Postgres 的主要区别上。

架构上最重要的差别是 Postgres 直接映射索引记录到数据元组的磁盘位置,而 InnoDB 维护了一个二级结构。不同于保存一个指向记录磁盘位置的指针(如 Postgres 的 ctid),InnoDB 的二级索引记录是一个指向主键值的指针。所以 MySQL 中的二级索引通过主键关联的索引:

为了执行上的(first, last)索引查找,我们实际上需要做两查找。第一次查找表,找到记录的主键。一旦找到主键,则根据主键找到记录在磁盘上的位置。

这种设计意味着 InnoDB 对 Postgres 在做非主键查找时有小小的劣势,因为 MySQL 要做两次索引查找,但是 Postgres 只用做一次。然后因为数据是标准化的,行更新的时候只需要更新相应的索引记录。而且 InnoDB 通常在相同的行更新数据,如果旧事务因为 MVCC 的 MySQL 从库而需要引用一行,老数据将进入一个特殊的区域,称为回滚段。

如果我们更新 al-Khwārizmī 的出生年份,我们看会发生什么。如果有足够的空间,数据库会直接更新 ID 为 4 的行(更新出生年份不需要额外的空间,因为年份是定长的 int)。出生年份这一列上的索引同时也会被更新。这一行的老版本被复制到回滚段。主键索引不需要更新,同样姓名索引也不需要更新。如果在这个表上有大量索引,数据库需要更新包含了 birth_year 的索引。因此,我们并不需要更新 signup_date,last_login_time 这些索引,而 Postgres 则必须全更新一遍。

这样的设计也使得 vocuum 和压缩效率更高。所有需要 vocuum 的数据都在回滚段内。相比之下,Postgres 的自动清理过程中必须做全表扫描,以确定删除的行。

MySQL 支持多个不同的复制模式:

语句级别的复制:复制 SQL语句(例如,它会从字面上直译复制的语句,如:更新用户 SET birth_year = 770 WHERE ID = 4 )

行级别的复制:复制所有变化的行记录

混合复制:混合这两种模式

这些模式都各有利弊。基于语句的复制通常最为紧凑,但可能需要从库来支持昂贵的语句来更新少量数据。在另一方面,基于行的复制,如同 Postgres 的 WAL 复制,是更详细,但会导致对从库数据更可控,并且更新从库数据更高效。

在 MySQL 中,只有主索引有一个指向行的磁盘上的指针。这个对于复制来说很重要。MySQL 的复制流只需要包含有关逻辑更新行的信息。复制更新如“更改行的时间戳 x 从 T_ 1 至 T_ 2 ”,从库自动根据需要更新相关的索引。

相比之下,Postgres 的复制流包含物理变化,如“在磁盘偏移,写字节XYZ。” 在 Postgres 里,每一次磁盘物理改变都需要被记录到 WAL 里。很小的逻辑变化(如更新时间戳)会引起许多磁盘上的改变:Postgres 必须插入新的 tuple,并更新所有索引指向新的 tuple。因此许多变化将被写入 WAL。这种设计的差异意味着 MySQL 复制二进制日志是显著比 PostgreSQL 的 WAL 流更紧凑。

复制如何工作也会影响从库的 MVCC。由于 MySQL 的复制流使用逻辑的更新,从库可以有真正的 MVCC 语义; 因此,读库查询不会阻塞复制流。相比之下,Postgres 的 WAL 流包含物理磁盘上的变化,使得 Postgres 的从库无法应用复制更新从而与查询相冲突,所以 PG 复制不能实现 MVCC。

MySQL 的复制架构意味着,bug 也许会导致表损坏,但不太可能导致灾难性的失败。复制发生在逻辑层,所以像一个重新平衡 B tree 这样的操作不会导致索引损坏。一个典型的 MySQL 复制问题是一个语句被跳过(或较少一点的情况,重复执行)的情况下。这可能会导致数据丢失或无效,但不会导致数据库出现灾难问题。

最后,MySQL 的复制架构使得它可以在 MySQL 不同版本之间进行复制。MySQL 只在复制格式改变的时候才增加版本号,这对 MySQL 来说很不常见。MySQL 的逻辑复制格式也意味着,在磁盘上的变化在存储引擎层不影响复制格式。做一个 MySQL 升级的典型方法是在一个时间来更新应用到一个从库,一旦你更新所有从库,你可以把它提为新的 master。这个操作几乎是 0 宕机的,这样也能保证 MySQL 能及时得到更新。

到目前为止,我们专注于 Postgres 和 MySQL 的磁盘架构。MySQL架构的其他一些重要方面也使它的性能明显优于Postgres。

首先,两个数据库缓冲池的工作方式不同。Postgres 用作缓存的内存比起内存的机器上的内存总数小很多。为了提高性能,Postgres 允许内核通过自动缓存最近访问的磁盘数据的页面缓存。举例来说,我们最大的 Postgres 的节点有 768G 可用内存,但只有大约 25G 的内存实际上是被 Postgres 的 RSS 内存使用,这让 700 多 GB 的可用内存留给 Linux 的页面缓存。

这种设计的问题是,相比访问 RSS 内存,操作系统的页面缓存访问数据实际上开销更大。从磁盘查找数据,Postgres 执行 lseek 和 read 系统调用来定位数据。这些系统调用的招致上下文切换,这比从主存储器访问数据更昂贵。事实上,Postgres 在这方面完全没有优化:Postgres 没有利用的 pread(2)系统调用,pread 会合并 seed + read 操作成一个单一的系统调用。

相比之下,InnoDB 存储引擎实现了自己的 LRUs 算法,它叫做 InnoDB 的缓冲池。这在逻辑上类似于 Linux 的页面缓存,但在用户空间实现的,因此也显著比 Postgres 设计复杂,InnoDB 缓冲池的设计有一些巨大的优势:

使得它可以实现一个自定义的 LRU 设计。例如,它可以检测到病态的访问模式,并且阻止这种模式给缓冲池带来太大的破坏。

它导致更少的上下文切换。通过 InnoDB 缓冲池访问的数据不需要任何用户/内核上下文切换。最坏的情况下的行为是一个的出现 TLB miss,但是可以通过使用 huag page 来搞定。

MySQL 的实现是对每个连接生成一个线程,相对来说开销较低;每个线程拥有堆栈空间的一些内存开销,再加上堆上分配用于连接特定的缓冲区一些内存。对 MySQL 来说扩展到 10,000 左右的并发连接不是罕见的事情,实事上我们现在的 MySQL 接近这个连接数。

Postgres 使用的是每连接一个进程的设计。这很明显会比每连接每线程的设计开销更大。启动一个新的进程比一个新的线程会占用更多的内存。此外,线程之间进行通讯比进程之间 IPC 开销低很多。Postgres 9.2 使用系统V IPC为IPC原语,而不是使用线程模型中轻量级的 futexes,futex 的非竞争是常见的情况,比 System V IPC 速度更快,不需要进行上下文切换。

除了与 Postgres 的设计相关联的内存和 IPC 开销,即使有足够的可用内存可用,Postgres 对处理大连接数的支持依然非常差。我们已经碰到扩展 Postgres 几百个活动连接就碰到显著的问题的情况,在官方文档中也没有确切的说明原因,它强烈建议使用独立的连接池来保证大连接数。因此,使用 pgbouncer 做连接池基本可行。但是,在我们后端系统使用过程中发现有些 BUG,这会导致开启大量的原本不需要的活跃连接,这些 BUG也已经造成好几次宕机。

Postgres 在 Uber 初期运行的很好,但是 PG 很遗憾没能很好适应我们的数据增长。今天,我们有一些遗留的 Postgres 实例,但我们的数据库大部分已经迁移到 MySQL(通常使用我们的 Schemaless 中间层),在一些特殊的情况下,也使用像Cassandra这样的NoSQL数据库。我们对 MySQL 的使用非常满意,后续可能会在更多的博客文章中介绍其在 Uber 一些更先进的用途。

作者:Evan Klitzke

作者简介:Evan Klitzke是一名软件工程师,他也是一名数据库爱好者,于2012年9月加入优步。

编译:AI推手

本文由AI推手编译,转载请注明出处

更多AI干货、资讯及福利,尽在AI推手!