介绍
Uber 的早期架构是由 Python 编写一个单体后端应用程序,使用 Postgres 作为数据持久化。后来 Uber 架构经历一系列显著改变,朝着微服务架构和新的数据平台发展。具体而言,在许多以前使用的 Postgres 的场景,现在更多的使用构建在 MySQL 之上的 schemaless 存储系统(小编:Uber的数据中间件)。在本文中,将探讨一些我们发现的 Postgres 的弊端,并解释我们切换 schemaless 和其他后端服务到 MySQL 数据库的原因。
Postgres 架构概述
我们遇到的大量 Postgres 限制如下:
- 效率低下的写入架构
- 低效的数据复制
- 表损坏的问题
- 糟糕的 MVCC 从库支持
- 难以升级到新的版本
我们将在所有这些限制,首先通过分析 Postgres 如何组织在磁盘上的表和索引进行分析,特别是比较与 MySQL 使用 InnoDB 存储相同数据的实现方式。需要注意的是,我们在这里提出的分析主要是基于我们有些老的 Postgres 9.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”:
为了解释布局的细节,让我们考虑一个简单的用户表的例子。对于每个用户,我们有一个自动递增的用户 ID 的主键,还有用户的名字和姓氏,以及用户的出生年份。我们还定义了用户的全名复合二级索引(姓和名),并在用户的出生年份加上另一个二级索引。创建这样一个表 DDL 可能是这样的:
注意这个定义中的三个索引:主键索引加上两个二级索引。
对于本文中的例子,我们看下表的数据,它由一个选择有影响力的历史数学家开始:
如上所述,每行中隐含有独特的,不透明 CTID。因此,我们可以这样理解表的内部结构:
主键索引,它映射 ID 与 ctids,是这样定义的:
B 树被用在 id 字段上,B 树中的每个节点上保存了 CTID 值。注意,在这种情况下,在 B 树的字段的顺序,刚好与表中顺序相同,这是由于使用自动递增的 id 的缘故,但这并不一定需要是这种情况。
二级索引看起来相似;主要的区别是字段存储顺序不同,因为 B 树,必须按字典顺序组织。姓名索引(first,last)按字母表的顺序排列:
同样,birth_year 聚簇索引按升序排列,就像这样:
正如你所看到的,在这两种情况下,在各自的二级索引 CTID 字段本身并不是有序的,不象第一个自动递增的主键的情况。
假设我们需要更新此表中的记录。举例来说,假设要更新 al-Khwārizmī’ 的出生年份到 770 CE。正如前面提到的,行的 tuple 是不可变的。因此,要更新记录,需要添加一个新的 tuple。这种新的 tuple 有一个新的不透明 CTID,我们称之为 I。Postgres 需要能够从旧的 tuple D 处找到新的 I。在内部,Postgres 存储每个 tuple 中的版本字段,以及指向前一 tuple 的 ctid 指针(如果有)。因此,该表的新结构如下:
只要 al-Khwārizmī 的两个版本存在,索引则必须维护两行的记录。为简单起见,我们省略了主键索引并显示只有在这里的二级索引,它是这样的:
我们将旧版本标识成红色,将新版标识成绿色。在此之下,Postgres 使用另一个字段来保存该行版本,以确定哪一个 tuple 是最新的。这个新增的字段允许数据库确定事务看到的是那一个行的 tuple。
在 Postgres,主索引和二级索引都指向磁盘上的 tuple 偏移。当一个 tuple 的位置变化,各项索引都必须更新。
复制
当我们插入数据到表中,如果启用了流复制机制,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. 写放大(Write Amplification)
在 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. 复制
因为复制发生在磁盘的变化上,因此写入放大问题自然会转化为复制层的放大。一个小的逻辑记录,如“更改出生年份为 CTID D 到 770”,WAL 会将上述描写的 4 步从网络上同步到从库,因此写入放大问题也等同一个复制放大问题,从而 Postgres 的复制数据流很快变得非常冗长,可能会占用大量的带宽。
在 Postgres 的复制发生一个数据中心内的情况下,复制带宽可能不是一个问题。现代网络设备和交换机可以处理大量的带宽,许多托管服务提供商提供免费或廉价的内部数据中心带宽。然而,当复制必须在不同数据中心之间发生的,问题都可以迅速升级。
例如,Uber 原本使用的物理服务器在西海岸机房。为了灾难恢复的目的,我们在东海岸托管空间添加了一批服务器。在本设计中,我们西部数据中心作为主库,东海岸增加了一批服务器作为从库。
级联复制可以降低跨数据中心的带宽要求,只需要主库和一个从库之间同步一份数据所需的带宽和流量,即便在第二个数据中心配置了多个从库。然而,Postgres 的复制协议的详细程度,对于使用了大量二级索引的数据库,仍可能会导致数据的海量传输。采购跨国的带宽是昂贵的,即使有钱的土豪公司,也无法做到跨国的带宽和本地的带宽一样大。
这种带宽的问题也导致我们曾经在 WAL 归档方面出现过问题。除了发送所有从西海岸到东海岸的 WAL 更新,我们将所有的 WAL 记录归档到一个文件存储的 Web 云服务,这样当出现数据灾难情况时,可以从备份的 WAL 文件恢复。但是流量峰值时段,我们与存储网络服务的带宽根本无法跟上 WAL 写入的速度。
3. 数据损坏
在一次例行主数据库扩容的变更中,我们遇到了一个 Postgres 9.2 的 bug。从库的切换时间顺序执行不当,导致他们中的一些节点误传了一些 WAL 记录。因为这个 bug,应该被标记为无效的部分记录未标记成无效。
以下查询说明了这个 bug 如何影响我们的用户表:
SELECT * FROM users WHERE ID = 4;
此查询将返回两条记录:修改出生年份之前的老记录,再加上修改后的新记录。如果将 CTID 添加到 WHERE 列表中,我们将看到返回记录中存在不同的 CTID 记录,正如大家所预料的,返回了两个不同行的 tuple。
这个问题是有几个原因非常伤脑筋。首先,我们不能轻易找出这个问题影响的行数。从数据库返回的结果重复,导致应用程序逻辑在很多情况下会失败。我们最终使用防守编程语句来检测已知有这个问题表的情况。因为 bug 影响所有服务器,损坏的行在不同的服务器节点上可能是不同的,也就是说,在一个从库行 X 可能是坏的,Y 是好的,但对另一个从库,用行 X 可能是好的,Y 行可能是坏。事实上,我们并不确定数据损坏的从库节点数量,以及主库是否也存在数据损坏。
虽然我们知道,问题只是出现在每个数据库的少量几行,但我们还是非常担心,因为 Postgres 复制机制发生在物理层,任何小的错误格式有可能会导致彻底损坏我们的数据库索引。B 树的一个重要方面是,它们必须定期重新平衡 ,并且这些重新平衡操作可以完全改变树的结构作为子树被移到新的磁盘上的位置。如果错误数据被移动,这可能会导致树的大部分地区变得完全无效。
最后,我们追踪到了实际的 bug,并用它来确定新的 master 不存在任何损坏行。然后再把 master 的快照同步到所有从库上去,这是一个艰苦的体力活的过程(小编:看到美帝的 DBA 也这么苦逼心理终于平衡一点了),因为我们每次只能从在线的池子里面拿出有限几台来操作。
虽然我们遇到的这个 bug 仅影响 Postgres 9.2 的某些版本,而且目前已经修复了很久。但是,我们仍然发现这类令人担忧的 bug 可以再次发生。可能任意一个新的 Postgres 版本,它会带着这种致命类型的 bug,而且由于其复制的不合理的设计,这个问题一旦出现,就会立即蔓延到集群中所有复制链的数据库上。
4. 从库无 MVCC
Postgres 没有真正的从库 MVCC 支持。在从库任何时刻应用 WAL 更新,都会导致他们与主库物理结构完全一致。这样的设计也给 Uber 带来了一个问题。
为了支持 MVCC,Postgres 需要保留行的旧版本。如果流复制的从库正在执行一个事务,所有的更新操作将会在事务期间被阻塞。在这种情况下,Postgres 将会暂停 WAL 的线程,直到该事务结束。但如果该事务需要消耗相当长的时间,将会产生潜在的问题,Postgres 在这种情况下设定了超时:如果一个事务阻塞了 WAL 进程一段时间,Postgres 将会 kill 这个事务。
这样的设计意味着从库会定期的滞后于主库,而且也很容易写出代码,导致事务被 kill。这个问题可能不会很明显被发现。例如,假设一个开发人员有一个收据通过电子邮件发送给用户一些代码。这取决于它是如何写的,代码可能隐含有一个的保持打开,直到邮件发送完毕后,再关闭的一个数据库事务。虽然它总是不好的形式,让你的代码举行公开的数据库事务,同时执行无关的阻塞 I / O,但现实情况是,大多数工程师都不是数据库专家,可能并不总是理解这个问题,特别是使用掩盖了低级别的细节的 ORM 的事务。(小编:美帝程序员代码习惯跟咱们也很类似)
Postgres 的升级
因为复制记录在物理层面工作,这导致不能在不同的 Postgres GA 版本之间进行复制。运行的 Postgres 9.3 主数据库无法复制数据到 Postgres 9.2 的从库上,也无法在运行 9.2 的主数据库复制数据到 Postgres 9.3 的从库上。
我们按照以下这些步骤,从一个 Postgres 的 GA 版本升级到另一个:
- 关闭主数据库。
- 在主库上运行 pg_upgrade 命令,这是更新主库数据的命令 。在一个大的数据库上,这很容易需要几个小时的时间,执行期间不能够提供任何访问服务。
- 再次启动主库。
- 创建主库的新快照,这一步完全复制一份主库的所有数据,因此对于大型数据库,它也需要几个小时的时间。
- 清除所有从库上的数据,将从主库导出的快照恢复到所有从库。
- 把每个从库恢复到原先的复制层次结构。等待从库追上主库的最新的更新数据。
我们使用上述方法将 Postgres 9.1 成功升级到 Postgres 9.2。然而,这个过程花了太多时间,我们不能接受这个过程再来一次。到 Postgres 9.3 出来时,Uber 的增长导致我们的数据大幅增长,所以升级时间将会更加漫长。出于这个原因,我们的 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 存储引擎。
InnoDB 的磁盘数据结构
与 Postgres 一样,InnoDB 支持如 MVCC 和可变数据这样的高级特性。详细讨论 InnoDB 的磁盘数据格式超出了本文的范围;在这里,我们将重点放在从 Postgres 的主要区别上。
最重要的架构区别在于 Postgres 的索引记录直接映射到磁盘上的位置时,InnoDB 保持二级结构。而不是拿着一个指向磁盘上的行位置(如 CTID 在 Postgres),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 使用额外的间接层:二级索引记录指向主索引记录,而主索引本身包含在磁盘上的排的位置。如果一个行偏移的变化,只有主索引需要更新。
复制
MySQL 支持多个不同的复制模式:
- 语句级别的复制:复制 SQL语句(例如,它会从字面上直译复制的语句,如:更新用户 SET birth_year = 770 WHERE ID = 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 也许会导致表损坏,但不太可能导致灾难性的失败。复制发生在逻辑层,所以像一个重新平衡 B tree 这样的操作不会导致索引损坏。一个典型的 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 缓冲池访问的数据不需要任何用户/内核上下文切换。最坏的情况下的行为是一个的出现 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 中间层),在一些特殊的情况下,也使用 NoSQL 数据库如 Cassandra。我们对 MySQL 的使用非常满意,后续可能会在更多的博客文章中介绍其在 Uber 一些更先进的用途。
作者 Evan Klitzke 是 Uber 核心基础架构组资深软件工程师。他也是一个数据库爱好者,是 2012 年 9 月加入 Uber 的一名早鸟。
英文原文:
https://eng.uber.com/mysql-migration/