说明:本文翻译自Why Uber Engineering Switched from Postgres to MySQL
引言
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在概念上表示元组的磁盘位置(即物理磁盘偏移量)。多个ctid可以潜在地描述单行(例如,当出于MVCC目的存在多个版本的行,或者当autovacuum进程尚未回收行的旧版本时)。有组织的元组的集合形成一个表。表本身具有索引,这些索引被组织为数据结构(通常是b树),将索引字段映射到ctid有效负载。
通常,这些ctid对用户是透明的,但是了解它们的工作方式有助于理解Postgres表的磁盘结构。要查看一行的当前ctid,可以在WHERE子句中将" 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映射到ctidds,定义如下:
B-tree定义在id字段上,B-tree中的每个节点都包含ctid值。注意,在这种情况下,由于使用了自动递增的id, b -tree中字段的顺序恰好与表中的顺序相同,但这并不一定是这种情况。
二级索引看起来相似;主要的区别是字段以不同的顺序存储,因为b-tree必须按字典顺序组织。(第一个,最后一个)索引从字母表顶部的名字开始:
类似地,birth_year索引按升序聚集,如下所示:
可以看到,在这两种情况下,各自辅助索引中的ctid字段都没有按字典顺序增加,这与自动递增主键的情况不同。
假设我们需要更新该表中的一条记录。例如,假设我们正在更新出生年份字段,以对al-Khwārizmī的出生年份进行另一个估计,即770 CE。如前所述,行元组是不可变的。因此,为了更新记录,我们向表中添加一个新的元组。这个新的元组有一个新的不透明的ctid,我们称之为I。Postgres需要能够区分I中的新的活动元组和d中的旧元组。在内部,Postgres在每个元组中存储一个版本字段和指向前一个元组的指针(如果有的话)。相应地,表的新结构如下所示:
只要al-Khwārizmī行存在两个版本,索引就必须包含这两个行的条目。为了简洁起见,我们省略了主键索引,只显示了二级索引,如下所示:
我们用红色表示旧版本,用绿色表示新行版本。在底层,Postgres使用另一个包含行版本的字段来确定哪个元组是最新的。这个添加的字段允许数据库确定为不允许查看最新行版本的事务服务的行元组。
使用Postgres,主索引和辅助索引都直接指向磁盘上的元组偏移量。当元组位置改变时,必须更新所有索引。
复制
当我们向表中插入新行时,如果启用了流复制,Postgres需要复制它。为了恢复崩溃,数据库已经维护了一个预写日志(WAL),并使用它来实现两阶段提交。即使没有启用流复制,数据库也必须维护这个WAL,因为WAL允许ACID的原子性和持久性。
我们可以通过考虑如果数据库意外崩溃(比如突然断电)会发生什么来理解WAL。WAL表示数据库计划对表和索引的磁盘内容所做更改的分类账。当Postgres守护进程第一次启动时,该进程将这个分类账中的数据与磁盘上的实际数据进行比较。如果分类账包含未反映在磁盘上的数据,数据库将更正任何元组或索引数据,以反映WAL指示的数据。然后,它将回滚出现在WAL中但来自部分应用事务的任何数据(意味着事务从未提交)。
Postgres通过将主数据库上的WAL发送到副本来实现流复制。每个副本数据库都有效地像在崩溃恢复中一样工作,不断应用WAL更新,就像在崩溃后启动一样。流复制和实际崩溃恢复之间的唯一区别是,处于“热备”模式的副本在应用流WAL时提供读查询,而实际上处于崩溃恢复模式的Postgres数据库通常拒绝提供任何查询,直到数据库实例完成崩溃恢复过程。
因为WAL实际上是为崩溃恢复目的而设计的,所以它包含关于磁盘上更新的低级信息。WAL的内容处于行元组及其磁盘偏移量(即行ctids)的实际磁盘表示的级别。如果在副本完全被捕获时暂停Postgres主副本,副本上的实际磁盘内容与主字节上的内容完全匹配。因此,像rsync这样的工具可以修复一个已损坏的副本,如果它与主副本过时的话。
写放大
Postgres设计的第一个问题在其他环境中被称为写放大。通常,写放大指的是将数据写入SSD磁盘的问题:一个小的逻辑更新(例如,写入几个字节)在转换到物理层时变成一个更大、更昂贵的更新。同样的问题也出现在Postgres中。在我们前面的例子中,当我们对al-Khwārizmī的出生年份进行小的逻辑更新时,我们必须发布至少四个物理更新:
- 将新的行元组写入表空间
- 更新主键索引,为新的元组添加一条记录
- 更新(第一个,最后一个)索引,为新的元组添加一条记录
- 更新birth_year索引,为新的元组添加一条记录
事实上,这四次更新只反映了对主表空间的写操作;这些写操作也都需要反映在WAL中,因此磁盘上写操作的总数会更大。
这里值得注意的是更新2和3。当我们更新al- khw - ārizmī的出生年份时,我们实际上没有改变他的主键,也没有改变他的姓和名。但是,这些索引仍然必须在数据库中为行记录创建一个新的行元组时进行更新。对于具有大量二级索引的表,这些多余的步骤会导致极大的低效率。例如,如果我们有一个表,表上定义了十几个索引,那么对仅由一个索引覆盖的字段的更新必须传播到所有12个索引中,以反映新行的ctid。
这种写放大问题自然也会转化为复制层,因为复制发生在磁盘上更改的级别。数据库不再复制一个小的逻辑记录,例如“将ctid D的出生年份更改为现在的770”,而是为我们刚才描述的所有四个写入写入WAL条目,并且所有这四个WAL条目都在网络上传播。因此,写放大问题也转化为复制放大问题,Postgres复制数据流很快就会变得非常冗长,可能会占用大量带宽。
在Postgres复制完全发生在单个数据中心的情况下,复制带宽可能不是问题。现代网络设备和交换机可以处理大量带宽,许多托管提供商提供免费或廉价的数据中心内部带宽。但是,当必须在数据中心之间进行复制时,问题可能会迅速升级。例如,优步最初在西海岸的一个托管空间中使用物理服务器。出于灾难恢复的目的,我们在第二个东海岸托管空间中添加了服务器。在这个设计中,我们在西部数据中心有一个主Postgres实例(加上副本),在东部数据中心有一组副本。
级联复制将数据中心间带宽需求限制为仅主副本和单个副本之间所需的复制量,即使第二个数据中心中有许多副本。但是,对于使用大量索引的数据库来说,Postgres复制协议的冗长仍然会导致大量数据。购买非常高带宽的跨国链路是昂贵的,即使在钱不是问题的情况下,也不可能获得与本地互连相同带宽的跨国网络链路。带宽问题也给WAL存档带来了问题。除了将所有WAL更新从西海岸发送到东海岸之外,我们还将所有WAL存档到一个文件存储web服务中,这既是为了在灾难发生时恢复数据的额外保证,也是为了存档的WAL可以从数据库快照中生成新的副本。在早期的流量高峰期间,我们到存储web服务的带宽不够快,无法跟上向其写入wal的速度。
数据损坏
在一次常规的主数据库升级以增加数据库容量时,我们遇到了Postgres 9.2漏洞。副本不正确地遵循时间轴切换,导致其中一些副本错误地应用了一些WAL记录。由于这个错误,一些应该被版本控制机制标记为非活动的记录实际上没有被标记为非活动。
下面的查询说明了这个错误将如何影响我们的用户表示例:
SELECT * FROM users WHERE id = 4;
这个查询将返回两条记录:原始的al- kw ārizmī行,年份为公元780年,以及新的al- kw ārizmī行,年份为公元770年。如果我们将ctid添加到WHERE列表中,我们将看到两个返回记录的不同ctid值,就像对两个不同的行元组所期望的那样。
由于几个原因,这个问题非常令人烦恼。首先,我们不容易判断这个问题影响了多少行。在许多情况下,从数据库返回的重复结果会导致应用程序逻辑失败。我们最终添加了防御性编程语句来检测已知存在此问题的表的情况。因为这个错误影响了所有的服务器,所以损坏的行在不同的副本实例上是不同的,这意味着在一个副本上,行X可能是坏的,行Y可能是好的,但在另一个副本上,行X可能是好的,行Y可能是坏的。事实上,我们不确定带有损坏数据的副本的数量,也不确定这个问题是否影响了主服务器。
据我们所知,这个问题只出现在每个数据库的几行上,但是我们非常担心,因为复制发生在物理层上,最终可能会完全破坏数据库索引。b-树的一个重要方面是它们必须定期重新平衡,当子树移动到磁盘上的新位置时,这些重新平衡操作可能会完全改变树的结构。如果移动了错误的数据,可能会导致树的很大一部分完全无效。
最后,我们能够追踪到实际的错误,并使用它来确定新升级的主服务器没有任何损坏的行。我们通过从主服务器的新快照重新同步所有副本来修复副本上的损坏问题,这是一个费力的过程;我们每次只有足够的容量从负载平衡池中取出几个副本。
我们遇到的错误只影响Postgres 9.2的某些版本,现在已经修复了很长一段时间。然而,我们仍然担心这类错误会发生。任何时候发布的Postgres新版本都可能存在这种性质的错误,而且由于复制的工作方式,这个问题有可能蔓延到复制层次结构中的所有数据库。
MVCC副本
Postgres没有真正的副本MVCC支持。副本应用WAL更新的事实导致它们在任何给定时间点拥有与主数据相同的磁盘数据副本。这种设计给Uber带来了一个问题。
Postgres需要为MVCC维护旧行版本的副本。如果一个流副本有一个打开的事务,那么对数据库的更新如果影响了事务打开的行,就会被阻塞。在这种情况下,Postgres暂停WAL应用程序线程,直到事务结束。如果事务花费很长时间,这就会出现问题,因为副本可能严重滞后于主服务器。因此,Postgres在这种情况下应用超时:如果一个事务阻塞WAL应用程序一段时间,Postgres将终止该事务。
这种设计意味着副本通常会比主服务器滞后几秒钟,因此很容易编写导致事务终止的代码。对于编写模糊事务开始和结束位置的代码的应用程序开发人员来说,这个问题可能并不明显。例如,假设开发人员有一些代码必须通过电子邮件向用户发送收据。根据代码的编写方式,代码可能隐式地拥有一个数据库事务,该事务一直保持打开状态,直到电子邮件发送完成。虽然让代码在执行不相关的阻塞I/O时保持打开的数据库事务总是一种糟糕的形式,但现实是大多数工程师都不是数据库专家,可能并不总是理解这个问题,特别是在使用模糊了打开事务等底层细节的ORM时。
PostgreSQL升级
因为复制记录工作在物理层,所以不可能在不同的Postgres通用可用版本之间复制数据。运行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或更高版本,您可以使用类似于plogical的东西,它为Postgres实现了一个逻辑复制层。使用pglical,你可以在不同的Postgres版本之间复制数据,这意味着可以在不导致大量停机的情况下进行升级,比如从9.4升级到9.5。这个功能仍然存在问题,因为它没有集成到Postgres主线树中,而且对于运行在旧版本Postgres上的人来说,pgloical仍然不是一个选项。
MySQL架构
除了解释Postgres的一些限制之外,我们还解释了为什么MySQL是新的Uber工程存储项目(如Schemaless)的重要工具。在很多情况下,我们发现MySQL更适合我们的使用。为了理解其中的差异,我们研究了MySQL的架构,以及它与Postgres的架构有何不同。我们具体分析了MySQL如何与InnoDB存储引擎协同工作。我们不仅在Uber使用InnoDB;它可能是最流行的MySQL存储引擎。
InnoDB磁盘表示
像Postgres一样,InnoDB支持MVCC和可变数据等高级特性。对InnoDB磁盘上格式的详尽讨论超出了本文的范围;相反,我们将关注它与Postgres的核心区别。
最重要的架构差异是,**Postgres直接将索引记录映射到磁盘上的位置,而InnoDB则维持二级结构。InnoDB二级索引记录保存了一个指向主键值的指针,而不是像Postgres中的ctid那样保存一个指向磁盘上行的指针。**因此,MySQL中的二级索引将索引键与主键关联起来:
为了在(第一个,最后一个)索引上执行索引查找,我们实际上需要执行两次查找。第一个查找搜索表并找到记录的主键。找到主键后,第二个查找将搜索主键索引以查找该行在磁盘上的位置。
这种设计意味着InnoDB在进行二级键查找时相对于Postgres略显劣势,因为InnoDB必须搜索两个索引,而Postgres只需搜索一个索引。但是,因为数据是规范化的,所以行更新只需要更新行更新实际更改的索引记录。此外,InnoDB通常会在适当的位置执行行更新。如果为了MVCC的目的,旧事务需要引用一行,MySQL将旧行复制到一个称为回滚段的特殊区域。
让我们来看看当我们更新al-Khwārizmī的出生年份时会发生什么。如果有空格,则id为4的行中的出生年份字段将在适当的位置更新(实际上,这种更新总是在适当的位置进行,因为出生年份是一个占用固定数量空间的整数)。出生年份指数也会及时更新,以反映新的日期。旧行数据被复制到回滚段中。主键索引不需要更新,(名,姓)名称索引也不需要更新。如果在这个表上有大量的索引,我们仍然只需要更新实际索引birth_year字段的索引。比如我们在诸如signup_date, last_login_time等字段上有索引。我们不需要更新这些索引,而Postgres必须更新。
这种设计也使vacuum和compation更有效率。所有有资格被vacuum的行都可以直接在回滚段中使用。相比之下,Postgres autovacuum进程必须执行全表扫描来识别已删除的行。
MySQL使用了一个额外的间接层:二级索引记录指向主索引记录,主索引本身保存磁盘上的行位置。如果行偏移量发生变化,则只需要更新主索引。
复制
MySQL支持多种不同的复制模式:
- 基于语句的复制复制逻辑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的逻辑复制格式也意味着存储引擎层中的磁盘更改不会影响复制格式。进行MySQL升级的典型方法是一次将更新应用到一个副本,一旦您更新了所有副本,您将其中一个副本提升为新的主副本。这可以在几乎零停机的情况下完成,并且简化了保持MySQL的更新。
MySQL其他优势
Buffer Pool
首先,缓存在这两个数据库中的工作方式不同。Postgres为内部缓存分配了一些内存,但是这些缓存与机器上的内存总量相比通常很小。为了提高性能,Postgres允许内核通过页面缓存自动缓存最近访问的磁盘数据。例如,我们最大的Postgres副本有768 GB可用内存,但实际上只有25 GB的内存是由Postgres进程故障的RSS内存。这为Linux页面缓存留下了超过700 GB的空闲内存。
这种设计的问题在于,与访问RSS内存相比,通过页面缓存访问数据实际上有点昂贵。为了从磁盘中查找数据,Postgres进程发出lseek(2)和read(2)系统调用来定位数据。这些系统调用中的每一个都会引起上下文切换,这比从主存中访问数据更昂贵。事实上,Postgres在这方面甚至没有完全优化:Postgres没有使用pread(2)系统调用,它将seek + read操作合并到一个单一的系统调用中。
相比之下,InnoDB存储引擎在它称为InnoDB缓冲池的地方实现了自己的LRU。这在逻辑上类似于Linux页面缓存,但在用户空间中实现。虽然比Postgres的设计要复杂得多,但InnoDB的缓冲池设计有一些巨大的优点:
- 它使得实现自定义LRU设计成为可能。例如,有可能检测到病态的访问模式,这将炸毁LRU,并防止它们造成太大的伤害。
- 它导致较少的上下文切换。通过InnoDB缓冲池访问的数据不需要任何用户/内核上下文切换。最坏的情况是发生TLB错误,这种情况相对较少,可以通过使用大页面来最小化。
连接处理
MySQL通过每个连接生成一个线程来实现并发连接。这是相对较低的开销;每个线程都有一些堆栈空间的内存开销,还有一些为特定于连接的缓冲区在堆上分配的内存。将MySQL扩展到10,000个左右并发连接并不罕见,事实上,我们现在的一些MySQL实例已经接近这个连接数了。
但是Postgres使用每个连接一个进程的设计。由于多种原因,这比每个连接线程的设计要昂贵得多。派生一个新进程比生成一个新线程占用更多的内存。此外,进程间的IPC比线程间的IPC更昂贵。Postgres 9.2在使用线程时使用System V IPC原语代替轻量级futexes。futex比System V IPC快,因为在futex是无争用的常见情况下,不需要进行上下文切换。
除了与Postgres设计相关的内存和IPC开销外,Postgres似乎对处理大型连接计数的支持很差,即使有足够的可用内存。我们在将Postgres扩展到几百个活动连接时遇到了重大问题。虽然文档没有非常具体地说明原因,但它强烈建议使用进程外连接池机制来使用Postgres扩展到大连接数。因此,使用pgbouncer与Postgres进行连接池,对我们来说通常是成功的。但是,我们的后端服务中偶尔会出现应用程序错误,导致它们打开了比服务应该使用的更多的活动连接(通常是“事务中空闲的”连接),这些错误为我们造成了延长的停机时间。
总结
在Uber的早期,Postgres为我们提供了很好的服务,但随着我们的增长,我们在扩展Postgres方面遇到了重大问题。今天,我们有一些遗留的Postgres实例,但我们的大部分数据库要么构建在MySQL之上(通常使用我们的Schemaless层),要么在某些特殊情况下,构建在Cassandra这样的NoSQL数据库之上。总的来说,我们对MySQL非常满意,将来我们可能会有更多的博客文章来解释它在Uber的一些更高级的用法。