Uber 的早期架构包含了一个用 Python 开发的单体后端应用程序,这个应用程序使用 Postgres 作为数据存储。从那个时候开始,Uber 的架构已经发生了巨大变化,变成了微服务,并采用新的数据平台模型。具体地说,之前使用 Postgres 的地方,现在改用 Schemaless,一种构建在 MySQL 之上的新型数据库分片层。在本文中,我们将探讨 Postgres 的一些缺点,并解释为什么我们要在 MySQL 之上构建 Schemaless 和其他后端服务。
1. Postgres 架构
我们遭遇了 Postgres 的诸多限制:
- 低效的写入操作;
- 低效的数据复制;
- 数据损坏问题;
- 糟糕的副本 MVCC 支持;
- 难以升级到新版本。
我们将通过分析 Postgres 的表和索引在磁盘上的表示方式来探究以上这些限制,并将其与 MySQL 的 InnoDB 存储引擎进行比较。请注意,我们的分析主要是基于我们对较旧的 Postgres 9.2 版本系列的经验。据我们所知,在本文中讨论的内部架构在较新的 Postgres 发行版中并未发生显著变化,并且至少自 Postgres 8.3 发行版(现在已近 10 岁)以来,9.2 版本的基本设计都没有发生显著变化。
磁盘表示
一个关系型数据库必须能够执行一些关键任务:
- 提供插入、更新和删除能力;
- 提供修改模式的能力;
- 支持 MVCC,让不同的数据库连接具有各自的事务视图。
这些功能如何协同工作是设计数据库磁盘数据表示的重要部分。
Postgres 的一个核心设计是不可变数据行。这些不可变数据行在 Postgres 中被称为“元组”。这些元组通过 ctid 来唯一标识。从概念上看,ctid 表示元组在磁盘上的位置(即物理磁盘偏移)。可能会有多个 ctid 描述单个行(例如,为了支持 MVCC,可能存在一个数据行的多个版本,或者一个数据行的旧版本还没有被 autovacuum 进程回收掉)。元组集合构成一张表。表本身是有索引的,这些索引被组织成某种数据结构(通常是 B 树),将索引字段映射到 ctid。
通常,这些 ctid 对用户是透明的,但了解它们的工作原理有助于了解 Postgres 表的磁盘结构。要查看当前行的 ctid,可以在语句中将“ctid”添加到列列表中:
- uber@[local] uber=> SELECT ctid, * FROM my_table LIMIT 1;
- -[ RECORD 1 ]--------+------------------------------
- ctid | (0,1)
- ...其他字段...
我们通过一个简单的用户表来解释这个。对于每个用户,我们都有一个自动递增的用户 ID 主键、用户的名字和姓氏以及用户的出生年份。我们还针对用户全名(名字和姓氏)定义了复合二级索引,并针对用户的出生年份定义了另一个二级索引。创建表的 DDL 可能是这样的:
- CREATE TABLE users (
- id SERIAL,
- first TEXT,
- last TEXT,
- birth_year INTEGER,
- PRIMARY KEY (id)
- );
- CREATE INDEX ix_users_first_last ON users (first, last);
- CREATE INDEX ix_users_birth_year ON users (birth_year);
这里定义了三个索引:一个主键索引和两个二级索引。
我们往表中插入以下这些数据,包括一些有影响力的历史数学家:
如前所述,这里的每一行都有一个隐式、唯一的 ctid。因此,我们可以这样考虑表的内部表示形式:
主键索引(将 id 映射到 ctid)的定义如下:
B 树索引是在 id 字段上定义的,并且 B 树中的每个节点都存有 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ī的出生年份进行小的逻辑更新,必须进行至少四个物理更新:
- 将新的行元组写入表空间;
- 更新主键索引;
- 更新 (first,last) 索引;
- 更新 birth_year 索引。
实际上,这四个更新也只反映了对主表空间的写操作。除此之外,这些写操作也需要反映在 WAL 中,因此磁盘上的写操作总数会变得更多。
这里值得注意的是更新 2 和更新 3。在更新 al-Khwārizmī的出生年份时,实际上并没有修改它的主键,也没有修改名字和姓氏。但尽管如此,仍然必须在数据库中创建新的行元组,以便更新这些索引。对于具有大量二级索引的表,这些多余的步骤可能会导致效率低下。例如,如果我们在一张表中定义了十二个索引,即使只更新了单个索引对应的字段,也必须将该更新传播给所有 12 个索引,以便反映新行的 ctid。
复制
这个写入放大问题自然也转化到了复制层,因为复制发生在磁盘级别。数据库并不会复制小的逻辑记录,例如“将 ctid D 的出生年份更改为 770”,而是将之前的 4 个 WAL 条目传播到网络上。因此,写入放大问题也转化为复制放大问题,Postgres 复制数据流很快变得非常冗长,可能占用大量带宽。
如果 Postgres 复制仅发生在单个数据中心内,那么复制带宽可能就不是问题。现代网络设备和交换机可以处理大量带宽,很多托管服务提供商还提供了免费或便宜的数据中心内部带宽。但是,如果要在数据中心之间进行复制,问题就会迅速升级。例如,Uber 最初使用了西海岸托管中心里的物理服务器。为了进行灾备,我们在东海岸托管中心添加了服务器。于是,我们在西部数据中心里有一个主 Postgres 实例(加上副本),在东部也有一个副本集。
级联复制将数据中心间的带宽限制为只能满足主数据库和单个副本之间的带宽需求,虽然第二个数据中心里还有很多副本。因为 Postgres 复制协议的冗繁,使用了大量索引的数据库会有很大的数据量。购买跨地域大带宽成本非常高昂,即使钱不成问题,也不可能获得与本地带宽类似的效果。这个带宽问题也给 WAL 归档带来了麻烦。除了将所有 WAL 更新从西海岸发送到东海岸之外,我们还要将所有 WAL 都存档到文件存储服务中,这是为了确保在发生灾难时我们可以还原数据。在早期的流量高峰期间,我们写入存储服务的带宽不够快,无法跟上 WAL 的写入速度。
数据损坏
在例行升级主数据库以便增加数据库容量的过程中,我们遭遇了 Postgres 9.2 个一个 bug。因为副本在切换时间方面出现了错误,导致其中一些副本错误地应用了一小部分 WAL 记录。由于这个问题,一些本应由版本控制机制标记为无效的记录实际上并未被标记为无效。
下面的查询说明了这个错误将如何影响我们的用户表:
- SELECT * FROM users WHERE id = 4;
这个查询将返回两条记录:初始的 al-Khwārizmī行(出生年份为 780 CE)和新的 al-Khwārizmī行(出生年份为 770 CE)。如果将 ctid 添加到 WHERE 中,对于这两条返回的记录,我们将看到不同的 ctid 值。
这个问题非常烦人。首先,我们无法得知这个问题究竟影响了多少行数据。数据库返回的重复结果在很多情况下会导致应用程序逻辑故障。我们最终添加了防御性编程语句,用来检测会出现这个问题的表。这个错误影响到了所有服务器,而在不同的副本实例上损坏的数据行是不一样的。也就是说,在其中一个副本实例上,行 X 可能是坏的,行 Y 是好的,但是在另一副本实例上,行 X 可能是好,行 Y 可能是坏的。我们无法确定数据损坏的副本数量以及问题是否影响了主数据库。
据我们所知,每个数据库只有几行数据会出现这个问题,但我们担心的是,由于复制发生在物理级别,最后可能会完全破坏数据库索引。B 树索引很重要的一点是必须定期进行重新平衡(rebalance),并且当子树移动到新的磁盘位置时,这些重新平衡操作可能会完全改变树的结构。如果移动了错误的数据,则可能导致树的大部分完全无效。
最后,我们找到了问题所在,并确定新的主数据库没有损坏的数据行。我们通过从主数据库的最新快照重新同步所有副本(这是一个费力的过程)来修复副本的数据损坏问题。
我们遇到的错误只出现在 Postgres 9.2 的某些版本中,并且已经修复了很长时间了。但是,我们仍然担心此类错误会再次发生。新版本的 Postgres 可能还会出现此类错误,并且由于数据复制的方式,这类问题有可能被传播到所有的数据库中。
副本 MVCC
Postgres 没有提供真正的副本 MVCC 支持。副本只应用 WAL 更新,导致它们在任何时候都具有与主数据库相同的磁盘数据副本。这种设计给 Uber 带来了麻烦。
Postgres 需要为 MVCC 维护旧数据的一个副本。如果流式复制遇到一个正在执行的事务,而数据库更新影响到了事务范围内的行,那么更新操作就会被阻塞。在这种情况下,Postgres 会暂停 WAL 线程,直到事务结束。如果事务处理要花费很长时间,这就会是个问题,因为副本可能严重滞后于主数据库。因此,Postgres 在这种情况下应用超时策略:如果一个事务导致 WAL 发生阻塞一定的时间,Postgres 将会终止这个事务。
这种设计意味着副本通常会比主数据库落后几秒钟,很容易出现事务被终止的情况。例如,假设开发人员写了一些代码,需要通过电子邮件将收据发送给用户。根据编写方式的不同,代码可能会隐式地让数据库事务处于打开状态,直到电子邮件完成发送为止。尽管在执行不相关的阻塞 IO 时一直打开数据库事务是很糟糕的做法,但大多数工程师并不是数据库专家,他们可能也不知道有这个问题,特别是在使用隐藏了底层细节的 ORM 框架时。
升级 Postgres
由于复制发生在物理层面,所以我们无法在 Postgres 的不同版本之间复制数据。Postgres 9.3 的主数据库不能被复制到 Postgres 9.2 的副本,而 Postgres 9.2 的主数据库也不能被复制到 Postgres 9.3 的副本。
我们按照以下这些步骤从一个 Postgres GA 版本升级到另一个版本:
- 关闭主数据库。
- 在主数据库上运行 pg_upgrade 命令,这个命令会就地更新主数据库数据。对于大型数据库,通常需要花费数小时,并且在这个过程过程中无法从主数据库读取数据。
- 再次启动主数据库。
- 创建主数据库的最新快照。这一步骤完全复制了主数据库的所有数据,因此大型数据库也需要花费数小时。
- 擦除所有副本,并将最新的快照从主数据库还原到副本上。
- 将副本带回到复制层次结构中。等待副本完全跟上主数据库的所有更新。
我们从 Postgres 9.1 开始,并成功完成了升级过程,迁移到了 Postgres 9.2。但是,这个过程花费了数小时,我们无力承担再次执行这种升级过程的费用。到 Postgres 9.3 发布时,Uber 的规模增长极大增加了我们的数据集,因此升级时间就变得更长了。因此,即使 Postgres 9.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 中需要更新。
这种设计还让数据清理和压缩变得更加高效。回滚段中的数据可以直接清除,相比之下,Postgres 的 autovacuum 进程必须进行全表扫描来识别哪些行可以清除。
MySQL 使用了额外的中间层:二级索引记录指向主索引记录,主索引保存了数据行在磁盘上的位置。如果数据行偏移量发生变化,只需要更新主索引。
复制
MySQL 支持多种不同的复制模式:
- 基于语句的复制将会复制逻辑 SQL 语句(它将按字面意义复制 SQL 语句,例如:UPDATE users SET birth_year = 770 WHERE id = 4);
- 基于行的复制将会复制发生变化的行记录;
- 混合复制将这两种模式混合在一起。
这几种模式各有优缺点。基于语句的复制通常是最紧凑的,但可能需要副本应用大量语句来更新少量数据。另一方面,基于行的复制(与 Postgres WAL 复制类似)虽然更为冗繁,但更具可预测性和在副本上的更新效率。
在 MySQL 中,只有主索引有指向行的磁盘偏移量的指针。在进行复制时,这具有重要的意义。MySQL 复制流只需要包含有关行的逻辑更新信息。对于类似“将行 X 的时间戳从 T_1 更改为 T_2”这样的更新,副本会自动推断需要修改哪些索引。
相比之下,Postgres 复制流包含了物理变更,例如“在磁盘偏移量 8,382,491 处写入字节 XYZ”。在使用 Postgres 时,对磁盘进行的每一个物理变更都需要包含在 WAL 流中。较小的逻辑修改(例如更新时间戳)也需要执行很多磁盘变更:Postgres 必须插入新的元组,并更新所有索引,让它们指向这个元组,所以会有很多变更被放入 WAL 流中。这种设计差异意味着 MySQL 复制二进制日志比 PostgreSQL WAL 流更紧凑。
复制方式也对副本的 MVCC 产生重要影响。由于 MySQL 复制流具有逻辑更新,副本可以具有真正的 MVCC 语义,所以对副本的读取查询不会阻塞复制流。相比之下,Postgres WAL 流包含了磁盘上的物理更改,Postgres 副本无法应用与读取查询相冲突的复制更新,因此无法实现 MVCC。
MySQL 的复制架构意味着即使有 bug 导致表损坏,也不太可能会发生灾难性故障。因为复制发生在逻辑层,所以像重新平衡 B 树之类的操作永远不会导致索引损坏。一个典型的 MySQL 复制问题是语句被跳过(或者被应用两次),这可能导致数据丢失或无效,但不会导致数据库中断。
最后,MySQL 的复制架构可以很容易在不同的 MySQL 版本之间进行复制。MySQL 的逻辑复制格式还意味着存储引擎层中的磁盘变更不会影响复制格式。在进行 MySQL 升级时,典型的做法是一次将更新应用于一个副本,在更新完所有副本后,将其中一个提升为新的主副本。这几乎可以实现零停机升级,很容易就可以让 MySQL 保持最新状态。
4. MySQL 的其他优势
到目前为止,我们介绍了 Postgres 和 MySQL 的磁盘架构。MySQL 还有其他一些重要方面也让它的性能明显优于 Postgres。
缓冲池
首先,两个数据库的缓存方式不同。Postgres 为内部缓存分配了一些内存,但是与计算机上的内存总量相比,这些缓存通常很小。为了提高性能,Postgres 允许内核通过页面缓存自动缓存最近访问的磁盘数据。例如,我们最大的 Postgres 副本有 768 GB 的可用内存,但实际上只有 25 GB 被用作 Postgres 的进程 RSS 内存,这样就为 Linux 页面缓存留出了 700 GB 以上的可用内存。
这种设计的问题在于,与访问 RSS 内存相比,通过页面缓存访问数据实际上开销更大。为了从磁盘上查找数据,Postgres 进程发出 lseek 和 read 系统调用来定位数据。这些系统调用中的每一个都会引起上下文切换,这比从主存储器访问数据的开销更大。实际上,Postgres 在这方面甚至还没有完全进行优化:Postgres 并未利用 pread 系统调用,这个系统调用会将 seek 和 read 操作合并为一个系统调用。
相比之下,InnoDB 存储引擎通过缓冲池实现了自己的 LRU。从逻辑上讲,这与 Linux 页面缓存相似,但它是在用户空间中实现的。尽管 InnoDB 缓冲池的设计比 Postgres 的设计要复杂得多,但它具备一些优势:
- 可以实现自定义 LRU。例如,可以检测出可能会破坏 LRU 的访问模式,并防止其造成更大问题。
- 较少的上下文切换。通过 InnoDB 缓冲池访问的数据不需要进行用户 / 内核上下文切换。最坏的情况是发生 TLB 未命中,这些开销相对较小,可以通过使用大页面来缓解。
连接处理
MySQL 通过一个连接一个线程的方式来实现并发连接。这种开销相对较低,每个线程都有自己的栈内存和分配给特定连接的缓冲堆内存。在 MySQL 中使用 10000 个左右的并发连接,这种情况并不少见,实际上,在我们现有的某些 MySQL 实例上,连接数已经接近这个数字。
但是,Postgres 采用的是一个连接一个进程的设计,这比一个连接一个线程的设计要昂贵得多。派生新进程比生成新线程占用更多的内存。此外,进程之间的 IPC 比线程之间的 IPC 也昂贵得多。Postgres 9.2 通过 System V IPC 原语实现 IPC,而不是使用轻量级的 futex。futex 比 System V IPC 更快,因为通常情况下,futex 不存在竟态条件,因此无需进行上下文切换。
除了内存和 IPC 开销,Postgres 似乎也无法很好地支持大量连接,即使有足够的可用内存。我们在 Postgres 中使用数百个活动连接时遇到了大问题。Postgres 文档建议采用进程外连接池机制来处理大量连接,但没有详细说明是为什么。因此,我们使用 pgbouncer 来处理 Postgres 的连接池。但是,我们的后端服务偶尔会出现 bug,导致它们打开的活动连接过多,从而延长了宕机时间。
5. 结论
在 Uber 早期,Postgres 为我们提供了很好的服务,但是随着公司规模的增长,我们遇到了伸缩性问题。现在,我们仍然保留了一些旧的 Postgres 实例,但大部分数据库都建立在 MySQL 之上(通常使用 Schemaless 层),或者在某些特殊情况下会使用像 Cassandra 这样的 NoSQL 数据库。