当前位置: 首页 > 科技观察

为什么Uber放弃Postgres而选择迁移到MySQL?

时间:2023-03-12 09:41:48 科技观察

Uber的早期架构由一个用Python编写的单体后端应用程序组成,该应用程序使用Postgres作为数据存储。从那以后,Uber的架构发生了翻天覆地的变化,变成了微服务,并采用了新的数据平台模型。具体来说,以前使用Postgres的地方,现在使用的是Schemaless,这是一个建立在MySQL之上的新的数据库分片层。在本文中,我们将探索Postgres的一些缺点,并解释为什么我们在MySQL之上构建Schemaless和其他后端服务。1.Postgres体系结构我们遇到了Postgres的许多局限性:写操作效率低下;低效的数据复制;数据损坏问题;副本MVCC支持不佳;很难升级到新版本。我们将通过分析Postgres的表和索引在磁盘上的表示方式并将其与MySQL的InnoDB存储引擎进行比较来探索这些限制。请注意,我们的分析主要基于我们对较旧的Postgres9.2版本系列的经验。据我们所知,本文讨论的内部架构在较新的Postgres版本中没有发生重大变化,至少自Postgres8.3版本(现在已经快10岁了)以来,9.2版本的基本设计没有发生重大变化。磁盘意味着关系数据库必须能够执行一些关键任务:提供插入、更新和删除功能;提供修改模式的能力;支持MVCC,让不同的数据库连接有自己的事务视图。这些功能如何协同工作是设计数据库的磁盘数据表示的重要部分。Postgres的核心设计是不可变的数据行。这些不可变数据行在Postgres中称为“元组”。这些元组由ctid唯一标识。从概念上讲,ctid表示元组在磁盘上的位置(即物理磁盘偏移量)。可能有多个ctid描述单个行(例如,为了支持MVCC,一个数据行可能有多个版本,或者一个数据行的旧版本还没有被autovacuum进程回收)。元组的集合形成一个表。表本身是有索引的,这些索引被组织成某种数据结构(通常是B树),将索引字段映射到ctid。通常,这些ctid对用户是透明的,但了解它们的工作原理有助于理解Postgres表的磁盘结构。要查看当前行的ctid,可以在语句中的列列表中添加“ctid”:uber@[local]uber=>SELECTctid,*FROMmy_tableLIMIT1;-[RECORD1]--------+-----------------------------ctid|(0,1)...其他字段...我们使用一个简单的用户表来解释这个。对于每个用户,我们都有一个自动递增的主键,包括用户ID、用户的名字和姓氏以及用户的出生年份。我们还在用户全名(名字和姓氏)上定义了一个复合二级索引,在用户出生年份上定义了另一个二级索引。用于创建表的DDL可能如下所示:CREATETABLEusers(idSERIAL,firstTEXT,lastTEXT,birth_yearINTEGER,PRIMARYKEY(id));CREATEINDEXix_users_first_lastONusers(第一个,最后一个);CREATEINDEXix_users_birth_yearONusers(birth_year);这里定义了三个索引和两个主键:二级索引。我们将以下数据插入到表中,其中包括一些有影响力的历史数学家:如前所述,这里的每一行都有一个隐式的、唯一的ctid。因此,我们可以认为表的内部表示如下:主键索引(将id映射到ctid)定义如下:B-tree索引定义在id字段上,B-tree中的每个节点存储ctid的值。请注意,在这种情况下,由于使用了自动递增ID,B树中字段的顺序恰好与表中的顺序相同,但情况并非总是如此。二级索引看起来很相似,主要区别在于字段的存储顺序不同,因为B树必须按字典顺序组织。(first,last)索引以名字的字母顺序开始:同理,birth_year索引是升序排列,如下图:对于后两种情况,二级索引中的ctid字段不是按字典序递增的,即与自动递增主键的情况不同。假设我们需要更新此表中的一条记录,比如说我们要更新al-Khwārizmī的出生年份。如前所述,行元组是不可变的。因此,要更新记录,我们向表中添加一个新元组。这个新元组有一个新的ctid,我们称之为I。Postgres需要区分新元组I和旧元组D。在内部,Postgres在每个元组中保留一个版本字段和一个指向前一个元组(如果有)的指针。因此,该表的最新结构如下:每当al-Khwārizmī一行有两个版本时,索引中必须包含这两行的条目。为了简洁起见,我们省略了主键索引,只显示了二级索引,如下所示:我们用红色表示旧数据行,用绿色表示新数据行。Postgres使用另一个版本字段来确定哪个元组是最新的。数据库使用此字段来确定哪些元组对不允许查看新版本数据的事务可见。在Postgres中,主索引和辅助索引都直接指向磁盘上的元组偏移量。当元组位置发生变化时,必须更新所有索引。复制当我们在表中插入一个新行时,如果启用了流复制,Postgres需要复制它。为了能够在崩溃后恢复,数据库维护一个预写日志(WAL)并使用它来实现两阶段提交。即使不开启流复制,数据库也必须维护WAL,因为WAL在ACID中可以保证原子性和持久性。为了更好地理解WAL,想象一下如果数据库意外崩溃(例如突然断电)会发生什么。WAL表示一系列数据库计划对表和索引的磁盘内容进行的更改。Postgres守护进程在启动时将WAL中的数据与磁盘上的实际数据进行比较。如果WAL包含未反映在磁盘上的数据,数据库会更正元组或索引数据并回滚WAL中存在但未在事务中提交的数据。Postgres通过将主服务器上的WAL发送到副本服务器来实现流式复制。每个副本数据库都在持续应用WAL更新,就像在崩溃恢复中一样。流复制和实际崩溃恢复之间的唯一区别是,处于“热备用”模式的副本可以在应用WAL时为查询提供服务,但处于崩溃恢复模式的真正的Postgres数据库通常会拒绝提供查询,直到数据库实例完成崩溃恢复过程。因为WAL实际上是为崩溃恢复而设计的,它包含底层的磁盘更新信息。WAL包含元组的磁盘表示及其磁盘偏移量(即行ctid)。如果副本与主副本完全同步,此时Postgres中的主副本暂停,副本的磁盘内容将与主副本的磁盘内容完全相同。因此,如果副本与主服务器不同步,可以使用rsync等工具来修复它。2.Postgres设计的后果Postgres的设计导致Uber的数据效率低下,给我们带来了很多麻烦。写放大Postgres的第一个问题是写放大。一般来说,写放大是指向SSD盘写入数据时遇到的问题:小的逻辑更新(比如写入几个字节)在翻译到物理层时会被放大,成本变高。在前面的示例中,如果我们对al-Khwārizmī的出生年份进行了一次小的逻辑更新,则至少必须进行四次物理更新:将新的行元组写入表空间;更新主键索引;更新(第一个,最后一个)索引;更新birth_year索引。事实上,这四个更新只反映了对主表空间的写入。除此之外,这些写操作还需要反映到WAL中,所以磁盘的总写次数会变多。值得注意的是更新2和更新3。在更新al-Khwārizmī的出生年份时,它的主键实际上并没有被修改,名字和姓氏也没有被修改。但尽管如此,仍必须在数据库中创建新的行元组以更新这些索引。对于具有大量二级索引的表,这些额外的步骤可能会导致效率低下。例如,如果我们在一个表上定义了12个索引,即使只更新了与单个索引对应的字段,更新也必须传播到所有12个索引才能反映新行的ctid。复制的写入放大问题自然会转化为复制层,因为复制发生在磁盘级别。数据库不会复制诸如“将ctidD的出生年份更改为770”之类的小逻辑记录,而是通过网络传播之前的4个WAL条目。因此,写入放大问题也转化为复制放大问题,并且Postgres复制数据流会很快变得非常冗长并且可能会占用大量带宽。如果Postgres复制只发生在单个数据中心内,那么复制带宽可能不是问题。现代网络设备和交换机可以处理大量带宽,许多托管服务提供商还在其数据中心内提供免费或廉价的带宽。但是,如果您在数据中心之间进行复制,问题可能会迅速升级。例如,Uber最初使用的是西海岸托管中心的物理服务器。为了灾难恢复,我们在东海岸托管中心添加了服务器。因此,我们在西部数据中心有一个主Postgres实例(加上副本),在东部有一个副本集。级联复制将数据中心之间的带宽限制为仅主数据库和单个副本之间的带宽,即使第二个数据中心中有许多副本也是如此。由于Postgres复制协议的冗长,使用大量索引的数据库的数据量会很大。跨区域购买大带宽的成本非常高。即使钱不是问题,也不可能获得与本地带宽类似的效果。这个带宽问题也会给WAL归档带来问题。除了将所有WAL更新从西海岸发送到东海岸外,我们还将所有WAL归档到一个文件存储服务中,这是为了确保我们可以在发生灾难时恢复数据。在早期的流量高峰期间,我们对存储服务的写入带宽不够快,无法跟上WAL写入。数据损坏在例行升级主数据库以增加数据库容量的过程中,我们遇到了Postgres9.2中的错误。由于副本在切换时间上出错,其中一些副本错误地应用了少量WAL记录。由于这个问题,一些应该被版本控制机制标记为无效的记录实际上并没有被标记为无效。下面的查询说明了这个错误将如何影响我们的用户表:SELECT*FROMUsersWHEREid=4;此查询将返回两条记录:原始的al-Khwārizmī行(出生年份为公元780年)和新的al-Khwārizmī行(出生年份为公元770年)。如果我们在WHERE中添加ctid,我们将看到返回的两条记录的ctid值不同。这个问题很烦人。首先,我们无法知道有多少行数据受到该问题的影响。在许多情况下,数据库返回的重复结果会导致应用程序逻辑失败。我们最终添加了防御性编程语句来检测可能存在此问题的表。这个错误影响了所有的服务器,并且损坏的数据行在不同的副本实例上是不同的。也就是说,在一个副本实例上,X行可能是坏的,Y行可能是好的,但在另一个副本实例上,X行可能是好的,Y行可能是坏的。我们无法确定数据损坏的副本数量以及该问题是否影响了主数据库。据我们所知,这个问题发生在每个数据库只有几行数据时,但我们担心的是,由于复制发生在物理层面,它最终可能会完全破坏数据库索引。B树索引的一个重要方面是它们必须定期重新平衡,并且当子树移动到新的磁盘位置时,这些重新平衡操作可能会完全改变树的结构。如果移动了错误的数据,可能会导致树的大部分完全无效。最后,我们发现了问题并确定新的主数据库没有损坏的行。我们通过从主数据库的最新快照重新同步所有副本来修复副本的数据损坏问题(一个费力的过程)。我们遇到的错误只存在于Postgres9.2的某些版本中,并且已经修复了很长时间。但是,我们仍然担心这样的错误会再次发生。较新版本的Postgres可能仍然存在此类错误,并且由于数据复制的方式,此类问题可能会传播到所有数据库。复制的MVCCPostgres不提供真正的复制MVCC支持。副本仅应用WAL更新,导致它们始终具有与主副本相同的磁盘数据副本。这种设计给优步带来了问题。Postgres需要为MVCC维护旧数据的副本。如果流复制遇到正在进行的事务,而数据库更新影响到事务范围内的行,则更新操作将被阻塞。在这种情况下,Postgres暂停WAL线程直到事务结束。如果事务需要很长时间,这可能会成为一个问题,因为副本可能明显落后于主副本。因此,Postgres在这种情况下应用了超时策略:如果一个事务导致WAL阻塞了一定时间,Postgres将终止该事务。这种设计意味着副本通常会落后于主副本几秒钟,从而很容易中止事务。例如,假设开发人员编写了一些代码,需要通过电子邮件将收据发送给用户。根据它的编写方式,代码可能会隐式地让数据库事务保持打开状态,直到电子邮件发送完毕。虽然在执行不相关的阻塞IO时保持数据库事务打开是一种不好的做法,但大多数工程师不是数据库专家并且可能不知道这是一个问题,尤其是在使用隐藏底层细节的ORM框架时。升级Postgres由于复制发生在物理层,我们不能在不同版本的Postgres之间复制数据。Postgres9.3主数据库不能复制到Postgres9.2副本,Postgres9.2主数据库不能复制到Postgres9.3副本。我们按照以下步骤从一个PostgresGA版本升级到另一个:关闭主数据库。在主数据库上运行pg_upgrade命令,这将就地更新主数据库数据。对于大型数据库,这通常需要数小时,并且在此过程中无法从master数据库读取数据。再次启动主数据库。创建主数据库的最新快照。此步骤完全复制主数据库中的所有数据,因此即使是大型数据库也可能需要数小时。所有副本都被擦除,最新的快照从主数据库恢复到副本上。将副本带回复制层次结构。等待副本完全赶上主服务器的所有更新。我们从Postgres9.1开始,并成功完成了到Postgres9.2的升级过程。然而,这个过程花了几个小时,我们无法承受再次执行这个升级过程。到Postgres9.3发布时,Uber规模的增长已经大大增加了我们的数据集,因此升级时间变长了。所以即使Postgres9.5已经发布,我们的Postgres实例仍然是9.2版本。如果你的Postgres是9.4或更高版本,你可以使用类似pgologic的东西,它为Postgres实现了一个逻辑复制层。您可以使用它在不同Postgres版本之间复制数据,这意味着您可以从9.4升级到9.5而无需大范围停机。但是,这个特性仍然存在问题,因为它还没有集成到Postgres主线中。对于那些使用旧版本Postgres的用户,pgologic不可用。3.MySQL架构上面已经解释了Postgres的一些局限性,下面我们来解释一下为什么MySQL会成为Uber工程团队存储Schemaless等项目的新工具。在许多情况下,我们发现MySQL更适合我们的用例。为了理解这些差异,我们研究了MySQL的体系结构并将其与Postgres进行了比较。我们专门分析了MySQL的InnoDB存储引擎。InnoDB的磁盘表示与Postgres一样,InnoDB支持MVCC和可变数据等高级功能。关于InnoDB的磁盘表示的详尽细节超出了本文的范围,我们将重点关注它与Postgres的主要区别。主要的架构差异是Postgres直接将索引记录映射到磁盘上的位置,而InnoDB使用二级结构。InnoDB的二级索引有一个指向主键值的指针,而不是一个指向磁盘位置的指针(如Postgres中的ctid)。因此,MySQL会将索引键与主键与二级索引相关联:要执行基于(first,last)索引的查询,需要两次查找。第一次搜索表以查找记录的主键。找到主键后,搜索主键索引,找到数据行对应的磁盘位置。所以,InnoDB在进行二次查找时比Postgres略有劣势,因为InnoDB需要搜索两个索引,而Postgres只需要搜索一个。但是,由于数据已经归一化,更新行数据时只需要更新实际发生变化的索引记录即可。此外,InnoDB通常就地执行行数据更新。为了支持MVCC,如果旧事务需要引用一行数据,MySQL会将旧行复制到一个称为回滚段的特殊区域。让我们看看当我们更新al-Khwārizmī的出生年份时会发生什么。如果有足够的空间,则原地更新id为4的行中的出生年份字段(实际上,这种更新总是原地发生,因为出生年份是一个占用固定空间的整数).出生年份索引也会就地更新。旧数据行将被复制到回滚段。主键索引不需要更新,(first,last)索引也不需要。尽管这张表有很多索引,但只需要更新包含birth_year字段的索引。假设我们有基于signup_date、last_login_time等字段的索引,我们不需要更新这些索引,但在Postgres中它们需要更新。这种设计还使数据清理和压缩更加高效。回滚段中的数据可以直接清除。相反,Postgresautovacuum进程必须执行全表扫描以确定可以清除哪些行。MySQL使用了一个额外的中间层:二级索引记录指向主索引记录,主索引保留数据行在磁盘上的位置。如果数据行偏移发生变化,只需要更新主索引。复制MySQL支持几种不同的复制模式:基于语句的复制将复制逻辑SQL语句(它将按字面意义复制SQL语句,例如:UPDATEusersSETbirth_year=770WHEREid=4);基于行的复制将复制更改的行记录;混合复制将两种模式混合在一起。这些模式中的每一种都有优点和缺点。基于语句的复制通常是最紧凑的,但可能需要副本应用大量语句来更新少量数据。另一方面,基于行的复制(类似于PostgresWAL复制)更冗长,但在副本上更具可预测性和更新效率。在MySQL中,只有主索引具有指向行的磁盘偏移量的指针。这在进行复制时具有重要意义。MySQL复制流只需要包含有关行的逻辑更新信息。对于像“将X行的时间戳从T_1更改为T_2”这样的更新,副本会自动推断出哪些索引需要修改。相反,Postgres复制流包括物理更改,例如“在磁盘偏移量8,382,491处写入字节XYZ”。使用Postgres时,对磁盘所做的每个物理更改都需要包含在WAL流中。较小的逻辑修改(例如更新时间戳)也需要执行许多磁盘更改:Postgres必须插入新的元组并更新所有索引以指向该元组,因此有许多更改被放入WAL流中。这种设计差异意味着MySQL复制的二进制日志比PostgreSQLWAL流更紧凑。复制方法对副本的MVCC也有重要影响。由于MySQL复制流具有逻辑更新,副本可以具有真正的MVCC语义,因此对副本的读取查询不会阻塞复制流。相比之下,PostgresWAL流包含磁盘上的物理更改,并且Postgres副本无法应用与读取查询冲突的复制更新,因此无法实现MVCC。MySQL的复制架构意味着即使错误导致表损坏,也不太可能出现灾难性故障。因为复制发生在逻辑层,所以像重新平衡B树这样的操作永远不会导致索引损坏。典型的MySQL复制问题是一条语句被跳过(或被应用两次),这可能会导致数据丢失或无效,但不会导致数据库中断。最后,MySQL的复制架构可以很容易地在不同的MySQL版本之间进行复制。MySQL的逻辑复制格式还意味着存储引擎层中的磁盘更改不会影响复制格式。在进行MySQL升级时,通常一次更新一个副本,并在所有副本都更新后,将其中一个提升为新的主副本。这允许几乎零停机时间升级,使MySQL保持最新状态变得容易。4.MySQL的其他优点至此,我们已经介绍了Postgres和MySQL的磁盘架构。MySQL的其他重要方面使其性能明显优于Postgres。BufferPool首先,两个数据库的缓存方式不同。Postgres为内部缓存分配一些内存,但这些缓存与计算机上的内存总量相比通常很小。为了提高性能,Postgres允许内核通过页面缓存自动缓存最近访问的磁盘数据。例如,我们最大的Postgres副本有768GB的可用内存,但实际上只有25GB用作Postgres的进程RSS内存,剩下超过700GB的内存可用于Linux页面缓存。这种设计的问题是通过页面缓存访问数据实际上比访问RSS内存更昂贵。为了从磁盘中查找数据,Postgres进程发出lseek和read系统调用来定位数据。这些系统调用中的每一个都会导致上下文切换,这比从主内存访问数据更昂贵。事实上,Postgres在这方面甚至没有完全优化:Postgres没有利用pread系统调用,它将查找和读取操作组合到一个系统调用中。相比之下,InnoDB存储引擎通过缓冲池实现了自己的LRU。从逻辑上讲,这类似于Linux的页面缓存,但它是在用户空间中实现的。尽管InnoDB缓冲池的设计比Postgres复杂得多,但它有一些优点:可以实现自定义LRU。例如,可以检测到可能会破坏LRU的访问模式,并防止造成更大的问题。更少的上下文切换。通过InnoDB缓冲池访问的数据不需要用户/内核上下文切换。最坏的情况是TLB未命中,这些开销相对较小,可以通过使用大页面来减轻。连接处理MySQL通过一连接一线程的方式实现并发连接。这种开销比较低,每个线程都有自己的栈内存和缓冲堆内存分配给特定的连接。在MySQL中使用10,000个左右的并发连接并不少见,事实上,在我们现有的一些MySQL实例上,连接数正在接近这个数字。然而,Postgres使用了join-to-process设计,这比join-to-thread设计要昂贵得多。分叉一个新进程比产生一个新线程需要更多的内存。此外,进程之间的IPC比线程之间的IPC昂贵得多。Postgres9.2通过SystemVIPC原语而不是使用轻量级的futex来实现IPC。Futex比SystemVIPC更快,因为一般来说,futex没有竞争条件,因此不需要上下文切换。除了内存和IPC开销之外,Postgres似乎也不能很好地支持大量连接,即使有足够的可用内存也是如此。我们在Postgres中遇到了数百个活动连接的大问题??。Postgres文档建议使用进程外连接池机制来处理大量连接,但没有详细说明原因。因此,我们使用pgbouncer来处理Postgres的连接池。但是,我们的后端服务偶尔会出现错误,导致它们打开过多的活动连接,从而延长停机时间。5.结论在Uber的早期,Postgres为我们提供了很好的服务,但随着公司的发展,我们遇到了可扩展性问题。今天,我们仍然保留着一些旧的Postgres实例,但大多数数据库都建立在MySQL之上(通常带有Schemaless层),或者在某些特殊情况下使用NoSQL数据库,如Cassandra。