为什么Uber宣布从Postgres切换到MySQL-爱可生
导读:近期 Uber 宣布将数据库从 Postgres 迁移到 MySQL,在多个技术社区中引起了轩然大波,通过本文我们来详细了解 Uber 做出以上决策背后的原因。
介绍
Uber 的早期架构是由 PyR n ? l Dthon 编写一个单体后端应用程序,使用 Postgres 作为数据持久化。后来 Uber 架构经历一系列显著改变,朝着微服务架构和新的数据平台发展。具体而言,在许多以6 N A前使用的 Postgres 的场景,现在更多的使用构建在 MySQl y aL 之上的 schemaless 存储系统(小编:Uber的数据中间件)。在本文中,将探讨一些我们发现的 Postgres 的弊端,并解释我们切换 schemaless 和其他后端服务到 MySQL 数据库的原因。
Postgres 架构概述
我们遇到的大量 Posto z Z # ]gres 限制如下c 9 !:
- 效率低下的写入架构
- 低效c w ^ z s的数据复制
- 表损坏的问题
- 糟糕的 MVCC 从库支持
- 难以升v j V d级到新的版本
我们将在所有这些限制,首先通过分析 Postgres 如何组织在磁盘上的表和索引进行分析,3 2 Z v P特别是比# 0 W }较与 MySQL 使用 InnoDB 存储相同数据的实现方式。需要注意的是,我们在这里提出的分析主要是基于我们有些老的 Postgres 9.2 版本系列的经验。但据我们所知,本文中讨论的 PG 内部架构,并没有显著在新的 Postgres 版本中改变,就如在 9.2 版的磁盘数据设计,也没有比 Postgres 的 8.3 版(10 年前的版本)有什么显著变化。
磁盘数据格式
关系数据库必须执行一些关键v Z &任务:
- 提供插入X = H U = } Q R考虑一个简单的用户表的例子。对于每个用户,我们有一个自动递增的用户 ID 的主键,还有用户的名字和姓氏,以及用户的出生年$ C G份。我们还定义了用户的全名复合二级索引(姓和名),并在用户的出生年份加上另一个二级索引。创建这样一个表 DDL 可能是这样的:
注意这个定义中的三? 8 c Q 8 ;个索引:主键索引加上两个二级索引。
对于本文中的例子,我们看下表的数据,它由一个选择有影响力的历史数学家开始:
如上所述,每行中隐含有独特的,不透明 CTIDp V + d z } { ;。因此,我们可以这样理解表的内部结构:
主键索引,它映射 ID 与 ctids,是这样定义的:
B 树被用在 id 字段上,B 树中的每个节r 7 9 _ Y { b点上保存了 CTID 值。注意,在这种情况下,在 B 树的字段的顺序,刚好与表中顺序相同,这是由于使用自动递增的 id 的缘故,但这并不一定需要是这种情况。
二级索引看起来相似;主要的区别是字段存储顺序不同,因为 B 树,必须按字典顺序组织。姓名索引(first( d I F 6,last)按字母表的顺序排列:
同样,biF ] ) ^ , erth_year 聚簇索引按升序排列,就像这样:
正如你所看到的,. 4 i C 9在这两种情况下,在各自的二级索引 CTID 字段本身并不是有序的,不象第一个自动递增的主键的情况。
假设5 t _ T D G u K我们需要更新此S @ z G 7 i表中的记录。举例来说,假设要更新 al-Khwrizm’ 的出生年份到 770 CE。正如前面提到的,行的 tuple 是不可变的。因此,要更新记录,需要添加一个新的 tT & O .uple。这D } C种新的 tuple 有一个新的不透明 CTID,我们称之为 I。Postgres 需要能够从旧的 tuple D 处找到新的 I。在内部,Postgres 存储每个t 5 v @ _ ( 9 [ tuple 中的版本字段,以及指向前一 tuple 的 ctid 指针(如果有)。因此,该表的新结构如下:
只要 al-Khwrizm 的两个版本存在,索引则必须维护两行的记录。为简单起见,我们省略了主键索引并显示只有在这里的二级索引,它是这样的:
我们将旧版本标识成红色,将新版标识成绿色。在此之U L [下,Post@ s Y M b + kgres 使用另一个字段来保存该行版本,以确定哪一个 tuple 是最新的。这个新增的字段允许数据库确定事务看到的是那一个行的 tuple。
在 Postgres,主索引和二级索引都指向磁盘上的 tuple 偏移。当一个 tuple 的位置变化,各项索引都必须1 8 e o更新。
复制
当我们插入数据到表中,如果启用了流复制机制,Postgres 将会对数据进行复制,处于崩溃恢复的目的,数据库启用了预写日志 (W: { 4 a _AL)并使用它来实现两阶段提交(2PC)。即使不启用复制的情况下,数据库也必须保留 WAL ,因为 WAL 提供了 ACj ^ k L l _ Q - kID 的原子性(Atomicity)及持久性(Durability)能力。
我们可以通过如下场景来更好的理解 WAL,如果数据库遇到突然断电时意外崩溃,WAL 就提供8 $ F t 4 - z N k了磁盘上表与索引更新变化的一个账本。当 Postgres 的守护( w V , 6 . 0 Z k程序再次启动后,就会对比账本上的记录与磁盘上的实际数据是否一致。如果帐本包含未在磁盘上的体现的数l & & y H L k据,则可以利用 WAL 的记录来修正磁盘上的数据。
另一方面,+ 2 ( ` { rPostgres 也利用 WAL 将其在主从之间发送来实现流复制功能。每个从库b B w )复制数据与上述崩溃恢复的过程类似。流复制与实际崩溃{ * d g恢复之间的唯一区别是,在恢复数据过程中是& ; W i 4否能对外提供数据访问服务。
由于 WAL 实际上是为崩溃恢复目的而设计,它包含在物理磁盘的低级别更新的信息。WAL 记录的内容是在行 tup- 2 f 8 % t / Fle 和它们的磁盘偏移量(即一行 ctids) 的实际磁盘上的代表y V = y $ Q f x级别。如果暂停一个 Postgres 主库,从库数据完全赶上后,在从库的} V z实际磁盘上的内容完全匹配主库。因此,像工具 rsync 都可以恢复一个同步失败的从库。
Pow O y I T 5stgres 上述设计的大I / 8 D ) 9 坑
Postgreo O $ R一条记录;
- 关闭主数u 1 v t } 1 r U 7 Postgres 9.4 或更高版本,你可以使用类似 pglogical,它实现了 Postgres 的一个逻辑复制层。使用 pglogical,可以在不同的 Postgres 版1 & o | 2 2 y 5本之间复制数据,这意味着升级比如从 9.4 到 9.5,不会s L Q 9 m q 5产生显著的[ t + l 停机时间。但这个工具的能力依然存疑,因为它没有集成到 Postgres 主干,另外对于老版本的用户,pglogical 仍然不能支持。
MySQL 架构概述
为了更进一步解释的 Postgres 的局限性,我们了解为什q l n ` + E R (么 MySQL 是 Uber 新存储工程 Schemaless 的底层存储 。在许多情况下,我们发现 MySQL 更有利于我们的使用场景。D 1 : t J 0 +为了了解这些0 : w _ N g差Z J & u t (异,我们考察了 MySQL 的架构,并与 PosR 8 % g h Dtgres 进行对比。我们特别分析 MySQL 和 InnoDB 存储引擎如何一同工作。Innodb 不仅在 Uber 大量使用,它也是世界上使用最广泛的 MySQL 存储引擎。
InnoDB 的磁盘数据结构n c U 4 5 J D
与 Postgres 一样,g 2 2 4 q q t FInnoDB 支持如 MVCC 和可变数据这样的高级特性。详细讨论 InnoDB 的磁盘数据格u G b式超出了本文的范围;在这里,我们将重点放在从 Postgres 的主要区别上。
最重要的架构区别在于 Postgres 的索引记录直接映射到磁盘上的位置时,InnoDB 保持二级结构。而不是拿着一个指向磁盘上的行位置(如 CTID 在 Postgres),InnoDB 的第二个索引记录持有一个指向主键值。因此,在 MySQL 中的二级索引与相关联的主键索引键,是如下所示:
为了执行上的(first, last)索引查找,我们实际上需要做P 4 $ | ; C 5两查找。第一次查找表,找到记录的主键。一旦找到主键,则根据主键找到记录在磁盘上的位置。
这种设计意味着 InnoDB 对 Postgres 在做非主键查找时有小小的劣势,因为 MySQL 要做两次索引查找,但是 Postgres 只用做一次。然后因为数据是标准化的% [ W _,行更新的时候只需要更新相Y L R y F应的索引记录。
而且} - U u M Inj n D E l o $ tnoDB 通常在相同的行更新数据,如果旧事务因为 MVCC 的 MySQL 从库而需要引用一行,老数据将进入一个特殊的区域,称为回滚( C t S D I & 8 y段。
如果我们更新 al-Khwrizm 的出生年份,我们看会发生什么。如果有足够的空间,数据库会直接更新 ID 为 4 的行(更新出生年份不需要额外的空间,u ! 6 & *因为年份是定长的 int)。出生年份这一列上的索= ! * ^ | * / /引同时也会被更新。这一行的老版本被复制到回滚段。主键索引不需要更新,同样姓名索引也不需要更新。( I O { Q如果在这个J a , S =表上有大量索引,数据库需要更新包含了 birth_year$ 5 S z / t Q p 的索引。因此,我们并不需要更新 signup_date,last_login_time 这些索引,而 Postgres 则必须全更新一遍。
这样的设计也使得 vocuum 和压缩效率更高。所有需要 vocuum 的数据都在Q Q s 0回滚段内。相比之下,Postgres 的自动清理过程中必须做全表扫描,以确定删除的行。
MySQL 使用额外的间接层:二级索引记录指向主索引记录,而主索引本身J % e包含在磁盘上的排的位置。如果一个行偏移的/ J 7 R O v , )变化,只有主索引需要更新。
复制
MySQL 支, w . s b持多个不同的复制模式:
- 语句级别的复制:复制 SQL语句(例如,它会从字面上直译复制的语句,如:更新用户 SET bT f H Birth_year = 770 WHERE ID = 4 )
- 行级别的复制:复制所有变化的行记录
- 混合复制:混合这两种模式
这些模式都各有利弊。基于语句的g - $ Z 0 G r A }复制通常最为紧凑,但可能需要从库来支持昂贵的语句来更新少量数据。在另一方面,基于行的复制,如同 Post5 q G 7 c h , n Xgres 的 WAL 复制,是更详细,但会导致对从库数据更可控,并且更新从库数据更高效。
在 MySQL 中,只有主索引有一个指向行的磁盘上的指针。这个对于复制来说很重要。MySQL 的复制流只需要包含有关逻辑更新行的信息。复制更新如“更改行的时间戳 xU w x K | 从 T_ 1 至 T_ 2 ”,从库自动根据需要更新相关的索引。
相比之下,PoN L d W b 8stgres 的w A . d / d复制流包含物理变化,如“在磁盘偏移8382491,写字节XY} 9 ^ + t 9 tZ。” 在 Postgres 里,每一次磁盘物理改变都需要被记录到 WAL 里。很小的逻辑变化(如更新时间戳)会引起许多磁盘上的改变:Postgres 必须插入新的 tuple,并更新所有索引指向新的 tuple。因此许多变化将被写入 WAL。这种设计的差异意味着 MySQL 复制二进制日志是显著比 PostgreSQL 的 WAL 流更紧凑。
复制如何工作也会影响从库的 MVCC。由于 My~ U G pSQL 的复制流使用逻辑的更新,从库可T t J / U Q以有真正的 MVCC 语义; 因此,读库查询不会阻塞复制流。相比之下,Postgres 的 WAL 流包含物理磁盘上的变化,H & Y | * X B 1使得 Postgres 的从库无法应用复制更新从而与查询相冲突,所以 PG 复制不能实现 MVCC。
MySQL 的复制架构意味) U { L w着,bug 也许K * C l K T S +会导致表损( _ $ 6 C D坏,但不太可能导致灾难性的失败。复制发生在逻辑层,所以像一个9 G U重新平衡 B tree 这样的操作不会导致索引损坏。一个典型的 MySQL 复制问题是一个语句被跳过(或较少一点的情况,重复执行)的情况下。这可能会导致数据丢失或无效,但不会导致数据库出现灾难% k | R问题。
最后,MySQL 的复制架构使得它可以在 MySQL 不同版本之间进行复制。MySQL 只在复制格式改变的时候才增加版本号,这对 MySQL 来说很不常见。MySQL 的逻辑复制格式也意味着,在磁盘上的变化在存储引擎层不影响复制格式。做一个 MySQL 升级的典型方法是在一个时间来更新应用到一个从库,一旦你更新所有从库,你可以把它提为新的 master。这个操作几乎是 0 宕机的,这样也能保证 MySQL 能及时得到更新。
其他 MySQL 设计优势
到目前为止,我们集中于 Postgres 和 MySQL 在磁盘上的架构。MySQL 的架构导致性能@ n P比 Postgres 有显著优势。
缓冲= g S k 调用,pread 会合并 seed + read 操作成一个单一的系统调用。
相比之下,InnoDB 存储引擎实现了自己的 LRUs 算法,它叫做 InnoDB 的缓冲池。这在逻辑上类似于 Linux 的页面缓存,但在用户空间实现的,因此也显著比X ` s r C q X * | Postgres 设计复杂,InnoDB 缓冲池的设计有一些巨大的优势:
- 使得它B u i a @ w可以实现一个自定义的J | n + r , LRU 设计。例如,它可以检测到病态& / (的访问模式,并且阻止这种模式给缓冲池带C C o 4 |来太大的破坏。 S n p扩展到 10,000 左右的并发连接不是. 0 A H ; 3 P罕见的事情,实事上我们现在的 MySQL 接近这个连接数。
Postgres 使用的是每连接一个进程的设计。这很明显会比每连接每线程的设计开销更大。启动一个新的进程比一个新的线程会占用更多的内存。此外,线程之间进行通讯比进程之间 IPC 开销低很多。Postgres 9.2 使用 * J # O q系统V IPC为IPC原语,而不是使用线程模型中轻量级的 futexes,futex 的非竞争是常见的情况,比 System V IPC 速度更7 a K ` 8 W快,不需要进行上下文切换。
除了与 Postgres 的设计相关联的内存和 IPC 开销,即使有足够的可用内存可用,Postgres 对处理大连接数的支持依然非常T Y 0 N差。我们已经碰到扩展 Postgry d @ , S j @es 几百个活c z Y u y & A : i动连接就碰到显7 l p 0 d * P著的问题的情况,在官方文档中也没有确切的说明e _ N原因,它强烈k V d r I 5 }建议使用独立的连接池来保证大连接数。因此,使u j I k用 pgbouncer 做连接池基本可行。但是,在我们后端系统使用过程中发现有些 BUG,这会导致开启大量的原本不需要的_ h b w * w L活跃连接,这些 BUG 也已经造成好几次宕机。
结论
Postgres 在 Uber 初期运行的很好,但是 PG 很M x -遗憾没能很好适应我们的数据增长。/ 0 F L今天,我们有一些遗留的 Postgres 实例,但我们的数据库大部分已经迁移到 MySQL(通常使用我们的 Schemaless 中间层),在一些特殊的情况下,也使用 NoSQL 数据库如 Cassandra。我们对 MySQL 的使用非常满意,后续可能会在更多的博客文章中介绍其在 Ub) Y r f u h 2 o `er 一些更先进的用途。
事实上,这四步更新仅为了反映一个到主表的写操作;并且每个这些写入也同样需要在 WAL 得到体现,所以在D g U p 0 e 0 W |磁盘上写入的总数目甚至比 4 步更大。
值得一提的是这里z v $ R R Z b } U更新 2 和 3。当我们更新了 al-Khwrizm 的出生年份,我们实际上并没有改变他的主键,我们也没有改变他的名字和姓氏。然而,这~ ` O L g些索引仍必须与创建在数据库中的行记录了新的行的 tuple 的更新。对于具有大量二级索引的表,这些多余的步骤可能会导致巨大的低效。举例来说,如果我们有一个表上定义了十几个二级索引f 7 ! A 1 C e ;,更新{ N f一个字段,仅由一个单一的索引覆盖必须传播到所有的 12 项索引,以@ t q %反映新行的 CTID。
2. 复制
因为复制发生在磁盘的变化上,因此写入放大问题自然会转化为复制层的放大。一个小的逻辑记录,如“更改出生年份为 CTID D 到 770”,WAL 会将上述描写的 4 步从网络上同步到从库,因此写7 u ~入放大问题也等同一个F ? U _ s B复制放大问题,从而 Postgres 的复制数据流很快变得非常冗长,可能会占用大量的带宽。
在 Poh - h 1stgres 的复制发生一个数据中心内的情况下,复制带宽可能不是一个问题。现代网络设备和交换机可以处理大量的带宽,许多托管服务提供商提供免费或廉价的内部数据中心带宽。然而,当复制必须在不同数据中心之间$ ] _ ` c ; a发生的,问题都可以迅速升级。
例如,Uber 原本使用的物理服务器在西海岸机房。为了灾难恢复的目的,我们在东海岸托管空V + X B Q S间添加了一批服务器。在本设计中,我们西部数据中心作为主库,东海岸增加了一批服务器作为从库。
级联复制可以降低跨数据中心的带宽要求,只需要主库和一个从库之间同步一份数据所需的带宽和流量,即便在第二个数据中心配置了多个从库。然而4 ? ~ Z z -,Postgres 的复制协议的详细程度,对于使用了大量二级索引的数据( ~ y库^ J F [,仍可能会导致数据的海量传输。采购跨国的带宽是昂贵的,即使有钱的土豪公司,也无法做到d ` R % 1 e )跨国的带宽和本地的带N 7 $ G y D宽一样大。
这0 H I S ) j W _种带宽的问题也导致我们曾经在 WAL 归档方面出现过问题。除了发送所有从西海岸到东海岸的t C 3 T WALJ d o 更新,我们将所有的 WAL 记录归档到一个文件存储的 Web 云服务,这样当出现数据灾难情况时,可以从备份的 WAL 文件恢复。但是流量峰值时段,我们与存储网络服务的带宽根本无法跟上 WAL 写入的速度r @ ,。
3. 数据+ j w Y Z 7 # { +损坏
在一次例行主数据{ Y T库扩容的变更中,我们遇到- 6 9 u了一个 Postgres 9A X b.2$ R : c确定新的 master 不存在任何损坏行。然后再把 master 的快照同步到所有从库上去,这是一个艰苦的体力活的过程(小编:看到美帝的 DBA 也这么苦逼心理终于平衡一点了),因为H ! d z S / F : t我们每次只能从在线的池子里面拿出有限几台来操作。
虽然我们遇到的这个 bug 仅影响 Postgres 9.2 的某些版本,而且目前已经修复了很久。但是,我们仍然发现这类令人担忧的 bug 可以再次发生。可能任意一个新的 Postgres 版本,它会带W B 8 0 8 S着这种致命类型的 bug,) M O X而且由于其复制的不合理的设计,这个问题一旦出现,就会立即蔓延到集群中所有复制链的数据库上。
4. 从库无 MV: 1 l , *CC
Postgres 没有真正的从库 MVCC 支持。在从库任何时刻应用 WAL 更新,都会导致他们与主库物理结构完全一致。这样的设计也给 Uber 带来了一个问题。
为了支持 MVCC,Postgres 需要保留行的旧版本。如果流复制的从库正在执行一个事务,所有的更新操作将会在事务期间被阻塞。在这种情况下,P# ? G } O c 8ostgres 将会暂停 WAL 的线程,直到该事务结束。但如果该事务需要消耗相当长的时间,将会产生潜在的问题,Postgres 在这种情况下设定了超时:如果一个事务阻塞了 WAL 进程一段时间,Postgres 将会 kill 这个事务。
这样的设计意味着从库会定期的滞后于主库,而! J x o . h n [ #且也很容易写出代码,导致事务被 kill。这个问题可能不会很明显被发现。例如,假设一个开发人员有一个收据通过电子邮件发送给用户一些代码。这取决于它是如何写的,代码可能隐含有一个的保持打开,直到邮件发送完毕后,再关闭的一个数据: d a F O .库事务。虽然它总是不好的形式,让你的代码举行公开的数据库事务,同时执行无关的阻塞 I / O,但现实情况是,大多数工程师都不是数据库专家,可能并不总是理解这个问题,特别是使E - Y x 6 U l . %用掩盖了低级别的细节的 ORM 的事务t @ F M E X Z。(小编:美帝程序员代码习惯跟咱们也很类似)
Postgres 的升级
因为复制记录在物理层面工作,这导致r j {不能( ? $在不同c s 2 `的 Postgres GA 版本之间进行复制。运行的 Postgres 9.3 主数据库无法复制数据到 Postgres 9.2 的从库上,N N _ j K U也无法在运行 9.2 的主数据库复制数据到 Postgres 9.3 的从库上。
我们按照以下这些步骤,从一个 Postgres 的[ F 7 v E / & [ GA 版本升级到另一个: