UDN-企业互联网技术人气社区

板块导航

浏览  : 1034
回复  : 2

[讨论交流] 为什么Uber宣布从Postgres切换到MySQL?

[复制链接]
瞌睡虫的头像 楼主
发表于 2016-8-10 09:02:54 | 显示全部楼层 |阅读模式
  导读:近期Uber宣布将数据库从Postgres迁移到MySQL,在多个技术社区中引起了轩然大波,通过本文我们来详细了解Uber做出以上决策背后的原因。

1.webp.jpg



  介绍

  Uber的早期架构是由Python编写一个单体后端应用程序,使用Postgres作为数据持久化。后来Uber架构经历一系列显著改变,朝着微服务架构和新的数据平台发展。具体而言,在许多以前使用的Postgres的场景,现在更多的使用构建在MySQL之上的schemaless存储系统(小编:Uber的数据中间件)。在本文中,将探讨一些我们发现的Postgres的弊端,并解释我们切换schemaless和其他后端服务到MySQL数据库的原因。

  Postgres架构概述

  我们遇到的大量Postgres限制如下:

  效率低下的写入架构

  低效的数据复制

  表损坏的问题

  糟糕的MVCC从库支持

  难以升级到新的版本

  我们将在所有这些限制,首先通过分析Postgres如何组织在磁盘上的表和索引进行分析,特别是比较与MySQL使用InnoDB存储相同数据的实现方式。需要注意的是,我们在这里提出的分析主要是基于我们有些老的Postgres9.2版本系列的经验。但据我们所知,本文中讨论的PG内部架构,并没有显著在新的Postgres版本中改变,就如在9.2版的磁盘数据设计,也没有比Postgres的8.3版(10年前的版本)有什么显著变化。

  磁盘数据格式

  关系数据库必须执行一些关键任务:

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

  进行schema更改的能力

  实现多版本并发控制(MVCC)机制,使不同的连接分别有他们各自的数据的事务视图

  考虑如何将上述这些功能组合在一起工作,是数据库设计时的重要考虑部分。

  Postgres的核心设计之一是不变的(immutable)行数据。这些不变的行在Postgres中称为“tuple”。Tuple在Postgres内部实现中由CTID来唯一标识。一个CTID代表tuple在磁盘上的位置(即物理磁盘偏移)。多个ctid可以潜在描述一个单列(例如,当用于MVCC目的,或存在的行的多个版本时,行的旧版本尚未被autovacuum回收处理)。有组织的tuple的集合形成表。表本身具有的索引,通常被组织为B树数据结构,映射索引字段到CTID的负载。

  通常,这些ctids对用户透明,但知道他们是如何工作,可以帮助您了解Postgres在磁盘上的数据结构。要查看某行当前CTID,可以在查询的时候显式加上“CTID”:

2.webp.jpg



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

3.webp.jpg



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

  对于本文中的例子,我们看下表的数据,它由一个选择有影响力的历史数学家开始:

4.webp.jpg



  如上所述,每行中隐含有独特的,不透明CTID。因此,我们可以这样理解表的内部结构:

5.webp.jpg



  主键索引,它映射ID与ctids,是这样定义的:

6.webp.jpg



  B树被用在id字段上,B树中的每个节点上保存了CTID值。注意,在这种情况下,在B树的字段的顺序,刚好与表中顺序相同,这是由于使用自动递增的id的缘故,但这并不一定需要是这种情况。

  二级索引看起来相似;主要的区别是字段存储顺序不同,因为B树,必须按字典顺序组织。姓名索引(first,last)按字母表的顺序排列:

7.webp.jpg



  同样,birth_year聚簇索引按升序排列,就像这样:

8.webp.jpg



  正如你所看到的,在这两种情况下,在各自的二级索引CTID字段本身并不是有序的,不象第一个自动递增的主键的情况。

  假设我们需要更新此表中的记录。举例来说,假设要更新al-Khwārizmī’的出生年份到770CE。正如前面提到的,行的tuple是不可变的。因此,要更新记录,需要添加一个新的tuple。这种新的tuple有一个新的不透明CTID,我们称之为I。Postgres需要能够从旧的tupleD处找到新的I。在内部,Postgres存储每个tuple中的版本字段,以及指向前一tuple的ctid指针(如果有)。因此,该表的新结构如下:

9.webp.jpg



  只要al-Khwārizmī的两个版本存在,索引则必须维护两行的记录。为简单起见,我们省略了主键索引并显示只有在这里的二级索引,它是这样的:

10.webp.jpg



11.webp.jpg



  我们将旧版本标识成红色,将新版标识成绿色。在此之下,Postgres使用另一个字段来保存该行版本,以确定哪一个tuple是最新的。这个新增的字段允许数据库确定事务看到的是那一个行的tuple。

  在Postgres,主索引和二级索引都指向磁盘上的tuple偏移。当一个tuple的位置变化,各项索引都必须更新。

12.webp.jpg



  复制

  当我们插入数据到表中,如果启用了流复制机制,Postgres将会对数据进行复制,处于崩溃恢复的目的,数据库启用了预写日志(WAL)并使用它来实现两阶段提交(2PC)。即使不启用复制的情况下,数据库也必须保留WAL,因为WAL提供了ACID的原子性(Atomicity)及持久性(Durability)能力。

  我们可以通过如下场景来更好的理解WAL,如果数据库遇到突然断电时意外崩溃,WAL就提供了磁盘上表与索引更新变化的一个账本。当Postgres的守护程序再次启动后,就会对比账本上的记录与磁盘上的实际数据是否一致。如果帐本包含未在磁盘上的体现的数据,则可以利用WAL的记录来修正磁盘上的数据。

  另外一方面,Postgres也利用WAL将其在主从之间发送来实现流复制功能。每个从库复制数据与上述崩溃恢复的过程类似。流复制与实际崩溃恢复之间的唯一区别是,在恢复数据过程中是否能对外提供数据访问服务。

  由于WAL实际上是为崩溃恢复目的而设计,它包含在物理磁盘的低级别更新的信息。WAL记录的内容是在行tuple和它们的磁盘偏移量(即一行ctids)的实际磁盘上的代表级别。如果暂停一个Postgres主库,从库数据完全赶上后,在从库的实际磁盘上的内容完全匹配主库。因此,像工具rsync都可以恢复一个同步失败的从库。

  Postgres上述设计的大坑

  Postgres的上述设计给Uber在PG的使用上,导致了效率低下和其他很多问题。

  1.写放大(WriteAmplification)

  在Postgres设计的第一个问题是已知的写入放大。

  通常的写入放大是指一种问题数据写入,比如在SSD盘上,一个小逻辑更新(例如,写几个字节)转换到物理层后,成为一个更大的更昂贵的更新。

  同样的问题也出现在Postgres,在上面的例子,当我们做出的小逻辑更新,比如修改al-Khwārizmī的出生年份时,我们不得不执行至少四个物理的更新:

  在表空间中写入新行的tuple;

  为新的tuple更新主键索引;

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

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

  事实上,这四步更新仅为了反映一个到主表的写操作;并且每个这些写入也同样需要在WAL得到体现,所以在磁盘上写入的总数目甚至比4步更大。

  值得一提的是这里更新2和3。当我们更新了al-Khwārizmī的出生年份,我们实际上并没有改变他的主键,我们也没有改变他的名字和姓氏。然而,这些索引仍必须与创建在数据库中的行记录了新的行的tuple的更新。对于具有大量二级索引的表,这些多余的步骤可能会导致巨大的低效。举例来说,如果我们有一个表上定义了十几个二级索引,更新一个字段,仅由一个单一的索引覆盖必须传播到所有的12项索引,以反映新行的CTID。

  2.复制

  因为复制发生在磁盘的变化上,因此写入放大问题自然会转化为复制层的放大。一个小的逻辑记录,如“更改出生年份为CTIDD到770”,WAL会将上述描写的4步从网络上同步到从库,因此写入放大问题也等同一个复制放大问题,从而Postgres的复制数据流很快变得非常冗长,可能会占用大量的带宽。

  在Postgres的复制发生一个数据中心内的情况下,复制带宽可能不是一个问题。现代网络设备和交换机可以处理大量的带宽,许多托管服务提供商提供免费或廉价的内部数据中心带宽。然而,当复制必须在不同数据中心之间发生的,问题都可以迅速升级。

  例如,Uber原本使用的物理服务器在西海岸机房。为了灾难恢复的目的,我们在东海岸托管空间添加了一批服务器。在本设计中,我们西部数据中心作为主库,东海岸增加了一批服务器作为从库。

  级联复制可以降低跨数据中心的带宽要求,只需要主库和一个从库之间同步一份数据所需的带宽和流量,即便在第二个数据中心配置了多个从库。然而,Postgres的复制协议的详细程度,对于使用了大量二级索引的数据库,仍可能会导致数据的海量传输。采购跨国的带宽是昂贵的,即使有钱的土豪公司,也无法做到跨国的带宽和本地的带宽一样大。

  这种带宽的问题也导致我们曾经在WAL归档方面出现过问题。除了发送所有从西海岸到东海岸的WAL更新,我们将所有的WAL记录归档到一个文件存储的Web云服务,这样当出现数据灾难情况时,可以从备份的WAL文件恢复。但是流量峰值时段,我们与存储网络服务的带宽根本无法跟上WAL写入的速度。

  3.数据损坏

  在一次例行主数据库扩容的变更中,我们遇到了一个Postgres9.2的bug。从库的切换时间顺序执行不当,导致他们中的一些节点误传了一些WAL记录。因为这个bug,应该被标记为无效的部分记录未标记成无效。

  以下查询说明了这个bug如何影响我们的用户表:

  SELECT*FROMusersWHEREID=4;

  此查询将返回两条记录:修改出生年份之前的老记录,再加上修改后的新记录。如果将CTID添加到WHERE列表中,我们将看到返回记录中存在不同的CTID记录,正如大家所预料的,返回了两个不同行的tuple。

  这个问题是有几个原因非常伤脑筋。首先,我们不能轻易找出这个问题影响的行数。从数据库返回的结果重复,导致应用程序逻辑在很多情况下会失败。我们最终使用防守编程语句来检测已知有这个问题表的情况。因为bug影响所有服务器,损坏的行在不同的服务器节点上可能是不同的,也就是说,在一个从库行X可能是坏的,Y是好的,但对另一个从库,用行X可能是好的,Y行可能是坏。事实上,我们并不确定数据损坏的从库节点数量,以及主库是否也存在数据损坏。

  虽然我们知道,问题只是出现在每个数据库的少量几行,但我们还是非常担心,因为Postgres复制机制发生在物理层,任何小的错误格式有可能会导致彻底损坏我们的数据库索引。B树的一个重要方面是,它们必须定期重新平衡,并且这些重新平衡操作可以完全改变树的结构作为子树被移到新的磁盘上的位置。如果错误数据被移动,这可能会导致树的大部分地区变得完全无效。

  最后,我们追踪到了实际的bug,并用它来确定新的master不存在任何损坏行。然后再把master的快照同步到所有从库上去,这是一个艰苦的体力活的过程(小编:看到美帝的DBA也这么苦逼心理终于平衡一点了),因为我们每次只能从在线的池子里面拿出有限几台来操作。

  虽然我们遇到的这个bug仅影响Postgres9.2的某些版本,而且目前已经修复了很久。但是,我们仍然发现这类令人担忧的bug可以再次发生。可能任意一个新的Postgres版本,它会带着这种致命类型的bug,而且由于其复制的不合理的设计,这个问题一旦出现,就会立即蔓延到集群中所有复制链的数据库上。

  4.从库无MVCC

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

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

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

  Postgres的升级

  因为复制记录在物理层面工作,这导致不能在不同的PostgresGA版本之间进行复制。运行的Postgres9.3主数据库无法复制数据到Postgres9.2的从库上,也无法在运行9.2的主数据库复制数据到Postgres9.3的从库上。

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

  关闭主数据库。

  在主库上运行pg_upgrade命令,这是更新主库数据的命令。在一个大的数据库上,这很容易需要几个小时的时间,执行期间不能够提供任何访问服务。

  再次启动主库。

  创建主库的新快照,这一步完全复制一份主库的所有数据,因此对于大型数据库,它也需要几个小时的时间。

  清除所有从库上的数据,将从主库导出的快照恢复到所有从库。

  把每个从库恢复到原先的复制层次结构。等待从库追上主库的最新的更新数据。

  我们使用上述方法将Postgres9.1成功升级到Postgres9.2。然而,这个过程花了太多时间,我们不能接受这个过程再来一次。到Postgres9.3出来时,Uber的增长导致我们的数据大幅增长,所以升级时间将会更加漫长。出于这个原因,我们的Postgres的实例一直运行Postgres9.2到今天,尽管当前的PostgresGA版本是9.5。

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

  MySQL架构概述

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

  InnoDB的磁盘数据结构

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

  最重要的架构区别在于Postgres的索引记录直接映射到磁盘上的位置时,InnoDB保持二级结构。而不是拿着一个指向磁盘上的行位置(如CTID在Postgres),InnoDB的第二个索引记录持有一个指向主键值。因此,在MySQL中的二级索引与相关联的主键索引键,是如下所示:

13.webp.jpg



  为了执行上的(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的自动清理过程中必须做全表扫描,以确定删除的行。

14.webp.jpg



  MySQL使用额外的间接层:二级索引记录指向主索引记录,而主索引本身包含在磁盘上的排的位置。如果一个行偏移的变化,只有主索引需要更新。

  复制

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

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

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

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

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

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

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

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

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

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

  其他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缓冲池访问的数据不需要任何用户/内核上下文切换。最坏的情况下的行为是一个的出现TLBmiss,但是可以通过使用huagpage来搞定。

  连接处理

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

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

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

  结论

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

文章来源:高可用架构
文章作者:EvanKlitzke

发表于 2016-8-10 11:19:09 | 显示全部楼层
Uber的早期架构是由Python编写一个单体后端应用程序,使用Postgres作为数据持久化。后来Uber架构经历一系列显著改变,朝着微服务架构和新的数据平台发展。
使用道具 举报

回复

发表于 2016-9-13 20:36:17 | 显示全部楼层
6666
使用道具 举报

回复

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

关于我们
联系我们
  • 电话:010-86393388
  • 邮件:udn@yonyou.com
  • 地址:北京市海淀区北清路68号
移动客户端下载
关注我们
  • 微信公众号:yonyouudn
  • 扫描右侧二维码关注我们
  • 专注企业互联网的技术社区
版权所有:用友网络科技股份有限公司82041 京ICP备05007539号-11 京公网网备安1101080209224 Powered by Discuz!
快速回复 返回列表 返回顶部