查看原文
其他

Uber的底层存储从Postgres换成MySQL之后

2016-08-02 足下 InfoQ
最近,Uber软件工程师Evan Klitzke写了一篇文章介绍系统的底层存储由Postgres换成MySQL的原因。我们先来看看Uber文章里表达了哪些观点。

背景

早期的Uber后台软件由Python写成,数据存储使用Postgres。后期随着业务的飞速发展后台架构也变化巨大,演进成了微服务加数据平台。数据存储也由Postgres变成了Schemaless——Uber自主研发的以MySQL做为底层的高可用数据库。

Uber的数据库主要存储的是Trip数据,就是一个叫车订单从下单起,到上车、下车、付费等的全过程跟踪及处理。从2014年初起,由于业务增长迅猛,Uber的原有基础架构已经无法继续支撑业务。改进的项目花了将近一年时间。

对于新的数据库存储系统,Uber的主要关键需求是:

  1. 要有能力通过增加服务器而线性地增加容量。增加服务器不但要增加可用的硬盘容量,还要减少系统的响应时间。

  2. 需要有写缓冲能力,万一持久化到数据库失败时,仍可以稍后重试。

  3. 需要通知下游依赖关系的方式,数据变更要能无损的通知出去。

  4. 需要二级索引。

  5. 系统要足够健壮,可以支持7*24服务。

在调查对比了Cassandra、Riak和MongoDB等等之后,Uber技术团队没有发现能完全满足需求的现成解决方案。而再考虑到数据可靠性、对技术的把握能力等因素,他们决定自己开发一套数据库管理系统——Schemaless,一个键值型存储库,可以存放JSON数据而无需严格的模式验证,是完全的无模式风格。

用MySQL作底层存储,其中只有顺序写入,在MySQL主库故障时支持写入缓冲。并有一个数据变更通知的发布-订阅功能(命名为trigger),支持数据的全局索引。

Schemaless项目技术负责人Jakob Thomsen认为:

Schemaless的强大与简单更多是因为我们在存储节点中使用了MySQL。Schemaless本身是在MySQL之上相对较薄的一层,负责将路由请求发送给正确的数据库。借助于MySQL第二索引及InnoDB的BufferPool,Schemaless的查询性能很高。

在Evan Klitzke的文章中,他是从Postgres与Innodb的底层存储机制对比开始的,后面提到了他们碰到的若干Postgres问题:

  • 写入效率不高。

  • 数据主从复制效率不高。

  • 表损坏问题。

  • 从库上的MVCC支持问题。

  • 难于升级到新版本。

    在Postgres的底层设计中,它的行数据是不可修改的,每个不可修改的行都叫做“元组”,每个唯一的元组都由一个唯一的ctid标志,ctid也就实际指出了这个元组在磁盘上的物理偏移量。这样对于一行修改过的数据来说,就会对应着在物理上有多个元组。表是有索引的,主键索引和第二索引都以B树组织,都直接指向ctid。

    除了ctid之外还有一个关键字段prev,它的默认值为null,但对于有数据修改的记录,新的元组里面的prev字段里存储的就是旧元组的ctid值。

    与Postgres相对应的是,MySQL的InnoDB引擎主键索引和第二也都以B树组织,但是索引指向的是主键,而主键才真正指向数据记录。而且,InnoDB的数据是可以修改的。两者实现MVCC的机制不同,MySQL依靠UNDO空间中的回滚段,而不是象Postgres依靠在数据表空间对同一条数据保持多份。

    Postgres和InnoDB都通过WAL(Write Ahead Log)来保证数据可以在数据库上安全写入,但对于主从库的数据复制实现原理并不同。Postgres会直接把WAL发送到从库上,让从库也执行WAL来复制数据。而MySQL则是发送Binlog,在从库上应用Binlog。

    Uber对Postgres有哪些不满意?

    写放大

    一般来说大家介意写放大的问题是由于对SSD磁盘的使用。SSD磁盘是有寿命的,它的写入次数是有限的(虽然数字很大)。这样如果应用层只是想写入少量数据而已,但数据落入磁盘时却变大了许多倍,那大家就会比较介意了。比如你只是想写入1K的数据,可是最终却有10K数据落盘。

    Postgres的写放大问题主要表现在对有索引的表进行数据更新上。因为Postgres的索引都是指向元组的ctid,而元组又是不可更新的,所以当你更新一条记录时,它会创建一个新的元组存入磁盘,并且要针对所有的索引,为每个索引都创建一条新记录来指向新的元组,不管你更改的字段和这个索引有没有关系。这样对于WAL来说,Postgres更改一条记录操作会写入新的完整记录,再加上多条索引记录。

    作者注:不过MySQL的InnoDB其实也是有写放大问题的。InnoDB是以数据页的形式组织数据的,Linux上默认数据页的大小是16K。这样当你更改了一条记录时,最终会把这条记录所在的数据页整页刷回磁盘,设想一下你可能只是改了一个小字段,也许只有4个字节,可是最终却会导致16K字节的写入。

    另外,Postgres的这个设计也是有其好处的,它的第二索引直接指向元组的ctid,这样在读取数据时效率就非常高。相对应地,通过MySQL的第二索引去读数据会经历“第二索引——主键——数据”的过程,MySQL的读效率不如Postgres。这是一个经典的读写性能权衡问题,在此Evan没有给出具体的数字让我们体会他们的业务特征。

    主从复制

    Postgres的写放大问题最终也反应在了主从复制的日志传输上,变成了流量放大问题。Postgres的主从复制传输的是WAL日志,所以对于一条数据更新来说,它要传输新的数据,还要传输这张表上每一条索引修改的日志。

    这样的流量放大在同一机房内还稍可接受,但对于跨机房的情况,传输速度和价格等问题让Uber产生了顾虑。Uber是有跨机房从库的,一方面是容灾,另一方面是WAL的备份,以备有时需要靠它来搭建新的从库。

    MySQL的确没有引起流量放大。MySQL的主从复制依靠的是Binlog,它只是记录这条数据的修改,而不在乎这张表上到底有多少索引,所以可以认为与Postgres相比,它的Binlog是一种对数据修改的“逻辑”描述。

    MySQL从库上应用Binlog日志时,如果有第二索引涉及了改动的字段,那就更新第二索引,否则第二索引压根不需要修改。而且,MySQL有三种不同的Binlog格式,包含了不同数量的信息来供使用者选择:

    • Statement:只传输DML的SQL语句,如:UPDATE users SET birth_year=770 WHERE id = 4。这种模式日志量最小,但在某些场景下和对某些字段来说容易出错。

    • Row:对于更改了的数据,会把修改前和修改后的所有字段值都打印在Binlog中。这种模式日志量最大,但也最严谨,越来越多的公司在转向这种日志格式。很多日志解析工具更是只工作在这种模式下。

    • Mixed:上面两种的结合体,MySQL会根据不同的语句来自行判断。这种模式日志量居中。

    数据损坏

    Uber使用Postgres 9.2时曾经因为一个BUG导致了很大的故障。当时由于硬件升级的原因他们做了主从切换,结果就引发了这个BUG导致各个从库的数据全都乱掉了,而且还没有办法判断哪个从库的哪些数据是正确的或者乱的。

    最终他们确认了新的主库上的数据全部正确后,用新主库的数据把所有从库数据全覆盖了一遍,才算过了这一关。可是一朝被蛇咬十年怕井绳,他们最后用的版本仍是Postgres 9.2,原因之一是不想再去踩别的版本的坑了。

    从库上的MVCC支持不好

    Postgres的从库上并没有真正的MVCC,它的数据表空间、表空间文件内容和主库是完全一样的,在从库上就是依次应用WAL。可如果从库上有一个正在进行中的事务的话,它就会挡住WAL的应用,从而导致看起来主从同步延迟很大。

    Postgres实现了一个机制,如果某个业务程序的事务挡住同步线程太久的话,就直接将那个事务杀掉。所以如果在从库上有一些比较大的事务在运行的话,你可能就会经常看见莫名其妙的主从同步就延迟了,也会看见自己的操作运行了一段时间就不知被谁杀掉了。并不是每个程序员都很熟悉数据库的底层工作机制,所以这些现象会让大家觉得很诡异。

    Postgres数据库的升级

    Postgres的数据复制是物理级的,主从数据文件完全一致,所以不能支持不同版本之间的主从复制,比如主库使用9.2从库使用9.3,或者相反,等等。Uber最初使用的是Postgres 9.1,他们成功的升级成了9.2,但升级耗费了相当长的时间,再加上后来业务爆发式增长,让他们再也没能安排下一次升级。

    而且Postgres直到9.4之后才有了工具pglogical来帮助减少升级耗时,可是pglogical又不在Postgres主分支里,让使用旧版本的人无所适从。

    MySQL的其他优点

    除了上文所述的几点,MySQL还有几个其他Postgres不具备的优点。

    1. BufferPool:虽然Postgres在内部有比较小的缓存,但和现在动辄几百G的服务器内存比起来,它的缓存还是太小,对硬件利用率太低了。InnoDB则有BufferPool,可以同时用于写缓冲和读缓存,用LRU管理,大小可配,这样就把硬件资源充分合理的利用起来了。

    2. 连接管理:MySQL的连接管理是每个连接一个线程,每个线程消耗的资源都很有限,所以MySQL可以轻松支持10000个以上的连接。可是Postgres是每个连接一个进程的,进程之间通信和共享资源复杂,消耗资源严重,而且对多连接支持不好。Uber的业务已经需要极大的增加数据库连接数,Postgres已经无法满足需要。

    Evan Klitzke总结说:

    在初期Postgres还是工作得很好的,但业务扩展时我们就碰上了非常严重的问题。现在我们还是在用着一些Postgres数据库,但是主要的数据已经挪到了Schemaless上,有些特别的业务也用了Cassandra等NoSQL数据库。我们现在用MySQL用得很好,我们也会写更多的博客来分享更多关于MySQL在Uber的使用内容。

    诚然,Schemaless只是将MySQL用于数据存储,存的内容是简单的“键-值”对模型,虽然也用到了第二索引,可是这些并不是放弃Postgres的充分理由。说到写放大和流量放大,这些钱能解决的问题也不应该对如日中天的Uber产生困挠。也许在确定了由单机版的Postgres换成分布式的Schemaless这样的方向之后,再选择底层存储时,反正之前使用Postgres的经历也不愉快,加上流量问题,就索性换成MySQL?

    PostgreSQL真的有那么差吗?

    Uber的本意可能是说,对于他们的业务需求来说,他们发现MySQL比PostgreSQL更合适。本来他们可以说“PostgreSQL对于更新操作很多的系统有些局限”,如果你的系统更新操作不是非常频繁的话,其实你不必担心Uber的问题。

    那么,Uber的选择能作为大家为数据库选型时的一般参考吗?为什么MySQL可能的确是适合Uber业务的?为什么企业成功了之后可能会碰到新问题而不仅仅是要扩展数据存储规模?接下来就针对Uber文章提出的观点进行深入的分析。

    关于更新操作

    Uber的文章中首先重点写可是又没有提供足够细节的问题是:在更新一条记录时PostgreSQL总会更新那张表里的所有索引。相对应的是MySQL的InnoDB则只更新那些包含了更新字段的索引。PostgreSQL的底层会因为更新没有被索引到的字段而产生更多的磁盘IO(文中提到的“写放大”问题)。如果这个对于Uber的确是个大问题的话,那这种更新操作也许占他们系统负载的很大一部分。

    但是,虽然Uber的文章中没有提到,PostgreSQL的Heap-Only-Tuples (HOT)机制。从PosgreSQL的源码中可以看到,HOT机制只对某些场景适用:“当一个元组被不断地更改没有被索引到的字段时。”

    在这种场景下,如果新版本的元组可以和旧版本存储在相同的数据页里的话,PosgreSQL可以不更新任何索引就完成更新。而调节参数fillfactor可以达到这个效果。可能Uber的技术团队知道HOT机制对他们的问题没帮助,也许每次更新都会至少更新到一个被索引了的字段。

    文章中有一段话也从一个侧面印证了这个推理:“如果我们在表上定义了12个索引,那即使更新的字段只被一个索引索引到了,那这12个索引也都要更新ctid来指向新记录。”它明确地说到“只被一个索引索引到了”,这就是一种边界情况——只要有一个索引——要不然的话,从PosgreSQL的HOT机制是可以解决这个问题的。

    看起来Uber的系统会进行许多更新操作,每次都会至少更新一个被索引到的字段。如果占绝大多数的用例都要做这样的更新,那关于采用MySQL而不是PostgreSQL的观点就是有道理的。

    关于查询操作

    文章说到MySQL/InnoDB使用聚簇索引,并且承认:“这样的设计意味着在进行第二索引查询时,InnoDB与PostgreSQL相比稍有逊色,因为InnoDB要进和两次索引查询,而PostgreSQL只用一次。”

    他们的用词是“稍有逊色”。依我之见,如果你要通过第二索引进行大量查询的话,这个可是相当大的不足。如果他们认为这只是一点点问题的话,那也许就表明他们的第二索引很少会被用到。那就表明,他们主要是通过主键搜索(这种场景下没有第二索引惩罚)。注意,这里用词是“搜索”(searching)而不是“查询”(selecting),原因是第二索引惩罚会影响任何带有where子句的语句,而不仅仅是查询。这表明高频率的更新操作主要是依据主键的。

    最后,还有一点他们没有提到的和他们的查询相关的东西:他们没有提及PostgreSQL只依靠索引检索的不足。尤其是在更新频繁的数据库中,PostgreSQL的只依靠索引检索的实现就更是无用。我甚至会说这一个问题就影响了我绝大多数的客户。我在2011年曾写过关于这个问题的文章。

    2012年,PostgreSQL 9.2对只依靠索引检索的功能进行了有限支持(只适用于非常静态的数据)。在2014年,我甚至在PostgreSQL大会上表达了我对这个问题的顾虑。可是Uber并没有抱怨这个问题,看来查询速度不是他们的问题所在。我猜他们的查询主要是靠在从库上查询(见下文)来提升速度,而且非常有可能主要做的是索键查询。

    到目前为止,他们的用例总结起来似乎比较适合键值型存储。要知道InnoDB是个相当好用而且受欢迎的底层存储,MySQL和MariaDB就是最广为人知的结合了InnoDB存储并提供了一些非常有限的SQL前端的数据库。

    严肃的说,如果你主要是需要一个键值型存储并且偶尔需要运行一些简单的SQL查询,那MySQL或者MariaDB就是非常不错的选择。我猜它们至少要比任何类型的刚开始提供类SQL语言查询的NoSQL键值型存储要好。另外,Uber刚刚基于MySQL和InnoDB搭建了他们自己的分布式数据库Schemaless。

    关于索引重平衡

    在文中描述索引时的最后一句,它在讲述B树索引时用到了“重平衡”这个词,它还引用了Wikipedia上一篇关于“删除之后的重平衡”文章。可惜,Wikipedia上的文章并不普遍适用于数据库索引,因为它上面描述的算法隐含的前提是每个节点必须都是半满的。

    PostgreSQL使用了B树的Lehman、 Yao变种,它为了提高并发度支持稀疏索引。因此,PostgreSQL仍然会从索引中删除空白页(见幻灯片“索引的内部原理”第15页),但这只是一个次要问题而已。

    真正令人担忧的是这一句:“B树的一个重要特性是它们会定期做重平衡……”在这里需要澄清,这并不是每天都会运行的一个周期性过程。每次索引改变(也可能更糟)都可能会引起索引重平衡,但文章中继续说:“当子树移动到磁盘其他位置时,这种重平衡操作会完全改变树的结构。”如果你认为“重平衡”的过程会导致数据移动,那你就错了。

    B树最重要的过程是节点分裂。从字面上也许就能理解,当一个节点中没办法再写入一条新记录时,它就会分裂。粗略地说在经过100次插入之后就会有一次分裂。要分裂的节点会再生成一个新节点,然后从自己这里挪一半的记录过去,再把新节点和旧的、下一个以及父亲节点都连接起来,这就是Lehman,Yao的算法节省了许多锁操作的地方。

    但在某些情况下,新节点并不能直接被加到父亲节点里,因为如果父亲节点也恰好满了而无法再添加新的子节点时,父亲节点也会分裂,上述所有过程都会重做。

    在最差的情况下,分裂操作会一直向上传递到根节点,它也会分裂并在它的上一级再加上一个新的根节点。只有在这种情况下,B树的深度会增加。注意根节点分裂时为了保持树的平衡会进行整棵树的调整,但这并不一定会导致大量的数据移动。在最差的情况下,每一层会修改三个节点,再加上个新的根节点。

    事实上,当今世界绝大多数的生产库B树索引都不超过5层,也就是说最差的情况——根节点分裂——可能在经过十亿次插入操作的过程中才会发生5次,而且也不会整棵树都受影响。总的来说,索引维护并不是周期性的,也并不频繁,它压根不会完全改动树的结构,至少是磁盘上的数据。

    关于物理复制

    Uber的文章中提到了关于PostgreSQL的另一个我不赞成的主要问题:物理复制。文章提到索引“重平衡”问题的原因是Uber曾经碰到过一个PostgrSQL的复制BUG,导致所有的从库数据都损坏了(“BUG影响了PostgreSQL 9.2的好几个子版本,已经被修复很长时间了”)。

    因为PostgreSQL 9.2只在内核中提供了物理复制功能,那一个主从复制的BUG的确“会导致树的大部分内容都完全错误”。解释一下,如果一个节点分裂复制出错结果导致它不能指向正确的子节点的话,那整棵子树都会失效。这是绝对正确的,就象一句“如果有BUG就要糟”这样的话一样正确。要破坏一个树的结构并不需要改很多数据:只要一个坏指针就够了。

    Uber的文章也提到了物理复制的一些其他问题:超大的复制流量(部分原因是更新操作导致的写放大)、升级到新PostgreSQL版本时需要过长的停服时间。第一个对我来说合理,但第二个我就实际无法评论了(但是在PostgreSQL-hackers邮件组里是有一些评论的)。

    最后,文章声称“PostgreSQL没有真正意义上的从库MVCC支持”。幸运的是,文章连接到了PostgreSQL文档中解释这个问题的页面。问题主要是说主库完全不知道从库在做什么,所以对于某些要做大操作而延迟非常大的从库来说,有可能主库会删除从库仍未取走的日志。

    按PostgreSQL文档来说,有两个办法解决这个问题:

    1. 为了让读事务可以有机会完成操作,允许复制流在一定的超时时间内延迟应用数据。如果超时之后读事务仍然没有完成,就把事务杀死,让复制流应用数据。

    2. 配置从库向主库发送回应消息说明自己当前的复制状态,避免主库清除掉任何从库仍然需要的历史版本数据。Uber的文章直接排除了第一种办法,但压根没提第二种办法,还批评了Uber的开发者不了解数据库底层原理。

    关于开发者

    引用全部原文:“比如,一个开发写了一段代码来把一个收据通过邮件发给另一个用户。依代码的写法不同,可能会隐式地用到一个数据库事务,直到邮件成功发送之后事务才结束。

    当然,让你的代码在做一些不相干的阻塞式IO时还打开一个的事务并不好,但现实情况是绝大部分工程师们都不是数据库专家,可能并不会意识到这个问题的存在,尤其是当他们使用ORM等架构的时候,那些开启事务之类的底层细节都被掩盖起来了。

    我理解并支持这样的说法,对于这句话:“大多数工程师都不是数据库专家。”实际上,大多数工程师的数据库知识都少得可怜。其实即使不是数据库专家,每一个要使用SQL的工程师还是要知道事务的。

    我工作的主要内容就是给开发者们做SQL培训,各种规模的公司里都做过。如果有一件事我敢肯定的话,那就是大家的SQL知识实在是难以置信地差。在上文刚刚提到的“打开的事务”的问题中,我可以肯定,很少有开发者知道只读事务是真的存在的,大多数开发者只知道事务是用来保护写操作的。我太多次碰到这样的问题了。

    关于成功

    这里要说的最后一个问题:一个公司雇的人越多,他们的能力就会越趋近平均水平。夸张地说,如果你把地球上所有人都雇了,那你恰好就是水平达到那个平均值。雇的人多只是增大了样本集合而已。

    有两个办法可以避免这种可能:

    1. 只雇最好的人。这个办法的难点在于当暂时找不到水平高于一般的人时,只好耐心等待。

    2. 雇水平一般的人再把他们在工作中培养起来。新员工要上手的话可能需要比较长的热身期,而且老员工也可能会需要培训。

    这两个方法的共有问题是都需要很长时间。如果你的业务迅猛增长而你的时间等不及的话,你就只好雇水平一般的人,他们对数据库不会很了解(2014年的经验数据)。换句话说,对于一个快速发展的公司,技术比人更容易改变。

    随着在不同时期的业务需求不同,成功的因素也会影响团队的技术能力需求。在创业初期,公司需要现成的、可以立刻上手并且足够方便实现业务需求的技术。SQL就是其中之一,因为它非常灵活(想怎么用就怎么用),而且要找懂点SQL知识的人也非常容易。

    好,咱们开工吧!许多——也可能是大多数——的公司就到此为止了。即使他们比较成功了,业务也发展了,他们可能还是很能接受SQL数据库的各种缺点而不求改变。这并不是在说Uber。

    有些幸运的初创公司最终会不满足于SQL的功能。但到那时,他们已经有能力去接触更多的(或者说理论上无限的?)资源,这时候有趣的事就发生了:他们发现如果把现在用着的通用数据库换成一个专门为自己的需求而开发的系统的话,那很多问题就都可以解决了!这就是一种新的NoSQL数据库诞生的时刻。在Uber,他们叫它Schemaless。

    关于Uber对于数据库的选型

    到目前为止,我并不认为Uber和他们的文章中说的一样用MySQL替换了PostgreSQL。看起来他们实际上是用他们的定制解决方案替换了PostgreSQL,而且仅仅是用了MySQL/InnoDB做底层存储而已。

    看起来那篇文章只是在说为什么MySQL/InnoDB比PostgreSQL更适合作Schemaless的底层存储。对于使用Schemaless的人来说可以听取他们的意见。但不幸的是,文章并没有说明白这一点,因为它没有提到是怎样的业务变化促使Uber引入了Schemaless,要知道在2013年他们刚从MySQL迁移到PostgreSQL上。

    好可惜,现在读者们只留下了个PostgreSQL很糟糕的印象……

    延展阅读(点击标题):


    本周四晚八点半,InfoQ大咖说直播报名中!

    看“中国个人站长第一人”——高春辉的死磕创业之路

    阅读原文,了解更多详情!


    喜欢我们的会点赞,爱我们的会分享!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存