(原文来自Uber Engineering, 原文链接点这,作者为Evan Klitzke)
简介
Uber的早期的架构设计是由Python编写的一体式后端架构,并使用Postgres进行数据持久化。时至今日,Uber的架构已发生了巨大变化,已经成为了业界微服务和新型数据平台的模板。具体来说,以前大多情况下我们会优先考虑Postgres,但现在我们放弃了Postgres而选择使用Schemaless。这是一种基于MySQL的新型的数据库分片技术。
在本文中,我们将探讨在团队在使用Postgres中发现的一些缺点,并解释为何要放弃Postgres转而在MySQL上构建Schemaless和其他相关的后端服务决定。
Postgres的体系结构
在我们刚使用postgres的时候意识到了一些Postgres的问题:
- 单次操作导致磁盘的多次写入
- 数据冗余
- 表数据容易污染问题
- 糟糕的MVCC
- 版本升级困难
我们通过postgres对磁盘上的表和索引的分析来进一步认识这些问题,并与MySQL(InnoDB)进行对比。另外,我们的所有分析基于Postgres 9.2版本。据我所知,以下将要讨论的内容在较新的Postgres版本(此处我对比了下日期指的应该是指v10.1)中并未发生明显的改动。
磁盘存储方式(on-disk format)
关系数据库必须提供一些关键的功能,诸如:
- 增删改查
- 提供进行数据结构更改的功能
- 提供多版本并发控制(MVCC)
- 最重要的是如何使这些功能协同工作
Postgres的核心设计之一便是一种不可变行数据,在Postgres中被称为“元组”(tuple)。这些元组被定义为唯一标识称之为ctid。ctid表示了元组在磁盘上的位置(物理盘偏移)。多个ctid可以潜在地描述单个行(例如,当出于MVCC目的而存在该行的多个版本时,或者当自动真空处理尚未回收该行的旧版本时)。有组织的元组的集合构成一个表。表自身具有索引,这些索引被构造成一些特定的数据结构(如B-trees),并将索引字段映射到ctid 有效。
通常,用户是在使用中是察觉不到ctid的,但是了解它们的工作原理将有助于了解Postgres的磁盘存储结构。要查看行的当前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树是在主键字段定义的,并且每个节点都保存了ctid的 值。注意,在这种情况下,由于使用了自增的id ,因此B树中字段的顺序恰好与表中的顺序相同。
二级索引看起来都差不多。主要区别在于字段的存储顺序不同,因为B树必须像字典一样顺序组织。每个索引以名字开头,指向字母表的顶部:
同样,birth_year的索按升序排列,如图所示:
在这两种情况下,二级索引中的ctid 字段在字典上都没有增加,这与自增的主键是不同的。
假设我们需要更新该表中的一条记录。举个栗子,假设我们要更新出生年份字段,以估算al-Khwārizmī(代数之父-阿尔·花剌子模)的出生年份公约前770。像我刚才所说的,元组是不变的。因此,为了更新记录,我们向表中插入一个新的元组。这种新的元组有一个新的CTID ,定义为I 。Postgres需要将I处的元组与D处的旧元组区分开。在Postgres的内部,每个元组都存储一个版本字段和一个指向前一个元组的指针(如果有的话)。因此,表的新结构如图所示:
只要存在al-Khwārizmī行的两个版本,索引就必须同时包含两个行的条目。为简便起见,我们省略了主键索引,而在此处仅显示了辅助索引,如图所示:
我们用红色表示旧版本,用绿色表示新版本。Postgres使用另一个保存行版本的字段来确定哪个元组是最新的。依靠这样的字段,数据库可以确定哪个元组可以会不被新版的事务所看到。
Postgres通过将主库上的WAL发送到从库来实现流复制。每个从库在崩溃恢复中都是有效的,通过不断地更新WAL,就像崩溃后启动一样。流复制和实际场景的崩溃恢复之间的唯一区别就是,处于“热备用”(hot standyby)模式的从库在应用WAL时会提供查询,但实际上处于崩溃恢复状态下的Postgres数据库通常会拒绝提供任何查询,直到数据库的实例完成崩溃恢复过程。
因为WAL实际上是为崩溃恢复目的而设计的,所以它包含有关磁盘更新的底层信息。WAL的内容处于元组及其磁盘偏移量(即ctids )的实际磁盘位置的层面上。如果在主库和从库同步之前关闭Postgres主库和从库,那么从库上的实际磁盘内容与主库上的内容将完全匹配。因此,像rsync之类的工具有可能修复出现数据污染的从库。
但是,这样的设计导致我们处理数据时更复杂更繁琐。
写入放大(Write Amplification)
Postgres设计的第一个问题在其他情况下称为写放大。通常,写入放大是指将数据写入SSD磁盘时遇到的问题:小的更新(例如,写入几个字节)在转换到物理层时会变得更大,或者说更昂贵。在Postgres中也会出现同样的问题。在前面的示例中,当我们对al-Khwārizmī的出生年进行了小的逻辑更新时,我们至少要触发四个底层的更新:
- 将新的行元组写入表空间
- 更新主键索引以添加新元组记录
- 更新first和last的索引以添加新元组记录
- 更新birth_year 索引以添加新元组记录
实际上,这四个更新仅反映了对主表空间的写操作。这些写操作中的每一个也需要反映在WAL中,因此磁盘上的写操作总数甚至更大。
这里值得注意的是二、三步骤。当我们更新al-Khwārizmī的出生年份时,我们实际上没有更改他的主键,也没有更新他的last和first。即便如此,我们仍然通过在数据库中为行记录创建新的元组来更新这些索引。对于具有大量二级索引的表,这些多余的步骤可能会导致查询效率机器低下。例如,如果我们在一个表上定义了十二个索引,那么将必须对仅由一个索引覆盖的字段的更新传播到其他11个索引中。
主从复制
由于复制发生在磁盘级别,因此将多次写入的问题自然也转化到了物理层。postgres没有复制一个个数据库改动的记录,例如“将ctid = D的出生年份更改为现在的770”,而是为我们刚才描述的所有四次操作都写入了WAL,并且所有这四个WAL条目都同步给其他的服务。因此,多次写入问题又转化为了多次传输的问题,并且Postgres同步的数据流很快变得贼长,并且占用大量带宽。
如果Postgres复制仅发生在单个数据中心内,则同步所需的带宽可能没啥问题。现代网络设备和交换机可以轻松得多处理大量带宽,许多托管服务提供商提供免费或廉价的内部数据中心带宽。但是,当必须在数据中心之间进行复制时,问题会迅速升级。例如,Uber最初在西海岸的托管空间中使用物理服务器。为了容灾的目的,我们在第二个东海岸托管空间中添加了服务器。在此设计中,我们在西部数据中心有一个主库和一堆从库,在东部有一个容灾的副本。
级联复制将数据中心间的带宽要求限制为仅在主副本和单个副本之间所需的复制数量,即使第二个数据中心中有很多副本也是如此。但是,Postgres复制协议的详细信息仍然可能导致使用大量索引的数据库的数据量巨大。购买高带宽的“跨国宽带”(此处指从美国最西边连接到美国最东边)非常昂贵,即使不考虑钱的问题,也根本不可能获得具有与本地互连相同网速的“跨国宽带”。带宽问题也给WAL同步带来了麻烦。除了将所有WAL更新从西海岸发送到东海岸之外,我们还将所有WAL存档到云存储服务中,两者都提供了额外的保证,即在发生灾难时我们也可以很快的恢复数据,并且存档的WAL可以从数据库快照中调出新的副本。在早期的高峰流量期间,我们存储的Web服务的带宽级别根本不够,无法跟上写入WAL的速度。
数据污染
在例行数据库扩容的过程中,我们遇到了Postgres 9.2错误。从库跟随时间切换不正确,导致其中一些从库错误地读取了某些WAL记录。由于存在此错误,本应由版本控制机制标记为非活动的数据实际上并没有被标记。
以下查询说明了该错误将如何影响用户表示例:SELECT * FROM users where ID = 4;
在该错误的情况下,查询将返回两条记录:原始的al-Khwārizmī行与780 CE出生年份,以及新的al-Khwārizmī行与770 CE出生年份。
这问题可太特么烦人了。首先,我们无法得知这个问题影响了多少数据。从数据库返回的重复的数据导致许多情况下App系统异常。最终我们迫不得已添加了防御性编程语句,以检测已知有此问题的表的情况。因为数据污染可能蔓延到了数据服务器,所以在不同的从库上损坏的行也是不同的,这意味着在一个从库上,行X可能是坏的,而行Y是好的,但是在另一从库上,行X可能是好的,而行Y可能是坏的。况且,我们也不确定数据损坏的从库数量和问题是否会影响了原版的数据。
据目前所知,该问题仅出现在每个数据库的少量数据上,但是我们仍然非常担心。由于扩容时出现的数据复制发生在物理级别,因此最终可能会完全破坏数据库索引。B树的一个重要特性就是必须定期平衡它们,并且当子树移动到新的磁盘位置时,这些重新平衡操作可能完全改变树的结构。如果移动了错误的数据,则可能导致树上大部分数据变为完全无效。
最后,我们发现了问题所在并用来确定新升级的的主库没有任何损坏的行。我们通过从主服务器的快照重新同步所有副本(老费劲了)来修复副本上的损坏问题。
我们遇到的错误仅影响了Postgres 9.2的某些版本,并且已经修复了很长时间。但是,我们仍然发现此类问题很难杜绝。随时可能会发布具有这种类似问题的Postgres新版本,并且由于Postgres的主从之间同步的方式,此问题有可能传播到复制层次结构中的所有数据库中。
MVCC
Postgres并没有真正的支持MVCC。从库应用WAL更新的模式导致它们在任何给定时间点都具有与主数据库相同的磁盘数据。这种设计给Uber带来了很多麻烦。
Postgres需要维护MVCC的旧版本的副本。如果进行信息同步时有打开的事务,则如果数据库更新影响事务保持打开的行,则阻止该更新。(原文:If a streaming replica has an open transaction, updates to the database are blocked if they affect rows held open by the transaction.)
在这种情况下,Postgres将会暂停WAL的程序线程,直到事务结束。如果该事务处理要花费很长时间就会出现一些问题,因为从库的版本可能严重滞后于主服务器。因此,Postgres使用了一种超时策略应对这种情况:如果事务所涉及的WAL的时间超出设定量,Postgres将直接kill该事务。
这种设计意味着从库通常会比主库落后几秒钟,因此开发人员很容易写出导致事物被kill的代码。对于需要编写事务相关代码的开发人员来说,此问题可能不太明显。例如,假设开发人员需要编写一些代码通过电子邮件将收据发送给用户。根据编写方式的不同,代码可能会隐式地将事务置于打开状态,直到电子邮件完成发送为止。尽管在执行不相关的阻塞IO时让代码保持开放的数据库事务并不是一个好办法,但现实上大多数工程师不是数据库专家,可能并不了解这个问题,特别是在使用掩盖了事物底层细节的ORM时。
版本升级
由于同步数据会在物理级别上起工作,因此不可能在不同版本的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 最新版本是9.5,我们仍在运行Postgres 9.2。
如果您运行的是Postgres 9.4或更高版本,则可以使用pgologic之类的工具,它为Postgres实现了一个逻辑复制层。使用pgologic,您可以在不同的Postgres版本之间复制数据,这意味着可以进行从9.4到9.5的升级,而不会造成大量的停机时间。但是该功能仍然存在问题,因为它尚未集成到Postgres的"主线"(此处存疑,原文为mainline tree)中,对于在较旧版本上运行Postgres,pgologic并不能提供帮助。
MySQL的设计构造
除了讲述Postgres的一些局限性之外,我们还会解释了为什么MySQL会成为Uber Engineering的重要工具。在许多情况下,我们发现MySQL更适合我们的使用。为了理解这些差异,我们将MySQL的设计构造与Postgres的进行了对比。我们专门研究了使用InnoDB的MySQL的底层原理。
InnoDB在磁盘上的工作原理
关于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则需要更新。
这种设计还使?和?(原文:vacuuming and compaction)更加有效。在回滚段中直接可以使用所有有资格进行清理的行。相比之下,Postgres的清理过程必须进行全表扫描以识别已删除的行。
主从复制
MySQL支持多种不同的复制模式:
- 基于语句的复制将复制SQL语句(例如,它将从字面上复制文字语句,例如:UPDATE USER 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的复制体系结构意味着,如果错误导致数据污染,则该问题不太可能导致灾难性故障。因为复制发生在逻辑层,因此像重新平衡B树之类的操作永远不会导致索引损坏。一个典型的MySQL复制问题是语句被跳过(或者被运行两次)的情况。这可能导致数据丢失或无效,但不会导致大规模的事故。
最后,MySQL的复制体系结构使得在不同的MySQL版本之间进行复制变得简单。如果复制格式发生更改,MySQL仅会改变其版本。MySQL的逻辑复制格式还意味着存储引擎层中的磁盘更改不会影响复制格式。进行MySQL升级的典型方法是一次将更新应用于一个从库,一旦更新所有从库,便将其中一个提升为新的主库。这几乎可以实现零停机时间,并且简化了使MySQL保持最新状态。
其他MySQL设计优势
到目前为止,我们专注于Postgres和MySQL的磁盘体系结构。MySQL体系结构的其他一些重要方面也使它的性能明显优于Postgres。
缓存
首先,两个数据库中的缓存工作方式不同。Postgres为内部缓存分配了一些内存,但是与计算机上的内存总量相比,这些缓存通常很小。为了提高性能,Postgres允许内核缓存最近访问的磁盘数据。例如,我们最大的Postgres从库具有768 GB的可用内存,但是实际上只有25 GB的内存是Postgres进程内存。
这种设计的问题在于,与访问RSS内存相比,通过页缓存(page cache)存访问数据开销更大。为了从磁盘上查找数据,Postgres进程发出lseek(2)和read(2)系统调用来定位数据。这些系统调用中的每一个都会引起上下文切换,这比从内存访问数据的开销更大。实际上,Postgres在这方面甚至还没有完全优化:Postgres没有利用pread(2)系统调用,该系统调用将seek + read 操作合并为一个系统调用。
相比之下,InnoDB存储引擎以称为InnoDB的方式实现了自己的LRU称为LRU buffer pool。从逻辑上讲,这与Linux分页缓存存相似。虽然比Postgres的设计复杂得多,但InnoDB缓存池的设计有很大的优势:
- 这使得实现自定义LRU成为可能。例如,可以检测出会破坏LRU并防止其造成破坏性的访问模式。
- 这可以减少不必要的上下文切换。通过InnoDB buffer pool访问的数据不需要任何用户/内核上下文切换。最坏的情况是TLB未命中,但影响不大,可以通过使用大页面来最小化处理。
连接处理
MySQL通过产生多个连接线程(thread-per-connection)来实现并发连接。开销相对较低;每个线程都有一些用于堆栈空间的内存开销,以及一些在堆上分配给特定于连接的缓冲区的内存。将MySQL扩展到10,000个左右的并发连接很常见,实际上,我们系统的某些MySQL实例上,已经接近这个连接数了。
但是,Postgres使用(process-per-connection)设计。出于很多原因,这比MySQL的设计开销大得多。派生新进程比生成新线程占用更多的内存。此外,进程之间的IPC也比线程之间的昂贵得多。Postgres 9.2使用System V IPC原语进行IPC而不是轻量级的futex使用线程时,。Futex的速度比System V IPC快,这是因为在通常情况下,futex不受竞争,所以不用进行上下文切换。
除了与Postgres的设计相关的内存和IPC开销外,即使有足够的可用内存,Postgres也不能很好地处理大量连接数。我们在将Postgres扩展到数百个连接时遇到了重大问题。尽管官方文档没有很详细地说明为啥,但是它强烈建议采用进程外连接池机制来扩展到Postgres的大量连接。因此,使用pgbouncer与Postgres建立连接池后表现就会好很对。但是,我们后端服务中偶尔会出现应用程序错误,导致它们打开的活动连接(通常是“空闲的事务”连接)多于服务应使用的错误,这些问题极大的延长了我们的停机时间。
结论
在Uber成立初期,Postgres为我们提供了很好的支持,但是随着数据规模的增长,我们遇到了很多Postgres的问题。时至今日,我们扔有一些旧的Postgres实例在我们系统上运行,但是我们的大部分数据库都建立在MySQL之上(Schemaless层),或者在某些特殊情况下,例如Cassandra这样的NoSQL数据库。绝大部分情况下我们对MySQL感到很满意,并且将来我们可能还会有更新一些文章来介绍它在Uber中的一些高级用法。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。